Stack and Queue
两个场景都在处理“等待中的一串对象”,但离开顺序完全不同。
函数 A 调用 B,B 又调用 C。当 C 结束时,程序应该先回到谁?不是最早进入的 A,而是最后进入的 B。
再看打印机:三份文档依次进入打印队列。若没有特殊优先级,最自然的规则却是第一份先打印。
同样都是“等待处理的一串对象”,为什么一个需要后进先出(Last In, First Out,LIFO),另一个需要先进先出(First In, First Out,FIFO)?
这种差异不是函数和打印机的偶然现象:
这些场景共同说明:限制插入和删除的位置,反而能得到非常强的语义。
栈把操作限制在同一端,得到后进先出;队列把插入和删除分离到两端,得到先进先出。
栈和队列的高效来自“只在端点操作”。
操作系统中的等待队列、输入/输出缓冲区和事件循环都依赖这种“受限操作换取可预测成本”的思想。
| 操作 | 含义 |
|---|---|
create() |
创建空栈 |
empty() |
判断栈是否为空 |
full() |
判断栈是否已满 |
push(x) |
把 x 压入栈顶 |
top() |
读取栈顶元素 |
pop() |
删除栈顶元素 |
topAndPop() |
读取并删除栈顶元素 |
栈的核心约束是:只能从栈顶观察和修改。
链式栈把单链表头部作为栈顶;入栈和出栈都只改表头附近的指针。
空栈条件是 topOfStack == null。
static class LinkedStack<T> {
private Node<T> top;
private int size;
void push(T value) {
top = new Node<>(value, top);
size++;
}
T pop() {
if (top == null) {
throw new NoSuchElementException("stack is empty");
}
T value = top.data;
top = top.next;
size--;
return value;
}
T peek() {
if (top == null) {
throw new NoSuchElementException("stack is empty");
}
return top.data;
}
boolean isEmpty() {
return size == 0;
}
int size() {
return size;
}
}LinkedStack 用 top 指向唯一栈顶,链上的先后关系就是后进先出次序。
top 始终指向最近压入且尚未弹出的结点,size 与可达结点数同步;这两个状态共同定义栈。
push(…) 的契约是把新元素变成唯一栈顶,同时保持已有元素的后进先出相对次序。
top 前面,再更新入口;已有结点不移动,因此是 O(1)。
pop(…) 必须返回并移除当前栈顶;空栈时没有合法结果,不能继续修改结构。
top 推向后继;删除的永远是最后压入的元素,保持后进先出。
peek(…) 只读取当前栈顶而不改变任何链接、下标或元素个数。
peek 与 pop 共享同一空栈边界,但只读取 top.data,不会改变任何结构状态。
isEmpty(…) 只判断当前逻辑元素数是否为 0,不需要遍历或改变结构。
size 让这个判断无需遍历链表。
size(…) 只报告当前逻辑规模,不应触发结构遍历或修改。
peek(…) 只读取当前栈顶而不改变任何链接、下标或元素个数。
top 已经直接指向答案,读取数据即可;不改链接也不改长度,所以 peek 是稳定的 O(1)。
数组栈把连续数组的一段作为有效栈区,topOfStack 指向当前栈顶下标。
空栈条件是 \(topOfStack=-1\);满栈条件是 \(topOfStack=capacity-1\)。push/pop 不搬动已有元素,只移动栈顶边界。
static class ArrayStack {
private final String[] data;
private int size;
ArrayStack(int capacity) {
data = new String[capacity];
}
void push(String value) {
if (size == data.length) {
throw new IllegalStateException("stack is full");
}
data[size++] = value;
}
String pop() {
if (isEmpty()) {
throw new NoSuchElementException("stack is empty");
}
int topIndex = --size;
String value = data[topIndex];
data[topIndex] = null;
return value;
}
boolean isEmpty() {
return size == 0;
}
}ArrayStack 用连续数组保存栈元素;数组下标给出物理位置,后进先出次序由 size 的边界维护。
data[0..size),因此非空时 size-1 就是唯一栈顶。
size=0,所以一开始没有有效栈元素。
push 在满栈时拒绝写入,否则把新值放到第一个空槽 data[size] 并扩大有效前缀,整个操作是 O(1)。
pop 先缩短有效前缀,再取出原栈顶并清掉旧引用;其余元素不移动,因此仍是 O(1)。
isEmpty 只检查逻辑长度是否为 0;底层数组即使已经分配,也不代表栈里有元素。
pop(…) 必须返回并移除当前栈顶;空栈时没有合法结果,不能继续修改结构。
–size 仍指向一个真实元素。
观察下面几个字符串。哪些是正确的括号写法?判断时,你在关注什么?
static boolean balancedParentheses(String expression) {
Objects.requireNonNull(expression, "expression");
int balance = 0;
for (int i = 0; i < expression.length(); i++) {
char ch = expression.charAt(i);
if (ch == '(') {
balance++;
} else if (ch == ')') {
if (balance == 0) {
return false;
}
balance--;
}
}
return balance == 0;
}balancedParentheses(…) 判断整个圆括号串是否合法;状态只需记录当前尚未闭合的左括号数量。
balance 只表示当前前缀还有多少个左括号没闭合;一种括号没有类型差异,因此不需要保存每一层的具体身份。
balance 都等于“已打开但尚未关闭”的圆括号个数。
balance==0,当前前缀已经先关闭了不存在的左括号,后续字符无法修复;否则正常抵消一层。
O(1) 辅助空间。
圆括号、方括号、花括号各自维护一个计数器,看起来很自然:每一类都不欠括号,整个字符串是否就一定正确?
([)] 和 ([]) 的每类括号数量都相同,为什么只有一个正确?把对应的左、右括号连接起来,观察它们的闭合关系。
扫描 {[()]}:每读到一个字符,栈里应该留下哪些左括号?当前内层闭合以后,外层如何重新成为栈顶?
扫描 ([)]:错误出现在哪个位置?栈中有同类型的左括号,就一定能配对吗?
static boolean balancedBrackets(String expression) {
Objects.requireNonNull(expression, "expression");
LinkedStack<Character> stack = new LinkedStack<>();
for (int i = 0; i < expression.length(); i++) {
char ch = expression.charAt(i);
if (ch == '(' || ch == '[' || ch == '{') {
stack.push(ch);
} else if (ch == ')' || ch == ']' || ch == '}') {
if (stack.isEmpty()) {
return false;
}
char open = stack.pop();
if ((ch == ')' && open != '(')
|| (ch == ']' && open != '[')
|| (ch == '}' && open != '{')) {
return false;
}
}
}
return stack.isEmpty();
}balancedBrackets(…) 判断三类括号是否按类型和嵌套顺序匹配;待关闭左括号必须按后进先出顺序保存。
LinkedStack,保存当前所有尚未关闭的左括号;栈内容就是扫描前缀仍需记住的全部上下文。
O(n),最坏辅助空间 O(n)。
1. 只有圆括号:判断是否完全匹配。
时间 \(\Theta(n)\),辅助空间 \(\Theta(1)\)。一个 balance 计数器就能保存全部必要状态。
2. 只有圆括号:输出所有未匹配左、右括号的位置。
时间 \(\Theta(n)\)。输出规模最坏为 \(\Theta(n)\);若要在单趟扫描中保留这些位置,最坏还需要 \(O(n)\) 额外存储。
3. 三种括号混合:判断是否完全匹配。
时间 \(\Theta(n)\),辅助空间 \(\Theta(n)\)。除了数量,还必须保存尚未关闭括号的类型和顺序。
4. 三种括号混合:输出所有未匹配左、右括号的位置。
时间 \(\Theta(n)\),辅助空间 \(\Theta(n)\)。栈中还要保存对应的位置,才能在发现冲突或扫描结束后报告它们。
四题的时间下界都是 \(\Omega(n)\):最坏情况下,最后一个字符就能改变答案。第 2、4 题的输出规模本身最坏也是 \(\Theta(n)\)。
这里讨论形式语言中的普通正则表达式。工程正则表达式引擎可能提供更多扩展;经典正则表达式由有限几种组合规则构成。
a;特殊字符要转义,如 \(。ab 表示 a 后面紧跟 b。a|b 表示二选一。a* 为 0 次或更多,a+ 为 1 次或更多,a? 为 0 或 1 次。(ab|cd)* 把子表达式作为一个整体参与重复。[0-9] 表示一个数字;^...$ 常用于约束整串。例如,ab*c 可以匹配 ac、abc、abbc;(cat|dog)s? 可以匹配 cat、dog、cats、dogs;\([0-9]+\) 可以匹配 (7)、(123),但这里只描述一对字面圆括号。
关键直觉: 普通正则表达式可以转成有限自动机。状态数固定,因此它没有一个会随着输入嵌套深度不断增长的记忆区。
有限深度的例子可以直接写成固定模式:
() → \(\)()() → \(\)\(\)(()()) → \(\(\)\(\)\)甚至固定最大嵌套深度 \(K\) 也能做到:
(\(\))*;(\((\(\))*\))*;无法支持任意层嵌套:左括号可能有 1000 层、10000 层,正则表达式不可能把所有深度逐一写完。
结论: 任意深度的括号匹配需要无界记忆。只有一种括号、只判断真伪时,一个可无限增长的计数器就够;多种括号混合时,还必须记住类型顺序,因此自然使用栈 / 下推自动机(pushdown automaton)。支持“递归模式”的工程正则表达式引擎已经超出了这里所说的经典正则表达式能力。
编译器和计算器常把中缀表达式(infix expression)转成后缀表达式(postfix expression),再求值。
| 中缀表达式 | 后缀表达式 |
|---|---|
A*B-C*D# |
AB*CD*-# |
(A+B)*((C-D)*E+F)# |
AB+CD-E*F+*# |
后缀表达式没有括号,操作数次序不变,运算符出现的次序就是执行次序。
中缀:\(2 * 3 - 4 * 5\) 后缀:\(2 3 * 4 5 * -\)
static double evalPostfix(List<Token> tokens) {
LinkedStack<Double> stack = new LinkedStack<>();
for (Token token : tokens) {
if (token.type() == TokenType.NUMBER) {
stack.push(token.value());
continue;
}
if (stack.size() < 2) {
throw new IllegalArgumentException("not enough operands");
}
double right = stack.pop();
double left = stack.pop();
stack.push(apply(token.type(), left, right));
}
if (stack.size() != 1) {
throw new IllegalArgumentException("postfix expression is incomplete");
}
return stack.pop();
}
private static double apply(TokenType type, double left, double right) {
return switch (type) {
case PLUS -> left + right;
case MINUS -> left - right;
case TIMES -> left * right;
case DIVIDE -> {
if (right == 0.0) {
throw new ArithmeticException("division by zero");
}
yield left / right;
}
case NUMBER -> throw new IllegalArgumentException("NUMBER is not an operator");
};
}evalPostfix(…) 从左到右计算后缀表达式;操作数压栈,运算符只消费最近的两个未使用结果。
right、再弹出 left,减法和除法的次序绝不能交换。
apply(…) 只负责把一个运算符作用到两个操作数,并返回这一步的局部计算结果。
apply 只负责普通运算符的数学语义,栈管理留在外层;两部分因此可以独立验证。
NUMBER 也不能进入运算符分支;这些检查保证每次折叠都有定义。
| 当前字符 | 动作 |
|---|---|
| 操作数 | 直接输出 |
( |
入运算符栈 |
) |
弹出并输出,直到遇到 ( |
| 运算符 | 弹出优先级不低于当前运算符的栈顶,再把当前运算符入栈 |
结束符 # |
弹出并输出剩余运算符 |
该算法同样是线性时间。
队列是只允许在一端插入、另一端删除的线性表。
| 端点 | 操作 |
|---|---|
rear |
新元素入队 |
front |
旧元素出队 |
队列也称先进先出结构。
| 操作 | 含义 |
|---|---|
create() |
创建空队列 |
empty() |
判断是否为空 |
full() |
判断是否已满 |
front() |
读取队头 |
back() |
读取队尾 |
enqueue(x) |
在队尾加入 x |
dequeue() |
删除并返回队头 |
若每次出队都把元素整体左移,出队是 \(O(n)\)。
若只移动 front,前面会出现空洞。
循环数组的目标是复用这些空洞。
边界检查必须发生在任何数组读写和下标推进之前。非法的入队或出队操作不应改变队列状态。
static class CircularQueue {
private final String[] data;
private int front;
private int size;
CircularQueue(int capacity) {
data = new String[capacity];
}
boolean offer(String value) {
if (size == data.length) {
return false;
}
int rear = (front + size) % data.length;
data[rear] = value;
size++;
return true;
}
String poll() {
if (size == 0) {
throw new NoSuchElementException("queue is empty");
}
String value = data[front];
data[front] = null;
front = (front + 1) % data.length;
size--;
return value;
}
}CircularQueue 用数组保存物理槽位,用 front 和 size 描述逻辑队列。
front 指向当前队头,size 给出有效元素数;其余逻辑位置通过取模绕回数组开头。
front=0,size=0,因此队列为空。
offer 用 (front+size)%capacity 找到队尾后的空槽;满队列直接拒绝,成功入队只写一个槽并增加 size,所以是 O(1)。
poll 取出 front,清掉旧槽,再按取模推进队头并减小 size;其余元素完全不搬动。
下面切换到教材习题常见的另一种记号:这里 rear 指最后一个有效元素的下标,不再表示上面代码中的“下一次写入位置”。若数组长度为 \(m\),保存队尾下标 rear 和队列长度 length,队头位置为:
\[ front=(rear-length+1+m)\bmod m \]
队空条件:
\[ length=0 \]
队满条件:
\[ length=m \]
队头删除,队尾插入。
static class LinkedQueue<T> {
private Node<T> front;
private Node<T> rear;
private int size;
void offer(T value) {
Objects.requireNonNull(value, "value");
Node<T> node = new Node<>(value, null);
if (rear == null) {
front = node;
} else {
rear.next = node;
}
rear = node;
size++;
}
T poll() {
if (front == null) {
throw new NoSuchElementException("queue is empty");
}
T value = front.data;
front = front.next;
size--;
if (front == null) {
rear = null;
}
return value;
}
boolean isEmpty() {
return size == 0;
}
}LinkedQueue 同时维护队头和队尾,使入队与出队都只改常数个链接。
front 指向下一次出队位置,rear 指向最后一个节点;空队列时两者必须同时为 null。
offer(…) 只在队尾加入新元素;已有元素的先进先出次序不能改变。
front,非空时只改旧尾的 next,因此都是 O(1)。
poll(…) 返回并移除队头元素;其余元素仍保持原来的先进先出次序。
front;若删掉最后一个节点,还必须把 rear 一并清空,否则会留下指向已移除节点的尾指针。
isEmpty(…) 只判断当前逻辑元素数是否为 0,不需要遍历或改变结构。
size 让空判断保持常数时间,也作为两端引用是否应同时为空的逻辑校验。
杨辉三角的前五行是:
\[\begin{array}{ccccccccc} &&&&1\\ &&&1&&1\\ &&1&&2&&1\\ &1&&3&&3&&1\\ 1&&4&&6&&4&&1 \end{array}\]两侧永远是 \(1\);中间每个数都等于上一行相邻两个数之和。
例如第 5 行中的 \(4,6,4\) 来自:
\(1+3=4,\qquad 3+3=6,\qquad 3+1=4\)
生成下一行时,我们其实只需要从左到右读取上一行,不需要保存整个杨辉三角。
以第 4 行生成第 5 行为例:
\(1,3,3,1 \quad\longrightarrow\quad 1,4,6,4,1\)
把边界看成两个 \(0\):
\(0,\boxed{1,3,3,1},0\)
然后依次计算相邻两数之和。问题变成:怎样一边从左到右消费旧的一行,一边把新的一行接到后面?
队列正好适合这个过程。
初始化:队列保存上一行,并在队尾补一个右边界 \(0\);变量 prev=0 表示左边界:\(Q=[1,3,3,1,0],\qquad prev=0\)。
每次重复:先出队得到 \(x\),计算 \(y=prev+x\),再把 \(y\) 入队并令 prev=x。一行生成完毕后,如果还要继续生成下一行,需要先把 prev 重置为 \(0\),并再次在队尾补一个右边界 \(0\)。
给定一个网格:
a 是起点,b 是终点a 到 b 的最短路径从 a 出发,可以把所有位置按距离分层:
a如果始终先处理更早发现的位置,就会把距离较小的一整层处理完,再进入下一层。
这正是先进先出队列的规则。因此:
一个位置出队 → 检查它的未访问邻格 → 给邻格编号并入队
第一次发现 b 时,得到的距离一定最短。
左边是网格中的编号过程,右边始终显示当前队列。队头就是下一次要扩展的位置。
static boolean findPath(boolean[][] open, Position start, Position finish) {
validateGrid(open);
requireInside(open, start);
requireInside(open, finish);
if (!open[start.row()][start.col()] || !open[finish.row()][finish.col()]) {
return false;
}
int rows = open.length;
int cols = open[0].length;
boolean[][] visited = new boolean[rows][cols];
LinkedQueue<Position> queue = new LinkedQueue<>();
queue.offer(start);
visited[start.row()][start.col()] = true;
int[][] delta = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!queue.isEmpty()) {
Position current = queue.poll();
if (current.equals(finish)) {
return true;
}
for (int[] direction : delta) {
int nextRow = current.row() + direction[0];
int nextCol = current.col() + direction[1];
if (0 <= nextRow && nextRow < rows
&& 0 <= nextCol && nextCol < cols
&& open[nextRow][nextCol]
&& !visited[nextRow][nextCol]) {
visited[nextRow][nextCol] = true;
queue.offer(new Position(nextRow, nextCol));
}
}
}
return false;
}
private static void validateGrid(boolean[][] open) {
Objects.requireNonNull(open, "open");
if (open.length == 0 || open[0] == null || open[0].length == 0) {
throw new IllegalArgumentException("grid must be non-empty");
}
int columns = open[0].length;
for (boolean[] row : open) {
if (row == null || row.length != columns) {
throw new IllegalArgumentException("grid must be rectangular");
}
}
}
private static void requireInside(boolean[][] open, Position position) {
Objects.requireNonNull(position, "position");
if (position.row() < 0 || position.row() >= open.length
|| position.col() < 0 || position.col() >= open[0].length) {
throw new IndexOutOfBoundsException(position.toString());
}
}findPath(…) 在网格中返回从起点到终点的一条可行路径;访问标记避免重复搜索同一格。
visited;队列保存“已发现但尚未扩展”的前沿,先进先出规则让它们按距离层次被处理。
validateGrid(…) 只验证输入边界,使后续核心算法可以在合法前提下运行。
requireInside(…) 只验证输入边界,使后续核心算法可以在合法前提下运行。
设网格规模为 \(m\times m\)。
编号阶段每个格子最多入队一次:
\[ O(m^2) \]
路径重构只沿最短路径回退:
\[ O(PathLen) \]
| 维度 | 栈 | 队列 |
|---|---|---|
| 删除规则 | 最近加入者先删除 | 最早加入者先删除 |
| 典型算法 | 深度优先搜索、递归、表达式处理 | 广度优先搜索、缓冲区、调度 |
| 关键指针 | top |
front 与 rear |
| 数组实现 | 单端增长 | 循环数组 |
| 链式实现 | 表头作为栈顶 | 头删尾插 |
Q[m] 保存循环队列,rear 指示队尾,length 表示元素个数。