题目描述

✅ 381. O(1) 时间插入、删除和获取随机元素 - 允许重复

image-20260928235434466

image-20260928235434467

题意分析

实现允许重复值的集合,支持插入、删除一次出现以及随机取值,三个操作的平均时间都要求为常数。

插入无论值是否已经存在,都要真正新增一次出现;返回值只表示插入前是否没有这个值。删除时只移除一次出现,存在则返回真,不存在则返回假。随机取值不删除元素,同一个值被抽到的概率应与它当前的出现次数成正比。题目保证调用随机查询时集合非空。

解法:动态数组 + 下标集合

核心思路

[!blue]

用动态数组 values 紧密保存每一次出现。同一个值可以占多个位置,随机查询时等概率选择一个合法数组下标即可:某值占据多少个位置,就有多少份被抽中的机会,自然得到按出现次数加权的概率。

还需要从指定值迅速找到它的一次出现。indices 保存每个值对应的全部数组下标。插入时,将新下标登记到对应记录,并把值追加到数组尾部;重复值同样追加,只是返回 false。删除时,从记录中取一个下标 removeIndex,无需在线性数组中查找。

直接删除数组中间元素会移动后面整段。由于题目不要求保存顺序,可以用最后一个元素补到被删位置,再截短数组。这样只改动一个存活元素的位置,同时把它在 indices 中登记的旧尾下标改为补洞下标即可。

Java 用 LinkedHashSet 保存每个值的下标。它既支持平均常数时间的下标增删,也能沿链直接取得首项。普通 HashSet 每次新建迭代器取第一项,可能先扫描许多空桶,不能据此保证删除操作的平均常数成本。

Go 用每个值的位置列表保存下标,直接取列表末项作为待删位置。为避免移动尾元素时在线性列表中寻找旧下标,另用 slots[i] 记录 values[i] 这次出现在自己位置列表中的槽位。于是数组尾项的旧记录可以通过 slots[lastIndex] 直接定位,改成 removeIndex;移动后的数组位置也接收这个槽位编号。

更新时先移除目标出现的记录,再修正被移动尾项的记录,最后截短数组并清除空的值记录。尾项可能与待删值相同,此时修改的是同一份记录。Go 已先保存尾项槽位;若两者物理位置不同,尾项槽位就不是刚弹掉的列表末槽,仍然有效。Java 同样先删目标下标,再把旧尾下标换成新位置,完成后才判断集合是否为空。

若待删位置已经是数组尾部,则不用补洞,也不需要改动其他出现的映射。上述对应关系保持后,后续查找、删除和随机访问都可以继续直接定位。

解题步骤

  1. 插入时检查该值此前是否存在,登记新的数组下标,并追加这一次出现;Go 同步记录它在位置列表中的槽位。
  2. 删除时先查找对应记录,不存在则返回假。
  3. 取出待删下标,保存数组尾项的值和位置元数据,再移除目标下标记录。
  4. 待删位置不是尾部时,用尾项补洞,并同步更新尾值对应的下标记录;Go 同步移动槽位编号。
  5. 截短数组,删除已经为空的值记录,返回真。
  6. 随机查询时在 [0, values.size()) 内等概率选取下标并返回对应值。

代码实现

class RandomizedCollection {
    private final List<Integer> values = new ArrayList<>();
    private final Map<Integer, Set<Integer>> indices = new HashMap<>();

    public boolean insert(int val) {
        Set<Integer> set = indices.computeIfAbsent(val, key -> new LinkedHashSet<>());
        // 重复值仍插入,返回值只表示此前是否存在
        boolean first = set.isEmpty();

        set.add(values.size());
        values.add(val);

        return first;
    }

    public boolean remove(int val) {
        Set<Integer> removeSet = indices.get(val);

        if (removeSet == null) {
            return false;
        }

        int removeIndex = removeSet.iterator().next();
        int lastIndex = values.size() - 1;
        int lastValue = values.get(lastIndex);

        // 先移除目标下标,再修正可能属于同一集合的尾项
        removeSet.remove(removeIndex);

        if (removeIndex != lastIndex) {
            values.set(removeIndex, lastValue);
            Set<Integer> lastSet = indices.get(lastValue);

            lastSet.remove(lastIndex);
            lastSet.add(removeIndex);
        }

        values.remove(lastIndex);

        // 完成补洞后,才判断该值是否已无剩余出现
        if (removeSet.isEmpty()) {
            indices.remove(val);
        }

        return true;
    }

    public int getRandom() {
        int index = ThreadLocalRandom.current().nextInt(values.size());

        return values.get(index);
    }
}
import "math/rand"

type RandomizedCollection struct {
    values  []int
    slots   []int
    indices map[int][]int
}

func Constructor() RandomizedCollection {
    return RandomizedCollection{indices: make(map[int][]int)}
}

func (this *RandomizedCollection) Insert(val int) bool {
    positions := this.indices[val]
    first := len(positions) == 0
    this.indices[val] = append(positions, len(this.values))
    this.slots = append(this.slots, len(positions))
    this.values = append(this.values, val)
    return first
}

func (this *RandomizedCollection) Remove(val int) bool {
    positions, exists := this.indices[val]
    if !exists {
        return false
    }

    removeIndex := positions[len(positions)-1]
    lastIndex := len(this.values) - 1
    lastValue := this.values[lastIndex]
    lastSlot := this.slots[lastIndex]
    this.indices[val] = positions[:len(positions)-1]

    if removeIndex != lastIndex {
        this.values[removeIndex] = lastValue
        this.slots[removeIndex] = lastSlot
        this.indices[lastValue][lastSlot] = removeIndex
    }

    this.values = this.values[:lastIndex]
    this.slots = this.slots[:lastIndex]
    if len(this.indices[val]) == 0 {
        delete(this.indices, val)
    }
    return true
}

func (this *RandomizedCollection) GetRandom() int {
    return this.values[rand.Intn(len(this.values))]
}

复杂度分析

  • 时间复杂度:插入和删除为期望、均摊 $O(1)$,哈希表定位和记录更新为期望常数,动态数组追加为均摊常数。Java 直接取链式集合首项,Go 直接取列表末项并通过反向槽位更新,都不扫描记录。随机查询为 $O(1)$。
  • 空间复杂度:$O(n)$,n 为插入操作总次数。每次插入至多增加常数条数组与下标记录;删除会减少有效记录,但动态容器可能保留已经分配的容量。

关键点总结

[!green]

  • 随机等概率的是每次出现对应的数组位置,不是不同数值的种类。
  • 尾项补洞把数组删除化为常数次改写,但数组位置与反向记录必须一起更新。
  • 找到某个值之后,取出和修改它的一次出现也必须能直接定位,不能隐藏一次线性扫描。

易错点总结

[!yellow]

  • 看到插入返回假,就不把重复值加入数组,混淆了返回语义与实际操作。
  • 为每个值只记录一个下标,无法保留或删除其余重复出现。
  • 补洞后没有改写尾值的下标记录,下一次删除会访问已经失效的位置。
  • 默认尾值一定不同于待删值,会在同一份记录上按错误顺序增删,破坏仍存活的出现。
  • Go 只更新位置列表、没有把尾项槽位写到补洞位置,后续移动会定位到错误记录。
  • 从不同值的键集合中等概率抽样,会丢掉重复次数带来的概率权重。
  • 为了删除中间位置而移动数组后面的全部元素,结果虽然可能正确,却不满足平均常数时间要求。

相似题目

题目 难度 关联与区别
380. O(1) 时间插入、删除和获取随机元素 中等 允许重复后,值不能只映射到一个下标,删除时要维护每个值的下标集合。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/77586827
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!