建议先阅读: 容器概览 — 理解栈的底层存储(数组 vs 链表)和扩容机制。


原理


栈(Stack)是受限的线性数据结构——所有操作均发生在同端(栈顶),遵循后进先出(LIFO: Last In, First Out)。这种”单向开口”的约束看似限制了灵活性,实则赋予了栈一种精确的时序语义:最近入栈的数据最优先被处理。

栈在哪里

在写第一行栈代码之前,先看它已经在哪运转:每次函数调用都在向调用栈压入一个新栈帧,return 就是弹栈;编辑器的 Ctrl-Z 弹出的正是最近一次操作;浏览器的后退按钮弹出的是上一个页面;编译器判断 ({[]}) 是否配对靠的是括号栈。这些场景的共同主题——需要按”最近发生优先”的次序回溯——正是 LIFO 的用武之地。本章沿这条线展开:先把基础操作写扎实,再逐一拆解括号匹配与表达式求值,最后深入到硬件调用栈的底层机制。

操作描述时间复杂度
push(x)将 x 压入栈顶
pop()弹出栈顶元素
top()不弹出,只读栈顶
empty()是否为空
size()元素个数

栈的物理实现

栈作为抽象数据类型,有两种物理实现方式。选择取决于对容量、内存开销和缓存行为的需求。

数组实现(连续存储):

  • 在内存中预留一块连续区域,用 top 索引标记栈顶
  • 扩容策略见 容器章节 的均摊分析
  • 缓存友好——push/pop 都是顺序访问栈顶附近的内存

链表实现(节点存储):

  • 无容量上限,每个节点单独 malloc
  • 每次 push 带有堆分配开销(~50-200ns),pop 有释放开销
  • 由于只在头部操作,链表的”指针追踪”问题比完整链表遍历轻微——但连续 10 万次 push/pop 的累计堆开销远超数组版

深入底层

硬件级栈:x86-64 调用栈

栈不仅是抽象数据结构,更是 CPU 硬件原生支持的核心机制。x86-64 架构通过 rsp(栈指针寄存器)和 rbp(基址指针寄存器)提供硬件级栈支持:

flowchart TB
  H["⬆ 高地址"]
  M["main 的栈帧(局部变量等)\n← rbp (main 的基址指针)"]
  R["返回地址 ← call 指令推入的 rip, 8 字节"]
  F["foo 的栈帧\n← rbp (foo 的基址指针) ← rsp\nint a = 3 [rbp-4]\nint b = 5 [rbp-8]\nchar buf[32] [rbp-40]"]
  L["⬇ 低地址"]
  H --- M --- R --- F --- L
; C 代码: int add(int a, int b) { return a + b; }
 
add:
 push rbp ; 保存调用者的 rbp
 mov rbp, rsp ; 建立自己的栈帧基址
 mov DWORD PTR [rbp-4], edi ; 参数 a (通过 edi 传入)
 mov DWORD PTR [rbp-8], esi ; 参数 b (通过 esi 传入)
 mov eax, DWORD PTR [rbp-4]
 add eax, DWORD PTR [rbp-8] ; eax = a + b
 pop rbp ; 恢复调用者的 rbp
 ret ; 弹出返回地址并跳转

call 指令等价于 push rip; jmp targetret 指令等价于 pop rip。这两个指令在硬件层面由 CPU 的返回栈缓冲器(Return Stack Buffer, RSB)加速——RSB 是 CPU 内部的一个微型硬件栈,专门缓存返回地址,使得 ret 指令可以达到接近 0 周期的延迟。

栈溢出(Stack Overflow)

栈段的大小受操作系统限制(Linux 默认 8MB,ulimit -s)。以下场景触发栈溢出:

  • 递归过深(如无终止条件的递归)
  • 局部变量过大(如在栈上声明 int arr[1000000],占 4MB,超出剩余栈空间)
  • 无限递归相互调用(A 调 B,B 调 A)

