栈的全面解析:ADT、实现与应用
一、栈的基础概念与ADT
栈是一种先进后出(FILO, First In Last Out)的线性数据结构,它就像现实生活中堆叠的盘子,只能从顶部添加或移除元素。在计算机科学中,栈的"栈顶"是唯一可操作的位置,而"栈底"固定不动。
作为抽象数据类型(ADT),栈提供了一组标准操作:
- push:向栈顶添加元素
- pop:移除并返回栈顶元素
- peek:查看栈顶元素但不移除
- isEmpty:判断栈是否为空
- size:获取栈中元素数量
这些操作确保了栈的"后进先出"特性,使其在程序执行、表达式求值、括号匹配等场景中发挥着关键作用。
二、数组实现栈
数组是开箱即用的线性数据结构。在JavaScript中数组的相关操作:
// 在JavaScript数组的相关操作
const arr = [1,2,3];
arr.push(4); // 尾部添加
arr.unshift(0); // 头部添加
console.log(arr); // [0,1,2,3,4]
arr.pop(); // 弹出尾部元素
arr.shift(); // 弹出头部元素
console.log(arr); // [1,2,3]
在用数组实现栈之前我们先了解以下知识:
ES6引入的class语法为实现栈提供了优雅的解决方案。通过class,我们可以创建一个类模板,封装栈的所有属性和方法。
es6引入的constructor(构造方法)------ 它是 ES6 class 中唯一用于初始化实例的特殊方法 ,当通过 new 关键字创建类的实例时,constructor 会自动执行 ,核心作用是初始化实例的属性(尤其是私有属性,如下面代码中的#stack)。
es6 中,get 和 set 是用于定义访问器属性(Accessor Property) 的语法,访问器属性不直接存储值,而是通过函数拦截对属性的读取(get) 和赋值(set) 操作,隐藏内部状态(封装性),从而实现对属性访问的精细化控制。
数组实现栈代码
// 数组来实现stack
class ArrayStack {
#stack;
constructor() {
this.#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)、查看栈顶(peek)、获取大小(size)、判断为空(isEmpty);
#stack:ES6 新增的私有字段(Private Field) ,前缀#表示该属性只能在类内部访问 / 修改,外部无法直接操作(比如stack.#stack会报错),保证了栈的封装性(避免外部随意修改栈的底层数据);constructor():类的构造函数,创建ArrayStack实例时自动执行。初始化私有属性#stack为一个空数组,作为栈的底层存储结构 (数组的 "末尾" 对应栈的 "栈顶",因为数组的push/pop操作时间复杂度为 O (1),效率最高)。
数组实现的栈非常直观,利用了JavaScript数组的push和pop方法,实现了栈的核心功能。
三、链表实现栈
链表实现栈需要自定义节点和栈结构。
链表实现栈代码
// 链表来实现栈
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++;
}
pop() {
const num = this.peek();
this.#stackPeek = this.#stackPeek.next;
this.#size--;
return num;
}
peek() {
if (!this.#stackPeek) {
throw new Error('栈为空');
}
return this.#stackPeek.val;
}
get size() {
return this.#size;
}
isEmpty() {
return this.size === 0;
}
}
代码相关解释:
-
采用单向链表实现栈,核心逻辑是将链表头节点作为栈顶 ------ 链表头增删节点仅需修改指针(时间复杂度 O (1)),完美适配栈 "后进先出(LIFO)" 的核心操作要求。
-
节点类(ListNode)作用:定义无序列表的最小存储单元,
val存储栈元素值,next仅用于关联后继节点(离散存储),无任何排序相关逻辑,是实现无序列表的基础。 -
入栈方法(push):创建新节点→新节点
next指向原栈顶→更新栈顶指针为新节点→栈大小 + 1,全程仅修改指针,无遍历操作,效率 O (1)。 -
出栈方法(pop):复用
peek完成空栈校验和栈顶值获取→栈顶指针后移(原栈顶节点被垃圾回收)→栈大小 - 1→返回栈顶值,效率 O (1)。 -
栈顶查看方法(peek):先校验栈是否为空(空栈抛错),再返回栈顶节点的
val值(仅读取,不修改栈结构),对外屏蔽无序列表的底层细节。 -
辅助属性 / 方法:
size:通过 ES6 访问器属性(get)定义为只读属性,外部可获取但无法赋值篡改;isEmpty:基于size判断栈是否为空,语义清晰且无需重复编写空栈校验逻辑。
链表实现栈的关键在于使用#stackPeek作为栈顶指针,通过链表的链接关系实现栈的操作。
四、数组与链表实现栈的优缺点比较
时间效率
数组实现:
- 入栈和出栈操作在尾部进行,时间复杂度为O(1)
- 当数组容量不足时,触发扩容(复制所有元素到新数组),时间复杂度为O(n)
- 但扩容是低频操作,平均时间复杂度仍为O(1)
链表实现:
- 入栈操作需要创建新节点,时间复杂度为O(1)
- 无需扩容,始终能保持O(1)的时间复杂度
- 但链表节点需要额外操作(指针设置),在入栈时略慢于数组
空间效率
数组实现:
- 可能造成空间浪费(如分配了100个元素空间,但只使用了50个)
- 但内存是连续的,缓存命中率高,访问效率更好
链表实现:
- 每个节点需要额外空间存储指针(next引用)
- 内存不连续,缓存命中率较低
- 但不会造成空间浪费,空间利用率更高
五、栈的实践:括号匹配问题
栈在括号匹配问题中展现出了其独特优势。这个问题要求判断字符串中的括号是否正确闭合。
const leftToRight = {
"(":")",
"[":"]",
"{":"}",
}
const isValid = function(s) {
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 {
if(!stack.length || stack.pop() != ch) {
return false;
}
}
}
return stack.length === 0;
}
这个实现利用了栈的FILO特性:
- 遇到左括号时,将对应的右括号压入栈
- 遇到右括号时,弹出栈顶元素进行匹配
- 最终栈为空表示所有括号都正确闭合
例如,对于字符串"({[]})":
(→ 压入){→ 压入}[→ 压入]]→ 弹出],匹配}→ 弹出},匹配)→ 弹出),匹配- 栈为空,返回
true
六、总结
栈作为计算机科学中最基础、最常用的数据结构之一,其简洁性和高效性使其成为解决许多编程问题的首选工具。通过ES6的类特性,我们可以更清晰、更安全地实现栈,同时理解其背后的原理和适用场景。
在实际应用中,根据具体需求选择数组或链表实现栈:
- 数组实现:适合数据量相对稳定、对性能要求高且内存空间有限的场景
- 链表实现:适合数据量不确定、需要频繁动态扩展的场景
栈的实践应用远不止括号匹配问题,它在函数调用栈、表达式求值、浏览器历史记录管理等众多领域都有广泛应用。理解栈的本质------"后进先出"的特性,以及如何在不同场景下选择合适的实现方式,将使你在编程中更加得心应手。