返回上一级
2156 字
11 分钟
栈

p14(8 分钟)、p15(12.7 分钟)、p16(10.6 分钟)、p17(15.8 分钟)、p18(13.7 分钟)

栈 ADT#

三个现实里的例子:一摞餐盘、汉诺塔、只能从一侧开口的网球盒。

共同点是只能从同一端进出,这一端叫栈顶。这不只是个性质,而是硬性约束——只有栈顶能访问。

餐盘那个例子可以抬杠说能抽中间的盘子,但汉诺塔和网球盒不行:要拿一件,得先把压在上面的全移走。

一句话定义:只能从一端插入和删除的列表,那一端就是栈顶。

接口就四个:

push 压入一个元素
pop 弹出最新的那个
top 只看栈顶,不移除
isEmpty 是否为空

push/pop 是基本操作,top 和 isEmpty 通常也会有。没有 size / count 这类操作。

这四个操作全是 O(1)。

LIFO:最后压进去的最先弹出来,所以叫「后进先出」。

初始 空栈
push(2) [2]
push(10) [2, 10]
pop() 弹出 10 → [2]
push(7) [2, 7]
push(5) [2, 7, 5]
top() 返回 5
isEmpty() 假
pop() 弹出 5

应用场景:

  1. 函数调用(前面链表部分讲过)
  2. 递归——本质也是一连串函数调用,只是调的都是同一个函数
  3. 编辑器撤销(Ctrl+Z)
  4. 编译器检查括号是否匹配

数组实现#

术语约定#

用数组 A 存栈,约定 A[0] 到 A[top] 这一段属于栈,其余是空闲
top 是「栈顶元素的索引」
空栈时 top = -1
数组大小由宏 MAX_SIZE 定义为 101

代码#

#define MAX_SIZE 101
int A[MAX_SIZE];
int top = -1;
void push(int x) {
if (top == MAX_SIZE - 1) { /* 溢出判断 */
printf("Error: stack overflow\n");
return;
}
A[++top] = x; /* 前置递增:先递增再赋值 */
}
void pop() {
if (top == -1) { /* 空栈判断 */
printf("Error: No element to pop\n");
return;
}
top--; /* 本实现不返回元素,只递减 */
}
int Top() {
return A[top];
}
int isEmpty() {
if (top == -1) return 1;
return 0;
}

几个要点:

  • A[++top] = x 用前置递增,等价于 top++; A[top] = x;
  • 判满条件是 top == MAX_SIZE - 1(栈顶索引等于数组最高可用索引)
  • pop 不返回元素,只是 top--(有些语言的库会把 pop 与 top 合并,即 pop 同时移除并返回)
  • 数组与 top 声明为全局变量,函数内可直接访问,不必传参

演示运行:push 3、5、10 → pop 一次(10 出栈)→ push 12。输出与预期吻合。

弹出后不必清空旧单元:不在栈内的单元格里是什么垃圾数据无所谓,下次 push 会覆盖。

局限与应对#

栈占满整个数组时再 push 会溢出(overflow),push 失败。两种应对:

① push 检查并抛错(这并非很好的行为表现)
② 用动态数组:溢出时新建更大数组、复制旧内容、删除小数组

复制成本 O(n),与栈中元素数成正比。

最优扩容策略:新数组大小 = 旧数组的两倍。

摊还分析:

N 次 push 总耗时与 N 成正比(O(n))
除以 N → 均摊每次 push 为 O(1)
单次 push 最坏 O(n)

「均摊 O(1)」和「单次最坏 O(n)」要一起说。

链表实现#

栈顶就是链表头部#

尾部:插入要遍历到最后一个节点,删除要走到倒数第二个节点 → O(n) ✗
头部:只改常数个链接 → O(1) ✓

所以:入栈 = 头部插入,出栈 = 头部删除。

栈顶指针宁愿命名为 top,而不是 head。

代码#

struct Node {
int data;
struct Node *next;
};
struct Node *top = NULL; /* 初始化为 NULL */
void push(int x) {
struct Node *temp = malloc(sizeof(struct Node));
temp->data = x;
temp->next = top;
top = temp;
}
void pop() {
if (top == NULL) { /* 通过 top 是否为空判断栈空 */
printf("Error: No element to pop\n");
return;
}
struct Node *temp = top;
top = top->next;
free(temp); /* 必须释放 */
}

演示:push 2(新节点地址 100)→ push 5(新节点地址 250,top 变为 250)。

优势#

不像数组实现,不必担心溢出(除非机器自身内存耗尽)。

每个节点多用一点内存存地址,但按需分配、不用即释放,push/pop 更「优雅」。

复杂度:push / pop 均为 O(1)。

应用一:反转字符串#

用栈的做法#

