一、什么是栈?🤔
栈 (Stack)是一种经典的线性数据结构 ,其核心特性是 "先进后出" (Last In First Out, LIFO)。
你可以把它想象成一摞盘子🍽️:每次只能从顶部放入或取出盘子,最晚放进去的盘子最先被拿出来。
在计算机科学中,栈广泛应用于以下场景:
- 🔁 函数调用(调用栈)
- 🧮 表达式求值与转换(如中缀转后缀)
- ✅ 括号匹配验证
- 🖥️ 浏览器前进/后退历史
- 🔄 撤销(Undo)操作
💡 栈的核心思想:只操作一端(栈顶),另一端封闭。
二、栈的抽象数据类型(ADT)🧩
一个标准的栈应具备以下属性和方法:
| 方法 / 属性 | 说明 |
|---|---|
push(item) |
➕ 入栈:将元素压入栈顶 |
pop() |
➖ 出栈:移除并返回栈顶元素 |
peek() / top() |
👀 查看栈顶元素但不移除 |
isEmpty() |
❓ 判断栈是否为空 |
size |
🔢 获取栈中元素数量 |
toArray()(可选) |
📤 将栈内容转为数组(用于调试或展示) |
⚠️ 注意 :栈不允许随机访问中间元素,只能操作栈顶!
三、ES6 Class 与栈的封装 🛠️
ES6 引入了 class 语法,使面向对象编程更清晰。结合私有字段(#)、get/set 访问器等新特性,我们可以优雅地实现栈。
下面深入解析这些关键特性👇:
1️⃣ class:定义类的模板 📐
在 ES6 之前,JavaScript 通过构造函数 + 原型链模拟类:
javascript
// 🕰️ ES5 风格
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log('Hello, ' + this.name);
};
ES6 的 class 是对上述模式的语法糖,但结构更清晰、更接近传统 OOP:
javascript
// ✨ ES6 class
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
console.log('Hello, ' + this.name);
}
}
✅ 关键点:
- 底层仍基于 原型(prototype)
- 提供声明式、结构化的代码组织方式
- 显著提升可读性与可维护性,尤其适合大型项目
2️⃣ #privateField:私有属性 🔒
传统 JS 中所有属性都是公开的,容易被外部篡改:
ini
class Counter {
constructor() {
this.count = 0; // 😱 外部可随意修改!
}
}
const c = new Counter();
c.count = 999; // 破坏封装!
ES2022 引入 私有字段 (以 # 开头),仅限类内部访问:
javascript
class Counter {
#count = 0; // 🔒 私有属性
increment() { this.#count++; }
getCount() { return this.#count; } // ✅ 安全暴露
}
const c = new Counter();
c.increment();
console.log(c.getCount()); // 1
// c.#count; // ❌ SyntaxError!
✅ 优势:
- 封装性:隐藏实现细节
- 安全性:防止外部误操作
- 可维护性:内部逻辑变更不影响外部调用
⚠️ 私有字段必须显式声明,不能动态添加。
3️⃣ constructor():初始化实例 🧬
constructor 是类的构造函数,在 new 实例时自动调用:
kotlin
class Stack {
#items;
constructor(initialItems = []) {
this.#items = [...initialItems]; // 初始化私有数组
}
}
✅ 作用:
- 初始化实例属性(包括私有属性)
- 接收参数设置初始状态
- 若未定义,JS 会提供空默认构造函数
🔁 注意 :每个类最多只能有一个 constructor。
4️⃣ get size():只读属性访问器 📏
有时我们希望暴露某个值,但禁止修改 。这时可用 get 定义访问器:
arduino
class ArrayStack {
#stack = [];
get size() {
return this.#stack.length; // 📊 像读属性一样使用
}
}
const stack = new ArrayStack();
console.log(stack.size); // 0
// stack.size = 10; // ❌ 无效(严格模式报错)
✅ 好处:
- 语义清晰:
size看似属性,实为计算值 - 可加入校验、日志、缓存等逻辑
- 实现只读接口,避免误写
💡 同理,set 可拦截赋值:
javascript
set maxSize(value) {
if (value < 0) throw new Error('maxSize 不能为负');
this._maxSize = value;
}
5️⃣ 方法共享于原型链,节省内存 🧠
这是 class 最重要的性能优势!
所有实例方法(非静态、非箭头函数)都定义在类的原型上:
javascript
class Stack {
push() { /* ... */ }
pop() { /* ... */ }
}
const s1 = new Stack();
const s2 = new Stack();
console.log(s1.push === s2.push); // ✅ true!
✅ 这意味着:
- 方法只在内存中存在一份
- 所有实例通过原型链共享方法
- 极大节省内存,尤其适合创建大量对象(如游戏实体、UI 组件)
❌ 对比反模式(ES5 常见陷阱):
javascript
function BadStack() {
this.push = function() { /* 每次 new 都新建函数!*/ };
}
📌 建议 :现代项目优先使用 ES6+
class,善用私有字段与访问器,构建高内聚、低耦合的组件。
四、两种实现方式:数组 vs 链表 ⚖️
栈可以用数组 或链表实现,各有优劣:
1️⃣ 基于数组的栈(ArrayStack)📦
kotlin
class ArrayStack {
#stack = [];
get size() { return this.#stack.length; }
isEmpty() { return this.size === 0; }
push(num) { this.#stack.push(num); }
pop() {
if (this.isEmpty()) throw new Error('栈为空');
return this.#stack.pop();
}
peek() {
if (this.isEmpty()) throw new Error('栈为空');
return this.#stack[this.size - 1];
}
toArray() { return [...this.#stack]; }
}
✅ 优点:
- 时间效率高 :
push/pop在尾部操作,平均 O(1) - 内存连续,缓存友好(CPU 更快访问)
- 代码简洁,JS 数组原生支持
❌ 缺点:
- 扩容成本高 :容量不足时需复制所有元素 → O(n)
- 可能存在空间浪费(预分配未用完)
💡 实际中,扩容是低频事件 ,均摊时间复杂度仍为 O(1) 。
2️⃣ 基于链表的栈(LinkedListStack)⛓️
kotlin
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
class LinkedListStack {
#stackPeek = null;
#size = 0;
get size() { return this.#size; }
isEmpty() { return this.size === 0; }
push(num) {
const node = new ListNode(num);
node.next = this.#stackPeek;
this.#stackPeek = node;
this.#size++;
}
peek() {
if (!this.#stackPeek) throw new Error('栈为空');
return this.#stackPeek.val;
}
pop() {
const num = this.peek();
this.#stackPeek = this.#stackPeek.next;
this.#size--;
return num;
}
toArray() {
const arr = new Array(this.size);
let node = this.#stackPeek;
let i = this.size - 1;
while (node) {
arr[i--] = node.val;
node = node.next;
}
return arr;
}
}
✅ 优点:
- 动态扩容 :每次插入只需 O(1) ,无复制开销
- 空间按需分配,无浪费
❌ 缺点:
- 每个节点需额外存储
next指针 → 内存开销更大 - 节点在内存中离散分布 → 缓存局部性差
- 实例化
ListNode有一定性能损耗
✅ 总结:
- 🚀 日常开发、轻量场景 → 数组实现
- 🏗️ 大数据、稳定性要求高 → 链表实现
五、实战应用:有效的括号匹配 ✅
栈的经典应用场景之一!
📌 问题描述:
给定字符串 s,仅含 '(', ')', '[', ']', '{', '}',判断是否有效:
- 左右括号必须正确闭合
- 顺序必须匹配(如
"([)]"❌ 无效)
🧠 解题思路:
- 遇到左括号 → 将其对应的右括号压入栈
- 遇到右括号 → 检查是否与栈顶匹配
- 遍历结束 → 栈必须为空
💻 代码实现:
arduino
const leftToRight = {
"(": ")",
"[": "]",
"{": "}",
};
function isValid(s) {
if (!s) return true;
const stack = [];
for (let ch of s) {
if (ch in leftToRight) {
stack.push(leftToRight[ch]); // 压入期望的右括号
} else {
if (!stack.length || stack.pop() !== ch) {
return false; // 不匹配或栈空
}
}
}
return stack.length === 0; // 栈空则有效
}
🧪 测试:
arduino
console.log(isValid("()")); // ✅ true
console.log(isValid("()[]{}")); // ✅ true
console.log(isValid("(]")); // ❌ false
console.log(isValid("([)]")); // ❌ false
console.log(isValid("{[]}")); // ✅ true
🔍 为什么压入"右括号"?
这样遇到右括号时可直接比较
stack.pop() === ch,无需二次查表,逻辑更简洁高效!
六、总结对比 📊
| 维度 | 数组栈 📦 | 链表栈 ⛓️ |
|---|---|---|
| 时间复杂度(平均) | O(1) | O(1) |
| 扩容开销 | O(n)(低频) | O(1) |
| 空间效率 | 可能浪费 | 指针开销(约 +50%) |
| 实现难度 | ⭐ 简单 | ⭐⭐ 中等 |
| 适用场景 | 通用、轻量级 | 大数据、稳定性要求高 |
🎯 结语
栈虽简单,却是理解程序运行机制(如调用栈)和解决算法问题(DFS、表达式解析、回溯)的基石数据结构。
掌握其两种实现方式及典型应用,不仅能写出更高效的代码,还能在面试中展现扎实的基本功!
📌 终极建议:
- 日常开发 → 优先用 数组实现(简单高效)
- 面试/性能敏感场景 → 主动讨论 链表方案,展现深度思考 💡
📚 延伸思考:你能用栈实现"浏览器后退"功能吗?或者用两个栈实现一个队列?欢迎动手尝试!