栈(Stack):从实现到应用的详解
栈是一种遵循后进先出(LIFO,Last In First Out) 原则的线性数据结构,它在计算机科学中有着广泛的应用,如表达式求值、括号匹配、函数调用栈等。本文将结合具体代码,详细介绍栈的两种实现方式(数组、链表)及其经典应用。
一、栈的基本概念
栈只允许在一端进行数据操作(插入 / 删除),这一端被称为 “栈顶”,另一端则称为 “栈底”。栈的核心操作包括:
- 入栈(Push) :向栈顶添加元素
- 出栈(Pop) :从栈顶移除元素
- 查看栈顶(Peek) :获取栈顶元素但不移除
- 判空(isEmpty) :判断栈是否为空
- 获取大小(Size) :获取栈中元素的数量
二、栈的数组实现
数组是实现栈最简单的方式,因为数组的尾部操作(push/pop)时间复杂度为 O (1),非常适合作为栈的底层存储结构。
1. 基础数组操作实现栈
const stack = [];
// 入栈:向数组尾部添加元素(栈顶)
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(4);
stack.push(5); // 此时栈为 [1,2,3,4,5],栈顶是5
// 访问栈顶元素:数组最后一个元素
const peek = stack[stack.length - 1]; // 5
console.log(peek);
// 出栈:移除数组尾部元素(栈顶)
const pop = stack.pop(); // 移除5,返回5,此时栈为 [1,2,3,4]
// 栈的长度
const size = stack.length; // 4
// 栈是否为空
const isEmpty = stack.length === 0; // false(此时栈非空)
解析:数组的push方法直接在尾部添加元素(入栈),pop方法移除尾部元素(出栈),通过length-1可快速访问栈顶,操作高效且简洁。
2. 封装数组栈为类
为了更规范地使用栈,可将其封装为类,隐藏内部实现细节:
class ArrayStack {
#stack; // 私有属性,存储栈元素
constructor(){
this.#stack = []; // 初始化空数组
}
// 获取栈的大小(修正笔误:原代码为this.#stack,length)
get size(){
return this.#stack.length;
}
// 判断栈是否为空
isEmpty(){
return this.size === 0;
}
// 入栈(修正:原代码缺少参数num)
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隐藏内部数组,避免外部直接修改,保证栈的操作规范性。 - 封装
push/pop/peek等方法,统一栈的操作接口,同时在空栈操作时抛出错误,增强健壮性。
三、栈的链表实现
当数据量不确定或需要动态扩容时,链表实现的栈更灵活(数组可能需要预分配内存)。链表实现中,通常将头节点作为栈顶(操作更高效,避免尾部遍历)。
链表栈的实现
// 链表节点类
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(初始为空)
stack.push(1);
stack.push(2);
console.log(stack.toArray()); // [1,2](栈底到栈顶)
解析:
- 链表栈通过
#stackPeek指针指向栈顶(头节点),push时在头部插入新节点,pop时移除头节点,操作时间复杂度均为 O (1)。 toArray方法通过反向遍历链表,将栈元素从栈底到栈顶存入数组,方便查看栈的完整结构。
四、栈的经典应用:括号匹配
栈的 “后进先出” 特性非常适合解决括号匹配问题,例如判断字符串"()[]{}"是否有效。
括号匹配实现
// 存储左右括号的对应关系
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 {
// 遇到右括号:检查是否与栈顶匹配
// 若栈为空(无匹配的左括号)或不匹配,直接返回false
if(!stack.length || stack.pop() !== ch){
return false;
}
}
}
// 遍历结束后,栈必须为空(所有左括号都有匹配的右括号)
return !stack.length;
};
解析:
- 用
leftToRight映射左括号到对应的右括号,方便快速获取匹配目标。 - 遍历字符串时,遇到左括号则将其对应的右括号入栈(未来需要被匹配)。
- 遇到右括号时,若栈顶元素与当前右括号一致,则出栈(匹配成功);否则匹配失败。
- 遍历结束后,若栈为空,说明所有左括号都被匹配,字符串有效。
五、总结
栈作为一种简单而重要的数据结构,其核心是 “后进先出” 的特性。本文介绍了两种实现方式:
- 数组实现:简洁高效,适合数据量固定的场景。
- 链表实现:动态灵活,适合数据量不确定的场景。