LeetCode 380. O(1) 时间插入、删除和获取随机元素 题解

在算法题中,"O(1) 时间复杂度"是高频要求,尤其涉及插入、删除、随机访问三类操作时,单一数据结构往往难以满足所有需求。本文将拆解 LeetCode 380 题,讲解如何通过"数组+哈希表"的组合结构,实现一个高效的 RandomizedSet 类,并解析核心代码的设计逻辑与优化方向。

一、题目分析

题目要求

实现 RandomizedSet 类,支持以下三个操作,且每个操作的平均时间复杂度必须为 O(1):

  • insert(int val):元素 val 不存在时插入,返回 true;存在则返回 false。

  • remove(int val):元素 val 存在时删除,返回 true;不存在则返回 false。

  • getRandom():随机返回集合中的一个元素,每个元素被返回的概率相同(测试用例保证调用时集合非空)。

核心矛盾

单一数据结构的局限性:

  1. 数组:随机访问(arr[index])是 O(1),但插入/删除中间元素需移动后续元素,时间复杂度 O(n)。

  2. 哈希表(对象/Map):插入/删除/查找是 O(1),但无法通过索引随机访问元素,无法满足 getRandom() 需求。

解决方案:组合两者优势------用数组保证随机访问,用哈希表存储"元素→索引"映射,快速定位元素位置,规避数组插入/删除的 O(n) 操作。

二、代码实现与逐行解析

以下是基于 TypeScript 的实现代码,核心逻辑为"数组存元素,对象存索引映射",完全满足 O(1) 平均时间复杂度要求。

typescript 复制代码
class RandomizedSet {
  // 数组:存储元素,保证getRandom()的O(1)随机访问
  private arr: Array<number>;
  // 对象:存储"元素→索引"映射,实现O(1)查找元素位置
  private comObj: Record<number, number>;

  constructor() {
    this.arr = [];
    this.comObj = {};
  }

  insert(val: number): boolean {
    // 先判断元素是否存在(O(1):对象属性查找)
    if (val in this.comObj) {
      return false;
    }
    // 存储元素索引:数组长度即为新元素的索引(push前长度不变)
    this.comObj[val] = this.arr.length;
    // 数组尾部插入元素(O(1):无需移动其他元素)
    this.arr.push(val);
    return true;
  }

  remove(val: number): boolean {
    // 元素不存在则返回false(O(1)查找)
    if (!(val in this.comObj)) {
      return false;
    }
    // 获取待删除元素的索引
    const deleteIndex = this.comObj[val];
    // 获取数组最后一个元素(用于交换)
    const lastVal = this.arr[this.arr.length - 1];
    
    // 关键操作:将待删除元素与最后一个元素交换
    this.arr[deleteIndex] = lastVal;
    // 更新最后一个元素的索引映射(O(1)赋值)
    this.comObj[lastVal] = deleteIndex;
    
    // 数组尾部删除元素(O(1):无需移动其他元素)
    this.arr.pop();
    // 从对象中删除待删除元素的映射(O(1)删除)
    delete this.comObj[val];
    return true;
  }

  getRandom(): number {
    // 生成[0, arr.length)的随机整数(O(1)数学运算)
    const randomIndex = Math.floor(Math.random() * this.arr.length);
    // 数组索引访问元素(O(1))
    return this.arr[randomIndex];
  }
}

核心逻辑拆解

1. insert 方法(O(1))

步骤:先通过 val in this.comObj 判断元素是否存在(对象属性查找为 O(1));若不存在,先记录元素的索引(数组当前长度即为新元素插入后的索引),再将元素插入数组尾部(push 操作 O(1)),最后更新对象映射。

注意:必须先记录索引再 push,因为 push 后数组长度会增加,索引会对应变化。

2. remove 方法(O(1))

这是最关键的优化点,核心是"交换+尾部删除"规避 O(n) 遍历:

  1. 获取待删除元素的索引 deleteIndex 和数组最后一个元素 lastVal

  2. lastVal 赋值到 deleteIndex 位置,覆盖待删除元素(O(1) 赋值)。

  3. 更新 lastVal 在对象中的索引为 deleteIndex(保持映射一致性)。

  4. 删除数组最后一个元素(pop 操作 O(1)),再删除对象中待删除元素的映射。

通过这种方式,无需遍历数组删除元素,将时间复杂度从 O(n) 降至 O(1)。

3. getRandom 方法(O(1))

利用数组索引访问的优势:生成一个 [0, 数组长度) 的随机整数作为索引,直接通过 arr[randomIndex] 获取元素,全程无遍历,时间复杂度 O(1)。

三、关键注意事项与避坑点

1. 元素存在性判断:用 in 而非直接判断值

