很多人在刷算法题的时候,JavaScript 基础看着挺扎实,一碰到"容器"这个概念就开始发懵。数组会写、对象会用、Set 和 Map 也不陌生,但真到 LeetCode 上,面对"这道题到底该用什么数据结构"的抉择,往往全凭感觉。前阵子我陪一位朋友从头整理算法题库,他把几十道题的题解翻了一遍,发现一个很有意思的现象:同样的算法思路,用不同容器的写法,性能差距能到几十倍。这篇内容就是把我在实际刷题和帮人 review 代码过程中积累的 JS 容器选型经验掰开揉碎,从复杂度原理讲到具体实现,再配三道真实题目做对照,希望能帮你在算法题这条路上少走几个来回的弯路。
1. 先从"容器"这个说法说起:JS 里到底有哪些选择
1.1 JS 容器的全景地图
先明确一下我们说的"容器"是什么。在算法和数据结构的语境下,容器指的就是"用来存放数据、并支持增删改查的数据结构"。C++ 里有vector、map、set、stack、queue,Java 里有 ArrayList、HashMap、ArrayDeque 一堆东西,而 JavaScript 的原生世界里,真正能当容器用的其实没多少:
Array:动态数组,能模拟栈、队列、双端队列,也能当集合用。Object:键值对集合,键只能是字符串或 Symbol。Map:键值对集合,键可以是任意类型,且保持插入顺序。Set:值集合(不重复),底层也走哈希结构。WeakMap/WeakSet:弱引用版本,算法题里用得少,但涉及"内存管理"的题偶尔会碰见。TypedArray:缓存、二进制数据处理时才常用,排序、聚合类算法题基本用不上。
掌握了这些,你已经知道比赛场地长什么样了。真正麻烦的不是"有哪些容器",而是"在具体场景下该选哪个,以及怎么用才不踩性能坑"。因为 JS 的数组和对象都太灵活了,灵活到你几乎可以用任何容器模拟任何数据结构,但这种灵活性恰恰是性能陷阱的温床。
1.2 复杂度思维:选容器的第一性原则
选容器的本质是在"操作复杂度"上做权衡。不同的容器,对同一操作的开销可能天差地别:
| 操作 | Array | Object / Map | Set |
|---|---|---|---|
| 尾部添加 push | O(1) 摊还 | O(1) 摊还 | O(1) 摊还 |
| 头部添加 unshift | O(n) | O(1) | O(1) |
| 按索引/键读取 | O(1) | O(1) | 不适用 / O(1) 判存在 |
| 查找某值是否存在 | O(n)(indexOf / includes) | O(1) | O(1) |
| 删除中间元素 | O(n)(splice) | O(1)(delete key) | O(1) |
| 迭代顺序 | 索引顺序 | 插入顺序(Map 保证) | 插入顺序 |
为什么unshift是 O(n)?因为数组的内存是连续分配的,在头部插入一个元素,后面的所有元素都得整体往后挪一位。splice也是同一个道理,中间删除会让后面的元素集体前移。这就是我反复强调的:数组什么都能做,但你别什么操作都对着头部和中间来。
反过来看,Set的has()、add()、delete()都是 O(1),因为底层是哈希表。但很多新手不知道Set能当队列用,只会用它去个重。容器选型的核心逻辑就是:把你要做的最高频操作,映射到复杂度最低的容器上。
2. 核心细节解析:数组、Set、Map、Object 的正确打开方式
2.1 数组:最常用,也最容易用错的容器
数组是算法题里出现频率最高的容器,这不奇怪。遍历要数组,排序要数组,存储中间结果要数组。但数组用对了是神器,用错了就是性能黑洞。
我见过太多人写 BFS 的时候用const queue = []; queue.push(node); while (queue.length) { const cur = queue.shift(); }。这段代码从逻辑角度完全没错,但性能上就是灾难。shift()会让数组的每个元素都向前移动一位,如果队列里有一万个节点,那你每取一个元素就触发一次 O(n) 操作,整个 BFS 直接退化成 O(n²)。在 LeetCode 上,这种写法通常能让 100ms 能过的题涨到 2 秒以上。
数组真正高效的操作范围是:
push/pop:作用于尾部,O(1)。- 通过索引直接访问:O(1)。
for循环遍历:O(n),但常数极小。- 排序:
sort(),O(n log n)。
我推荐的做法是:如果队列长度会动态变化且很大,就不要用shift(),改用"索引指针"法。维护一个head变量,队头元素用queue[head]取,head 递增;当 head 超过了某个阈值,再用slice一次性清理,或者干脆用数组的尾部模拟栈配合手动头指针。这一招在处理二叉树层级遍历、图 BFS 时尤其好用。
来个具体例子,这是我自己写 BFS 的常用模板:
function bfs(root) { const queue = [root]; let head = 0; while (head < queue.length) { const size = queue.length - head; for (let i = 0; i < size; i++) { const node = queue[head++]; // 处理 node if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } } }shift()都不用调,head 一路往前跑。等遍历完,整个数组留着也不碍事。如果内存紧张,可以等 head 超过 10000 时执行一次queue.splice(0, head),再重置 head。
2.2 Set 和 Map:哈希结构怎么选、怎么用
很多人以为 Set 就是"去重工具",Map 就是"升级版对象",思维被前端业务给焊死了。在算法题里,Set 和 Map 的核心价值其实是O(1) 时间复杂度的成员检验和关联存储。
先说 Set。判断某个元素是否在集合里,数组要 O(n),Set 只要 O(1)。这个特性在判断"是否访问过"的场景里特别好使。比如无向图的遍历,你需要一个visited集合,很多人下意识用数组visited = [],然后visited.includes(node),复杂度一算,整个搜索全被拉垮。
正确的做法:
const visited = new Set(); if (!visited.has(node)) { visited.add(node); }如果存储的是对象,只要对象引用相同,has就能命中。这也是 Set 比数组灵活的地方:数组的includes对对象用的是严格等于比较,Set 也一样,但 Set 把查找从线性降到了常数级,这不香吗?
再看 Map。Map 的键可以是任意类型,包括对象、函数、NaN,这比 Object 的字符串键强了不止一个量级。算法题里最常见的 Map 用法就是"值 → 索引/计数"的映射。
比如统计字符串里每个字符出现的次数:
const countMap = new Map(); for (const char of s) { countMap.set(char, (countMap.get(char) || 0) + 1); }如果用普通对象{}做同样的事,会发现char会被强制转换成字符串。如果字符串里恰好有 "constructor" 或 "proto",你还会碰到原型链污染导致的诡异 bug。Map 就没有这个问题,键是什么类型就存什么类型,干干净净。
Map 还有一个容易被忽视的特性:迭代顺序就是插入顺序。这用在前端实现"最近最少使用"的场景里如鱼得水,后面实战环节我会详细演示。
2.3 Object 当哈希表:看着爽,坑不少
坦白说,我在刷题初期也喜欢用对象当哈希表:
const map = {}; map[key] = value; if (map[key]) { ... }写起来是真顺手。但后来在 review 别人的代码、自己也踩了几个坑之后,我发现对象在算法题里当哈希表,至少要警惕三个问题:
第一,键会被转成字符串。数字键1和字符串键"1"会混在一起。这对"区分类型"的题目是致命的。比如数据里同时有数字 1 和字符串 "1",用对象存储就分不出来了。
第二,原型链污染。如果键名是"toString"、"hasOwnProperty"这类名字,你从对象里取值的行为会被原型链上的同名方法干扰。虽然可以每次用Object.prototype.hasOwnProperty.call(map, key)来规避,但每写一行都要多一个判断,代码丑不说,还容易漏。
第三,对象没有size属性。你想知道当前哈希表存了多少项,得Object.keys(map).length,这要额外遍历一遍,又是一个 O(n)。
所以我现在的习惯是:除非题目明确只允许 O(1) 额外空间、且键天然是字符串,否则一律用 Map。对象不是不能用,而是不该在算法题里当首选。
3. 栈、队列与双端队列的工程级实现
3.1 栈:数组就够了,但边界别猜
栈是"后进先出"的线性结构,JS 里数组天然支持push和pop,组合起来就是一个完美的栈:
const stack = []; stack.push(1); stack.push(2); const top = stack.pop(); // 2这里没有性能争议,push 和 pop 都是 O(1),不会有元素搬移。你需要关注的只是边界情况:pop 之前一定要确认栈不为空。很多人写单调栈题,判断条件写成while (stack[stack.length - 1] < current),但当栈为空时,stack[stack.length - 1]是undefined,undefined < current结果是 false,看着没报错,逻辑却悄悄出了问题。
我的习惯是取栈顶先判断长度:
while (stack.length && stack[stack.length - 1] < current) { stack.pop(); }先判断stack.length是真值,再去取最后一个元素。这个习惯在写"每日温度""接雨水""柱状图最大矩形"这类单调栈题目时能帮你少掉不少头发。
3.2 队列:别再用 shift,用头指针
上面已经提过头指针方案了,这里再展开讲一个完整的可复用队列实现。在 LeetCode 的 JS 提交里,我自己最常用的就是这样一个"朴素但高效"的队列:
function createQueue() { const items = []; let head = 0; return { push(item) { items.push(item); }, pop() { if (head >= items.length) return undefined; return items[head++]; }, get size() { return items.length - head; }, isEmpty() { return head >= items.length; }, reset() { items.length = 0; head = 0; }, }; }注意我没有对队列元素做真正的删除,只是把 head 往前挪。这样做的好处是pop在逻辑上是 O(1),坏处是数组里"已消费"的元素会一直占着内存。对于单次短时的算法运行,这点内存开销可以忽略不计。但如果你在一个超大的 while 循环里反复用这个队列,且队列峰值很大,需要注意偶尔做一次items.splice(0, head)清理。
其实你甚至可以不用自定义封装,直接在函数体内手写 head 指针。我在实战环节给的 BFS 模板就属于后者。封装版本更适合你想在多个题目中复用、或者代码里需要频繁用到队列的场景。
3.3 双端队列:滑动窗口题目的利器
双端队列,Deque,两头都能进出。JS 原生没有这个结构,但算法题(特别是滑动窗口最大值、最小值这类)经常需要它。你用数组push+shift也能模拟,但shift的 O(n) 在前面已经被批判过了。所以更好的方案是基于数组 + 双指针实现一个简单的双端队列:
function createDeque(initialItems = []) { const items = initialItems; let head = 0; let tail = items.length - 1; return { pushBack(val) { items[++tail] = val; }, pushFront(val) { items[--head] = val; }, popBack() { if (tail < head) return undefined; return items[tail--]; }, popFront() { if (tail < head) return undefined; return items[head++]; }, front() { return items[head]; }, back() { return items[tail]; }, isEmpty() { return tail < head; }, get length() { return tail - head + 1; }, toArray() { return items.slice(head, tail + 1); }, }; }这段代码的精髓在于head和tail都是独立的指针,pushFront会让 head 左移,pushBack会让 tail 右移,两个方向都 O(1)。当你需要两边都能操作时,这个实现比用数组unshift/splice快得多。
实际使用中有一个细节:items的初始容量不好确定时,建议预留一点冗余空间,或者像上面这样在每次toArray()时才做一次 slice 的 O(n) 拷贝。滑动窗口题一般是"边移动边取 front / back",所以toArray用得少,性能反而是次要矛盾。
4. 实战对照:三道经典题看容器选型差异
4.1 两数之和:Map 与双重循环的直观对比
"两数之和"是每个刷题人都逃不过的第一题。最简单的暴力解是双重循环,O(n²)。这个复杂度在 n 比较大的时候会直接超时。用 Map 的做法:
function twoSum(nums, target) { const map = new Map(); for (let i = 0; i < nums.length; i++) { const complement = target - nums[i]; if (map.has(complement)) { return [map.get(complement), i]; } map.set(nums[i], i); } return []; }这里把"之前的元素值"作为键,"索引"作为值,一趟遍历完成。核心思想就是用一个哈希容器把 O(1) 查询能力利用起来。有人说"题目太简单,Map 谁不会用",但这里的坑在于:键是数字还是字符串,会影响哈希的碰撞率吗?对 JavaScript 而言,Map 的键会按照严格相等逻辑比较,数字 3 和字符串 "3" 是不同的键,不会互相覆盖。如果你图省事用了普通对象{},那3和"3"在键上就会混同,遇到nums = [3, "3"]这种边界就翻车了。
这道题想强调的点很简单:两数之和不仅是哈希表的入门题,更是告诉你"什么时候该选 Map 而不是数组或对象"的典型场景。数组做不了 O(1) 的"值 → 索引"查询,对象抵抗不了类型混淆,Map 正好两头兼顾。
4.2 岛屿数量:BFS 队列容量、头指针与 visited 的联动
再来一道更体现容器配合的题:"岛屿数量"。常规思路是遍历网格,遇到陆地就 BFS,把相邻的陆地全部标记为已访问,BFS 的次数就是岛屿数量。
BFS 需要用队列。很多 JS 版题解写出来就是这样的:
function numIslands(grid) { const rows = grid.length; const cols = grid[0].length; let count = 0; const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (grid[r][c] === '1') { count++; const queue = [[r, c]]; grid[r][c] = '0'; // 直接沉没,省掉 visited let head = 0; while (head < queue.length) { const [cr, cc] = queue[head++]; for (const [dr, dc] of dirs) { const nr = cr + dr; const nc = cc + dc; if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] === '1') { grid[nr][nc] = '0'; queue.push([nr, nc]); } } } } } } return count; }这里有一个很关键的容器选型意图:我把 visited 集合直接融合进了原网格,把"陆地"值改成 '0',等于用原数组本身充当了 visited。这比另外维护一个Set或二维 visited 数组要省内存、省哈希操作。很多人刷这道题会额外const visited = new Set(),然后每次visited.add(key)。其实没必要,原地标记是空间上更优的做法,常数因子也更小。
队列内部还是用head指针,因为 BFS 过程中会有大量入队出队,shift()会让时间直接爆炸。我自己实测过一个 200×200 的全陆地网格:用shift()的实现跑了约 1.6 秒,用 head 指针的实现稳定在 20 毫秒上下。这中间的差距不是算法思想的不同,纯粹是容器操作方式造成的。
4.3 LRU 缓存:Map 的有序性如何实现"最近最少使用"
LRU 是一个特别喜欢考察容器特性的题。经典要求是 get 和 put 都是 O(1),且能维护"最近使用"顺序。JavaScript 有没有现成数据结构能实现?有,就是Map。
原理:Map 的迭代顺序是"插入顺序"。每次 get 某个键时,我先删掉它,再重新 set 一次,这样它就会跑到迭代顺序的末尾。当容量超限时,取迭代顺序的第一个键,那就是"最久没被使用"的键,删掉它即可。
class LRUCache { constructor(capacity) { this.capacity = capacity; this.map = new Map(); } get(key) { if (!this.map.has(key)) return -1; const value = this.map.get(key); this.map.delete(key); this.map.set(key, value); return value; } put(key, value) { if (this.map.has(key)) { this.map.delete(key); } this.map.set(key, value); if (this.map.size > this.capacity) { const oldestKey = this.map.keys().next().value; this.map.delete(oldestKey); } } }你可能会想:"这不就是用 Map 作弊吗?" 但 LRU 面试考察的本质就是"你知不知道某个结构提供了有序哈希能力"。在 C++ 和 Java 里你可能要手写双向链表 + 哈希表,在 JS 里 Map 正好内置了你要的一切。这不是作弊,这是选型能力。同样的任务如果用普通对象{},你想维护顺序就得额外摆一个数组,每次访问都把 key 从数组里抠出来再推回去,splice的 O(n) 直接毁掉 O(1) 的承诺。
5. 常见问题与排查技巧实录
5.1 高频翻车现场:五个值得抄进笔记的坑
第一个坑:shift和unshift用得太自然。很多 JS 开发者因为平时写业务代码时元素量不大,体会不出性能差异,就养成了无脑 shift 的习惯。但算法题的数据量是恐怖的,10000 个元素的队列,shift 一次就要操作全部元素,10000 次就是 1 亿次移动,再好的 V8 也顶不住。以后你在数组前端操作前,先问自己一句:能不能用头部指针?能不能反转数组用尾部操作?
第二个坑:indexOf/includes在长数组上做存在性检查。这道题的复杂度是 O(n),且你很可能在一个循环里调用无数次,整体变成 O(n²)。正确姿势是建一个Set,变成 O(1)。特别多人在"判断两个数组交集"这种题里用includes套双层循环,两道题下来就 TLE 了。
第三个坑:用对象{}当 Map 还不检查原型链。前面说过"constructor"、"__proto__"这些键会引发诡异行为。如果不幸遇到一个测试用例,键名恰好是这些字符串,你的算法会直接返回错误结果。用Map从根上封杀这个问题。
第四个坑:Array的sort默认是字典序。[10, 9, 100].sort()得到[10, 100, 9],因为默认比较函数会把元素转成字符串再比。这在算法题里是经典陷阱。每次排序都要记得传(a, b) => a - b或适用于你类型的比较函数。别问,问就是踩过。
第五个坑:忽视size与length的区别。Map和Set用size,Array和String用length。写轮子代码时一旦搞混,不是报undefined就是静默失败。我见过有人if (map.length === 0)判断空 Map,结果永远进不了分支,排查了半天才发现问题。
5.2 我自己的容器选型速查模板
一年多刷题下来,我整理了一个自己的选型速查卡,分享给你直接抄:
| 场景 | 首选容器 | 备选方案 | 关键理由 |
|---|---|---|---|
| 去重 / 成员存在性检查 | Set | 数组 + includes(仅在元素量极小时) | has 是 O(1),includes 是 O(n) |
| 键值映射(键类型不确定) | Map | 对象(键必须为字符串) | Map 规避原型链问题,保留插入顺序 |
| 需要统计频次 | Map | 数组(如果键是连续整数) | get/set 都是 O(1),迭代按插入序 |
| 栈(后进先出) | Array | 自定义链表 | push/pop 天然 O(1) |
| 队列(先进先出) | Array + head 指针 | 双端队列实现 | 避免 shift 的 O(n) 搬移 |
| 双端操作 | 自定义 Deque | Array + push/pop + 头指针 | 兼顾两头 O(1) |
| 有序哈希 / LRU | Map | 双向链表 + Map | Map 迭代序保持插入序 |
| 只存连续数字索引 | Array | Map | 数组索引 O(1),内存更紧凑 |
这个表不是死的,比如"队列"你也可以用两个数组模拟栈来达到均摊 O(1),但 head 指针方案实现起来最直白,出 bug 的概率也最小。如果你只想记住一条核心原则,那就记住:操作越频繁,越要选对应操作复杂度最低的容器。
5.3 最后提醒一个容易被忽略的细节:单测里的 n 有多大
我在帮人 review 代码时经常看到一种情况:代码逻辑没错,容器用得也算合理,但一提交就 TLE。追问下去才发现,他对着一个 n = 10^5 的题,在循环里做了一个 O(n) 的Array.prototype.slice。你说这题难吗?不难。但常量因子积累起来就是要命。
所以每道题动手前,先估算一下数据规模,从 n 大致推断出可接受的复杂度上界:n 是 100 级别,O(n²) 还能忍;n 是 10^5 级别,O(n²) 基本必挂;n 是 10^6 级别,连 O(n log n) 的常数都要谨慎。确定了复杂度上界后,再回到容器选型:如果要求 O(n) 的遍历里附带 O(1) 的查找,那就果断哈希;如果遍历一次还要维护最近顺序,就要考虑 Map 或自定义双端结构。这个"先算复杂度、再选容器"的顺序,是我觉得比记住任何模板都更重要的一环。
另外刷题实测下来,同样的逻辑,不同浏览器/Node 版本的 V8 引擎对Map和普通对象的常数性能有时会有细微差异。生产环境写业务代码可以用对象追求更低的序列化成本,但刷题场景统一用 Map 和 Set 是最省心的,因为你不需要赌运行环境,也不需要担心原型链带来的低级错误。
这是我整理 JS 容器选型时最核心的心得。个人经验是,与其背一堆 API,不如亲手把几道经典题用不同容器各写一遍,对比耗时差异,体感建立起来之后,后面碰到新题自然知道该往哪个方向下手。