栈溢出的后果是 SIGSEGV——CPU 访存时 MMU 检测到访问地址超出栈的映射范围,硬件触发 page fault,内核检查发现地址不合法后向进程发送段错误信号。

栈溢出利用(Stack Smashing)

在安全领域,栈溢出是最经典的攻击向量。当程序向栈上的局部缓冲区写入超出其大小的数据时,溢出数据会覆盖更高的栈帧内容——包括返回地址。攻击者构造恶意输入,使返回地址指向其注入的 shellcode 或 ROP 链中的 gadget。

// 典型的栈溢出漏洞
void vulnerable(char* input) {
 char buf[64];
 strcpy(buf, input); // 如果 input 长度 > 64,覆盖返回地址
}
graph TD
 subgraph "正常栈帧"
 BUF["buf[0..63] (64字节)"] --- SFP["保存的 rbp (8B)"] --- RET["返回地址 (8B)"]
 end
 subgraph "溢出后的栈帧"
 BUF2["buf[0..63] + 溢出数据"] -->|"覆盖"| SFP2["伪造的 rbp"] -->|"覆盖"| RET2["指向 shellcode 的地址"]
 end

现代防御:栈 canary(返回地址前放随机值,ret 前检查)、W^X(栈页不可执行)、ASLR(随机化地址)、影子栈(shadow stack,硬件/软件维护一份返回地址副本用于验证)。

递归如何映射到栈帧

“递归就是系统替你维护了一个栈”——落到硬件上,每次递归调用执行 call 压入一个新栈帧(返回地址 + 参数 + 局部变量),基准情形触发后逐层 ret 弹栈。以 factorial(4) 为例,递推阶段栈帧的堆积:

flowchart TB
  M["main 帧"]
  F4["factorial(4)  [n=4 | 返回地址 | 等待 4 × f(3)]"]
  F3["factorial(3)  [n=3 | ...]"]
  F2["factorial(2)  [n=2 | ...]"]
  F1["factorial(1) ← 基准情形:直接返回 1"]
  M --> F4 --> F3 --> F2 --> F1

随后进入回归阶段:factorial(1) 返回 → 其帧弹出 → factorial(2) 拿到结果算出 2 → 弹出……直到 main。三个要点:

  1. 空间消耗 = 递归深度:同时存活的帧数即深度,——这正是链表章递归反转空间为 O(n) 的原因
  2. 深度超限 = 栈溢出:8MB 栈 ÷ 每帧几十字节 ≈ 十万量级深度上限(E2 实验可实测)
  3. 尾递归可优化:若递归调用是函数的最后一个动作(返回值不依赖后续计算),编译器可复用当前帧——gcc -O2 会做此优化,但 C 标准并不保证,不能依赖

单调栈(Monotonic Stack)

单调栈是栈的最强大变体之一——它维护栈内元素的单调性(递增或递减),通过 pop 掉破坏单调性的元素来找到”下一个更大/更小”的位置。

问题:给定数组 ,对每个位置 ,找出右侧第一个比 大的元素的下标。

例: A = [73, 74, 75, 71, 69, 72, 76, 73]

i=0 (73): 栈空,push 0
i=1 (74): A[1]=74 > A[栈顶=0]=73 → 答案[0]=1, pop 0, push 1
i=2 (75): A[2]=75 > A[栈顶=1]=74 → 答案[1]=2, pop 1, push 2
i=3 (71): A[3]=71 < A[栈顶=2]=75 → 栈单调递减,直接 push 3
i=4 (69): push 4
i=5 (72): A[5]=72 > A[栈顶=4]=69 → 答案[4]=5, pop 4
 A[5]=72 > A[栈顶=3]=71 → 答案[3]=5, pop 3, push 5