若写成 if (this.comObj[val]) 会出现误判:当元素索引为 0 时,this.comObj[val] = 0,转为布尔值为 false,会误判元素不存在。

val in this.comObj 直接判断"键是否存在",与值无关,是最严谨的方式。

2. 索引一致性维护

删除元素时,必须同步更新最后一个元素的索引映射,否则后续查找该元素会得到错误索引,导致功能异常。

3. 空数组边界处理

题目虽保证 getRandom() 调用时集合非空,但实际开发中可添加边界判断,避免数组为空时返回 undefined

typescript 复制代码
getRandom(): number {
  if (this.arr.length === 0) {
    throw new Error("集合为空,无法获取随机元素");
  }
  const randomIndex = Math.floor(Math.random() * this.arr.length);
  return this.arr[randomIndex];
}

四、优化方向:用 Map 替代 Object

当前代码用 Record<number, number> 作为哈希表,存在一个隐式问题:JavaScript 对象的键本质是字符串/符号,数字键会被自动转为字符串(如 comObj[5] 实际存储为 comObj["5"])。虽不影响功能,但类型不够精准。

优化方案:用Map<number, number> 替代对象,原生支持数字键,语义更清晰,且 has()set()delete() 方法更贴合键值对操作:

typescript 复制代码
class RandomizedSet {
  private arr: number[];
  private comMap: Map<number, number>;

  constructor() {
    this.arr = [];
    this.comMap = new Map();
  }

  insert(val: number): boolean {
    if (this.comMap.has(val)) {
      return false;
    }
    this.comMap.set(val, this.arr.length);
    this.arr.push(val);
    return true;
  }

  remove(val: number): boolean {
    if (!this.comMap.has(val)) {
      return false;
    }
    const deleteIndex = this.comMap.get(val)!; // 非空断言,已判断存在
    const lastVal = this.arr[this.arr.length - 1];
    if (deleteIndex !== this.arr.length - 1) { // 优化:若为最后一个元素,跳过交换
      this.arr[deleteIndex] = lastVal;
      this.comMap.set(lastVal, deleteIndex);
    }
    this.arr.pop();
    this.comMap.delete(val);
    return true;
  }

  getRandom(): number {
    const randomIndex = Math.floor(Math.random() * this.arr.length);
    return this.arr[randomIndex];
  }
}

额外优化:当待删除元素就是数组最后一个元素时,可跳过交换逻辑,减少无效操作,小幅提升性能。

五、复杂度证明

  • insert:对象/Map 查找(O(1))+ 数组 push(O(1))+ 映射更新(O(1)),整体 O(1)。

  • remove:对象/Map 查找(O(1))+ 元素交换(O(1))+ 数组 pop(O(1))+ 映射删除(O(1)),整体 O(1)。

  • getRandom:随机数生成(O(1))+ 数组索引访问(O(1)),整体 O(1)。

所有操作均为常数级,满足题目"平均 O(1) 时间复杂度"的要求。

六、总结

本题的核心思路是"组合数据结构弥补单一结构的不足":数组保证随机访问,哈希表(对象/Map)保证高效的插入/删除/查找。其中,删除操作的"交换+尾部删除"是实现 O(1) 的关键技巧,也是算法题中高频的优化思路。

无论是用对象还是 Map 作为哈希表,核心逻辑一致,可根据场景选择:对象更简洁,Map 类型更精准、性能更稳定(适合大量键值对操作)。掌握这种"1+1>2"的数据结构组合思路,能解决更多复杂的算法优化问题。

相关推荐
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #234:回文链表(双指针法、栈辅助法等多种方法详细解析)
算法·leetcode·链表·递归·双指针·快慢指针·回文链表
独自破碎E2 小时前
【动态规划】兑换零钱(一)
算法·动态规划
Sarvartha2 小时前
顺序表笔记
算法
宵时待雨2 小时前
数据结构(初阶)笔记归纳6:双向链表的实现
c语言·开发语言·数据结构·笔记·算法·链表
狐572 小时前
2026-01-20-LeetCode刷题笔记-3314-构造最小位运算数组I
笔记·算法·leetcode
0和1的舞者2 小时前
非力扣hot100-二叉树专题-刷题笔记(一)
笔记·后端·算法·leetcode·职场和发展·知识
FMRbpm2 小时前
树的练习7--------LCR 052.递增顺序搜索树
数据结构·c++·算法·leetcode·深度优先·新手入门
LawrenceLan2 小时前
Flutter 零基础入门(二十三):Icon、Image 与资源管理
开发语言·前端·flutter·dart
技术民工之路2 小时前
MATLAB线性方程组,运算符、inv()、pinv()全解析
线性代数·算法·matlab