在计算机科学的世界里,数据结构是构建高效程序的基石,而栈作为其中最基础且应用广泛的一种数据结构,其独特的 “后进先出(LIFO)” 特性,使其在众多领域发挥着关键作用。从算法设计到编译器实现,从函数调用机制到日常业务逻辑处理,栈无处不在。本文将深入剖析栈的核心概念、实现方式、典型应用场景、高阶用法,以及常见问题的解决方案,帮助读者全面掌握栈这一重要的数据结构。
栈(Stack) 是一种遵循后进先出(LIFO,Last In First Out) 原则的线性数据结构。这意味着最后进入栈的数据会最先被取出,就像一摞盘子,最后放上去的盘子总是最先被拿走。栈的核心操作主要有两个:
此外,栈通常还提供查看栈顶元素(Peek) 和判断栈是否为空(IsEmpty) 等辅助操作。栈的这种特性使其在许多场景中有着不可替代的作用,比如函数调用时记录调用信息、编译器处理表达式求值、浏览器的前进后退功能实现等。
在 Java 中,栈可以通过数组和链表两种方式实现,每种实现方式都有其独特的优缺点和适用场景。
使用数组实现栈时,需要预先指定数组的大小,即栈的容量。栈顶元素通过一个变量top
来记录其在数组中的位置,初始时top
为 -1,表示栈为空。
class ArrayStack {
private int[] data;
private int top = -1;
public ArrayStack(int capacity) {
data = new int[capacity];
}
public void push(int val) {
if (top == data.length - 1) {
throw new RuntimeException("Stack overflow");
}
data[++top] = val;
}
public int pop() {
if (isEmpty()) {
throw new RuntimeException("Stack underflow");
}
return data[top--];
}
public boolean isEmpty() {
return top == -1;
}
}
优点:数组实现的栈内存连续,通过索引访问元素速度快,适合对读取性能要求较高的场景。
缺点:容量固定,当栈中元素达到预设容量后,无法继续添加元素,可能会导致栈溢出。
适用场景:在明确知道数据量上限的情况下,使用数组实现栈可以获得较好的性能和空间利用率。
采用链表实现栈,每个节点存储一个数据元素和指向下一个节点的引用。栈顶元素由链表的头节点表示,当链表为空时,栈为空。
class LinkedStack {
private static class Node {
int val;
Node next;
Node(int val) { this.val = val; }
}
private Node top;
public void push(int val) {
Node newNode = new Node(val);
newNode.next = top;
top = newNode;
}
public int pop() {
if (isEmpty()) throw new RuntimeException("Stack is empty");
int val = top.val;
top = top.next;
return val;
}
public boolean isEmpty() {
return top == null;
}
}
优点:链表实现的栈可以动态扩容,无需预先指定容量,适合数据量变化较大的场景。
缺点:由于每个节点需要额外存储指针,内存开销相对较大;而且链表节点的内存地址不连续,访问效率不如数组。
适用场景:当无法预估数据量大小,或者数据量可能会频繁变化时,链表实现的栈更为合适。
实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
数组 | 内存连续,访问快 | 容量固定 | 明确数据量上限 |
链表 | 动态扩容,更灵活 | 内存开销大(指针) | 数据量变化大 |
栈的 “后进先出” 特性使其在许多实际问题中成为高效的解决方案,以下是一些常见的应用场景。
在代码编辑器、编译器等工具中,需要检查括号(如()
、[]
、{}
)是否匹配。可以使用栈来解决这个问题:遇到左括号时入栈,遇到右括号时检查栈顶元素是否为对应的左括号,如果是则出栈,否则说明括号不匹配。
public boolean isValid(String s) {
Deque stack = new ArrayDeque<>();
Map map = Map.of(')', '(', ']', '[', '}', '{');
for (char c : s.toCharArray()) {
if (map.containsValue(c)) {
stack.push(c);
} else if (stack.isEmpty() || stack.pop() != map.get(c)) {
return false;
}
}
return stack.isEmpty();
}
在程序执行过程中,函数调用遵循 “后进先出” 的原则。当一个函数被调用时,其相关信息(如局部变量、返回地址等)会被压入栈中;当函数执行完毕,这些信息会从栈中弹出。例如:
void funcA() {
System.out.println("进入A");
funcB();
System.out.println("离开A");
}
void funcB() {
System.out.println("进入B");
funcC();
System.out.println("离开B");
}
void funcC() {
System.out.println("执行C");
}
// 输出结果:
// 进入A → 进入B → 执行C → 离开B → 离开A
逆波兰表达式(后缀表达式)是一种无需括号即可明确运算顺序的表达式形式。通过栈可以方便地对其进行求值:遇到操作数时入栈,遇到操作符时从栈中弹出相应数量的操作数进行运算,并将结果入栈。
public int evalRPN(String[] tokens) {
Deque stack = new ArrayDeque<>();
for (String token : tokens) {
if (token.matches("-?\\d+")) {
stack.push(Integer.parseInt(token));
} else {
int b = stack.pop(), a = stack.pop();
switch (token) {
case "+": stack.push(a + b); break;
case "-": stack.push(a - b); break;
case "*": stack.push(a * b); break;
case "/": stack.push(a / b); break;
}
}
}
return stack.pop();
}
除了上述典型应用,栈在一些复杂问题中也能发挥强大的作用。
设计一个支持在常数时间内获取栈中最小元素的栈。可以使用两个栈,一个用于存储数据,另一个用于存储当前的最小元素。
class MinStack {
Deque dataStack = new ArrayDeque<>();
Deque minStack = new ArrayDeque<>();
public void push(int val) {
dataStack.push(val);
if (minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
public void pop() {
if (dataStack.pop().equals(minStack.peek())) {
minStack.pop();
}
}
public int getMin() {
return minStack.peek();
}
}
给定一个数组,对于每个元素,找出下一个比它大的元素出现的天数。可以使用单调栈来解决:维护一个单调递增的栈,当遇到比栈顶元素大的元素时,计算两者的索引差并更新结果数组。
public int[] dailyTemperatures(int[] T) {
int[] res = new int[T.length];
Deque stack = new ArrayDeque<>();
for (int i = 0; i < T.length; i++) {
while (!stack.isEmpty() && T[i] > T[stack.peek()]) {
int idx = stack.pop();
res[idx] = i - idx;
}
stack.push(i);
}
return res;
}
在使用栈的过程中,可能会遇到一些常见问题,以下是这些问题的解决方案。
场景:当递归函数调用层数过深,或者栈中元素过多超出了 JVM 分配的栈空间时,会发生栈溢出错误。
解决方案:
-Xss
增加栈的大小,例如-Xss2m
将栈空间设置为 2MB。但这种方法只是治标不治本,且可能会消耗过多内存。场景:在对空栈执行pop()
或peek()
操作时,会抛出空栈异常。
防御性编程:在执行相关操作前,先通过isEmpty()
方法判断栈是否为空,避免异常的发生。
public int peek() {
if (isEmpty()) throw new EmptyStackException();
return top.val;
}
场景:在多线程环境下,多个线程同时操作栈时,可能会出现数据不一致的问题。
解决方案:
Collections.synchronizedCollection
:将栈包装成线程安全的集合,例如Deque synchronizedStack = Collections.synchronizedDeque(new ArrayDeque<>());
。LinkedBlockingDeque
,它提供了线程安全的操作方法。栈是面试中的高频考点,以下是一些常见的面试题目:
3[a2[c]]
解码为accaccacc
。// 用队列实现栈(解法示例)
class MyStack {
Queue queue = new LinkedList<>();
public void push(int x) {
queue.offer(x);
for (int i = 1; i < queue.size(); i++) {
queue.offer(queue.poll());
}
}
public int pop() {
return queue.poll();
}
}
为了提高栈的性能和效率,可以采用以下优化策略:
除了软件层面的栈,栈在计算机系统的其他领域也有重要应用:
栈作为计算机科学中最基础且重要的数据结构之一,其 “后进先出” 的特性和丰富的应用场景使其在各种程序设计中扮演着不可或缺的角色。掌握栈的关键在于:
推荐学习路线:
从栈的基础概念和实现方式入手,逐步深入到典型应用场景和高阶变形问题,最后结合系统设计和实际项目,将栈的知识融会贯通。
希望本文能帮助读者深入理解栈数据结构,并在实际开发和学习中灵活运用。如果你在学习过程中有任何疑问或想法,欢迎在评论区交流讨论!