用栈解决「有效的括号」问题:从数据结构原理到大厂面试实战

89 阅读5分钟

前言

在力扣(LeetCode)高频算法题中,第20题《有效的括号》 是考察栈(Stack)这一基础数据结构的经典代表。题目要求判断一个只包含 '('')''{''}''['']' 的字符串是否有效——即所有左括号都有对应且顺序正确的右括号闭合。

看似简单,却深刻体现了 “栈的先进后出(LIFO)特性” 在匹配、回溯类问题中的强大威力。本文将从栈的本质出发,系统讲解其核心属性与方法(包括 pushpoppeek 等),对比数组与链表两种实现方式的优劣,并结合 ES6 class、私有字段 #get/set 访问器等现代 JavaScript 特性,手把手构建一个健壮的栈类。最后,我们将用这个栈工具,优雅地解决《有效的括号》问题,并延伸至大厂面试常考的变体与陷阱。

无论你是准备算法面试,还是希望夯实数据结构基础,本文都将为你提供一条清晰、完整的认知路径。


一、栈(Stack):先进后出的线性结构

栈是一种受限的线性表,只允许在一端(称为“栈顶”)进行插入和删除操作。其核心原则是:

Last In, First Out(LIFO)——后进先出

核心操作(ADT 接口)

方法作用时间复杂度
push(val)元素入栈(添加到栈顶)O(1) 平均
pop()元素出栈(移除并返回栈顶)O(1)
peek() / top()查看栈顶元素(不移除)O(1)
isEmpty()判断栈是否为空O(1)
size获取栈中元素个数O(1)

💡 关键特性

  • 只能操作栈顶,无法访问中间或底部元素
  • 天然适合处理“成对出现、顺序相反”的场景(如括号匹配、函数调用、浏览器回退)

二、JavaScript 中栈的两种实现方式

方案1:基于数组(推荐,简洁高效)

js
编辑
class ArrayStack {
    #stack = []; // 私有字段,封装实现细节

    get size() { // 使用 get 访问器,像属性一样读取
        return this.#stack.length;
    }

    isEmpty() {
        return this.size === 0;
    }

    push(val) {
        this.#stack.push(val);
    }

    pop() {
        if (this.isEmpty()) throw new Error('栈为空');
        return this.#stack.pop();
    }

    peek() {
        if (this.isEmpty()) throw new Error('栈为空');
        return this.#stack[this.size - 1];
    }
}

✅ 优点:

  • 利用 JS 数组原生 push/pop,代码简洁
  • 内存连续,缓存友好,平均性能极佳
  • 扩容虽为 O(n),但摊还后仍为 O(1)

❌ 缺点:

  • 预分配空间可能造成轻微内存浪费

方案2:基于链表(灵活但稍重)

js
编辑
class ListNode {
    constructor(val, next = null) {
        this.val = val;
        this.next = next;
    }
}

class LinkedListStack {
    #head = null; // 栈顶指针
    #size = 0;

    get size() { return this.#size; }

    push(val) {
        this.#head = new ListNode(val, this.#head);
        this.#size++;
    }

    pop() {
        if (!this.#head) throw new Error('栈为空');
        const val = this.#head.val;
        this.#head = this.#head.next;
        this.#size--;
        return val;
    }

    peek() {
        return this.#head?.val;
    }
}

✅ 优点:

  • 动态分配,无扩容开销,时间复杂度稳定 O(1)
  • 内存按需使用,无浪费

❌ 缺点:

  • 节点需额外存储指针,空间开销更大
  • 内存离散,缓存命中率低

📌 工程建议:除非明确需要避免扩容抖动,否则优先使用数组实现


三、ES6 关键特性解析:封装与访问控制

1. # 私有字段

  • 以 # 开头的属性/方法只能在类内部访问
  • 外部无法读取或修改,彻底实现封装
  • 示例:#stack#size 防止外部意外篡改

2. get / set 访问器

  • 将方法伪装成属性,提升 API 语义性

  • 可加入校验、日志等逻辑

  • 示例:

    js
    编辑
    get size() { return this.#stack.length; } // 读取
    set size(v) { /* 可加验证 */ }           // 设置(本例未使用)
    
  • 调用时无需括号:stack.size 而非 stack.size()

💡 面试加分点
“我使用 # 私有字段隐藏内部状态,通过 get size() 提供只读访问,既保证安全又提升接口清晰度。”


四、大厂面试题:栈的核心考点

  1. 如何用两个栈实现队列?
    → 利用 LIFO 两次反转得到 FIFO
  2. 如何获取栈中最小值(O(1))?
    → 辅助栈同步记录当前最小值
  3. 浏览器历史记录如何用栈实现?
    forward/back 对应两个栈的协同
  4. 递归本质是什么?
    → 系统调用栈的自动管理

五、实战:力扣 20. 有效的括号

题目描述

给定一个只包括 '('')''{''}''['']' 的字符串 s,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。
  3. 每个右括号都有一个对应的相同类型的左括号。

示例

js
编辑
输入: s = "()[]{}"
输出: true

输入: s = "([)]"
输出: false

解题思路

  1. 剪枝优化:若字符串长度为奇数,直接返回 false

  2. 建立映射:用 Map 存储右括号 → 左括号的对应关系

  3. 遍历字符

    • 遇到左括号(({[)→ 入栈

    • 遇到右括号 → 检查栈顶是否匹配

      • 匹配:出栈
      • 不匹配或栈空:返回 false
  4. 最终检查:栈为空则全部匹配成功

代码实现

js
编辑
function isValid(s) {
    // 剪枝:奇数长度必无效
    if (s.length % 2 !== 0) return false;
    
    // 右括号到左括号的映射
    const pairs = new Map([
        [')', '('],
        [']', '['],
        ['}', '{']
    ]);
    
    const stack = []; // 用数组模拟栈
    
    for (let char of s) {
        if (pairs.has(char)) {
            // 当前是右括号
            if (stack.length === 0 || stack.pop() !== pairs.get(char)) {
                return false;
            }
        } else {
            // 当前是左括号,入栈
            stack.push(char);
        }
    }
    
    // 栈空表示全部匹配
    return stack.length === 0;
}

关键点解析

  • 为什么用栈?
    括号匹配具有“最近相关性”——最后一个左括号必须最先匹配,完美契合 LIFO。
  • Map 的优势
    比 if-else 或对象字面量更语义化,且支持任意字符作为键。
  • 边界处理
    stack.pop() 在栈空时返回 undefined,与任何左括号都不等,自然返回 false

结语

栈虽简单,却是理解程序运行机制(如调用栈)、解决匹配问题的基石。通过《有效的括号》一题,我们不仅掌握了算法技巧,更深入理解了数据结构的设计哲学:用最合适的工具,解决特定模式的问题

在实际开发中,善用 ES6 的 class# 私有字段、get/set 等特性,能让你的数据结构实现既安全又优雅。而这些细节,正是大厂面试官考察工程素养的关键所在。

记住
“所有复杂的系统,都由简单的规则构建。栈的 LIFO,就是其中之一。”