① 从左到右把每个字符压入字符栈
② 再从索引 0 开始,把栈顶字符写到数组当前位置,然后 pop
③ 循环直到栈空

因为 LIFO,弹出顺序与压入相反,于是完成反转。

void reverse(char C[], int n) {
stack<char> S;
for (int i = 0; i < n; i++) S.push(C[i]);
for (int i = 0; i < n; i++) {
C[i] = S.top();
S.pop();
}
}

对象是 C 风格字符串(以 \0 结尾);空字符只标记结尾,不属于字符串内容。

运行验证:输入 hello、my code school 均正确。

复杂度:两个循环各 O(n),非嵌套(前后串行)→ 时间 O(n);栈中额外空间与 n 成正比 → 空间 O(n)。

不用栈的更优解#

int i = 0, j = n - 1;
while (i < j) {
swap(C[i], C[j]);
i++;
j--;
}

时间 O(n)、空间 O(1);交换次数约为 n/2。

因为空间复杂度,双指针法优于栈法。用栈只适合输入量小、时间空间不敏感、图实现直观的场合。

应用二:反转链表#

三种解法:

迭代 时间 O(n) 空间 O(1)
递归 时间 O(n) 空间 O(n)
显式栈 时间 O(n) 空间 O(n)

递归用的是「隐式栈(implicit stack)」——没有显式创建栈,但仍在用栈,也就是函数调用栈。

显式栈算法#

节点地址 100 → 150 → 250 → 300。

① 用临时指针遍历链表,把「每个节点的指针/引用」压栈(压的是指针,不是数据)
② 全部压完后逐个弹出,得到的就是「逆序的节点引用」
③ temp = 栈顶地址;head = 该地址(此时 head = 300,temp 也指向 300)
④ 循环 while (!S.empty()):
建立反向链接 temp->next = 栈顶地址
弹出
temp = temp->next(移动到下一个)
⑤ 循环退出后,最后一行 temp->next = NULL
——把反转后最后一个节点的链接置空

结论:用栈使反转链表更简单;仅仅按逆序打印链表元素用栈尤其容易。

应用三:检查括号匹配#

问题:给定只含 ()、{}、[] 的表达式字符串,判断括号是否平衡。括号内的内容无关紧要。这是编译器的常见任务。

平衡的定义#

每个开符号都有对应且顺序正确的闭符号
类型必须匹配({ 必须配 },「两者不会相互抵消」)
每个开括号必须在右侧找到匹配的闭括号,每个闭括号必须在左侧找到匹配的开括号
一个括号只有当「其后打开的所有括号都已关闭」时才能闭合

核心表述:任何闭合操作都应当针对最后那个未闭合的括号。

一个错误做法#

只统计三种符号的开/闭数量相等。反例:

)( 数量相等但不平衡
[ ( ] ) 与 [ ( ) ] 数量完全相同,但前者不平衡

正确算法#

创建字符栈 S,从左到右扫描:
若字符是 ( { [ → push(S, c)
否则若是三种闭符号之一 → 两种情况判失败:
① 栈为空
② 栈顶字符与该闭符号不成对
→ 返回 false(不平衡,直接退出)
否则 pop(S)
扫描结束:栈空 → 平衡;栈不空 → 不平衡

三个用例#

以闭符号开头(如 `)`):第一次进入 else 分支,栈为空 → 直接返回 false
`[ ( ] ...`:先压 `[`、再压 `(`,遇到 `]` 时栈顶是 `(`,本应是 `[` → false
`[ ( ) ]` 型:逐对配对弹出,扫描结束栈为空 → 平衡 ✓

为什么用栈#

整个过程总是在列表的同一端插入/移除,最后进到列表里的都会最先出去,这正是栈的定义;栈的插入/删除是恒定时间。

复杂度(这里没有明确给出,按流程推导):每个字符一次 push/pop,均为 O(1) → 时间 O(n)、空间 O(n)。

栈的两种实现对比#

数组实现链表实现
pushO(1)O(1)
popO(1)O(1)
容量固定,会溢出不固定(除非内存耗尽)
扩容需要新建 + 复制,均摊 O(1)按需分配
内存可能浪费每节点多一个指针开销
栈顶top 索引(初值 −1)top 指针(初值 NULL)

坑#

  • 数组实现:top 初值 −1,判空 top == -1,判满 top == MAX_SIZE - 1。
  • push 用 A[++top] = x(前置递增),别写成 A[top++]。
  • 这个实现的 pop 不返回元素,只是 top--。
  • 链表实现:入栈/出栈都在头部,在尾部做就是 O(n);栈顶指针叫 top,不叫 head。
  • 括号匹配不能只数数量,反例是 )( 和 [(])。
栈
https://me.622168.xyz/posts/data-structure/07-stack/
作者
Shaw 的小屋
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0