1. 为什么刷算法题要先聊容器
我刷 LeetCode 和各类面试题时,见过太多 JS 选手把数组当成万能容器。遇到数据先 push 进数组,要查重就 indexOf,要当队列用就 shift,结果到了中等难度的题直接超时或者代码写得极其别扭。其实标题里“整理数据结构”这几个字,核心说的就是一件事:在 JS 里,容器不只是存储数据的盒子,它直接决定了你的算法复杂度是 O(1) 还是 O(n),甚至决定这道题你能不能写出来。
JS 这门语言比较特殊,它不像 Java 或者 C++ 那样有标准库里的各种容器开箱即用,很多时候你得自己去模拟。比如 Java 有 PriorityQueue,C++ 有 priority_queue,JS 没有,得手写堆。这是劣势,但也是优势——因为手写一遍之后,你对堆的理解会深得多。
这篇文章面向的是准备算法面试的 JS 开发者,或者工作中需要用 JS 做数据处理、逻辑编排的人。我会把 JS 里可用的容器分门别类梳理一遍,讲清楚每个容器的适用场景、复杂度陷阱、还有我自己踩过的一些坑。你会发现,容器选对了,很多题的代码量能少一半,跑起来还更快。
2. 核心容器选型:数组、对象、Map 和 Set 到底怎么分工
2.1 数组不是万能容器,但它是基础
数组在 JS 里确实是最常用的线性容器,但这不代表它可以包打天下。数组适合的场景是:按下标访问、按顺序遍历、尾部增删。这三个操作的时间复杂度都是 O(1)(严格来说 V8 对数组做了一些优化,但大体可以这么理解)。可一旦涉及头部插入或删除,比如unshift和shift,就是 O(n) 了,因为整个数组的索引都要重新排列。
我实测过一个简单的场景:往数组头部插入 10 万条数据,unshift的耗时大概是尾部push的几十倍。这在算法题里是致命的。所以经常有人问“为什么我用数组模拟队列会超时”,答案就是shift的时间复杂度太高。
有人会说,那我用splice在中间插,不也是 O(n) 吗?没错,splice删除或插入元素,同样要把后续元素整体挪动。这些看似不起眼的 O(n) 操作,遇到大规模数据就会拖垮整体性能。
2.2 对象适合做映射,但原型链会坑人
JS 的对象(Object)算是一种哈希表容器,适合做键值映射。但用对象做容器有两个容易被忽视的问题。第一,对象的键会被自动转成字符串,如果你拿数字当键,访问时不会有问题,但遍历时顺序并不保证是数字顺序(虽然现代引擎对整数键做了特殊处理,但这是引擎实现细节,不该依赖)。第二,对象有原型链,也就是说你创建一个空对象obj = {},它其实继承了Object.prototype上的很多属性。
这意味着,如果你拿对象当字典存储数据,万一某个键的名字恰好叫constructor或者toString,就会出现奇怪的行为。我见过有人拿对象做哈希表存用户 ID,结果某个 ID 恰好是"constructor",然后if (map[key])的判断永远为真,排查了半天才发现问题出在原型链上。
所以如果你需要一个纯粹的键值映射容器,不要用普通的{},直接用Map。
2.3 Map 才是 JS 的哈希表本体
Map是 ES6 引入的容器,它解决了普通对象作为哈希表的几个核心痛点:
- 键可以是任意类型,包括对象、函数、
NaN都能作为键。 - 键的插入顺序就是遍历顺序。
- 没有原型链污染问题,它是一个纯数据结构。
我在做算法题时,凡是涉及“计数”或“映射”的场景,比如统计字符出现次数、记录某个值的索引位置,一律用Map。这里的性能收益在数据量小的时候不明显,但数据量一上来,和普通对象相比更稳定可靠,至少不用担心原型链的问题。
配合Map使用的方法是get、set、has、delete,这套 API 比直接操作对象属性要规范得多。我建议每一个刷题的 JS 选手,第一件事就是把Map的 API 用熟。
2.4 Set 解决“是否存在”的问题
Set和Map类似,但它的核心语义是“唯一性的集合”。它的底层实现也是哈希结构,add、has、delete的时间复杂度都是 O(1)。
在算法题里,Set最常见的用途是去重和判断存在性。比如判断一个数组中是否有重复元素,一行new Set(arr).size !== arr.length就能搞定,比嵌套循环优雅太多。再比如 BFS 中判断某个节点是否已经访问过,用Set存储已访问节点,has判断是 O(1),而用数组includes是 O(n)。
但Set有一个明显的局限性:它只存储值本身,不存储额外信息。如果你想记录每个元素出现的次数,或者元素最后一次出现的位置,就得用Map。所以这两者不是替代关系,而是协同关系。Set管“是否出现过”,Map管“出现多少次或在哪里出现”。
2.5 一个表格看明白基础容器的选型
| 容器 | 底层结构 | 适合解决的问题 | 时间复杂度注意点 | 典型场景 |
|---|---|---|---|---|
| Array | 动态数组 | 按下标访问、尾部增删 | 头部/中间操作 O(n) | 遍历、排序、二分查找 |
| Object | 哈希表(有原型链) | 简单键值映射 | 键转字符串,顺序不保证 | 不适合做纯净字典 |
| Map | 哈希表(纯净) | 计数、索引映射、任意键映射 | 读写均 O(1) | 字符计数、坐标映射 |
| Set | 哈希集合 | 去重、存在性判断 | has为 O(1) | 访问标记、去重、双指针配合 |
这个表本质上就是四个字:按需取用。你想表达“一段数据的有序集合”,用数组;你想表达“键和值的对应关系”,用 Map;你想表达“唯一值的集合”,用 Set;你千万别用对象去表达“唯一值的集合”,那是在给自己挖坑。
2.6 为什么前端算法题偏爱 Map 和 Set 组合
实际刷题时,Map 和 Set 经常组合使用。比如经典的“两数之和”问题,你可以用 Map 存储“数值 -> 下标”,边遍历边查。再比如“无重复字符的最长子串”这类滑动窗口问题,你用一个 Set 维护窗口内的字符集合,或者用 Map 维护字符的最近出现位置。
我个人理解是,算法题本质上是在考你对数据之间关系的建模能力,而 Map 和 Set 就是建模的基础积木。数组更像一个序列模型,Map 更像一个关系模型,Set 是一个边界模型。理解了这三者的区别,你解题时的思维会清晰很多。
3. 特殊容器:栈、队列、双端队列、优先队列怎么在 JS 里实现
3.1 栈:一个数组就够了,但要小心别乱用
栈是后进先出(LIFO)的结构。在 JS 里,栈直接用数组模拟就行,push入栈,pop出栈,这两个操作都是 O(1)。刷题时常见的“有效括号”“表达式求值”“函数调用栈模拟”这些场景,一个数组完全够用。
但是有一个细节值得注意:不要把数组的push和pop与unshift和shift搞混。很多人写着写着,入栈写成unshift,出栈写成pop,这就出问题了,因为unshift是 O(n),整个栈的性能就废了。我的习惯是:栈只用数组的尾端操作,也就是push和pop。
栈还有一种变体叫“单调栈”,它在算法题中出现频率很高,比如“下一个更大元素”“柱状图中最大的矩形”。单调栈的容器本质还是数组,但额外维护了栈内元素的单调性。这种题用普通数组模拟即可,不需要特殊容器。
3.2 队列:数组实现有坑,推荐用“头尾指针”方案
队列是先进先出(FIFO)的结构。很多人想在 JS 里用数组模拟队列,就直接push入队、shift出队。前面已经说过,shift是 O(n),这是性能杀手的来源。
正确的做法是用数组加头尾指针来模拟队列。思路很简单:
class Queue { constructor() { this.items = []; this.head = 0; this.tail = 0; } enqueue(val) { this.items[this.tail] = val; this.tail++; } dequeue() { if (this.head >= this.tail) return undefined; const val = this.items[this.head]; delete this.items[this.head]; this.head++; return val; } size() { return this.tail - this.head; } }这样入队出队都是 O(1),虽然数组会随着 head 增加留下一些空洞,但算法题里通常不是问题,你可以在 head 超过一定阈值时手动 compact 一下。
在 BFS(广度优先搜索)场景里,队列是标配容器。包括树的层序遍历、图的最近距离等题目,用一个高性能队列能省很多心。我建议直接把队列封装成一个类,放在本地工具集里,刷题时直接调用。
3.3 双端队列:JS 没有原生实现,需要手写或变通
双端队列(Deque)是可以在头部和尾部都能插入删除的队列。在算法题里有不少应用,最典型的就是“滑动窗口最大值”。
JS 没有原生双端队列,你有两种选择:一是用数组模拟,头尾都用push/pop,涉及到头部操作时用“头尾指针”方案扩展成双端队列;二是直接用现成的类库,比如denque,但在刷题环境里通常不能引包,所以自己实现一个小型双端队列更靠谱。
实现思路和队列差不多,只不过多了一个unshift的等价操作。核心逻辑是:
- 尾部入队:
this.items[this.tail] = val; this.tail++ - 头部入队:
this.items[--this.head] = val - 头部出队:
return this.items[this.head++] - 尾部出队:
return this.items[--this.tail]
这个方案保证了四个操作都是 O(1),而且代码量不多。我在做“滑动窗口最大值”这类题时,就用的这个结构,配合单调队列的思路,一次遍历就能搞定。
3.4 优先队列:JS 没有内置,必须手写堆
优先队列(PriorityQueue)是面试中经常会遇到但 JS 选手最容易卡壳的容器。很多题,比如“合并 K 个有序链表”“前 K 个高频元素”“数据流中的中位数”,最优解都依赖优先队列。可惜 JS 没有内置的优先队列,所以你得手写一个二叉堆来实现。
我在实践中总结出了一个通用的最小堆实现(改成最大堆只需把比较符号反转):
class MinHeap { constructor() { this.heap = []; } push(val) { this.heap.push(val); this._siftUp(this.heap.length - 1); } pop() { if (this.size() === 0) return null; const top = this.heap[0]; const last = this.heap.pop(); if (this.size() > 0) { this.heap[0] = last; this._siftDown(0); } return top; } peek() { return this.size() > 0 ? this.heap[0] : null; } size() { return this.heap.length; } _siftUp(index) { while (index > 0) { const parent = Math.floor((index - 1) / 2); if (this.heap[parent] <= this.heap[index]) break; [this.heap[parent], this.heap[index]] = [this.heap[index], this.heap[parent]]; index = parent; } } _siftDown(index) { const n = this.heap.length; while (true) { let smallest = index; const left = index * 2 + 1; const right = index * 2 + 2; if (left < n && this.heap[left] < this.heap[smallest]) smallest = left; if (right < n && this.heap[right] < this.heap[smallest]) smallest = right; if (smallest === index) break; [this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]]; index = smallest; } } }这个实现支持数字比较,如果你需要存对象并按某个属性排序,只需给MinHeap增加一个 comparator 参数即可。
提示:手写堆的代码要烂熟于心,因为面试中不会允许你现场查资料,而且很多题目只给你一个空白的编辑器,优先队列是你必须自己搭的基础设施。
3.5 链表需不需要自己实现
链表在 JS 算法题中也很常见,但好消息是它不是作为容器去用的,而是作为一种数据结构自己定义。比如ListNode类,在二叉树、链表反转、合并等题目中必须自己写定义:
class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }链表本身不是 JS 的内置容器,但它是算法题的常客。区别在于,数组适合随机访问,链表适合频繁插入删除。遇到“设计 LRU 缓存”这种题,你还需要把 HashMap(用 Map 模拟)和双向链表结合起来,这就是容器选型发挥价值的典型场景。
4. 实战对比:五种常见算法场景中的容器选择
4.1 字符串处理:用 Map 做字符统计
字符串处理题里最常见的是“判断异位词”“最长不重复子串”“字母异位词分组”。这类题的核心是字符计数。
我在做“有效的字母异位词”时,最初的写法是用一个普通对象存储字符计数,遍历一次字符串。后来把对象换成Map之后,代码并没有变多,但语义更清晰了:
function isAnagram(s, t) { if (s.length !== t.length) return false; const count = new Map(); for (const ch of s) { count.set(ch, (count.get(ch) || 0) + 1); } for (const ch of t) { if (!count.has(ch)) return false; const num = count.get(ch); if (num === 1) { count.delete(ch); } else { count.set(ch, num - 1); } } return count.size === 0; }这里用Map.delete删除计数为 0 的键,最后的size判断比遍历所有键值更方便。这种 API 设计是普通对象不具备的。
4.2 去重与判重:Set 是无脑选择
“数组去重”“两个数组的交集”“判断字符串是否包含重复字符”这类题,用 Set 能省大量时间。比如判断字符串是否有重复字符:
function hasDuplicate(str) { return new Set(str).size !== str.length; }一行搞定。如果用数组includes或者双重循环,复杂度就是 O(n^2)。
另外一个更进阶的用法是Set配合滑动窗口,比如“无重复字符的最长子串”。你维护一个 Set,窗口右边界不断向右移动,每遇到一个新字符就尝试加入;如果发现字符已存在,就移动左边界并删除 Set 中的对应字符,直到窗口合法。整个过程每个字符最多进出 Set 各一次,总复杂度 O(n)。
4.3 排序场景:数组 + 排序函数的取舍
排序相关题目,比如“合并区间”“数组中的第 K 个最大元素”,通常直接用数组的sort方法就能解决。但有几个地方必须注意:
Array.prototype.sort默认会把元素转为字符串再比较,所以给纯数字数组排序时,必须传比较函数:arr.sort((a, b) => a - b)。sort在 V8 引擎中是大元素用快排,小元素用插入排序,平均复杂度 O(n log n),是稳定的(不同引擎的稳定性有差异,但现代 V8 是稳定的)。- 如果需要维护额外信息,比如“按值排序但还要知道原始下标”,可以用数组存对象
{ value, index },再传入比较函数。
热词里提到的“归并排序”“暴力枚举”“剪枝”“贪心算法”“动态规划”等,虽然属于不同算法范式,但在实现层面都和容器的选择有关。比如归并排序需要一个临时数组来合并两个有序序列,这个临时数组就是你主动选择的容器。
4.4 哈希计数与频率统计:用 Map 管理频次
“前 K 个高频元素”是一道非常典型的频率统计 + 优先队列结合的题。先用 Map 统计每个数字的频率,再构建一个小顶堆来维护频率最高的 K 个元素。这里 Map 和堆是结合使用的。
我踩过一次坑:统计频率时图省事,直接用了{},但题目给的测试用例里数字包含了负数和小数,对象的键转字符串后出现了-1和1这样的字符串键冲突,导致统计错误。换成 Map 后问题立刻消失。这让我意识到,只要键不是纯粹的常规字符串,就不要用对象做映射容器。
4.5 双指针与滑动窗口:容器是辅助,指针是核心
双指针和滑动窗口算法本身不直接依赖容器,但它们通常配合 Set 或 Map 来维护“窗口内的状态”。比如“最小覆盖子串”这道题,需要用一个 Map 记录目标字符串中每个字符的需求量,再用另一个 Map 记录当前窗口中各字符的出现次数,边移动指针边比较两个 Map 是否匹配。
这种场景下,容器的核心作用是动态记录状态的增量变化。选择 Map 而不是对象,是因为 Map 可以方便地做get、set、delete,而且遍历顺序是插入顺序,调试时更直观。
5. 常见问题与性能误区排查
5.1indexOf和includes到底有多慢
很多新手在判重时喜欢用数组的includes或indexOf,这在数据量小的时候无所谓,但数据量一上来就是灾难。它们是 O(n) 的线性查找,需要遍历整个数组才能确认元素是否存在。如果你在一个循环里反复用includes查找,整体复杂度会变成 O(n^2)。
我建议养成一个条件反射:当你需要判断一个元素是否存在于某个集合中时,第一反应应该是 Set,而不是数组的 includes。同样的,当需要根据某个键查找对应的值时,用 Map 而不是数组去遍历查找。
5.2 稀疏数组和delete的隐患
在使用“头尾指针 + 数组”模拟队列或双端队列时,出队操作如果只把 head 后移而不清理数组元素,会出现稀疏数组。稀疏数组的问题在于:
- 遍历时会把空洞所在的位置也遍历到(值为 empty),可能干扰判断。
- 某些数组方法(如
map)会跳过空洞,产生难以排查的 bug。
所以在我的队列实现里,dequeue特意加了一句delete this.items[this.head],把出队的位置清空。这个操作不会提高复杂度,但让数据更干净。
5.3 遍历时直接修改容器内容的风险
这是我在刷题中踩过的最深刻的坑之一。有时候在for循环遍历数组时,如果条件成立就splice删除当前元素,删除之后数组的索引会整体前移,导致循环变量错过下一个元素。必须用倒序遍历或者手动修正索引。
类似的问题也会出现在 Map 和 Set 的遍历中。Map 在遍历时删除当前元素一般没问题,但如果一边遍历一边添加新元素,某些引擎下会导致遍历顺序异常或无限循环。我现在的做法是:如果需要在遍历过程中修改容器,先收集要操作的键或值,遍历结束后再统一处理。
5.4 优先队列手写时的常见错误
手写二叉堆时,最常见的错误集中在_siftDown里。很多人会把左右子节点的比较逻辑写错,比如先比较左子节点和右子节点,再决定和哪个交换。正确的逻辑应该是:先找出左右子节点中更小(或更大)的那一个,然后再和当前节点比较。我在上面的代码里就是这样实现的,先左右比较找出smallest,再和根节点换位置。
另一个常见错误是忘记处理size() === 0的情况。pop一个空堆时,如果你直接取this.heap[0]再pop,会得到一个undefined,好一点的情况是报错,坏的情况是静默出错,很大概率在后续计算中产生 NaN。
5.5 一个通用排查表
| 现象 | 可能原因 | 建议方案 |
|---|---|---|
| 大量数据下执行超时 | 使用了shift/unshift或includes/indexOf | 改用头尾指针队列、Set 或 Map |
| 哈希表查询结果异常 | 用普通对象{}存储键值对,遇到原型链属性 | 换成 Map,避免原型链干扰 |
| 数组中间插入导致性能急剧下降 | splice是 O(n) 操作,且频繁触发元素移动 | 用链表或调整存储结构 |
| 排序结果不符合预期 | 忘了给sort传比较函数,数字被转成字符串 | 显式传入(a, b) => a - b |
| Map 遍历时新增元素出现异常 | 遍历过程中修改容器结构 | 先收集再统一修改 |
6. 我的刷题容器模板与个人习惯
6.1 一个可以直接用在刷题环境里的容器工具箱
我把平时刷题常用的容器封装在一起,形成一个固定模板。拿到题目后,我会先把这些工具代码敲一遍,相当于热身,也确保后续代码可以直接复用。
// 栈直接用数组,无需封装 // 队列 class Queue { constructor() { this.items = []; this.head = 0; this.tail = 0; } enqueue(v) { this.items[this.tail++] = v; } dequeue() { if (this.head === this.tail) return null; const v = this.items[this.head]; this.items[this.head++] = undefined; return v; } size() { return this.tail - this.head; } } // 最小堆(可改为最大堆) class MinHeap { constructor() { this.heap = []; } push(v) { this.heap.push(v); this._up(this.heap.length - 1); } pop() { if (!this.heap.length) return null; const t = this.heap[0]; const last = this.heap.pop(); if (this.heap.length) { this.heap[0] = last; this._down(0); } return t; } peek() { return this.heap[0] ?? null; } size() { return this.heap.length; } _up(i) { while (i > 0) { const p = (i - 1) >> 1; if (this.heap[p] <= this.heap[i]) break; [this.heap[p], this.heap[i]] = [this.heap[i], this.heap[p]]; i = p; } } _down(i) { const n = this.heap.length; while (true) { let s = i, l = i * 2 + 1, r = i * 2 + 2; if (l < n && this.heap[l] < this.heap[s]) s = l; if (r < n && this.heap[r] < this.heap[s]) s = r; if (s === i) break; [this.heap[i], this.heap[s]] = [this.heap[s], this.heap[i]]; i = s; } } }这个工具箱里还有ListNode和TreeNode的定义,以及一个 Deque 的完整实现。平时我会把这些代码存在本地笔记里,刷题时先快速敲出来,然后开始做题。
6.2 如何根据题目判断该用什么容器
我总结了一套“看题选容器”的流程,分享给大家:
先看题目要求的数据关系。如果题目提到“保持顺序”——优先考虑数组或队列;如果提到“键值对”“统计次数”“记录位置” ——优先考虑 Map;如果提到“去重”“是否出现过” ——优先考虑 Set;如果提到“每次取最大/最小” ——优先考虑堆(优先队列);如果提到“先进先出”——队列;“后进先出”——栈;“两端操作”——双端队列。
这套流程不能覆盖所有情况,但能覆盖 80% 的常见题。剩下的 20% 需要你根据具体场景灵活变换,比如 LRU 缓存就要 Map 加链表配合,图的最短路径可能要结合邻接表和优先队列。
6.3 容器选择与代码可读性的平衡
除了性能,容器选择还会影响代码的可读性。我个人在看别人的题解时,看到用Map存状态、用Set存访问记录的代码,一眼就能理解意图,但如果对方用对象存状态,还得提防原型链,阅读成本高了不少。
所以在刷题时,我还有一个额外的原则:容器不只是为了跑得快,更是为了表达程序员的意图。你用 Set,读者就知道“这里需要一个唯一集合”;你用 Map,读者就知道“这里要建立映射关系”。这种自说明的代码,在面试中会让你加分不少。
7. 最后再说几句大实话
刷题这些年,我最大的体会是:算法题拼的不只是思维,还有对语言工具的熟练度。很多人算法思路完全正确,但因为在 JS 容器选择上失误,比如用了shift导致超时,或者用了对象做哈希表被原型链坑了,最后功亏一篑。
如果你正在准备面试,我建议把文章里的队列、双端队列、优先队列这几个实现反复手敲几遍,敲到不需要思考就能写出来为止。这些东西本身不难,难的是在紧张的环境下还能不出错。另外,每次刷完一道题,可以顺手想一想:如果我把容器换成另一种,代码会变简单还是复杂?复杂度会变好还是变差?想多了之后,容器选择和算法思路会慢慢融为一体,不再需要一个刻意的决策过程。
我没有给出“所有题目通用的万能容器”,因为这东西根本不存在。但如果你掌握了每种容器的能力边界和复杂度特性,再配上手写的实现,JS 刷算法题这件事,真的可以变得特别顺手。