栈的深度解析:概念、实现与典型应用
在数据结构的世界里,栈是一种简单却至关重要的线性结构。它遵循"先进后出"的核心原则,就像我们日常生活中堆叠的盘子——最后放上去的盘子,总能最先被取下来。本文将从栈的基本概念出发,带你用JavaScript实现两种不同底层的栈结构,并通过经典算法题巩固应用,全程结合ES6新特性,兼顾实用性与规范性。
一、栈的核心概念与ADT定义
1. 什么是栈?
栈是一种先进后出的线性数据结构,仅允许在结构的一端(称为"栈顶")进行元素的插入(入栈)和删除(出栈)操作,另一端(称为"栈底")则固定不可直接操作。
核心特性:先进后出,即最早入栈的元素最晚出栈,最晚入栈的元素最早出栈。
2. 栈的ADT(抽象数据类型)
抽象数据类型定义了栈的核心操作接口,无论底层如何实现,都应满足以下功能:
| 操作 | 描述 |
|---|---|
push(num) | 入栈:将元素压入栈顶 |
pop() | 出栈:移除并返回栈顶元素(栈空时抛错) |
peek() | 查看栈顶元素(栈空时抛错) |
isEmpty() | 判断栈是否为空(返回布尔值) |
size | 获取栈的元素个数(只读属性) |
toArray() | 将栈转换为数组(便于查看所有元素) |
二、ES6实现栈:数组 vs 链表
栈的底层实现主要有两种方式:基于数组和基于链表。下面结合ES6的class、私有属性等新特性,分别实现这两种栈,并分析其优缺点。
1. 基于数组的栈实现
数组是JavaScript中内置的线性数据结构,其push()和pop()方法天生符合栈的入栈、出栈逻辑(操作数组尾部,时间复杂度O(1)),因此实现起来非常简洁。
class ArrayStack {
// 私有属性:存储栈元素(外部无法直接访问,保护实现细节)
#stack;
constructor() {
// 初始化空数组作为栈的存储容器
this.#stack = [];
}
// 只读属性:获取栈的大小(通过getter实现,避免直接修改)
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;
}
}
这段代码实现了一个封装良好的栈(Stack)数据结构,基于 JavaScript 数组,并利用现代语法增强安全性与可读性:
- 使用 私有字段 #stack 隐藏内部实现,防止外部直接访问;
- 提供标准栈操作:push(入栈)、pop(出栈)、peek(查看栈顶)、isEmpty(判空)和只读 size 属性;
- 对空栈操作进行错误检查,提升稳定性;
- 通过 get 暴露栈大小,保持接口简洁。
2. 基于链表的栈实现
链表是另一种线性数据结构,通过节点串联数据。基于链表实现栈时,我们选择头插法(操作链表头部)实现入栈和出栈,确保操作时间复杂度为O(1)。
// 链表节点类(封装节点数据和指针)
class ListNode {
constructor(val) {
this.val = val; // 节点值
this.next = null; // 指向下一个节点的指针
}
}
class LinkedListStack {
// 私有属性:栈顶指针(指向链表头部,即栈顶)
#stackPeek;
// 私有属性:栈的大小
#size = 0;
constructor() {
// 初始化栈顶指针为空(栈空)
this.#stackPeek = null;
}
// 入栈:头插法,新节点成为新的栈顶
push(num) {
const Node = new ListNode(num);
Node.next = this.#stackPeek; // 新节点指向原栈顶
this.#stackPeek = Node; // 栈顶指针指向新节点
this.#size++; // 栈大小+1
}
// 查看栈顶元素(栈空时抛错)
peek() {
if (!this.#stackPeek) throw new Error("栈为空,无法查看栈顶元素");
return this.#stackPeek.val;
}
// 出栈:移除栈顶节点(栈空时抛错)
pop() {
const num = this.peek(); // 先获取栈顶值(触发空栈校验)
this.#stackPeek = this.#stackPeek.next; // 栈顶指针指向后一个节点
this.#size--; // 栈大小-1
return num;
}
// 只读属性:获取栈的大小
get size() {
return this.#size;
}
// 判断栈是否为空
isEmpty() {
return this.#size === 0;
}
// 转换为数组(从栈顶到栈底遍历,反向存储为数组)
toArray() {
let node = this.#stackPeek;
const res = new Array(this.size);
// 从栈底到栈顶的顺序存储(与入栈顺序一致)
for (let i = res.length - 1; i >= 0; i--) {
res[i] = node.val;
node = node.next;
}
return res;
}
}
const stack = new LinkedListStack();
console.log(stack.size); // 0
这段代码实现了一个基于单向链表的栈(LinkedListStack),具有以下特点:
- 核心结构:以链表头部作为栈顶,通过“头插法”实现入栈和出栈,时间复杂度均为 O(1);
- 封装良好:使用私有属性 #stackPeek 和 #size 防止外部直接访问或修改内部状态;
- 接口完整:提供 push、pop、peek、isEmpty、只读 size 等标准栈操作,并对空栈操作进行错误校验;
- 数组转换正确:toArray() 方法将栈中元素按入栈顺序(从栈底到栈顶)返回为数组,逻辑清晰;
- 无容量限制:相比数组实现,链表栈可动态扩展,适合不确定大小的场景。
3. 两种实现的优缺点对比
| 对比维度 | 基于数组的栈 | 基于链表的栈 |
|---|---|---|
| 时间效率 | 入栈/出栈平均O(1);数组扩容时O(n)(低频操作,平均效率高) | 入栈/出栈稳定O(1);无扩容开销 |
| 空间效率 | 可能浪费空间(数组预分配容量);无额外指针开销 | 扩容灵活(按需创建节点);节点需存储指针,空间开销较大 |
| 实现复杂度 | 简单(利用数组内置方法) | 稍复杂(需手动实现链表节点和指针操作) |
| 适用场景 | 元素数量可预估、追求简洁实现 | 元素数量不确定、需要稳定时间复杂度 |
三、ES6新特性在栈实现中的应用
上面的实现中,我们用到了ES6的多个核心特性,这里单独梳理,帮助大家理解其设计价值:
1. class 类语法
class 是ES6引入的语法糖,用于定义类(本质是函数的语法糖),相比ES5的原型链写法,结构更清晰、更易维护,完美适配栈这种抽象数据类型的封装需求。
2. 私有属性 #xxx
通过 # 前缀定义的私有属性(如#stack、#stackPeek),外部无法直接访问或修改,只能通过类提供的方法操作。这实现了封装性,保护了类的内部实现细节,避免外部误操作导致的数据不一致。
3. get 访问器
使用 get size() 定义只读属性,外部可以通过 stack.size 直接获取栈的大小,而无需调用方法(如stack.getSize()),语法更简洁,同时避免了外部直接修改size的值。
4. 构造函数 constructor
用于初始化类的实例属性(如初始化数组、栈顶指针),在创建对象时自动执行,确保实例从创建时就处于正确的初始状态。
四、栈的经典应用:有效的括号
栈的"先进后出"特性非常适合解决括号匹配问题,这是LeetCode上的经典算法题(LeetCode 20),下面结合栈的实现来解决该问题。
题目描述
给定一个只包括 '('、')'、'{'、'}'、'['、']' 的字符串s,判断字符串是否有效。有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
解题思路
- 用一个哈希表
leftToRight维护左括号与右括号的对应关系,便于快速查找匹配的右括号。 - 用栈存储左括号对应的右括号:遇到左括号时,将其对应的右括号压入栈;遇到右括号时,弹出栈顶元素,判断是否与当前右括号匹配。
- 匹配失败的情况:① 栈为空时遇到右括号(无对应的左括号);② 弹出的栈顶元素与当前右括号不匹配。
- 遍历结束后,栈必须为空(所有左括号都有对应的右括号闭合)。
代码实现
// 维护左括号与右括号的对应关系
const leftToRight = {
"(": ")",
"[": "]",
"{": "}"
};
const isValid = function(s) {
// 空字符串直接返回true
if (!s) return true;
const stack = []; // 用数组模拟栈
const len = s.length;
for (let i = 0; i < len; i++) {
const ch = s[i];
// 遇到左括号,将对应的右括号压入栈
if (ch === "(" || ch === "[" || ch === "{") {
stack.push(leftToRight[ch]);
} else {
// 遇到右括号:栈空(无匹配左括号)或栈顶不匹配,返回false
if (!stack.length || stack.pop() !== ch) {
return false;
}
}
}
// 遍历结束后,栈必须为空(所有左括号都已匹配)
return stack.length === 0;
};
// 测试用例
console.log(isValid("()")); // true
console.log(isValid("()[]{}")); // true
console.log(isValid("(]")); // false
console.log(isValid("([)]")); // false
console.log(isValid("{[]}")); // true
五、总结
栈作为一种先进后出的线性数据结构,虽然接口简单,但应用场景广泛(除了括号匹配,还包括函数调用栈、表达式求值等)。本文通过ES6的class语法,实现了基于数组和链表的两种栈结构,对比了它们的优缺点,并通过经典算法题巩固了栈的核心特性。
核心要点回顾:
- 栈的核心原则:先进后出。
- 两种实现:数组实现简洁高效,链表实现扩容灵活。
- ES6特性:
class、私有属性、get访问器提升代码的封装性和可读性。 - 经典应用:括号匹配问题是栈的典型场景,利用栈的"先进后出"特性实现高效匹配。
希望本文能帮助你深入理解栈的概念与实现,在实际开发和算法题中灵活使用这一基础数据结构。