欢迎光临
我们一直在努力

栈:为什么你的代码能“撤销”?从原理到手写实现

栈(Stack):为什么你的代码能"撤销"?

前言

你写过这样的代码吗?

printf("A");
fork();

你以为输出一个 A,结果蹦出两个。为什么?因为 printf 的数据躲在缓冲区里,而 fork 把它复制了一份。这个"隐身副本"的幕后推手,就是一个叫栈的数据结构。

栈是最简单的数据结构,没有之一。但正是这个"最简单"的结构,支撑着函数调用、表达式求值、浏览器后退、编辑器撤销……你日常用的每一个功能,底层都有它的身影。

本篇不堆概念,直接从你手写的 Stack.c 出发,边看代码边理解栈的每一个设计决策。

环境:C99 / GCC 11.4 / x86_64 Linux


📋 目录

  • 一、栈是什么?一摞盘子就够了
  • 二、为什么计算机离不开栈?
  • 三、我的栈:动态数组 + 自动扩容
  • 四、入栈:扩容的"小心机"
  • 五、出栈:不释放内存的聪明做法
  • 六、四种数据结构大乱斗
  • 七、踩过的坑
  • 八、总结

一、栈是什么?一摞盘子就够了

栈(Stack)就一件事:后进先出(LIFO)。

生活里最像栈的东西是什么?一摞盘子。你只能从最上面放盘子,也只能从最上面拿盘子。最先放上去的盘子,被压在最底下,最后才能拿到。

放盘子:10 → 20 → 30 → 50(最后放)
┌─────┐ ← 栈顶(最新)
│ 50 │
├─────┤
│ 30 │
├─────┤
│ 20 │
├─────┤
│ 10 │ ← 栈底(最早)
└─────┘
拿盘子:先拿 50,再 30,再 20,最后 10

栈只有五个动作:

动作干什么快不快
push 往栈顶放一个元素 O(1)
pop 从栈顶拿走一个元素 O(1)
top 看看栈顶是什么(不拿走) O(1)
isEmpty 栈是不是空的 O(1)
size 栈里有几个元素 O(1)

💡 一句话:栈就是"只能从顶部操作"的列表,所有操作都是 O(1)。


二、为什么计算机离不开栈?

栈看起来简单,但它在计算机里无处不在。来看看几个你可能每天都在用的场景:

场景栈在干什么
你按了编辑器的 Ctrl+Z 每次操作压栈,撤销时弹栈恢复
浏览器后退按钮 访问的页面依次压栈,后退时弹出
函数调用 每次调用把返回地址和局部变量压栈,返回时弹栈
计算 3 + 5 * 2 表达式求值需要栈来暂存操作符
检查括号是否匹配 左括号压栈,右括号弹栈比对
DFS 深度优先搜索 用栈模拟递归,避免栈溢出

函数调用栈的"幕后故事":

假设你的代码是这样的:

void bar() { int c = 3; }
void foo() { bar(); }
int main() { int a = 1; foo(); }

当 bar() 正在执行时,内存里的栈长这样:

┌─────────────────────┐ ← 栈顶(最新)
│ bar 的栈帧 │ ← 局部变量 c=3
├─────────────────────┤
│ foo 的栈帧 │ ← 返回地址
├─────────────────────┤
│ main 的栈帧 │ ← 局部变量 a=1
└─────────────────────┘ ← 栈底

bar() 执行完 → 弹栈 → 回到 foo() → foo() 结束 → 弹栈 → 回到 main()。

这就是栈在操作系统层面最核心的角色:维护调用上下文。每次函数调用 = 压栈,每次返回 = 弹栈。

💡 小结:栈的价值在于"维护上下文"。函数调用、撤销操作、括号匹配……所有需要"记住之前做过什么"的场景,本质上都是栈。


三、我的栈:动态数组 + 自动扩容

我实现的栈基于动态数组,核心就三个字段:

typedef struct {
DataType* data; // 指向动态数组的指针
int size; // 当前有多少个元素
int capacity; // 当前数组总共能放多少个
} Stack;

内存长什么样?

capacity=4, size=0(刚初始化,空空如也)
data ──→ ┌─────┬─────┬─────┬─────┐
│ │ │ │ │
└─────┴─────┴─────┴─────┘
↑
size=0(下一个插入位置)

