数据结构——堆栈
堆栈
一、堆栈的定义
1. 逻辑结构
堆栈(Stack),简称栈,是只允许在一端进行插入和删除操作的线性表。它是一种操作受限的线性表。
- 允许插入和删除的一端称为栈顶(Top)
- 另一端(不允许操作的一端)称为栈底(Bottom)
堆栈的操作规则是后进先出(Last In First Out, LIFO)——就像一摞盘子,后放上去的盘子反而先被拿走,而最早放上去的盘子被压在最下面,最后才能被取走。
当 $n = 0$ 时,称为空栈。
为什么叫"堆栈"? 在中文教材中,"堆栈"和"栈"通常混用,指的都是 Stack。严格来说,"堆栈"一词是为了与"堆"(Heap)区分,但实际使用中两者含义相同。不过在操作系统内存管理中,"堆"(Heap)和"栈"(Stack)是两种完全不同的内存区域,不要混淆。
2. 基本运算
堆栈上常见的操作有:
| 运算 | 含义 |
|---|---|
置空栈 InitStack(&S) |
初始化一个空的堆栈 |
判栈空 StackEmpty(S) |
判断堆栈是否为空 |
取栈顶 GetTop(S, &e) |
读取栈顶元素的值(不删除) |
入栈 Push(&S, e) |
将元素 $e$ 压入栈顶 |
出栈 Pop(&S, &e) |
删除栈顶元素,并将其值赋给 $e$ |
其中
&S表示需要修改堆栈本身,不加&表示只读取不修改。
二、堆栈的使用
1. 顺序栈
(1)什么是顺序栈?
顺序栈(Sequential Stack)是指用一组地址连续的存储单元(即数组)依次存储从栈底到栈顶的数据元素。同时附加一个栈顶指针(通常是一个整型变量)来指示当前栈顶元素的位置。
#define MAXSIZE 100 // 堆栈的最大容量
typedef struct {
int data[MAXSIZE]; // 用数组存储栈中元素
int top; // 栈顶指针(指示栈顶元素的下标)
} SqStack; // SqStack 即 Sequential Stack
top指示栈顶元素在数组中的下标。通常约定:top = -1表示空栈top = 0表示栈中有一个元素(下标 0 处)top = MAXSIZE - 1表示栈满
为什么用
top = -1表示空栈? 因为数组下标从 0 开始。空栈时没有任何元素,栈顶指针指向一个不存在的"虚拟位置"——下标 -1,这样当入栈第一个元素时,top先加 1 变成 0,再将元素存入data[0],非常自然。
(2)顺序栈的基本运算
① 置空栈
void InitStack(SqStack *S) {
S->top = -1; // 将栈顶指针置为 -1,表示空栈
}
② 判栈空
int StackEmpty(SqStack S) {
return S.top == -1;
}
③ 入栈
int Push(SqStack *S, int e) {
if (S->top == MAXSIZE - 1) // 栈满
return 0;
S->data[++S->top] = e; // 栈顶指针先加 1,再存入元素
return 1;
}
注意
++S->top的含义:先将top加 1,指向新的空位,再将元素放入该位置。不能写成S->data[S->top++] = e,否则会覆盖原来的栈顶元素。
④ 出栈
int Pop(SqStack *S, int *e) {
if (S->top == -1) // 栈空
return 0;
*e = S->data[S->top--]; // 先取栈顶元素,再将栈顶指针减 1
return 1;
}
⑤ 取栈顶元素(只读,不修改栈)
int GetTop(SqStack S, int *e) {
if (S.top == -1) // 栈空
return 0;
*e = S.data[S.top]; // 直接读取栈顶元素,不移动指针
return 1;
}
对比入栈和出栈的写法:
- 入栈:
data[++top] = e——先移动指针,再放元素- 出栈:
e = data[top--]——先取元素,再移动指针两者是"对称"的操作。
(3)性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 置空栈 | $O(1)$ | 只需重置栈顶指针 |
| 判栈空 | $O(1)$ | 比较 top 是否等于 -1 |
| 取栈顶 | $O(1)$ | 直接通过下标访问 |
| 入栈 | $O(1)$ | 只需修改 top 指针 |
| 出栈 | $O(1)$ | 只需修改 top 指针 |
顺序栈的所有基本操作都是 $O(1)$ —— 因为所有操作都只在栈顶进行,不需要移动其他元素。这与顺序表在中间插入/删除时需要移动大量元素形成了鲜明对比。
2. 链栈
(1)什么是链栈?
链栈(Linked Stack)是堆栈的链式存储结构,即用链表来实现堆栈。
链栈本质上就是一个不带头结点的单链表,并且限制了操作范围——只能在头部进行插入(入栈)和删除(出栈):
- 栈顶指针就是链表的头指针,指向链表的第一个结点
- 入栈相当于在链表头部插入一个新结点
- 出栈相当于删除链表的第一个结点
为什么链栈不需要头结点? 因为栈的所有操作都发生在栈顶(链表头部),如果加上头结点反而会让入栈和出栈的代码变得更复杂。链栈的结构决定了头指针直接指向栈顶元素是最自然的设计。
为什么用链栈? 顺序栈的大小是固定的(
MAXSIZE定死),而链栈可以动态增长,没有容量上限,适合元素个数不确定或变化较大的场景。不过每个结点需要额外存储一个指针域,存储密度较低。
(2)链栈的结点结构
typedef struct StackNode {
int data; // 数据域
struct StackNode *next; // 指针域,指向下一个结点
} StackNode;
链栈本身只需要一个栈顶指针:
typedef struct {
StackNode *top; // 栈顶指针(指向链表的第一个结点)
} LinkStack; // LinkStack 即 Linked Stack
也可以直接用一个
StackNode *top指针来表示链栈,而不必将其包装为结构体。但包装成结构体更符合"栈是一种数据类型"的抽象思想,也方便以后扩展(比如增加一个记录栈大小的字段)。
(3)链栈的基本运算
① 置空栈
注意:对于刚声明的局部变量
LinkStack S;,其top成员是未初始化的垃圾值,因此InitStack只需将S->top置为NULL即可,不能试图去释放"原有结点"——因为根本就不存在原有结点,强行遍历垃圾指针会导致程序崩溃。
void InitStack(LinkStack *S) {
S->top = NULL; // 将栈顶指针置空
}
如果希望销毁一个已有数据的栈(释放所有结点的内存),应使用专门的销毁函数:
void DestroyStack(LinkStack *S) {
StackNode *p = S->top;
while (p != NULL) {
StackNode *q = p; // 保存当前结点
p = p->next; // 指针后移
free(q); // 释放当前结点
}
S->top = NULL; // 最后将栈顶指针置空
}
② 判栈空
int StackEmpty(LinkStack S) {
return S.top == NULL;
}
③ 入栈
int Push(LinkStack *S, int e) {
StackNode *p = (StackNode *)malloc(sizeof(StackNode));
if (p == NULL) return 0; // 内存分配失败
p->data = e;
p->next = S->top; // 新结点指向原来的栈顶
S->top = p; // 栈顶指针指向新结点
return 1;
}
④ 出栈
int Pop(LinkStack *S, int *e) {
if (S->top == NULL) return 0; // 栈空
StackNode *p = S->top; // 指向要删除的栈顶结点
*e = p->data;
S->top = p->next; // 栈顶指针后移
free(p); // 释放原栈顶结点的内存
return 1;
}
⑤ 取栈顶元素(只读,不修改栈)
int GetTop(LinkStack S, int *e) {
if (S.top == NULL) return 0; // 栈空
*e = S.top->data; // 直接读取栈顶结点的数据
return 1;
}
注意:链栈的入栈和出栈操作涉及动态内存分配和释放(
malloc/free),频繁操作时会产生内存碎片开销。而顺序栈只需移动指针,效率更高。因此在元素个数大致已知、追求高效率的场景下,优先使用顺序栈。
三、堆栈的简单应用
1. 括号匹配
编译器在检查代码中的括号是否成对出现时,就用到了堆栈:
- 从左到右扫描字符串,遇到左括号(
{、[、()就入栈 - 遇到右括号(
}、]、))就出栈一个元素,检查是否匹配 - 扫描结束后,如果栈为空,则括号匹配;否则说明有多余的左括号
2. 表达式求值
计算器在计算算术表达式(如 3 + 5 * 2 - 8 / 4)时,需要处理运算符优先级。一种经典方法是用两个栈——一个操作数栈和一个运算符栈来实现:
- 扫描表达式,数字入操作数栈,运算符入运算符栈
- 遇到优先级较低的运算符时,先将栈中优先级高的运算符弹出计算,再将结果压回去
- 最终操作数栈中剩下的就是表达式的结果
该算法由 Dijkstra 提出,称为调度场算法(Shunting Yard Algorithm)。
3. 函数调用与递归
程序在运行过程中,每次调用函数时,系统会将返回地址、参数、局部变量等信息压入一个称为"调用栈"(Call Stack)的内存区域中;当函数返回时,再从栈中弹出这些信息。这也正是 Stack 被称为"堆栈"的由来——函数调用像叠盘子一样层层堆放,后调用的先返回。
递归函数特别依赖栈:以计算阶乘为例——
factorial(5)调用factorial(4),factorial(4)调用factorial(3),依次到factorial(1)返回 1,然后逐层返回计算结果。这一过程完全遵循栈的 LIFO 规则。
四、堆栈类别的对比
顺序栈 vs 链栈
| 对比维度 | 顺序栈 | 链栈 |
|---|---|---|
| 空间大小 | 固定(MAXSIZE),可能溢出或浪费 |
动态,只要内存够就不会溢出 |
| 存储密度 | 高(只存数据) | 较低(额外存指针) |
| 基本操作效率 | 均为 $O(1)$ | 均为 $O(1)$ |
| 内存分配 | 静态分配(编译时确定) | 动态分配(运行时 malloc) |
| 适用场景 | 元素个数已知、追求效率 | 元素个数不确定、变化较大 |
总结:堆栈是一种后进先出的线性表,所有操作都限制在栈顶进行。顺序栈用数组实现,操作简单高效但大小固定;链栈用链表实现,灵活可扩展但每个结点额外占用指针空间。堆栈在括号匹配、表达式求值、函数调用与递归等场景中有广泛应用,是所有数据结构中应用最广泛的之一。

浙公网安备 33010602011771号