i=6 (76): A[6]=76 逐个弹出 5,2 → 答案[5]=6, 答案[2]=6, push 6
i=7 (73): A[7]=73 < A[栈顶=6]=76 → push 7
// 单调递减栈:找右侧第一个更大元素
void next_greater(const int* A, int n, int* result) {
 int* stk = malloc(n * sizeof(int));
 int top = 0;
 for (int i = 0; i < n; i++) {
 while (top > 0 && A[i] > A[stk[top - 1]]) {
 result[stk[--top]] = i; // 当前元素是栈顶的"第一个更大"
 }
 stk[top++] = i;
 }
 while (top > 0)
 result[stk[--top]] = -1; // 无更大元素
 free(stk);
}

时间复杂度 —— 每个元素入栈一次、出栈至多一次,总操作数不超过 。这是均摊分析在栈上的典型应用。

单调栈揭示了一个深层原理:需要比较”前后元素关系”的问题,往往可以通过维护一个单调序列来避免 的朴素扫描。从直方图最大矩形到每日温度,单调栈是这类问题的统一解法框架。


实现

基于动态数组的栈

#include <stdlib.h>
 
typedef struct {
 int* data;
 size_t capacity;
 size_t top; // 指向下一个空位,也是元素个数
} ArrayStack;
 
void as_init(ArrayStack* s) {
 s->data = NULL;
 s->capacity = 0;
 s->top = 0;
}
 
void as_destroy(ArrayStack* s) {
 free(s->data);
 s->data = NULL;
 s->capacity = s->top = 0;
}
 
static int as_expand(ArrayStack* s) {
 size_t new_cap = s->capacity == 0 ? 8 : s->capacity * 2;
 int* new_data = realloc(s->data, new_cap * sizeof(int));
 if (!new_data) return -1;
 s->data = new_data;
 s->capacity = new_cap;
 return 0;
}
 
int as_push(ArrayStack* s, int value) {
 if (s->top >= s->capacity)
 if (as_expand(s) != 0) return -1;
 s->data[s->top++] = value;
 return 0;
}
 
int as_pop(ArrayStack* s) {
 if (s->top == 0) return -1;
 s->top--;
 return 0;
}
 
int as_top(const ArrayStack* s, int* out) {
 if (s->top == 0) return -1;
 *out = s->data[s->top - 1];
 return 0;
}

基于链表的栈

链表版栈所有操作均在头部——单向链表的 push_front + pop_front

#include <stdlib.h>
 
typedef struct SNode {
 int data;
 struct SNode* next;
} SNode;
 
typedef struct {
 SNode* head;
 size_t count;
} LinkedStack;
 
void ls_init(LinkedStack* s) { s->head = NULL; s->count = 0; }
 
void ls_destroy(LinkedStack* s) {
 while (s->head) {
 SNode* tmp = s->head;
 s->head = s->head->next;
 free(tmp);
 }
 s->count = 0;
}
 
int ls_push(LinkedStack* s, int value) {
 SNode* node = malloc(sizeof(SNode));
 if (!node) return -1;
 node->data = value;
 node->next = s->head;
 s->head = node;
 s->count++;
 return 0;
}
 
int ls_pop(LinkedStack* s) {
 if (!s->head) return -1;
 SNode* tmp = s->head;
 s->head = s->head->next;
 free(tmp);
 s->count--;
 return 0;
}

两栈共享空间(双端栈)

固定大小的内存区域里,两个栈从两端向中间生长,共享同一段空间——这是经典模型:

graph LR
 subgraph "双端栈 — 共享数组"
 direction LR
 PUSH1["Stack 1 →<br/>push → top1++"] -->|"← 空闲空间 →"| PUSH2["← Stack 2<br/>top2-- ← push"]
 end
typedef struct {
 int* data;
 size_t capacity;
 size_t top1; // Stack 1: 从左向右增长
 size_t top2; // Stack 2: 从右向左增长
} TwoStacks;
 
