「最后压上去的那张纸,永远最先被抽走。」——除非你从底下硬撕,那叫 undefined behavior,别学。
本篇大纲
1. 栈是啥(定义)
- 抽象:栈(stack)是一种只能在一端进出的线性结构;这一端常叫栈顶。
- 规矩:后进先出,英文 LIFO(Last In, First Out)——像叠盘子:最后放上去的最先拿。
- 基本操作:入栈 push、出栈 pop;常再配合 peek / top(只看栈顶不出栈)、判空。
- 复杂度直觉:在链表或动态数组实现的栈上,push / pop / peek 通常都是 O(1)——只动栈顶,不搬全队。
2. 和数组啥关系(在 JS 里怎么玩)
- 教材图:栈是抽象数据类型;数组是实现方式之一。
- JavaScript:用
[]+push+pop就能模拟栈——尾部当栈顶(别用shift当「出栈」,那是队列味,还 O(n) 肉疼)。 - 手写最小接口:用
class+ 实例字段(如this._items)包一层也行,核心是约定哪一端是顶,全队别乱摸。
3. 栈在题里怎么出现(套路)
- 括号匹配、HTML 标签配对(思想同源):遇到左符号入栈,右符号跟栈顶对账。
- 路径简化(
..、.、/):目录进栈出栈,像文件管理器里「返回上一级」。 - 单调栈、DFS 非递归版:后面章节会再见面;本篇先把 LIFO 刻进肌肉记忆。
4. 在前端脑子里栈长啥样(用在哪)
- 浏览器历史、路由回退:新页面压栈,点「后退」弹栈——本篇用纯数组模拟,不写任何框架路由 API。
- 撤销(Undo):每次操作记一条,撤销时从栈顶撤回——和「后退」是同一套时间倒流叙事。
- 函数调用栈:报错时控制台里那一串 at xxx,就是栈;懂栈就懂「为啥递归太深会爆」。
形象化:叠盘子、后退键、调用栈
- 叠盘子:你只能从最上面拿;中间硬抽会塌——栈也是:只认栈顶。
- 后退:你依次进了 A → B → C,点后退是 C → B → A,不是「随机跳到某年某月」——最近的路最先撤销,正是 LIFO。
- 函数调用:
a调b,b调c;c跑完先返回b,再返回a——后进入的先结束,还是栈。
人话:「最后发生的事,最先被抹掉。」 记 O(1) 的 push/pop,比背定义好使。
栈操作速查(数组模拟)
脱离场景死背没意义,但写业务、刷题时,这几个名字要对得上号:
| 操作 | 数组模拟(栈顶在尾部) | 复杂度直觉 | 注意 |
|---|---|---|---|
| 入栈 | stack.push(x) | O(1) 均摊 | 约定好哪一端是顶,别两头混用 |
| 出栈 | stack.pop() | O(1) | 空栈再 pop 得 undefined,要判空 |
| 看栈顶 | stack[stack.length - 1] | O(1) | 只看不弹 |
| 判空 | stack.length === 0 | O(1) | — |
坏味道:用 shift / unshift 当栈——相当于从队头当栈顶,数组要整体挪,O(n),列表一长就哭。
坏 vs 好(同一语义:连续弹三次「栈顶」):
// 坏:把「栈顶」放在数组头部 —— 每次 shift 全表前移,O(n)
let bad = [1, 2, 3];
bad.shift();
bad.shift();
bad.shift();
// 好:栈顶在尾部 —— pop 只动末尾,O(1)
let good = [1, 2, 3];
good.pop();
good.pop();
good.pop();
有复杂度直觉才会拒绝「图省事把栈顶放前面」;面试里这也是常问的实现细节。
下面给一个小封装,后面例题直接复用(实例字段 _items 存栈底到栈顶,栈顶在数组尾部;下划线表示「按约定别从外面直接改」):
class Stack {
constructor() {
this._items = [];
}
push(x) {
this._items.push(x);
}
pop() {
return this._items.pop();
}
peek() {
const a = this._items;
return a.length ? a[a.length - 1] : undefined;
}
get size() {
return this._items.length;
}
isEmpty() {
return this._items.length === 0;
}
}
// 使用示例(可整段贴进控制台跑)
const s = new Stack();
console.log(s.isEmpty()); // true
s.push('A');
s.push('B');
console.log(s.peek(), s.size); // 'B', 2
console.log(s.pop()); // 'B'
console.log(s.peek()); // 'A'
console.log(s.pop()); // 'A'
console.log(s.isEmpty()); // true
console.log(s.pop()); // undefined(空栈再 pop)
小算法 1:括号是否匹配
题意:字符串里只有 '(' ')' '[' ']' '{' '}',判断是否合法配对且顺序正确。
思路:左括号一律入栈;遇到右括号,若栈空或栈顶类型不对,当场 false;否则弹栈一对消掉。扫完栈必须空。
function isValidParentheses(s) {
const stack = [];
const pair = { ')': '(', ']': '[', '}': '{' };
for (const ch of s) {
if ('([{'.includes(ch)) {
stack.push(ch);
} else {
if (stack.length === 0 || stack[stack.length - 1] !== pair[ch]) {
return false;
}
stack.pop();
}
}
return stack.length === 0;
}
console.log(isValidParentheses('()[]{}')); // true
console.log(isValidParentheses('([)]')); // false
时间 O(n),额外空间 O(n)(栈最深不超过字符串长度)。这类题练的是:最近打开的,必须最先关上——和你在 IDE 里删括号是一个道理。
小算法 2:Unix 风格路径简化
给定形如 "/a/./b/../c" 的路径,输出规范路径。. 忽略,.. 弹掉上一级,多余斜杠合并。
function simplifyPath(path) {
const parts = path.split('/').filter(Boolean);
const stack = [];
for (const p of parts) {
if (p === '.' || p === '') continue;
if (p === '..') {
if (stack.length > 0) stack.pop(); // 已在根目录则不能再往「上」退
} else stack.push(p);
}
return '/' + stack.join('/');
}
console.log(simplifyPath('/a/./b/../c')); // "/a/c"
console.log(simplifyPath('/../')); // "/"
时间 O(n)(n 为分段数),栈里最多 O(n) 段。直觉:走进目录就 push,遇到 .. 就 pop——又是「最后走进去的,最先被退回」。
单页里的「后退」:用栈记住你来时的路
真实框架路由会做更多事(参数、滚动恢复、内存上限等),这里只抽算法骨架:每导航到一个新「屏」就把标识压栈,点后退就弹栈。
下面用纯 JavaScript 模拟「当前路径」与历史栈(无任何框架 API):
function createHistoryNavigator() {
const stack = [];
let current = '/';
return {
get path() {
return current;
},
/** 进入新路径:压入「从哪来」,再更新当前 */
push(next) {
stack.push(current);
current = next;
},
/** 后退:有历史则回到上一段 */
back() {
if (stack.length === 0) return current;
current = stack.pop();
return current;
},
canGoBack() {
return stack.length > 0;
},
};
}
const nav = createHistoryNavigator();
nav.push('/list');
nav.push('/detail/42');
console.log(nav.path); // '/detail/42'
console.log(nav.back()); // '/list'
console.log(nav.back()); // '/'
console.log(nav.canGoBack()); // false
要点:前进是 push 旧状态,后退是 pop——和浏览器「历史记录栈」的直觉一致。撤销编辑器操作同理:每次改动的逆操作(或快照指针)入栈,撤销时弹顶执行逆操作。你若把「栈顶」搞反了,用户点后退却前进,那就是产品事故了——LIFO 不是摆设。
小练习
- 说说为什么用
push/pop(尾部) 模拟栈,通常比unshift/shift(头部) 更划算? - 把
isValidParentheses改成只含小括号'(' ')'** 的版本**,手写一遍循环,体会「只关心栈顶」。 - (思考题)若历史栈无限增长,内存会炸——真实产品里常见 限制栈深度 或 换双端队列;你能想到一种「超过 N 条就丢掉最老的」的规则吗?
面试一句
「栈是 LIFO:数组用 push/pop 模拟栈顶 O(1);括号匹配、路径简化、路由后退和撤销都是『最近的操作最先撤回』。」
下篇预告
第 5 篇|队列:先排队的先办事——FIFO 与栈正好相反;任务排队、打印队列、并发限流都会用到。仍坚持 纯 JS。