1. 栈的基本概念
栈是一种特殊的线性表 ,它只允许在表的一端进行插入和删除操作。这一端称为栈顶 (Top),另一端称为栈底 (Bottom)。栈遵循 后进先出(LIFO,Last In First Out)的原则。
-
压栈(Push):在栈顶插入元素
-
出栈(Pop):从栈顶删除元素
生活中的类比:叠盘子。新盘子总是放在最上面,取盘子也是从最上面先取。后放的盘子先被取走。
示意图:
cpp
压栈方向 →
┌───┐
│ 5 │ ← 栈顶
├───┤
│ 4 │
├───┤
│ 3 │
├───┤
│ 2 │
├───┤
│ 1 │
└───┘
栈底
2. 栈的底层结构选型
栈可以用数组 或链表实现,两者各有特点:
| 实现方式 | 入栈时间复杂度 | 出栈时间复杂度 | 优势 |
|---|---|---|---|
| 数组(顺序栈) | O(1) | O(1) | 缓存友好,实现简单 |
| 链表(链式栈) | O(1) | O(1) | 不会满(除非内存耗尽) |
为什么通常选择数组?
因为栈的操作都在尾部进行,数组在尾部插入/删除的代价非常小(无需移动其他元素),而且连续内存对 CPU 缓存友好。链表则需要额外的指针存储开销。因此,绝大多数栈的实现都采用动态数组。
3. 栈的实现(C语言)
下面我们以动态数组的方式实现一个通用的栈。代码分为三个文件:Stack.h(声明)、Stack.c(定义)、test.c(测试)。
3.1 结构体定义(Stack.h)
cpp
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
// 栈存储的数据类型,可根据需要修改
typedef int STDataType;
// 栈结构体
typedef struct Stack
{
STDataType* arr; // 动态数组指针
int top; // 栈顶指针(指向下一个要插入的位置)
int capacity; // 当前数组的总容量
} ST;
// 函数声明
void StackInit(ST* ps);
void StackDestroy(ST* ps);
void StackPush(ST* ps, STDataType x);
void StackPop(ST* ps);
STDataType StackTop(ST* ps);
int StackSize(ST* ps);
bool StackEmpty(ST* ps);
关键点说明:
-
arr:指向动态分配的数组内存。 -
top:指向下一个待插入的位置 。当栈为空时,top = 0。栈中有效元素的下标范围是[0, top-1]。 -
capacity:数组的当前容量,当top == capacity时表示数组已满,需要扩容。
3.2 初始化(StackInit)
cpp
void StackInit(ST* ps)
{
ps->arr = NULL;
ps->top = ps->capacity = 0;
}
-
将
arr置为NULL,此时未分配任何内存。 -
top和capacity都初始化为0。
为什么 top 初始为 0 而不是 -1?
这是一种常见的设计选择。
top表示下一个空位置的下标 ,这样栈空时top == 0,栈中元素个数恰好等于top,非常直观。如果top指向栈顶元素,则栈空时需要top = -1,元素个数为top+1,容易引起混淆。
3.3 销毁(StackDestroy)
cpp
void StackDestroy(ST* ps)
{
if (ps->arr)
free(ps->arr);
ps->arr = NULL;
ps->top = ps->capacity = 0;
}
-
如果
arr非空,释放内存。 -
将指针置为
NULL,并将top和capacity归零,防止悬空指针被误用。
坑点 :调用
free(ps->arr)后,ps->arr仍指向已被释放的内存(野指针)。务必将其置为NULL,否则后续对ps->arr的判空可能导致错误。
3.4 入栈(StackPush)
cpp
void StackPush(ST* ps, STDataType x)
{
assert(ps); // 确保 ps 非空
if (ps->top == ps->capacity) // 空间不足,需要扩容
{
// 新容量:原容量为0时给4,否则翻倍
int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
STDataType* tmp = (STDataType*)realloc(ps->arr, newCapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail!");
exit(1);
}
ps->arr = tmp;
ps->capacity = newCapacity;
}
ps->arr[ps->top++] = x; // 放入元素,然后 top 自增
}
流程图:
cpp
开始
│
▼
断言 ps 不为空
│
▼
top == capacity ?
│
├─ 否 ──→ 直接放入元素 arr[top++] = x ──→ 结束
│
└─ 是 ──→ 计算新容量
│
▼
realloc 扩容
│
├─ 失败 ──→ 打印错误并退出程序
│
└─ 成功 ──→ 更新 arr 和 capacity
│
▼
放入元素
重点解释:
-
扩容策略:
-
初始容量为 0 时,分配 4 个元素的空间。
-
之后每次扩容为原来的 2 倍。这种倍增策略可以保证平均每次入栈的时间复杂度为 O(1)(摊还分析)。
-
-
realloc 的注意事项:
-
当
ps->arr == NULL时,realloc(NULL, size)等同于malloc(size),所以初始化状态可以直接复用。 -
realloc可能失败,返回NULL。此时原来的内存不会被释放,所以不能直接将ps->arr = realloc(...),而应先用一个临时指针保存返回值,检查成功后再赋值。 -
失败时我们直接
exit(1)终止程序(简单处理)。实际项目中可返回错误码。
-
-
top 的自增 :
ps->arr[ps->top++] = x等价于ps->arr[ps->top] = x; ps->top += 1;。先存值,后移动指针。
入栈过程示意图:
cpp
初始:capacity=4, top=0, arr 指向一块内存
┌───┬───┬───┬───┐
│ │ │ │ │
└───┴───┴───┴───┘
↑
top
Push 1: arr[0]=1, top=1
┌───┬───┬───┬───┐
│ 1 │ │ │ │
└───┴───┴───┴───┘
↑
top
Push 2: arr[1]=2, top=2
┌───┬───┬───┬───┐
│ 1 │ 2 │ │ │
└───┴───┴───┴───┘
↑
top
... 继续 Push 3,4
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │
└───┴───┴───┴───┘
↑
top (此时 top == capacity)
Push 5: 触发扩容,capacity 变为 8
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
top (5)
3.5 出栈(StackPop)
cpp
void StackPop(ST* ps)
{
assert(!StackEmpty(ps)); // 栈不能为空
--ps->top; // 直接让 top 减一
}
-
出栈只需将
top减一,逻辑上元素就被"删除"了。实际上数组中的旧值仍然存在,但后续入栈会覆盖它。 -
必须确保栈非空 ,否则对空栈执行
Pop会导致top变成 -1,后续操作会越界。
为什么不真正 free 内存?
栈顶指针下移后,原来占用的空间仍属于数组的已分配内存,后续入栈时可以直接复用,避免了频繁的内存分配/释放,提升效率。
3.6 取栈顶元素(StackTop)
cpp
STDataType StackTop(ST* ps)
{
assert(!StackEmpty(ps));
return ps->arr[ps->top - 1]; // 栈顶元素下标是 top-1
}
-
只读取,不删除。
-
同样需要断言栈非空。
3.7 获取栈中元素个数(StackSize)
cpp
int StackSize(ST* ps)
{
return ps->top;
}
- 因为
top正好表示元素个数,直接返回即可。
3.8 栈是否为空(StackEmpty)
cpp
bool StackEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
3.9 完整代码汇总(Stack.c)
cpp
#include "Stack.h"
void StackInit(ST* ps)
{
ps->arr = NULL;
ps->top = ps->capacity = 0;
}
void StackDestroy(ST* ps)
{
if (ps->arr)
free(ps->arr);
ps->arr = NULL;
ps->top = ps->capacity = 0;
}
void StackPush(ST* ps, STDataType x)
{
assert(ps);
if (ps->top == ps->capacity)
{
int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
STDataType* tmp = (STDataType*)realloc(ps->arr, newCapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail!");
exit(1);
}
ps->arr = tmp;
ps->capacity = newCapacity;
}
ps->arr[ps->top++] = x;
}
bool StackEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void StackPop(ST* ps)
{
assert(!StackEmpty(ps));
--ps->top;
}
STDataType StackTop(ST* ps)
{
assert(!StackEmpty(ps));
return ps->arr[ps->top - 1];
}
int StackSize(ST* ps)
{
return ps->top;
}
4. 测试代码及使用示例
cpp
#include "Stack.h"
void test01()
{
ST st;
StackInit(&st);
StackPush(&st, 1);
StackPush(&st, 2);
StackPush(&st, 3);
StackPush(&st, 4);
StackPush(&st, 5);
printf("size: %d\n", StackSize(&st)); // 输出 5
// 依次弹出并打印所有元素
while (!StackEmpty(&st))
{
int top = StackTop(&st);
printf("%d ", top);
StackPop(&st);
}
// 输出 5 4 3 2 1 (后进先出)
StackDestroy(&st);
}
int main()
{
test01();
return 0;
}
输出:
cpp
size: 5
5 4 3 2 1
5. 常见坑点与避坑指南
| 坑点 | 后果 | 正确做法 |
|---|---|---|
| 忘记初始化栈结构体 | arr 为随机值,top 随机,操作导致崩溃 |
始终调用 StackInit |
StackPop 或 StackTop 前未判空 |
对空栈操作,top 可能变负,导致越界访问 |
使用 assert(!StackEmpty(ps)) |
realloc 失败后未处理 |
内存泄漏,程序继续运行可能出错 | 用临时指针接收返回值,失败时处理错误 |
销毁后未置 NULL |
再次使用该栈时可能误判 arr 不为空而访问非法内存 |
ps->arr = NULL; |
top 含义混淆(指向栈顶元素还是下一个空位) |
入栈、出栈、取栈顶的逻辑全错 | 统一设计,建议 top 指向下一个空位 |
扩容时直接 ps->arr = realloc(...) |
如果 realloc 失败返回 NULL,原内存丢失 |
使用临时变量 |
| 多线程环境下未加锁 | 数据竞争 | 栈一般用于单线程或需同步 |
6. 栈的应用场景
-
函数调用栈:保存函数返回地址、局部变量。
-
表达式求值:中缀转后缀、计算器实现。
-
括号匹配:LeetCode 经典题目(有效的括号)。
-
撤销操作(Undo):编辑器中的撤销栈。
-
浏览器的后退功能:每次访问页面入栈,后退即出栈。
7. 复杂度总结
| 操作 | 时间复杂度 |
|---|---|
| 入栈(Push) | 均摊 O(1) |
| 出栈(Pop) | O(1) |
| 取栈顶(Top) | O(1) |
| 判空(Empty) | O(1) |
| 获取大小(Size) | O(1) |
8. 扩展:链式栈的简单对比
如果使用链表实现栈,通常采用头插法 (让头结点作为栈顶),这样入栈和出栈都是 O(1)。但需要额外的 next 指针,且每个节点单独分配内存,缓存局部性较差。代码结构大致如下:
cpp
typedef struct StackNode {
STDataType data;
struct StackNode* next;
} StackNode;
typedef struct LinkedStack {
StackNode* top;
int size;
} LinkedStack;
不过,在绝大多数场景下,动态数组实现的顺序栈是更优的选择。
总结:栈是一种简单但极其重要的数据结构。掌握它的数组实现,不仅要理解其逻辑,还要注意内存管理、边界条件等细节。希望本文能帮助你彻底搞懂栈的原理与实现。如果有任何疑问,欢迎留言讨论!