void ts_init(TwoStacks* ts, size_t cap) {
 ts->data = malloc(cap * sizeof(int));
 ts->capacity = cap;
 ts->top1 = 0;          // 栈 1 的下一个写入位(左端起)
 ts->top2 = cap - 1;    // 栈 2 的下一个写入位(右端起)
}
 
// push 到栈 1
int ts_push1(TwoStacks* ts, int value) {
 if (ts->top1 > ts->top2) return -1; // 两栈碰撞
 ts->data[ts->top1++] = value;
 return 0;
}
 
// push 到栈 2
int ts_push2(TwoStacks* ts, int value) {
 if (ts->top1 > ts->top2) return -1;
 ts->data[ts->top2--] = value; // 索引递减
 return 0;
}

三个必背结论(本代码的下标约定:top1 从 0 起、top2 从 cap−1 起,均指向”下一个写入位”):

栈满条件推导:空闲格是下标区间 ,共 个;它归零等价于 ,即代码里的碰撞检查。这种设计让两侧空间动态调剂——比对半分成两个独立数组更抗溢出,且全程无 realloc。

注意教材另有 top1=-1 / top2=cap 的”指向栈顶元素”约定,其判满为 、判空分别为 -1 与 cap——做题第一步先确认题目采用哪种初始值

手算示范:cap=8,栈 1 压 3 次、栈 2 压 2 次 → top1=3,top2=5;空闲格 个,总元素 个。

自测:两栈共享

cap=10,栈 1 已压 4 次、栈 2 已压 3 次。① 当前还能再压几个元素?② 若此后只在栈 1 一侧继续压,压几次溢出?

答案:

① top1=4、top2=6,空闲格 个——两侧合计最多再压 3 个
② 只压栈 1:top1 从 4 走到 7 时 触发拒绝,故能再压 3 次(top1=5、6 时都还满足 ,第 4 次被拒)。注意临界:top1==top2 时那一格仍可写入,属于最后的机会。

括号匹配:栈的第一应用

规则只有三条:左括号一律进栈;右括号到来时弹栈比对类型(()[]{});扫描结束时栈必须为空。对应三种失败情形:

