栈(Stack)
栈是一口只能从顶部开口的桶:最后放进去的盘子必须最先拿出来,后进先出(LIFO),像叠罗汉一样只能动最上面那个。
核心思想
栈是受限的线性表:只允许在一端(栈顶)做插入(压栈 Push)和删除(出栈 Pop),另一端(栈底)封死。这种「只能动一端」的限制反而造就了它独特的适用场景——凡是需要回溯、撤销、嵌套匹配、深度优先的地方,栈几乎都是首选。
我用动态数组实现栈(也可以用链表):
arr:动态数组,存数据。top:栈顶标记,约定指向栈顶元素的下一个空位(初始 0)。capacity:当前容量。
top 的两种约定对比:
| 约定 | 初始值 | 栈顶元素 | 元素个数 | 空栈判断 |
|---|---|---|---|---|
| 指向栈顶下一个空位(本项目) | 0 | arr[top-1] | top | top == 0 |
| 指向栈顶元素 | -1 | arr[top] | top+1 | top == -1 |
本项目用第一种(top=0),好处:元素个数直接等于 top,STSize 直接返回 top,空栈判断 top==0。代价:取栈顶要 arr[top-1]。
数据结构定义
typedef struct stack
{
STDataType *arr; // 动态数组
int top; // 指向栈顶下一个空位
int capacity; // 容量
}ST;
内存模型(压了 1,2,3,top=3,capacity=4):
arr -> [ 1 ][ 2 ][ 3 ][ ]
0 1 2 3
↑ ↑
栈顶 top=3(下一个写入位)
capacity=4
只能从 top 这一端进出,底端(下标0)封死
关键操作实现
初始化 STInit
void STInit(ST *pst)
{
pst->arr = NULL; // 延迟分配
pst->top = 0; // 指向栈顶下一个空位
pst->capacity = 0;
}
逐行解释:和顺序表一样延迟分配,top=0 表空栈。capacity 必须归零,否则第一次 Push 时 top==capacity 判断失效,跳过扩容直接写 NULL。
入栈 STPush
void STPush(ST *pst, STDataType x)
{
assert(pst);
if (pst->top == pst->capacity) // 栈满,扩容
{
int newcapacity = pst->capacity == 0 ? 4 : 2 * pst->capacity;
STDataType *tmp = (STDataType *)realloc(pst->arr, newcapacity*sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
exit(1); // 扩容失败必须终止,否则下面写入空指针段错误
}
pst->arr = tmp;
pst->capacity = newcapacity;
}
pst->arr[pst->top++] = x; // 在 top 位写入,top 再+1
}
逐行解释:和顺序表 SLCheckcapacity + SLPushBack 一模一样的套路。先判满扩容,用 tmp 接 realloc 防失败泄漏,扩容失败 exit(1) 终止。
修复前的 bug:原来扩容失败只 return,但函数没真正退出(return 在 if 内确实会返回函数),可是——等等,return 后函数确实返回了,不会执行到 pst->arr[pst->top++]。那 bug 在哪?问题在于 pst->arr 此时仍是旧指针(扩容前),如果旧容量已满,top==capacity,arr[top] 越界写。更隐蔽的是:扩容失败时旧内存还在但已满,继续写入是越界。改成 exit(1) 最干脆——内存都申请不到,程序没救了。
pst->arr[pst->top++] = x:后置++,先在 top 位写,再 top+1,指向新的空位。
出栈 STPop
void STPop(ST *pst)
{
assert(pst);
assert(pst->top > 0); // 空栈不能出
pst->top--; // 逻辑删除,top 往下移一格
}
逐行解释:出栈只需 top--,把「栈顶」下移一位。原 arr[top-1] 的数据还在内存里,但逻辑上已出栈,下次 Push 会覆盖它——惰性删除,O(1) 且不碰内存。assert(top > 0) 防止空栈出栈导致 top 变 -1(下溢),后续 arr[-1] 越界。
判空 STEmpty
bool STEmpty(ST *pst)
{
assert(pst);
return pst->top == 0; // top==0 即空栈
}
逐行解释:直接比较 top,O(1)。
取栈顶 STTop
STDataType STTop(ST *pst)
{
assert(pst);
assert(pst->top > 0); // 空栈没有栈顶
return pst->arr[pst->top - 1]; // top 指向空位,栈顶是 top-1
}
逐行解释:因为 top 指向「栈顶下一个空位」,真正的栈顶元素在 arr[top-1]。assert(top>0) 保证 top-1 >= 0,避免 arr[-1] 越界。
易错点:忘了 -1,直接 arr[top] 读到的是未初始化的空位(垃圾值)。这是 top=0 约定下最常见的错。
求大小 STSize
int STSize(ST *pst)
{
assert(pst);
return pst->top; // top 恰好等于元素个数
}
逐行解释:top=0 约定的红利——元素个数就是 top,直接返回。
销毁 STDestroy
void STDestroy(ST *pst)
{
free(pst->arr); // 释放动态数组
pst->arr = NULL; // 置空防悬空
pst->capacity = pst->top = 0;
}
逐行解释:释放数组,置空指针和计数器。栈的 ST 结构体通常由调用者在栈上定义(如 ST st;),所以只释放内部的 arr,不动 pst 本身。
复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 入栈 Push | 均摊 O(1) | 扩容时 O(n),均摊后常数 |
| 出栈 Pop | O(1) | 只改 top |
| 取栈顶 Top | O(1) | 数组下标访问 |
| 判空 / 求大小 | O(1) | 比较 top |
| 销毁 | O(1) | 只 free 一次(数组整体释放) |
栈的所有核心操作都是 O(1),这是它高效的根本。
常见陷阱清单
top约定混淆:top=0取栈顶用arr[top-1],top=-1用arr[top],两者代码完全不同,混用必错。- 空栈出栈不判空 →
top下溢为 -1,越界。 - 扩容失败处理不当 → 继续写入导致越界/段错误。
#define N 10;残留:头文件里这行多了分号且N从未使用,是垃圾代码(虽不影响编译,但不规范)。- 用
free删栈顶:栈是惰性删除,Pop不该free,否则arr整块内存被破坏。
我的实现 vs 教科书实现
- 整体标准:动态数组实现,扩容、判空、取顶齐全。
- 残留垃圾:
Stack.h第 8 行#define N 10;多分号且无用,应删除。 - 缺接口:没有
STClear(清空不销毁)。 top两种约定:代码注释保留了top=-1的写法作为对照,便于理解约定差异。- vs 链式栈:数组栈 cache 友好、实现简单;链式栈无扩容问题但每节点有指针开销。
拓展知识点
变体与进阶
- 链式栈:用单链表头插/头删实现(头做栈顶),避免扩容,但每节点多一个指针。
- 共享栈:两个栈共享一个数组,一个从底增长一个从顶增长,省空间,适合两个栈使用率互补的场景。
- 单调栈:维护单调递增/递减序列,O(n) 解决「下一个更大元素」「柱状图最大矩形」等问题,是面试高频考点。
- 函数调用栈:程序运行时的调用栈本质就是栈结构,递归就是压栈,返回就是出栈,栈溢出 = 递归太深。
常见考点(必刷)
- 括号匹配:左括号压栈,右括号弹栈匹配,栈空即匹配成功。
- 表达式求值:中缀转后缀(调度场算法),后缀求值,都靠栈。
- DFS 非递归:用栈模拟递归的调用过程。
- 浏览器前进/后退:两个栈,一个存后退历史一个存前进历史。
- 两个栈实现队列:一个栈入、一个栈出,出栈为空时把入栈全部倒过来。
与其他结构的关系
栈是受限线性表,和队列(FIFO)是对照结构。栈可用顺序表或链表实现(本项目用顺序表/数组)。栈是递归、DFS、表达式处理的基础设施。两个栈可以模拟队列,两个队列也能模拟栈(但效率不同)。
一句话记忆法
栈:后进先出(LIFO),只动栈顶一端;top=0 约定下栈顶是 arr[top-1],个数就是 top,出栈只 top-- 不 free。
#DataStructure #CS #Stack #LIFO #受限线性表
3543



被折叠的 条评论
为什么被折叠?



