leetcode 面试经典 150 题(12/150) 380. O(1) 时间插入、删除和获取随机元素

134 阅读5分钟

题目描述

实现 RandomizedSet 类,它支持以下操作,且所有操作的 平均时间复杂度为 O(1)

  • RandomizedSet(): 初始化 RandomizedSet 对象。
  • bool insert(int val): 当元素 val 不存在时,向集合中插入该项,并返回 true ;否则,返回 false
  • bool remove(int val): 当元素 val 存在时,从集合中移除该项,并返回 true ;否则,返回 false
  • int getRandom(): 随机返回现有集合中的一项(测试用例保证调用此方法时集合中至少存在一个元素)。每个元素应该有 相同的概率 被返回。

示例:

输入
["RandomizedSet", "insert", "remove", "insert", "getRandom", "remove", "insert", "getRandom"]
[[], [1], [2], [2], [], [1], [2], []]
输出
[null, true, false, true, 2, true, false, 2]

解释
RandomizedSet randomizedSet = new RandomizedSet();
randomizedSet.insert(1); // 向集合中插入 1 。返回 true 表示 1 被成功地插入。
randomizedSet.remove(2); // 返回 false ,表示集合中不存在 2 。
randomizedSet.insert(2); // 向集合中插入 2 。返回 true 。集合现在包含 [1,2] 。
randomizedSet.getRandom(); // getRandom 应随机返回 1 或 2 。
randomizedSet.remove(1); // 从集合中移除 1 ,返回 true 。集合现在包含 [2] 。
randomizedSet.insert(2); // 2 已在集合中,所以返回 false 。
randomizedSet.getRandom(); // 由于 2 是集合中唯一的数字,getRandom 总是返回 2 。

提示:

  • -2^31 <= val <= 2^31 - 1
  • 最多调用 insertremovegetRandom 函数 2 * 10^5
  • 在调用 getRandom 方法时,数据结构中 至少存在一个 元素。

算法思路

核心思想:哈希表 + 动态数组

为了实现插入、删除和随机访问操作的平均时间复杂度为 O(1),我们需要结合两种数据结构的优点:

  • 哈希表 (HashMap/Map): 用于 O(1) 时间复杂度的 查找 (判断元素是否存在) 和 删除 (通过键快速定位元素)。
  • 动态数组 (ArrayList/Slice): 用于 O(1) 时间复杂度的 末尾插入随机访问 (通过索引直接访问)。

思考过程:

  1. O(1) 插入和查找: 对于 insert 操作,我们需要快速判断元素是否已经存在,并且需要快速插入元素。 哈希表可以在 O(1) 平均时间复杂度内完成查找和插入操作。 因此,我们可以使用哈希表来存储元素,并用于快速判断元素是否存在。

  2. O(1) 删除: 对于 remove 操作,同样需要快速查找元素是否存在,如果存在则需要快速删除。 哈希表可以 O(1) 时间复杂度完成查找和删除键值对的操作。 但是,如果我们直接从数组中删除一个元素,并保持数组的连续性,通常需要移动后续元素,时间复杂度会是 O(N)。 为了实现 O(1) 删除,我们需要一种巧妙的方法。

  3. O(1) 随机访问: 对于 getRandom 操作,我们需要随机返回集合中的一个元素,并保证每个元素被返回的概率相同。 动态数组可以 O(1) 时间复杂度通过索引进行随机访问。 我们可以将集合中的元素存储在动态数组中,然后生成一个随机索引,访问数组中对应索引的元素即可。

  4. 结合哈希表和动态数组实现 O(1) 删除: 为了在 O(1) 时间内删除动态数组中的任意元素,我们可以使用 “交换-删除尾部元素” 的技巧。 当需要删除数组中索引为 idx 的元素时,将数组的 最后一个元素 复制到 idx 位置,然后将数组的长度减 1,相当于删除了尾部元素 (实际上是覆盖了要删除的元素,并删除了原尾部元素)。 这样,就避免了移动大量元素。 为了保证哈希表和动态数组的数据同步,哈希表需要存储 元素值到其在动态数组中索引的映射。 删除元素时,不仅要更新动态数组,还要更新哈希表中受影响元素的索引。

复杂度分析

  • 时间复杂度:

    • insert(val): 平均 O(1)。 哈希表查找和动态数组尾部插入都是 O(1) 平均时间复杂度。
    • remove(val): 平均 O(1)。 哈希表查找、动态数组元素交换和尾部删除都是 O(1) 平均时间复杂度。
    • getRandom(): O(1)。 动态数组随机访问是 O(1) 时间复杂度。
  • 空间复杂度: O(N),其中 N 是集合中元素的数量。 哈希表 indices 和动态数组 nums 都需要 O(N) 的空间来存储元素。

代码实现

package main

import "math/rand"

type RandomizedSet struct {
    indices map[int]int
    nums    []int
}

func Constructor() RandomizedSet {
    return RandomizedSet{
        indices: make(map[int]int, 0),
        nums:    make([]int, 0),
    }
}

func (this *RandomizedSet) Insert(val int) bool {
    if _, ok := this.indices[val]; ok { // O(1) 哈希表查找元素是否存在
        return false
    }
    // O(1) 动态数组尾部添加元素
    this.nums = append(this.nums, val)
    // O(1) 哈希表记录元素值和索引的映射
    this.indices[val] = len(this.nums) - 1
    return true
}

func (this *RandomizedSet) Remove(val int) bool {
    idx, ok := this.indices[val] // O(1) 哈希表查找元素索引
    if !ok {
        return false // O(1) 哈希表查找元素是否存在
    }
    // O(1) 获取数组最后一个元素的索引
    lastIdx := len(this.nums) - 1
    // O(1) 将最后一个元素覆盖到要删除的元素位置
    this.nums[idx] = this.nums[lastIdx]
    // O(1) 更新哈希表中最后一个元素的索引
    this.indices[this.nums[idx]] = idx
    // O(1) 动态数组尾部删除元素
    this.nums = this.nums[:lastIdx]
    // O(1) 哈希表删除元素
    delete(this.indices, val)
    return true
}

func (this *RandomizedSet) GetRandom() int {
    // O(1) 动态数组随机访问
    return this.nums[rand.Intn(len(this.nums))]
}

/**
 * Your RandomizedSet object will be instantiated and called as such:
 * obj := Constructor();
 * param_1 := obj.Insert(val);
 * param_2 := obj.Remove(val);
 * param_3 := obj.GetRandom();
 */

关键点

  • 哈希表 + 动态数组: 巧妙结合哈希表和动态数组,利用各自的优势,实现了 O(1) 时间复杂度的插入、删除和随机访问。
  • O(1) 平均时间复杂度: 所有操作的平均时间复杂度均为 O(1),满足题目要求。
  • “交换-删除尾部元素” 技巧: remove 操作中使用的 “交换-删除尾部元素” 技巧是实现 O(1) 删除的关键,避免了数组元素的移动。
  • 空间换时间: 使用哈希表和动态数组需要额外的空间,但换来了操作时间复杂度的降低,典型的空间换时间策略。