失败时机错误类型
弹栈时类型不符交叉嵌套([)]
栈空遇右括号右括号多余())
结束时栈非空左括号多余(()
int brackets_ok(const char* s) {
    char stk[256]; int top = 0;
    for (int i = 0; s[i]; i++) {
        char ch = s[i];
        if (ch == '(' || ch == '[' || ch == '{') {
            stk[top++] = ch;
        } else if (ch == ')' || ch == ']' || ch == '}') {
            if (top == 0) return 0;                    // 右括号多余
            char open = stk[--top];
            if ((ch == ')' && open != '(') ||
                (ch == ']' && open != '[') ||
                (ch == '}' && open != '{')) return 0;  // 类型交叉
        }
    }
    return top == 0;                                   // 结束时必须空
}

手算轨迹:判定 {[()]}——

字符动作栈内容(底→顶)
1{进栈{
2[进栈{ [
3(进栈{ [ (
4)(,匹配{ [
5][,匹配{
6}{,匹配(空)

结束时空栈 → 合法。反例 ([)]:第 3 步遇 ) 时弹出的却是 [,类型不符立即失败。

自测:括号匹配

① 判定 ([]{}) 的合法性并写出栈变化。
② 判定 ((] 在哪一步失败、属于哪种错误。
③ 若串中只含一种括号 (),能否用 空间判定合法性?

答案:

① 合法。轨迹:(([]弹出后剩(({}弹出后剩()弹出后空;结束空栈,判定成立。
② 第 3 步失败:遇 ] 时弹出的栈顶是 (,类型不符——属于交叉嵌套错误(] 只应配 [)。
可以。用一个计数器:左括号 +1、右括号 −1;中途出现负值(右多)或结束时非零(左多)即非法。这是栈在”只有一种括号”时的退化形式,也是面试的经典追问。

最小栈(MinStack)—— 用差值压缩空间

常规 MinStack 用两个同步栈(一个存数据,一个存前缀最小值)。更紧凑的做法是只存差值:栈内不存原始值,而存”当前值与当前最小值的差值”。

// 差值法:栈存储 value - min_sofar。通过差值的正负恢复 value 和 min
typedef struct {
 long* diff; // value - min_sofar (可能需要 long 防溢出)
 int* min_val; // 栈顶元素对应的当前最小值
 size_t capacity;
 size_t top;
} MinStackDiff;

此方案将空间从 2n 降至 n+1(只需 diff 数组和当前 min_val 变量),但每个 push 都涉及一次减法计算和溢出判断。常规的双栈法(空间 2n)因其简单性和无溢出风险,在实际工程中更为常用。

表达式求值

中缀 → 后缀(调度场算法)

int precedence(char op) {
 if (op == '+' || op == '-') return 1;
 if (op == '*' || op == '/') return 2;
 return 0;
}
 
// 将中缀表达式转为后缀(RPN)表示
void infix_to_postfix(const char* expr, char* output) {
 int len = strlen(expr);
 char* stk = malloc(len);
 int top = 0, out_idx = 0;
 for (int i = 0; i < len; i++) {
 char ch = expr[i];
 if (ch >= '0' && ch <= '9') {
 output[out_idx++] = ch;
 } else if (ch == '(') {
 stk[top++] = ch;
 } else if (ch == ')') {
 while (top > 0 && stk[top - 1] != '(')
 output[out_idx++] = stk[--top];
 top--; // 丢弃 '('
 } else { // 运算符
 while (top > 0 && precedence(stk[top - 1]) >= precedence(ch))
 output[out_idx++] = stk[--top];
 stk[top++] = ch;
 }
 }
 while (top > 0)
 output[out_idx++] = stk[--top];
 output[out_idx] = '\0';
 free(stk);
}

手算轨迹:把 9+(3-1)*3+10/2 转为后缀——逐步列出运算符栈与输出区:

读入动作运算符栈(底→顶)输出区
19直接入输出(空)9
2+栈空,进栈+9
3(进栈+ (9
43入输出+ (9 3
5-栈顶是 (,进栈+ ( -9 3
61入输出+ ( -9 3 1
7)弹栈输出至 (:弹出 -,丢弃 (+9 3 1 -
8*栈顶 + 优先级低,进栈+ *9 3 1 -
93入输出+ *9 3 1 - 3
10+弹出 *+(均 ≥ 新运算符),再进栈+9 3 1 - 3 * +
1110入输出+9 3 1 - 3 * + 10
12/栈顶 + 优先级低,进栈+ /同上
132入输出+ /... 10 2
14结束连续弹栈:先 /+(空)9 3 1 - 3 * + 10 2 / +

最终后缀式 9 3 1 - 3 * + 10 2 / +。三个关键规则对照表格记忆:括号内的运算符被 ( 保护(第 5 步 - 得以压在 + 上);弹栈条件是”栈顶优先级 ≥ 当前”(左结合);) 触发一次性清到 ( 为止。

自测:写出 (a+b)*c-d 的后缀式。

答案:a b + c * d -。轨迹要点:)+ 弹入输出;* 进栈后遇到 -,因 *- 先弹 * 再压 -;结尾弹 -

后缀表达式求值

// 计算后缀表达式(操作数为单个数字 0-9)
int eval_postfix(const char* postfix) {
 int len = strlen(postfix);
 int* stk = malloc(len * sizeof(int));
 int top = 0;
 for (int i = 0; i < len; i++) {
 char ch = postfix[i];
 if (ch >= '0' && ch <= '9') {
 stk[top++] = ch - '0';
 } else {
 int b = stk[--top]; // 弹出右操作数
 int a = stk[--top]; // 弹出左操作数
 switch (ch) {
 case '+': stk[top++] = a + b; break;
 case '-': stk[top++] = a - b; break;
 case '*': stk[top++] = a * b; break;
 case '/': stk[top++] = a / b; break;
 }
 }
 }
 int result = stk[0];
 free(stk);
 return result;
}
sequenceDiagram
 participant Input as 输入: "3 4 2 * +"
 participant Stack as 求值栈

 Input->>Stack: push 3
 Note over Stack: [3]
 Input->>Stack: push 4
 Note over Stack: [3, 4]
 Input->>Stack: push 2
 Note over Stack: [3, 4, 2]
 Input->>Stack: op '*': pop 2, pop 4
 Note over Stack: [3]
 Input->>Stack: push 8 (4*2)
 Note over Stack: [3, 8]
 Input->>Stack: op '+': pop 8, pop 3
 Note over Stack: []
 Input->>Stack: push 11 (3+8)
 Note over Stack: [11 ← 结果]

各语言标准库对比

语言栈类型说明
C无(手写)标准库不提供容器
C++std::stack<T>适配器,默认底层 std::deque
JavaArrayDeque<T>推荐;Stack 是遗留类
Pythonlistappend() / pop()
RustVec<T>push() / pop()
Go无内置用 slice 模拟:stack = append(stack, x)

应用场景

  • 函数调用:x86-64 的硬件栈。每次函数调用(call 指令)自动 push 返回地址,返回时(ret)自动 pop。C++ 异常处理中的栈展开(stack unwinding)沿调用链逐帧 destroy 局部对象
  • 表达式求值:编译器将中缀表达式转后缀(RPN),用栈求值。计算器、SQL 查询引擎的表达式树求值均依赖此模型
  • 括号匹配:编译器/编辑器的语法验证。左括号 push,右括号时检查栈顶是否匹配。栈空但仍有右括号 = 多余右括号;结束时栈非空 = 多余左括号
  • DFS 非递归:手动用显式栈替代递归,避免系统调用栈的深度限制。详见 图的 DFS
  • 撤销(Undo):编辑器将每个编辑操作推入栈。Ctrl-Z = pop 栈顶操作并执行反向操作。Text Editor 的 Undo/Redo 用两个栈(undo stack + redo stack)
  • 浏览器的前进/后退:两个栈分别维护后退历史与前进历史。每次跳转将当前页面推入后退栈,清空前进栈

练习

题号题目说明
20有效的括号栈匹配(见正文括号匹配节)
150逆波兰表达式求值后缀表达式
155最小栈辅助栈(正文有差值法变体)
739每日温度单调栈
921括号的有效化计数器退化(呼应自测③)
1047删除字符串中的所有相邻重复项栈消消乐

核心推演清单

练习题与上面的 LeetCode 互补——侧重手算推演,全部在正文中带完整答案:

自测位置内容
两栈共享 ×2 问双端栈节末top 推演、剩余容量、临界格判断
括号匹配 ×3 问括号匹配节三种失败情形识别、单括号计数器退化
中缀转后缀表达式求值节运算符栈逐步轨迹与三条弹栈规则

动手实验

编号题目说明
E1中缀→后缀→求值流水线手写中缀表达式 (3+4)*(5-2)/2,先后通过调度场算法转后缀和求值,验证结果。对比直接用 C 的运算符优先级计算,确认两者结果一致
E2递归深度 vs 手动栈实现深度优先遍历二叉树:(a) 递归方式,(b) 手动栈方式。对深度为 100000 的退化树(链)分别运行,记录递归的栈溢出阈值和手动栈的无限制特性
E3单调栈图形化验证随机生成长度 100 的数组 A,用单调递减栈计算每个位置的”右侧第一个更大元素”。打印 A 和结果数组,人工检查:对每个 i,A[result[i]] 确实是 i 右侧第一个 > A[i] 的元素