1. 栈与队列基础概念回顾
在正式进入算法实战之前,我们先快速梳理一下这两个数据结构的基本特性。栈(Stack)是一种后进先出(LIFO)的线性表,只允许在表的一端进行插入和删除操作。就像我们平时叠放的盘子,总是取用最上面的那个。队列(Queue)则是先进先出(FIFO)的线性表,允许在表的一端插入,另一端删除,就像排队买票的队伍。
栈的核心操作包括:
- push:元素入栈
- pop:栈顶元素出栈
- peek:查看栈顶元素但不移除
- isEmpty:判断栈是否为空
队列的核心操作包括:
- enqueue:元素入队
- dequeue:队首元素出队
- front:查看队首元素
- isEmpty:判断队列是否为空
在实际编程中,大多数主流语言都内置了栈和队列的实现。比如Java中的Stack类,Python中可以用list实现栈,collections.deque实现队列。C++的STL中有stack和queue容器。
2. 经典栈算法实战
2.1 有效的括号匹配
这是栈最经典的入门题目。给定一个只包括 '(', ')', '{', '}', '[', ']' 的字符串,判断字符串是否有效。有效字符串需满足:
- 左括号必须用相同类型的右括号闭合
- 左括号必须以正确的顺序闭合
解法思路:
- 初始化一个空栈
- 遍历字符串中的每个字符
- 遇到左括号就压栈
- 遇到右括号时:
- 如果栈为空,直接返回false
- 弹出栈顶元素,检查是否匹配
- 最后检查栈是否为空
python复制def isValid(s: str) -> bool:
stack = []
mapping = {")": "(", "}": "{", "]": "["}
for char in s:
if char in mapping:
top_element = stack.pop() if stack else '#'
if mapping[char] != top_element:
return False
else:
stack.append(char)
return not stack
注意:这里使用字典存储括号匹配关系可以简化代码,避免大量if-else判断。空栈处理使用'#'作为哨兵值也是常见技巧。
2.2 最小栈设计
设计一个支持 push,pop,top 操作,并能在常数时间内检索到最小元素的栈。
解法思路:
使用辅助栈同步存储最小值:
- 主栈正常存储所有元素
- 辅助栈栈顶始终存储当前最小值
- 当新元素 ≤ 辅助栈顶时,压入辅助栈
- 出栈时,如果主栈顶等于辅助栈顶,辅助栈也出栈
java复制class MinStack {
private Stack<Integer> stack;
private Stack<Integer> minStack;
public MinStack() {
stack = new Stack<>();
minStack = new Stack<>();
}
public void push(int val) {
stack.push(val);
if(minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
public void pop() {
if(stack.pop().equals(minStack.peek())) {
minStack.pop();
}
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
2.3 逆波兰表达式求值
根据逆波兰表示法(后缀表达式),求表达式的值。有效的算符包括 +, -, *, / 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
解法思路:
- 初始化一个空栈
- 遍历tokens:
- 遇到数字:压栈
- 遇到运算符:弹出栈顶两个数字,计算后将结果压栈
- 最后栈中剩余的数字就是结果
python复制def evalRPN(tokens: List[str]) -> int:
stack = []
for token in tokens:
if token not in "+-*/":
stack.append(int(token))
else:
b = stack.pop()
a = stack.pop()
if token == '+':
stack.append(a + b)
elif token == '-':
stack.append(a - b)
elif token == '*':
stack.append(a * b)
else:
stack.append(int(a / b)) # 注意除法向零取整
return stack.pop()
实际工程中处理除法时要特别注意不同语言的取整方式差异。Python的//是向下取整,而题目要求向零取整,所以要用int(a/b)。
3. 经典队列算法实战
3.1 用栈实现队列
仅使用栈的操作来实现队列的所有操作(push、pop、peek、empty)。
解法思路:
使用两个栈(输入栈和输出栈):
- push操作:直接压入输入栈
- pop/peek操作:
- 如果输出栈为空,将输入栈所有元素弹出并压入输出栈
- 然后对输出栈进行pop/peek
- empty:两个栈都为空时队列为空
javascript复制class MyQueue {
constructor() {
this.inStack = [];
this.outStack = [];
}
push(x) {
this.inStack.push(x);
}
pop() {
if(this.outStack.length === 0) {
while(this.inStack.length) {
this.outStack.push(this.inStack.pop());
}
}
return this.outStack.pop();
}
peek() {
if(this.outStack.length === 0) {
while(this.inStack.length) {
this.outStack.push(this.inStack.pop());
}
}
return this.outStack[this.outStack.length - 1];
}
empty() {
return this.inStack.length === 0 && this.outStack.length === 0;
}
}
3.2 滑动窗口最大值
给定一个数组和滑动窗口的大小,找出所有滑动窗口里的最大值。
解法思路:
使用双端队列维护可能成为窗口最大值的索引:
- 队列中存储的是索引,且对应元素单调递减
- 遍历数组:
- 移除队列中不在当前窗口的元素(从队首)
- 移除队列中所有小于当前元素的索引(从队尾)
- 将当前元素索引加入队尾
- 当窗口形成后(i ≥ k-1),队首元素即为当前窗口最大值
cpp复制vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> dq;
vector<int> res;
for(int i = 0; i < nums.size(); ++i) {
// 移除不在窗口内的元素
if(!dq.empty() && dq.front() == i - k) {
dq.pop_front();
}
// 移除小于当前元素的索引
while(!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
// 窗口形成后记录结果
if(i >= k - 1) {
res.push_back(nums[dq.front()]);
}
}
return res;
}
3.3 循环队列实现
设计你的循环队列实现。循环队列是一种线性数据结构,其操作表现基于 FIFO 原则并且队尾被连接在队首以形成一个循环。
解法思路:
使用数组和两个指针(head和tail):
- 初始化时head = tail = 0
- 判空:head == tail
- 判满:(tail + 1) % capacity == head
- 入队:检查是否已满,然后在tail位置插入,tail = (tail + 1) % capacity
- 出队:检查是否为空,然后head = (head + 1) % capacity
java复制class MyCircularQueue {
private int[] data;
private int head;
private int tail;
private int size;
public MyCircularQueue(int k) {
data = new int[k];
head = 0;
tail = 0;
size = 0;
}
public boolean enQueue(int value) {
if(isFull()) return false;
data[tail] = value;
tail = (tail + 1) % data.length;
size++;
return true;
}
public boolean deQueue() {
if(isEmpty()) return false;
head = (head + 1) % data.length;
size--;
return true;
}
public int Front() {
if(isEmpty()) return -1;
return data[head];
}
public int Rear() {
if(isEmpty()) return -1;
return data[(tail - 1 + data.length) % data.length];
}
public boolean isEmpty() {
return size == 0;
}
public boolean isFull() {
return size == data.length;
}
}
4. 栈与队列综合应用
4.1 柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。求在该柱状图中,能够勾勒出来的矩形的最大面积。
解法思路:
使用单调栈:
- 初始化栈和最大面积变量
- 遍历每个柱子:
- 当栈不为空且当前高度 < 栈顶高度时:
- 弹出栈顶作为高度
- 计算宽度:当前索引 - 新栈顶索引 - 1
- 更新最大面积
- 将当前索引入栈
- 当栈不为空且当前高度 < 栈顶高度时:
- 处理栈中剩余元素
python复制def largestRectangleArea(heights: List[int]) -> int:
stack = [-1]
max_area = 0
heights.append(0) # 哨兵值
for i in range(len(heights)):
while stack[-1] != -1 and heights[stack[-1]] > heights[i]:
h = heights[stack.pop()]
w = i - stack[-1] - 1
max_area = max(max_area, h * w)
stack.append(i)
return max_area
4.2 用队列实现栈
仅使用队列的基本操作来实现栈的基本操作(push、pop、top、empty)。
解法思路:
使用一个主队列:
- push操作:直接加入队列,然后将前面的元素依次出队再入队,使得新元素在队首
- pop操作:直接出队
- top操作:返回队首元素
- empty操作:检查队列是否为空
java复制class MyStack {
private Queue<Integer> queue;
public MyStack() {
queue = new LinkedList<>();
}
public void push(int x) {
queue.add(x);
int size = queue.size();
// 将前面的元素依次移到队尾,使新元素成为队首
while(size-- > 1) {
queue.add(queue.poll());
}
}
public int pop() {
return queue.poll();
}
public int top() {
return queue.peek();
}
public boolean empty() {
return queue.isEmpty();
}
}
4.3 每日温度
给定一个温度列表,生成一个列表,表示需要等待多少天才能观测到更高的温度。如果之后都不会升高,用0代替。
解法思路:
使用单调栈存储索引:
- 初始化结果数组为0,空栈
- 遍历温度数组:
- 当栈不为空且当前温度 > 栈顶温度时:
- 弹出栈顶索引
- 计算天数差并存入结果数组
- 将当前索引入栈
- 当栈不为空且当前温度 > 栈顶温度时:
javascript复制var dailyTemperatures = function(T) {
const res = new Array(T.length).fill(0);
const stack = [];
for(let i = 0; i < T.length; i++) {
while(stack.length && T[i] > T[stack[stack.length - 1]]) {
const idx = stack.pop();
res[idx] = i - idx;
}
stack.push(i);
}
return res;
};
5. 常见问题与优化技巧
5.1 栈溢出问题
递归算法本质上就是使用系统调用栈,当递归深度过大时会导致栈溢出。解决方法:
- 改用迭代+显式栈的实现方式
- 尾递归优化(部分语言支持)
- 增加栈空间(不推荐,只是临时解决方案)
例如,二叉树的中序遍历:
递归版本:
python复制def inorderTraversal(root):
res = []
def helper(node):
if not node: return
helper(node.left)
res.append(node.val)
helper(node.right)
helper(root)
return res
迭代+栈版本:
python复制def inorderTraversal(root):
res = []
stack = []
curr = root
while curr or stack:
while curr:
stack.append(curr)
curr = curr.left
curr = stack.pop()
res.append(curr.val)
curr = curr.right
return res
5.2 队列实现的选择
不同语言中队列实现的选择会影响性能:
- Java:LinkedList(双向链表)或ArrayDeque(循环数组)
- Python:collections.deque(双向链表)
- C++:std::queue(默认基于deque)或自行实现
- JavaScript:数组(但shift操作是O(n)),最好自己实现
对于频繁操作的场景,选择基于循环数组的实现通常性能更好,因为内存连续,缓存友好。
5.3 单调栈/队列的变种
单调数据结构不仅可用于求极值,还能解决多种问题:
- 求第一个比当前元素大/小的元素
- 求满足某些条件的子数组数量
- 维护滑动窗口的某种性质
关键点在于明确单调性代表什么信息,以及如何利用弹出元素时的计算时机。
例如,求数组中每个元素的下一个更大元素:
cpp复制vector<int> nextGreaterElement(vector<int>& nums) {
vector<int> res(nums.size(), -1);
stack<int> s;
for(int i = 0; i < nums.size(); ++i) {
while(!s.empty() && nums[s.top()] < nums[i]) {
res[s.top()] = nums[i];
s.pop();
}
s.push(i);
}
return res;
}
5.4 边界条件处理
栈和队列问题常见的边界陷阱:
- 操作空栈/队列时的处理
- 循环队列中判空和判满的区分
- 单调栈中最后剩余元素的处理
- 数值计算时的溢出问题(特别是乘积类问题)
例如,在实现最小栈时,处理相等最小值的情况:
java复制public void pop() {
int val = stack.pop();
// 注意这里要用equals而不是==
if(val == minStack.peek()) {
minStack.pop();
}
}
5.5 空间复杂度优化
某些问题可以通过巧妙的变量替换来优化空间:
- 用输入数组本身作为栈/队列的存储
- 复用已有的数据结构
- 使用位运算压缩状态
例如,括号匹配问题可以优化为不使用栈:
python复制def isValid(s: str) -> bool:
balance = 0
for char in s:
if char == '(':
balance += 1
else:
if balance == 0:
return False
balance -= 1
return balance == 0
当然,这只适用于单一类型括号的情况,多种括号混合时还是需要栈。