栈(Stack)
栈(Stack)详解
基本概念与特性
栈是一种遵循后进先出(LIFO,Last In First Out)原则的抽象数据类型,属于线性数据结构。其操作方式类似于现实生活中的一叠盘子:
- 入栈(Push):将新元素添加到栈顶,相当于在最上面放一个新盘子
- 出栈(Pop):移除并返回栈顶元素,相当于从最上面取走一个盘子
- 栈顶(Top):指向最新插入的元素位置,总是指向当前可操作的元素
- 栈底(Bottom):存放最早插入的元素,通常作为基准位置
栈的所有基本操作(push、pop、peek等)的时间复杂度均为O(1),这种极高的效率使其成为算法设计中重要的基础数据结构。
实现方式
数组实现(顺序栈)
实现要点
顺序栈使用连续的内存空间(数组)存储元素:
- 预分配固定长度的数组作为存储空间
- 通过整型变量top指示当前栈顶位置(初始值为-1表示空栈)
- 栈满条件:top == 数组长度-1(数组索引从0开始)
- 栈空条件:top == -1(无元素状态)
示例代码(C语言)
#define MAX_SIZE 100 // 预定义栈的最大容量typedef struct {int data[MAX_SIZE]; // 存储栈元素的数组int top; // 栈顶指针
} ArrayStack;// 入栈操作
void push(ArrayStack *s, int value) {if (s->top == MAX_SIZE - 1) { // 检查栈是否已满printf("Stack Overflow\n");return;}s->data[++s->top] = value; // 先移动指针再存储值
}// 出栈操作
int pop(ArrayStack *s) {if (s->top == -1) { // 检查栈是否为空printf("Stack Underflow\n");return INT_MIN; // 返回最小值表示错误}return s->data[s->top--]; // 返回当前值再移动指针
}
优缺点分析
- 优点:
- 实现简单直观,易于理解
- 内存连续分配,缓存命中率高(空间局部性好)
- 直接索引访问,操作速度快
- 缺点:
- 需要预先分配固定大小的空间
- 扩容时需要重新分配内存并复制元素,代价高昂
- 当存储需求远小于分配空间时,内存利用率低
链表实现(链式栈)
实现要点
链式栈使用动态分配的节点存储元素:
- 每个节点包含数据域和指向下一个节点的指针
- 栈顶即链表头节点(插入和删除都在链表头部进行)
- 不需要预先分配固定空间,可以动态增长
- 栈空条件:top指针为NULL
示例代码(Java)
// 节点类定义
class Node {int data; // 存储数据Node next; // 指向下一个节点的指针public Node(int data) {this.data = data;this.next = null;}
}// 链栈实现
class LinkedStack {private Node top; // 栈顶指针// 入栈操作public void push(int value) {Node newNode = new Node(value); // 创建新节点newNode.next = top; // 新节点指向原栈顶top = newNode; // 更新栈顶指针}// 出栈操作public int pop() {if (top == null) {throw new EmptyStackException(); // 栈空异常}int value = top.data; // 保存栈顶数据top = top.next; // 移动栈顶指针return value;}// 查看栈顶元素public int peek() {if (top == null) {throw new EmptyStackException();}return top.data;}
}
优缺点分析
- 优点:
- 动态内存分配,不需要预先设置大小
- 理论上只要内存足够就不会出现栈满情况
- 内存使用效率高(按需分配)
- 缺点:
- 每个节点需要额外存储指针,空间开销较大
- 内存不连续,缓存性能可能较差
- 访问非栈顶元素需要遍历,效率低下
- 频繁的内存分配/释放可能影响性能
典型应用场景
括号匹配
完整算法流程
- 初始化一个空栈
- 从左到右遍历输入字符串:
- 遇到任意左括号(‘(’、‘[’、‘{’)则将其压入栈中
- 遇到右括号时:
a. 首先检查栈是否为空(栈空则说明右括号无匹配)
b. 弹出栈顶元素并检查是否与当前右括号匹配
c. 不匹配则直接返回错误
- 字符串遍历完成后:
- 检查栈是否为空(栈非空说明有未匹配的左括号)
- 栈空则匹配成功,否则匹配失败
详细示例分析
以字符串"{([])}"为例:
- 遇到’{’ → 压入栈 → 栈:[‘{’]
- 遇到’(’ → 压入栈 → 栈:[‘{’, ‘(’]
- 遇到’[’ → 压入栈 → 栈:[‘{’, ‘(’, ‘[’]
- 遇到’]’ → 弹出’[’ → 检查匹配(‘[‘和’]‘匹配) → 栈:[’{’, ‘(’]
- 遇到’)’ → 弹出’(’ → 检查匹配(‘(‘和’)‘匹配) → 栈:[’{’]
- 遇到’}’ → 弹出’{’ → 检查匹配('{‘和’}'匹配) → 栈:[]
- 字符串遍历结束且栈空 → 匹配成功
表达式求值
中缀转后缀详细算法(逆波兰表示法)
- 初始化一个输出队列和一个操作符栈
- 从左到右扫描中缀表达式:
- 遇到操作数:直接加入输出队列
- 遇到左括号:压入操作符栈
- 遇到右括号:
a. 不断弹出栈顶元素加入输出队列
b. 直到遇见左括号(左括号弹出但不输出) - 遇到运算符:
a. 比较与栈顶运算符的优先级
b. 弹出栈顶优先级≥当前运算符的元素
c. 将当前运算符压入栈
- 表达式处理后弹出栈中剩余运算符
后缀表达式求值步骤
- 初始化操作数栈
- 从左到右扫描后缀表达式:
- 遇到数字:压入操作数栈
- 遇到运算符:
a. 弹出栈顶两个操作数(注意顺序)
b. 执行运算(次顶元素 op 栈顶元素)
c. 将结果压回栈中
- 最终栈中剩余的唯一元素即为表达式结果
完整计算示例
中缀表达式:3 + 4 × (2 - 1)
-
转换为后缀表达式:
- 3 → 输出
-
- → 压栈
- 4 → 输出 → 队列:[3,4]
- × → 优先级>+,压栈 → 栈:[‘+’,‘×’]
- ( → 压栈 → 栈:[‘+’,‘×’,‘(’]
- 2 → 输出 → 队列:[3,4,2]
-
- → 压栈 → 栈:[‘+’,‘×’,‘(’,‘-’]
- 1 → 输出 → 队列:[3,4,2,1]
- ) → 弹出直到’(’ → 队列:[3,4,2,1,-] → 栈:[‘+’,‘×’]
- 结束 → 弹出剩余 → 队列:[3,4,2,1,-,×,+]
-
后缀表达式求值:
- 初始栈:[]
- 3 → [3]
- 4 → [3,4]
- 2 → [3,4,2]
- 1 → [3,4,2,1]
-
- → 弹出1,2 → 2-1=1 → [3,4,1]
- × → 弹出1,4 → 4×1=4 → [3,4]
-
- → 弹出4,3 → 3+4=7 → [7]
-
最终结果:7
其他重要应用
-
函数调用栈:
- 保存函数调用的返回地址、参数、局部变量等上下文信息
- 递归调用本质上是栈的应用,递归深度受限于栈大小
- 栈溢出常见于递归未设置终止条件或递归过深
-
浏览器历史记录管理:
- 访问新页面时URL入栈(主栈)
- 点击后退时当前页出栈并进入辅助栈
- 前进时从辅助栈取回URL
- 新访问时清空辅助栈
-
文本编辑器的撤销(Undo)机制:
- 每个编辑操作作为命令对象入栈
- 执行撤销时弹出栈顶命令并执行逆操作
- 通常配合重做(Redo)栈实现完整的撤销/重做功能
-
深度优先搜索(DFS):
- 用栈保存待访问节点
- 每次处理栈顶节点并将其邻接节点入栈
- 避免递归带来的栈溢出风险
变体与扩展
-
最小栈:
- 在O(1)时间内获取当前栈中最小值
- 实现方式:
a. 使用辅助栈同步记录最小值
b. 节点额外存储当前最小值 - 示例:压入[3,5,1]时,主栈[3,5,1],辅助栈[3,3,1]
-
双栈队列:
- 用两个栈模拟队列的FIFO特性
- 入队栈负责接收新元素
- 出队栈为空时将入队栈元素全部转移
- 出队操作始终从出队栈弹出
-
共享栈:
- 两个栈共享同一存储空间
- 一个栈从数组起始位置向末端增长
- 另一个栈从数组末端向起始位置增长
- 空间利用率高,适合两栈空间需求相反的场景
常见面试题
-
最小栈实现:
- 设计支持push、pop、top和getMin操作的栈
- getMin需在O(1)时间内完成
-
用栈实现队列:
- 使用两个栈模拟队列的先进先出特性
- 重点在于元素转移的时机控制
-
有效的括号:
- 检查字符串中的括号是否匹配
- 需处理三种括号类型:()、[]、{}
-
逆波兰表达式求值:
- 根据后缀表达式计算结果
- 注意操作数顺序和除法处理
-
栈的压入、弹出序列:
- 判断给定弹出序列是否可能是压入序列的合法弹出顺序
性能考量
-
栈溢出防护:
- 递归算法必须有明确的终止条件
- 深度较大时考虑改用迭代算法
- 必要时调整系统栈大小
-
缓存优化:
- 数组实现比链表实现具有更好的缓存局部性
- 频繁操作时数组栈性能通常更优
-
线程安全:
- 多线程环境下需要同步控制
- 常见解决方案:
a. 使用锁机制
b. 采用线程局部存储
c. 使用并发栈实现
-
内存管理:
- 链表实现需注意及时释放出栈节点
- 防止内存泄漏和野指针问题
- 可考虑对象池技术优化频繁分配释放