从零到精通:彻底搞懂栈(Stack)—— 数组 vs 链表 vs 括号匹配全解析

87 阅读3分钟

从零到精通:彻底搞懂栈(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
  • 括号匹配
  • 撤销操作

他们全都是栈