capacity=4, size=3(放了 10、20、30)
data ──→ ┌─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ │
└─────┴─────┴─────┴─────┘
↑ ↑
size=3 capacity=4(还能塞1个)

初始化:申请第一块地

ErrorCode StackInit(Stack* stack) {
if (NULL == stack) return ERR_INVALID_PARAM;
stack->capacity = INIT_CAPACITY;
stack->size = 0;
stack->data = malloc(sizeof(DataType) * stack->capacity);
if (stack->data == NULL) return ERR_MALLOC;
return SUCCESS;
}

就三件事:设容量、设大小为 0、申请内存。C 语言数据结构的标准起手式。

销毁:干干净净地走

ErrorCode StackDestroy(Stack* stack) {
if (NULL == stack) return ERR_INVALID_PARAM;
free(stack->data);
stack->data = NULL;
stack->capacity = 0;
stack->size = 0;
return SUCCESS;
}

释放内存 → 指针置 NULL → 归零。防止悬空指针的经典操作。

💡 小结:栈的底层就是动态数组。data 指向内存,size 记录用了多少,capacity 记录总共多大。当 size == capacity 时,就要扩容了。


四、入栈:扩容的"小心机"

入栈本身很简单——把数据放到 size 位置,然后 size++。但问题来了:数组满了怎么办?

我的做法是两倍扩容:

static ErrorCode EnsureEnoughSpace(Stack* stack) {
if (stack->capacity == stack->size) {
int new_capacity = stack->capacity * 2;
DataType* tmp = realloc(stack->data, new_capacity * sizeof(DataType));
if (tmp == NULL) {
LOG("申请空间失败");
return ERR_MALLOC;
}
stack->data = tmp;
stack->capacity = new_capacity;
}
return SUCCESS;
}

ErrorCode StackPush(Stack* stack, DataType val) {
if (NULL == stack) return ERR_INVALID_PARAM;
ErrorCode ret = EnsureEnoughSpace(stack);
if (ret != SUCCESS) return ret;
stack->data[stack->size] = val; // 插入
stack->size++; // 大小+1
return SUCCESS;
}

扩容全过程可视化:

阶段1: capacity=4, size=0(空的)
┌─────┬─────┬─────┬─────┐
│ │ │ │ │
└─────┴─────┴─────┴─────┘

阶段2: push(10)(20)(30)(40) 后,size=4,满了
┌─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ ← 下次 push 触发扩容!
└─────┴─────┴─────┴─────┘

阶段3: push(50) → realloc 扩容 4→8
旧数组 ──→ 拷贝数据 ──→ 新数组(capacity=8)
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘

阶段4: capacity=8, size=5,继续 push…

为什么用 realloc 而不是 malloc + memcpy?

realloc 会先尝试在原内存块后面直接扩展,如果后面有空位就不需要拷贝数据,一步到位。只有没空间时才会分配新块并拷贝。我的代码用 realloc 一步完成"申请+拷贝+释放旧空间"。

两倍扩容为什么不是三倍、四倍?

  • 扩太少(+1):频繁扩容,总复杂度变成 O(n²)
  • 扩太多(×10):浪费内存
  • 扩两倍:摊还复杂度 O(1),内存浪费可控——中庸之道

💡 小结:入栈的核心不是"放进去",而是"满了怎么办"。两倍扩容 + realloc 是经典组合拳,摊还 O(1) 的同时控制内存浪费。


五、出栈:不释放内存的聪明做法

出栈更简单——size– 就行了。

ErrorCode StackPop(Stack* stack) {
if (NULL == stack) return ERR_INVALID_PARAM;
if (StackIsEmpty(stack)) return ERR_DATA_NOT_EXISTS;
stack->size—; // 关键:只做逻辑删除
return SUCCESS;
}

注意:我没有 free 内存!只是把 size 减 1,相当于"逻辑删除"。

出栈前:
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ │
└─────┴─────┴─────┴─────┴─────┴─────┘
↑ ↑
size=5 capacity=6

StackPop() → size–

出栈后:
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ │ ← 50 变成"垃圾数据"
└─────┴─────┴─────┴─────┴─────┴─────┘
↑ ↑
size=4 capacity=6

