从零到精通:彻底搞懂栈(Stack)—— 数组 vs 链表 vs 括号匹配全解析
一、栈到底是个啥?—— 薯片桶定律
请永远记住这张图:
┌─────┐ ← 只能从这里拿和放 → 栈顶(top)
│ 4 │ ← 最后放的,最先吃
│ 3 │
│ 2 │
│ 1 │ ← 最先放的,最后吃
└─────┘
这就是栈!
后进先出(LIFO),就像:
- 浏览器后退按钮
- Ctrl + Z 撤销
- 食堂叠盘子
- 枪的弹夹
记住:全世界所有栈的本质都是这个薯片桶!
二、数组实现栈:天生自带 push/pop 的神器
const stack = [];
// 入栈 = 往桶里塞薯片
stack.push(1); // [1]
stack.push(2); // [1,2] ← 2 在栈顶
stack.push(3); // [1,2,3] ← 3 是栈顶
// 出栈 = 吃最上面那片
stack.pop(); // 返回 3,剩下 [1,2]
// 看一眼最上面的(不吃)
stack[stack.length - 1] // 2
优点:快!快到飞起!
缺点:偶尔扩容 O(n),但均摊 O(1)
封装成 class 更优雅
class ArrayStack {
#stack = []; // 私有,外面动不了
get size() { // 关键!只读属性
return this.#stack.length;
}
push(val) {
this.#stack.push(val);
}
pop() {
if (!this.size) throw new Error("栈空了!");
return this.#stack.pop();
}
peek() {
return this.#stack[this.size - 1];
}
toArray() {
return [...this.#stack]; // 防御性拷贝!绝不泄露引用
}
}
重要:toArray 必须浅拷贝!
否则:
const arr = stack.toArray();
arr.pop(); // 外面一改,栈就坏了!
三、链表实现栈:永不扩容的钢铁战士
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
class LinkedListStack {
#head = null; // 栈顶
#size = 0;
push(val) {
const node = new ListNode(val);
node.next = this.#head;
this.#head = node;
this.#size++;
}
pop() {
if (!this.#head) throw new Error("空");
const val = this.#head.val;
this.#head = this.#head.next;
this.#size--;
return val;
}
get size() { return this.#size; }
}
链表栈的 push/pop 过程
push(3) 前: head → [2] → [1] → null
push(3) 后: head → [3] → [2] → [1] → null
pop() : head → [2] → [1] → null
每一步都是 O(1),永不扩容!
四、灵魂拷问:get 属性到底是啥?
get size() {
return this.#stack.length;
}
这不是普通属性!它是一个方法!
但可以像属性一样用:
console.log(stack.size); // 没括号!但实际调用了 get size()
get 的真实执行流程
用户写:stack.size
↓
JS 引擎:哦你要读 size 属性?
↓
发现有 get size() 方法
↓
执行函数体 return this.#stack.length
↓
把结果返回给用户
好处:
- 外面只能读,不能改(封装)
- 可以加日志、懒计算
- 比 stack.getSize() 更优雅
五、括号匹配:栈的巅峰之战
题目:判断字符串括号是否合法
const isValid = (s) => {
if (s.length % 2 === 1) return false;
const map = new Map([
['(', ')'],
['[', ']'],
['{', '}']
]);
const stack = [];
for (let ch of s) {
if (map.has(ch)) {
// 左括号:我不管你,我只关心我要等谁
stack.push(map.get(ch));
} else {
// 右括号:你是不是我等的那个?
if (!stack.length || stack.pop() !== ch) return false;
}
}
return stack.length === 0;
};
核心思想——「不存左括号,存期待的右括号」
遇到 ( → push ')' → 栈里存的是“我要等 )”
遇到 [ → push ']' → 栈里存的是“我要等 ]"
执行过程 "({[]})"
| 字符 | 操作 | 栈内容(从底到顶) |
|---|---|---|
| ( | push ')' | [')'] |
| { | push '}' | [')','}'] |
| [ | push ']' | [')','}','}'] |
| ] | pop ']' === ']' | [')','}'] |
| } | pop '}' === '}' | [')'] |
| ) | pop ')' === ')' | [] |
错误案例 "([)]" 被精准击杀
遇到 ) 时,栈顶是 ']' != ')' → 直接 return false
只用一个 if 就能 pop :
我们提前把「期待的右括号」压入了栈,
当右括号来了,直接跟栈顶对身份,
对得上就 pop(自然消掉一对),对不上就返回false!
六、终极对比表
| 项目 | 数组栈 | 链表栈 | 推荐场景 | |
|---|---|---|---|---|
| push/pop | 均摊 O(1) | 稳定 O(1) | 数组更快 | |
| 内存 | 连续,缓存友好 | 离散,指针开销 | 数组更省 | |
| 扩容 | 有(偶尔 O(n)) | 无 | 链表更稳定 | |
| 实现难度 | ★☆☆☆☆ | ★★★☆☆ | 数组更简单 | |
| 推荐指数 | 5 星(生产环境) | 4 星(面试手撕) |
总结
栈不是数据结构,它是一种思想。
当你看到:
- 函数调用(调用栈)
- 表达式求值(逆波兰)
- 浏览器历史
- 迷宫 DFS
- 括号匹配
- 撤销操作
他们全都是栈