前言
在力扣(LeetCode)高频算法题中,第20题《有效的括号》 是考察栈(Stack)这一基础数据结构的经典代表。题目要求判断一个只包含 '('、')'、'{'、'}'、'['、']' 的字符串是否有效——即所有左括号都有对应且顺序正确的右括号闭合。
看似简单,却深刻体现了 “栈的先进后出(LIFO)特性” 在匹配、回溯类问题中的强大威力。本文将从栈的本质出发,系统讲解其核心属性与方法(包括 push、pop、peek 等),对比数组与链表两种实现方式的优劣,并结合 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()提供只读访问,既保证安全又提升接口清晰度。”
四、大厂面试题:栈的核心考点
- 如何用两个栈实现队列?
→ 利用 LIFO 两次反转得到 FIFO - 如何获取栈中最小值(O(1))?
→ 辅助栈同步记录当前最小值 - 浏览器历史记录如何用栈实现?
→forward/back对应两个栈的协同 - 递归本质是什么?
→ 系统调用栈的自动管理
五、实战:力扣 20. 有效的括号
题目描述
给定一个只包括 '('、')'、'{'、'}'、'['、']' 的字符串 s,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
示例:
js
编辑
输入: s = "()[]{}"
输出: true
输入: s = "([)]"
输出: false
解题思路
-
剪枝优化:若字符串长度为奇数,直接返回
false -
建立映射:用
Map存储右括号 → 左括号的对应关系 -
遍历字符:
-
遇到左括号(
(、{、[)→ 入栈 -
遇到右括号 → 检查栈顶是否匹配
- 匹配:出栈
- 不匹配或栈空:返回
false
-
-
最终检查:栈为空则全部匹配成功
代码实现
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,就是其中之一。”