栈和队列的差别只有一条出队规则,但这条规则决定了调用栈会溢出、事件循环分微宏任务、撤销重做必须倒着来。
翻浏览器的前进后退、编辑器里连按 Ctrl+Z、Promise 回调比 setTimeout 先执行,背后是同一个判断:这个东西该后进先出,还是先进先出。选错一条出队规则,一万条数据就能把页面卡住。
| 维度 | 栈(LIFO) | 队列(FIFO) |
|---|---|---|
| 出队位置 | 同一端进出,栈顶 | 一端进、另一端出 |
| 语义 | 最近的优先处理 | 最早的优先处理 |
| 典型实现 | 数组 push / pop | 数组 + 头指针、链表 |
| 真实用途 | 调用栈、撤销重做、括号匹配、DFS | 任务队列、BFS、消息队列、缓冲区 |
| 前端对应 | 函数调用、路由历史 | 宏任务队列、微任务队列、事件派发 |
栈是数组最贴合的场景,push 和 pop 都在尾部,均摊 O(1):
class Stack {
constructor() { this.items = []; }
push(x) { this.items.push(x); }
pop() { return this.items.pop(); }
peek() { return this.items[this.items.length - 1]; }
get size() { return this.items.length; }
}
用 push 入队、shift 出队写起来最顺手,但 shift 要删掉下标 0 并让后面所有元素前移一位,是实打实的 O(n)。循环里对一万条数据做 shift,就是五千万次搬移:
const q = [];
for (let i = 0; i < 10000; i++) q.push(i);
while (q.length) q.shift(); // 每次 O(n),整体 O(n²)
正确做法是用头指针记录出队位置,或者用 Map 以自增下标为键。头指针版不搬移,但数组本身不会收缩,需要定期截断:
class Queue {
constructor() { this.arr = []; this.head = 0; }
enqueue(v) { this.arr.push(v); }
dequeue() {
if (this.head === this.arr.length) return undefined;
const v = this.arr[this.head++];
// 头指针越过一半且长度可观时压缩一次,避免数组无限增长
if (this.head > 1024 && this.head * 2 > this.arr.length) {
this.arr = this.arr.slice(this.head);
this.head = 0;
}
return v;
}
get size() { return this.arr.length - this.head; }
}
Map 版把下标当键,delete(this.head++) 不影响其它元素,代价是每次操作多一次哈希查找。
JS 引擎为每次函数调用压一个栈帧(局部变量、返回地址),返回时弹出。递归太深,栈帧堆到上限就抛 RangeError:
function f(n) {
return f(n + 1);
}
f(0); // RangeError: Maximum call stack size exceeded
V8 默认栈约 1MB,普通递归深度上限一万层以内。改成显式栈就能绕开限制,这也是树遍历优先写迭代版的原因。
浏览器把待执行的回调分成两个队列:
| 队列 | 存放 | 执行时机 |
|---|---|---|
| 微任务队列(microtask) | Promise 回调、queueMicrotask、MutationObserver | 当前宏任务结束后立刻清空 |
| 宏任务队列(macrotask) | setTimeout、setInterval、I/O、UI 事件 | 每轮事件循环取一个 |
微任务队列清空之前不会让出主线程,在微任务里无限 then 会饿死渲染。
优先队列按优先级出队,不是先进先出,标准实现是二叉堆(见 树与堆),入队出队都是 O(log n)。任务调度器、Top K、Dijkstra 都用它。
很多人以为 shift 只是"取第一个",和 pop 一样快。 实际上 pop 是 O(1),shift 是 O(n),它要维护数组的连续性;在循环里用它做 BFS 队列,复杂度会从 O(n) 掉到 O(n²)。
很多人以为"栈和队列是两种完全不同的结构"。 实际上它们只差一个出口方向,用两个栈可以拼出队列,用一个栈加递归也能模拟队列;判断该用哪个,看的是"谁应该先被处理"。
思路:遇到左括号压栈,遇到右括号就看栈顶是不是配对的那一个,最后栈必须为空。
function isValid(s) {
const pair = { ')': '(', ']': '[', '}': '{' };
const stack = [];
for (const ch of s) {
if (!pair[ch]) {
stack.push(ch);
} else if (stack.pop() !== pair[ch]) {
return false;
}
}
return stack.length === 0;
}
时间 O(n),空间 O(n)。易错点:只有右括号时 pop() 返回 undefined,比较自然失败;结尾必须检查栈空,否则 "(" 会被判成合法。
思路:辅助栈同步记录"压入当前元素后的最小值",取最小值 O(1),代价是多一份空间。
class MinStack {
constructor() { this.stack = []; this.min = []; }
push(x) {
this.stack.push(x);
const cur = this.min.length ? this.min[this.min.length - 1] : Infinity;
this.min.push(Math.min(x, cur));
}
pop() { this.min.pop(); return this.stack.pop(); }
top() { return this.stack[this.stack.length - 1]; }
getMin() { return this.min[this.min.length - 1]; }
}
各操作 O(1),空间 O(n)。易错点:pop 时两个栈必须同步弹,否则最小值会错位。
思路:in 栈负责入队,out 栈负责出队;out 空了才把 in 整体倒过去,每个元素最多倒一次,均摊 O(1)。
class MyQueue {
constructor() { this.in = []; this.out = []; }
push(x) { this.in.push(x); }
peek() {
if (!this.out.length) while (this.in.length) this.out.push(this.in.pop());
return this.out[this.out.length - 1];
}
pop() { const v = this.peek(); this.out.pop(); return v; }
empty() { return !this.in.length && !this.out.length; }
}
均摊时间 O(1),最坏单次 O(n),空间 O(n)。易错点:out 非空时不能往里倒,否则顺序会乱。
思路:单调递减队列存下标,队头永远是当前窗口最大值;新元素入队前把比它小的全部弹掉。
function maxSlidingWindow(nums, k) {
const dq = []; // 存下标,对应值单调递减
const res = [];
for (let i = 0; i < nums.length; i++) {
while (dq.length && dq[0] <= i - k) dq.shift(); // 移出窗口
while (dq.length && nums[dq[dq.length - 1]] <= nums[i]) dq.pop();
dq.push(i);
if (i >= k - 1) res.push(nums[dq[0]]);
}
return res;
}
时间 O(n)(每个下标最多进出队一次),空间 O(k)。易错点:队列里存值而不是下标就没法判断过期;窗口未满时不能往结果里写。