下次 push(60) 会直接覆盖 50 的位置:
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 10 │ 20 │ 30 │ 40 │ 60 │ │ ← 60 覆盖了 50
└─────┴─────┴─────┴─────┴─────┴─────┘

为什么不 free 内存?

因为下次 push 会直接复用这块空间。如果每次 pop 都 free,下次 push 又要 malloc,来回分配释放的开销比留着内存大得多。这是空间换时间的经典策略。

当然,如果你真的想释放内存,可以在 size 降到 capacity 的一半时触发缩容(类似扩容但方向相反)。不过我的实现里没有做,保持简洁。

判空和获取大小:

bool StackIsEmpty(const Stack* stack) {
if (NULL == stack) return false;
return stack->size == 0;
}

int StackSize(const Stack* stack) {
if (NULL == stack) return 0;
return stack->size;
}

💡 小结:出栈不做物理删除,只做 size–。这是性能优化的常见技巧——用一点闲置内存换取避免频繁分配释放的开销。


六、四种数据结构大乱斗

前面我写了四个数据结构:栈(Stack)、双端队列(Deque)、普通队列(NormalQueue)、循环队列(RingQueue)。它们各有所长,来看看怎么选:

核心特性对比

维度栈双端队列普通队列循环队列
规则 后进先出 两端进出 先进先出 先进先出
插入 仅栈顶 队头+队尾 仅队尾 仅队尾
删除 仅栈顶 队头+队尾 仅队头 仅队头
入栈/队 O(1)均摊 O(1) O(1) O(1)
出栈/队 O(1) O(1) O(n) O(1)
随机访问 ❌ ❌ ✅ ❌
扩容 realloc两倍 map两倍+块 顺序表扩容 固定容量

一句话选型

你的需求选哪个为什么
撤销/回溯 栈 LIFO 天然适合"倒序恢复"
两端都要操作 双端队列 头尾都是 O(1)
数据小、求快 普通队列 复用顺序表,代码少
固定缓冲区 循环队列 零分配,性能最稳
消息队列 循环队列 固定容量防内存暴涨
LRU 缓存 双端队列+哈希 两端操作+快速查找
表达式求值 栈 嵌套结构天然匹配

四种结构优缺点一览

栈:

  • ✅ 实现最简单,所有操作 O(1),内存连续缓存友好
  • ❌ 只能访问栈顶,扩容需拷贝,出栈不释放内存

双端队列:

  • ✅ 两端操作都是 O(1),块级管理减少碎片
  • ❌ 实现复杂,指针操作容易出错

普通队列:

  • ✅ 代码复用高,实现简单
  • ❌ 出队 O(n),效率低

循环队列:

  • ✅ 入队出队都是 O(1),固定内存零分配
  • ❌ 容量固定,牺牲一个位置,取模有开销

💡 小结:没有最好的数据结构,只有最合适的。栈适合嵌套回溯,双端队列适合两端操作,普通队列适合快速开发,循环队列适合高性能缓冲。理解每种结构的代价,才能做出正确的选型。


七、踩过的坑

坑原因怎么避
空栈 pop/top 没判空直接访问 size-1 所有操作开头做 isEmpty 检查
扩容后没更新 capacity 旧值导致错误判断满 realloc 后同步更新
销毁后指针悬空 没置 NULL 导致野指针 Destroy 后 data=NULL
销毁后继续使用 悬空指针被再次访问 调用方 Destroy 后置 NULL

八、总结

  • 栈 = 后进先出:我的实现基于动态数组 + realloc 两倍扩容,入栈出栈均摊 O(1)。
  • 扩容是核心设计:两倍扩容保证摊还 O(1),比每次+1 高效得多(避免 O(n²))。
  • 出栈不释放内存:size– 逻辑删除,下次 push 直接复用——空间换时间的经典策略。
  • C 语言工程规范:参数校验 → 边界检查 → 核心逻辑 → 错误码,这套模式适用于所有手写数据结构。
  • 选型心得:栈适合嵌套回溯,双端队列适合两端操作,普通队列适合快速开发,循环队列适合高性能缓冲——受限不是弱化,而是聚焦。
  • 赞(0)
    未经允许不得转载:171主机测评 » 栈:为什么你的代码能“撤销”?从原理到手写实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址