第三章 栈:后进先出的秩序

7368 字
37 分钟
第三章 栈:后进先出的秩序

第三章 栈 (Stack)#

一句话理解:栈是一种后进先出 (LIFO) 的受限线性表——它故意阉割了大部分操作,只留 Push / Pop / Top,换来极致的简洁性和明确的意图表达。


3.1 概念直觉 —— What & Why#

什么是栈?#

你可以把栈想象成一叠盘子或者一个弹夹:你只能把新盘子放在最顶上(Push),也只能从最顶上拿走盘子(Pop)。这就是 LIFO (Last In, First Out) 原则。

栈只暴露三个核心操作:

  • push(x):压栈——把元素放到顶部
  • pop():出栈——移除顶部元素(C++ 中不返回值)
  • top():查看栈顶元素(不移除)

为什么要限制能力?

相比于数组和链表提供了大量 API(头插、尾插、中间插、随机访问、反转等),栈故意把能力砍到最少。这在软件工程中是一种极具哲学意味的设计:少即是多。限制操作意味着:

  1. 一眼就能看出这个数据结构的意图——LIFO 顺序
  2. 底层实现可以极致优化——只需要关心尾部操作
  3. 不会被误用——你不可能”不小心”从中间删除元素

函数调用栈 —— 栈最本质的应用#

在计算机世界中,栈是极其核心的基础设施。最典型的就是 函数调用栈 (Call Stack)

main() 调用 foo()
→ foo() 调用 bar()
→ bar() 调用 baz()
→ baz() 执行完毕,回到 bar()
→ bar() 执行完毕,回到 foo()
→ foo() 执行完毕,回到 main()

每次函数调用时,CPU 会把返回地址、局部变量、参数打包成一个 栈帧 (Stack Frame) 压入调用栈。函数返回时弹出栈帧,恢复上一层的状态。这种”谁最后被调用,谁最先返回”的逻辑,完美契合 LIFO。

💡 面试常考点:递归的本质就是利用系统调用栈。每一层递归 = 一个栈帧。递归深度太大 → 栈帧太多 → Stack Overflow。这也是为什么游戏引擎中要把递归改成手动栈迭代的原因(见 3.6.4)。

栈的应用全景#

领域应用核心原理
编译器括号匹配、表达式求值配对 / 运算符优先级
操作系统函数调用栈、中断处理状态保存与恢复
浏览器前进/后退双栈维护历史
算法DFS、单调栈、回溯后进先出的遍历顺序
游戏引擎UI 栈、状态机、Undo/Redo层级压入/弹出

3.2 结构图解#

栈的 Push / Pop 过程#

block-beta columns 6 block:title:6 columns 1 t["Stack — LIFO 操作示意"] end block:s1:2 columns 1 s1t["初始状态"] s1a["TOP → 30"] s1b["20"] s1c["10 BOTTOM"] end block:s2:2 columns 1 s2t["Push(40)"] s2a["TOP → 40 ← NEW"] s2b["30"] s2c["20"] end block:s3:2 columns 1 s3t["Pop()"] s3a["TOP → 30"] s3b["20"] s3c["10 BOTTOM"] end

函数调用栈的工作方式#

graph TD subgraph "调用栈 (从底到顶)" direction BT F1["main()\\n局部变量: argc, argv\\n返回地址: OS"] F2["foo()\\n局部变量: x=10\\n返回地址: main+0x42"] F3["bar()\\n局部变量: y=20\\n返回地址: foo+0x1A"] F4["baz() ← 栈顶 (当前执行)\\n局部变量: z=30\\n返回地址: bar+0x0E"] F1 --- F2 --- F3 --- F4 end style F4 fill:#d00000,stroke:#e85d04,color:white style F1 fill:#1b4332,stroke:#2d6a4f,color:white

每个矩形就是一个栈帧 (Stack Frame)baz() 返回时,它的栈帧被弹出,控制权回到 bar()

单调栈的推演过程#

单调栈是面试中最高频的栈进阶题型。它的核心规则:栈内元素必须保持单调递增(或递减),当新元素破坏单调性时,循环弹栈直到恢复。

以 “寻找右侧第一个更大元素” 为例,输入 [2, 1, 5, 6, 2, 3],维护单调递减栈(栈底到栈顶递减):

graph LR subgraph "Step 1: 处理 2" direction BT S1["2"] end subgraph "Step 2: 处理 1" direction BT S2a["2"] --- S2b["1 ← top"] end subgraph "Step 3: 处理 5" direction BT S3["5"] end subgraph "Step 4: 处理 6" direction BT S4["6"] end subgraph "Step 5: 处理 2" direction BT S5a["6"] --- S5b["2 ← top"] end subgraph "Step 6: 处理 3" direction BT S6a["6"] --- S6b["3 ← top"] end style S3 fill:#2d6a4f,stroke:#40916c,color:white style S4 fill:#2d6a4f,stroke:#40916c,color:white
Step 3 细节:5 > 1 → 弹出 1(1 的答案 = 5)
5 > 2 → 弹出 2(2 的答案 = 5)
压入 5
Step 4:6 > 5 → 弹出 5(5 的答案 = 6),压入 6
Step 6:3 > 2 → 弹出 2(2 的答案 = 3),压入 3
遍历结束:栈中剩余 [6, 3],它们右边没有更大的 → 答案为 -1
最终结果:[5, 5, 6, -1, 3, -1]

💡 关键洞察:每个元素最多进栈一次、出栈一次,所以即使看起来有嵌套循环,总时间复杂度仍然是 O(n)


3.3 C++ 底层实现#

3.3.1 std::stack —— 适配器模式#

在 C++ STL 中,std::stack 不是一个容器,而是一个容器适配器 (Container Adapter)。它自己不管理内存,而是包装了另一个现成的容器,只暴露出栈允许的接口:

#include <deque>
template <typename T, typename Container = std::deque<T>>
class stack {
protected:
Container c; // 底层容器(默认为 std::deque)
public:
// ========== 容量查询 ==========
bool empty() const { return c.empty(); }
std::size_t size() const { return c.size(); }
// ========== 栈顶访问 ==========
T& top() { return c.back(); }
const T& top() const { return c.back(); }
// ========== 压栈 ==========
void push(const T& x) { c.push_back(x); }
void push(T&& x) { c.push_back(std::move(x)); }
template <typename... Args>
void emplace(Args&&... args) {
c.emplace_back(std::forward<Args>(args)...);
}
// ========== 出栈 ==========
void pop() { c.pop_back(); }
// 注意:pop() 不返回被弹出的元素!
// 原因:如果返回 T(按值),可能触发拷贝构造,
// 如果拷贝构造抛异常,元素已经被移除了 → 数据丢失。
// 分成 top() + pop() 两步操作是异常安全的设计。
};

💡 面试追问pop() 为什么不返回被弹出的元素? 这是 C++ 异常安全设计的经典案例。如果 pop() 返回 T(按值),需要调用拷贝/移动构造函数。如果构造函数抛出异常,元素已经从栈中移除但没成功交给调用者 → 数据丢失。分成 top() + pop() 两步,top() 返回引用(不会失败),pop() 只做移除(不会失败),是强异常保证的设计。

3.3.2 为什么默认底层是 std::deque#

graph LR subgraph "std::deque 的分段连续内存" MAP["中控 map\\n(指针数组)"] B0["Buffer 0\\n[a][b][c][d]"] B1["Buffer 1\\n[e][f][g][h]"] B2["Buffer 2\\n[i][j][ ][ ]"] MAP --> B0 MAP --> B1 MAP --> B2 end style B2 fill:#555,stroke:#888,color:#aaa

std::deque 是一种分段连续内存的结构:一个中控 map(指针数组)指向多个固定大小的 Buffer 块。这给了它三个关键优势:

对比维度deque (默认)vectorlist
扩容方式新增一个 Buffer 块,不搬老数据分配 2x 新内存,搬全部元素每次 new 一个节点
内存释放Buffer 空了即释放不主动释放(需 shrink_to_fit随删随释放
缓存友好✅✅(段内连续)✅✅✅(全局连续)❌(散布堆中)
头部操作✅ O(1)❌ O(n)✅ O(1)
额外开销中控 map 指针每元素 2 指针

总结dequevectorlist 的折中方案——既有不错的缓存局部性,又避免了 vector 扩容时的全量搬迁。对于栈这种”大小不确定、元素频繁增减”的场景,deque 是最安全的默认选择。

💡 如果你确定栈最大深度不大(比如 < 1000),完全可以用 std::stack<T, std::vector<T>>,因为 vector 的缓存局部性最好,且小规模下扩容代价可忽略。

3.3.3 手写定长数组栈#

在游戏开发中,我们往往极度关注堆分配。如果知道最大深度,直接用栈上数组实现,零堆分配

template <typename T, std::size_t MaxDepth>
class FixedStack {
static_assert(MaxDepth > 0, "MaxDepth must be > 0");
std::array<T, MaxDepth> _data;
int _top = -1; // 指向当前栈顶,-1 表示空
public:
bool push(const T& val) {
if (_top >= static_cast<int>(MaxDepth) - 1) {
return false; // 栈满
}
_data[++_top] = val;
return true;
}
template <typename... Args>
bool emplace(Args&&... args) {
if (_top >= static_cast<int>(MaxDepth) - 1) return false;
_data[++_top] = T(std::forward<Args>(args)...);
return true;
}
void pop() {
if (_top >= 0) --_top;
}
T& top() { return _data[_top]; }
const T& top() const { return _data[_top]; }
bool empty() const { return _top == -1; }
bool full() const { return _top == static_cast<int>(MaxDepth) - 1; }
std::size_t size() const { return static_cast<std::size_t>(_top + 1); }
std::size_t capacity() const { return MaxDepth; }
};

使用场景

// 游戏 UI 栈——最多嵌套 16 层界面
FixedStack<UIPanel*, 16> ui_stack;
// 寻路算法中的 DFS 栈——地图最大 256x256
FixedStack<GridPos, 65536> dfs_stack;

3.3.4 手写链表栈#

当元素非常大(如几 KB 的结构体),不想在栈上开巨大数组时,可以用链表实现。每次 push 只分配一个节点:

template <typename T>
class LinkedStack {
struct Node {
T data;
Node* next;
Node(const T& val, Node* n) : data(val), next(n) {}
};
Node* _top = nullptr;
std::size_t _size = 0;
public:
~LinkedStack() { while (!empty()) pop(); }
void push(const T& val) {
_top = new Node(val, _top); // 新节点指向旧栈顶
++_size;
}
void pop() {
if (!_top) return;
Node* old = _top;
_top = _top->next;
delete old;
--_size;
}
T& top() { return _top->data; }
const T& top() const { return _top->data; }
bool empty() const { return _top == nullptr; }
std::size_t size() const { return _size; }
};

链表栈的好处:push/pop 严格 O(1)(没有均摊),且可以配合自定义 Pool Allocator(第 2 章的 Free List)来避免每次 new/delete 的系统调用开销。


3.4 复杂度速查表#

std::stack 操作复杂度#

操作时间复杂度说明
pushO(1) 均摊底层容器的 push_back
popO(1)底层容器的 pop_back
topO(1)底层容器的 back
size / emptyO(1)
查找 / 随机访问不支持栈不暴露随机访问接口

不同底层容器对比#

底层容器push 复杂度pop 复杂度优势劣势适用场景
std::deque (默认)O(1) 均摊O(1)扩容不搬老数据、可释放空闲块段间跳转略慢通用场景首选
std::vectorO(1) 均摊O(1)内存完全连续、缓存最友好扩容全量拷贝、不主动释放小规模、缓存敏感
std::listO(1) 严格O(1) 严格无扩容抖动、严格 O(1)每节点额外 16B、缓存极差超大元素、严禁延迟抖动
FixedStack (数组)O(1) 严格O(1) 严格零堆分配、栈上内存大小固定、可能浪费游戏热路径、嵌入式

横向对比:栈 vs 其他受限结构#

特性栈 (LIFO)队列 (FIFO)双端队列优先队列
插入端顶部尾部两端任意
删除端顶部头部两端最大/最小
查看top()front()front()/back()top()
典型用途DFS、回溯、配对BFS、排队滑动窗口贪心、Top-K
STLstd::stackstd::queuestd::dequestd::priority_queue

3.5 面试高频题#

3.5.1 有效的括号 (LeetCode 20)#

判断给定字符串中的括号 ()[]{} 是否成对合法闭合。

思路:最经典的栈应用。遇到左括号压栈,遇到右括号弹栈检查配对。

bool isValid(const std::string& s) {
std::stack<char> st;
for (char c : s) {
// 遇到左括号:压入对应的右括号
if (c == '(') st.push(')');
else if (c == '[') st.push(']');
else if (c == '{') st.push('}');
// 遇到右括号:栈空或不配对 → 非法
else if (st.empty() || st.top() != c) return false;
else st.pop(); // 配对成功
}
return st.empty(); // 栈没清空 → 左括号多了
}
// 时间 O(n), 空间 O(n)

💡 技巧:压入”期望的右括号”比压入左括号再做映射更简洁。

3.5.2 最小栈 (LeetCode 155)#

设计一个栈,在常数时间检索到最小元素。支持 push, pop, top, getMin

思路:用辅助栈同步维护当前最小值。每次 push 时,如果新元素 ≤ 当前最小值,就也把它推入辅助栈。

class MinStack {
std::stack<int> _data;
std::stack<int> _min; // 同步维护最小值
public:
void push(int val) {
_data.push(val);
if (_min.empty() || val <= _min.top()) {
_min.push(val);
}
}
void pop() {
if (_data.top() == _min.top()) {
_min.pop(); // 弹出的恰好是当前最小值
}
_data.pop();
}
int top() { return _data.top(); }
int getMin() { return _min.top(); }
};
// 所有操作 O(1), 空间 O(n)

💡 面试变体:面试官可能追问”能不能只用一个栈?“——可以,每次 push 时把 val 和当前 min 打包成 pair 一起压栈,但本质上空间复杂度不变。

3.5.3 逆波兰表达式求值 (LeetCode 150)#

给定逆波兰表示法(后缀表达式),求值。例如 ["2","1","+","3","*"](2+1)*3 = 9

逆波兰表达式是编译器内部处理表达式的常用形式。它的求值过程天然就是一个栈操作:

int evalRPN(std::vector<std::string>& tokens) {
std::stack<int> st;
for (const auto& token : tokens) {
if (token == "+" || token == "-" ||
token == "*" || token == "/") {
// 遇到运算符:弹出两个操作数,计算后压回
int b = st.top(); st.pop(); // 注意:b 是后弹出的(右操作数)
int a = st.top(); st.pop(); // a 是先弹出的(左操作数)
if (token == "+") st.push(a + b);
else if (token == "-") st.push(a - b);
else if (token == "*") st.push(a * b);
else if (token == "/") st.push(a / b); // 题目保证不除 0
} else {
// 遇到数字:直接压栈
st.push(std::stoi(token));
}
}
return st.top(); // 栈中最后剩的就是结果
}
// 时间 O(n), 空间 O(n)

💡 面试考点:注意操作数弹出顺序!b 先弹(栈顶),a 后弹。对于减法和除法,顺序是 a op b,不能反。

3.5.4 每日温度 (LeetCode 739) —— 单调栈入门#

给一个数组表示每天的温度,返回一个新数组:需等待几天才能遇到更高的温度。遇不到填 0。

核心模型:找右边第一个比自己大的数。维护一个单调递减栈(存下标)。

std::vector<int> dailyTemperatures(std::vector<int>& temperatures) {
int n = temperatures.size();
std::vector<int> result(n, 0);
std::stack<int> st; // 存下标,栈内下标对应的温度单调递减
for (int i = 0; i < n; ++i) {
// 当前温度 > 栈顶下标的温度 → 栈顶找到了它的答案
while (!st.empty() && temperatures[i] > temperatures[st.top()]) {
int prev = st.top();
st.pop();
result[prev] = i - prev; // 天数差
}
st.push(i);
}
// 栈中剩余的下标,右边没有更大的 → 结果保持 0
return result;
}
// 时间 O(n):每个元素最多进栈一次、出栈一次
// 空间 O(n)

3.5.5 柱状图中最大的矩形 (LeetCode 84) —— 单调栈进阶#

给定非负整数数组 heights 表示柱状图的高度,找出能勾勒出的最大矩形面积。

这是单调栈最经典的 Hard 题,面试区分度极高。

🧠 思路推导(面试时怎么想到的)

Step 1: 暴力想法
对每个柱子 i, 向左右扩展找到"以 heights[i] 为高的最大矩形"
→ 对每个 i, 找左边第一个比它矮的 L[i], 右边第一个比它矮的 R[i]
→ 面积 = heights[i] × (R[i] - L[i] - 1)
→ 暴力找 L[i] 和 R[i] 各 O(n) → 总 O(n²), 太慢
Step 2: 优化方向
"找左/右边第一个更小的元素" ← 这就是单调栈的经典模型!
Step 3: 单调递增栈
维护一个栈底到栈顶单调递增的栈 (存下标)
当新柱子比栈顶矮时, 栈顶柱子找到了它的"右边界" (当前 i)
弹出后, 新的栈顶就是它的"左边界"
→ 面积 = height[弹出] × (i - 新栈顶 - 1)
Step 4: 技巧
在末尾追加高度为 0 的虚拟柱子 → 保证所有柱子都被弹出处理
(否则循环结束后还要额外处理栈中剩余元素)

核心思路:对每个柱子,找到它左边第一个比它矮的右边第一个比它矮的,这两个边界之间就是以该柱子高度为高的最大矩形宽度。

int largestRectangleArea(std::vector<int>& heights) {
int n = heights.size();
std::stack<int> st; // 单调递增栈(存下标)
int max_area = 0;
for (int i = 0; i <= n; ++i) {
// 在末尾加一个高度为 0 的虚拟柱子,保证所有柱子都会被弹出
int cur_height = (i == n) ? 0 : heights[i];
while (!st.empty() && cur_height < heights[st.top()]) {
int h = heights[st.top()]; // 被弹出柱子的高度
st.pop();
// 宽度 = 左边界到右边界
// 左边界:栈顶的下一个元素(如果栈空则为 -1)
// 右边界:当前的 i(第一个比 h 矮的)
int width = st.empty() ? i : (i - st.top() - 1);
max_area = std::max(max_area, h * width);
}
st.push(i);
}
return max_area;
}
// 时间 O(n), 空间 O(n)

图解关键步骤(以 [2, 1, 5, 6, 2, 3] 为例):

处理到 i=4, heights[4]=2:
弹出 6 (index=3): 高=6, 宽=4-2-1=1, 面积=6
弹出 5 (index=2): 高=5, 宽=4-1-1=2, 面积=10 ← 最大!
最终答案:10

💡 面试技巧:在数组末尾追加一个高度为 0 的”哨兵”,可以保证循环结束时栈被完全清空,省去了循环后的额外处理逻辑。这是一个非常优雅的技巧。

3.5.6 用栈实现队列 (LeetCode 232)#

仅使用两个栈实现先入先出 (FIFO) 队列。

思路in_stack 专管推入,out_stack 专管弹出。当 out_stack 空了,把 in_stack 全部倒过来——LIFO 翻转一次就变成了 FIFO。

class MyQueue {
std::stack<int> _in; // 专管入队
std::stack<int> _out; // 专管出队
// 惰性转移:只在 out 空时才倒
void _transfer() {
if (_out.empty()) {
while (!_in.empty()) {
_out.push(_in.top());
_in.pop();
}
}
}
public:
void push(int x) { _in.push(x); }
int pop() {
_transfer();
int val = _out.top();
_out.pop();
return val;
}
int peek() {
_transfer();
return _out.top();
}
bool empty() { return _in.empty() && _out.empty(); }
};
// push: O(1)
// pop/peek: 均摊 O(1)(每个元素最多被倒一次)

💡 面试追问:“均摊 O(1) 怎么理解?” —— 每个元素一生中最多从 _in 倒到 _out 一次。n 次操作中,所有倒腾加起来最多 n 次,均摊下来每次操作 O(1)。


3.5.7 接雨水 (LeetCode 42) —— 单调栈的另一个经典#

给定 n 个非负整数表示每个宽度为 1 的柱子的高度,计算下雨后能接多少水。

与 LC 84(最大矩形)齐名的单调栈 Hard 题。面试出现频率极高。

🧠 思路推导(面试时怎么想到的)

Step 1: 直觉
每个位置 i 能接的水 = min(左边最高, 右边最高) - height[i]
(像一个桶, 水面高度由最矮的那边决定)
Step 2: 暴力 O(n²)
对每个 i, 向左扫找 left_max, 向右扫找 right_max
water[i] = min(left_max, right_max) - height[i]
Step 3: DP 预处理 O(n) 时间 / O(n) 空间
预计算 left_max[i] 和 right_max[i] 数组 → 避免重复扫描
Step 4: 单调栈 O(n) / O(n)
和 LC 84 同一模型: 维护单调递减栈
遇到比栈顶高的柱子时, 可以"横向填水"
→ 弹出凹槽底部, 用左右墙高度算积水
Step 5: 双指针 O(n) / O(1) ← 最优
关键洞察: 对于位置 i, 如果 left_max < right_max,
那 water[i] = left_max - height[i] (右边一定够高, 不用管)
→ 双指针从两端往中间走, 总是移动较矮的一端
int trap(std::vector<int>& height) {
std::stack<int> stk; // 存下标,对应高度单调递减
int water = 0;
for (int i = 0; i < static_cast<int>(height.size()); ++i) {
// 当前柱子比栈顶高 → 可以接水
while (!stk.empty() && height[i] > height[stk.top()]) {
int bottom = stk.top(); // 凹槽底部
stk.pop();
if (stk.empty()) break; // 左边没有墙,接不住水
int left = stk.top(); // 左墙
int w = i - left - 1; // 宽度
int h = std::min(height[left], height[i]) - height[bottom]; // 高度
water += w * h;
}
stk.push(i);
}
return water;
}
// 时间 O(n), 空间 O(n)

图解推演height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]):

遍历到 i=3 (h=2):
栈顶 i=2 (h=0) < 2 → 凹槽!
bottom=0, left=1(h=1), right=3(h=2)
水 = (3-1-1) × (min(1,2)-0) = 1 × 1 = 1
遍历到 i=7 (h=3):
连续弹出多个凹槽,逐层计算积水
这就是单调栈"横向分层"计算的精髓
总积水 = 6

三种解法对比

方法时间空间思路
暴力(每个位置找左右最大值)O(n²)O(1)直观但慢
DP 预处理O(n)O(n)预计算 left_max[] 和 right_max[]
单调栈O(n)O(n)横向分层,遇到右墙时计算
双指针O(n)O(1)最优空间,但较难理解
// 双指针解法(O(1) 空间)
int trap_two_pointers(std::vector<int>& height) {
int left = 0, right = height.size() - 1;
int left_max = 0, right_max = 0;
int water = 0;
while (left < right) {
if (height[left] < height[right]) {
left_max = std::max(left_max, height[left]);
water += left_max - height[left]; // 被左右较矮的一侧限制
++left;
} else {
right_max = std::max(right_max, height[right]);
water += right_max - height[right];
--right;
}
}
return water;
}
// 时间 O(n), 空间 O(1)

💡 面试中的选择:如果面试官没有具体要求,优先写单调栈解法——它和 LC 84 同一个模式,说明你对单调栈理解深刻。如果追问空间优化,再给出双指针版本。

3.5.8 字符串解码 (LeetCode 394)#

给定编码字符串如 "3[a2[c]]",返回解码字符串 "accaccacc"

核心思路:用两个栈——一个存数字(重复次数),一个存字符串(外层已累积的结果)。遇到 [ 就把当前状态压栈,遇到 ] 就弹栈拼接。

std::string decodeString(const std::string& s) {
std::stack<int> num_stack;
std::stack<std::string> str_stack;
std::string current;
int num = 0;
for (char c : s) {
if (std::isdigit(c)) {
num = num * 10 + (c - '0'); // 处理多位数(如 "12[a]")
}
else if (c == '[') {
// 保存当前状态
num_stack.push(num);
str_stack.push(current);
// 重置
num = 0;
current.clear();
}
else if (c == ']') {
// 弹出重复次数和外层字符串
int repeat = num_stack.top(); num_stack.pop();
std::string outer = str_stack.top(); str_stack.pop();
// 重复 current,拼到 outer 后面
for (int i = 0; i < repeat; ++i) {
outer += current;
}
current = std::move(outer);
}
else {
current += c; // 普通字符直接累积
}
}
return current;
}

图解"3[a2[c]]"):

c='3': num=3
c='[': push(3, ""), num=0, current=""
num_stack: [3] str_stack: [""]
c='a': current="a"
c='2': num=2
c='[': push(2, "a"), num=0, current=""
num_stack: [3,2] str_stack: ["","a"]
c='c': current="c"
c=']': pop(2, "a") → outer = "a" + "c"×2 = "acc"
current = "acc"
num_stack: [3] str_stack: [""]
c=']': pop(3, "") → outer = "" + "acc"×3 = "accaccacc"
current = "accaccacc"
结果: "accaccacc" ✅

💡 栈的嵌套性:字符串解码的嵌套括号与函数调用的嵌套、括号匹配的嵌套本质相同——每进入一层嵌套就压栈保存上下文,离开一层嵌套就弹栈恢复上下文。“栈 = 嵌套结构的天然载体”。


3.5.9 面试题速查表#

题号题目核心技巧难度
LC 20有效的括号栈配对Easy
LC 155最小栈辅助栈维护 minMedium
LC 150逆波兰表达式求值栈模拟计算Medium
LC 232用栈实现队列双栈翻转Easy
LC 225用队列实现栈双队列 / 单队列翻转Easy
LC 739每日温度单调递减栈Medium
LC 496下一个更大元素 I单调栈 + 哈希Easy
LC 503下一个更大元素 II单调栈 + 循环遍历Medium
LC 84柱状图中最大矩形单调递增栈Hard
LC 85最大矩形逐行转化为 LC 84Hard
LC 42接雨水单调栈 / 双指针Hard
LC 71简化路径栈处理 ...Medium
LC 394字符串解码双栈(数字栈 + 字符串栈)Medium
LC 32最长有效括号栈记录下标Hard

3.6 🎮 实战场景#

3.6.1 UI 层级管理 (UI Stack)#

任何有嵌套菜单的游戏(RPG 属性面板、背包系统、设置页),当玩家层层点击 主菜单装备栏强化界面 时,按 ESC 应该逐层退出。这就是典型的栈结构。

// UI 面板的生命周期接口
class UIPanel {
public:
virtual ~UIPanel() = default;
virtual void OnEnter() = 0; // 首次进入(播放进场动画)
virtual void OnExit() = 0; // 被关闭(播放退场动画,释放资源)
virtual void OnPause() = 0; // 被新面板覆盖(失去焦点,停止接收输入)
virtual void OnResume() = 0; // 上层面板关闭,重新获得焦点
virtual void OnUpdate(float dt) = 0;
virtual void OnRender() = 0;
};
class UIManager {
std::stack<std::unique_ptr<UIPanel>> _stack;
public:
// 打开新面板 —— Push
void OpenPanel(std::unique_ptr<UIPanel> panel) {
if (!_stack.empty()) {
_stack.top()->OnPause(); // 当前面板失焦
}
panel->OnEnter(); // 新面板入场
_stack.push(std::move(panel));
}
// 关闭当前面板 —— Pop
void CloseCurrentPanel() {
if (_stack.empty()) return;
_stack.top()->OnExit(); // 当前面板退场
_stack.pop(); // 销毁
if (!_stack.empty()) {
_stack.top()->OnResume(); // 下层面板恢复焦点
}
}
// 关闭所有面板(回到主界面)
void CloseAll() {
while (!_stack.empty()) {
_stack.top()->OnExit();
_stack.pop();
}
}
// 只更新/渲染栈顶面板(或者根据需求渲染多层)
void Update(float dt) {
if (!_stack.empty()) {
_stack.top()->OnUpdate(dt);
}
}
void Render() {
// 可以选择渲染所有层(半透明叠加效果)
// 简化版:只渲染栈顶
if (!_stack.empty()) {
_stack.top()->OnRender();
}
}
bool HasPanel() const { return !_stack.empty(); }
std::size_t Depth() const { return _stack.size(); }
};

3.6.2 GameState Stack (游戏状态机嵌套)#

整个游戏的流转本身就是一个大号状态栈。不同于传统有限状态机(FSM)的 “切换”,状态栈支持 “叠加”——新状态压在旧状态上面,旧状态暂停但不销毁。

class GameState {
public:
virtual ~GameState() = default;
virtual void OnEnter() = 0;
virtual void OnExit() = 0;
virtual void OnPause() = 0;
virtual void OnResume() = 0;
virtual void Update(float dt) = 0;
virtual void Render() = 0;
};
class GameStateManager {
std::stack<std::unique_ptr<GameState>> _states;
public:
// 压入新状态(不销毁旧状态)
void PushState(std::unique_ptr<GameState> state) {
if (!_states.empty()) {
_states.top()->OnPause();
}
state->OnEnter();
_states.push(std::move(state));
}
// 弹出当前状态(恢复下层状态)
void PopState() {
if (_states.empty()) return;
_states.top()->OnExit();
_states.pop();
if (!_states.empty()) {
_states.top()->OnResume();
}
}
// 替换当前状态(弹出旧的 + 压入新的)
void ChangeState(std::unique_ptr<GameState> state) {
if (!_states.empty()) {
_states.top()->OnExit();
_states.pop();
}
state->OnEnter();
_states.push(std::move(state));
}
void Update(float dt) {
if (!_states.empty()) {
_states.top()->Update(dt);
}
}
// 渲染可以从底部往上全部渲染(实现半透明叠加)
void Render() {
// 简化:用临时容器逆序渲染
// 完整实现可以用 vector<GameState*> 替代 stack 来支持底层遍历
if (!_states.empty()) {
_states.top()->Render();
}
}
};

典型流程

[Boot] → PushState(MainMenu)
PushState(Loading) // MainMenu.OnPause()
PopState() // Loading 结束, MainMenu.OnResume()
ChangeState(InGame) // MainMenu 被替换
PushState(PauseMenu) // InGame.OnPause() — 暂停但不销毁
// 玩家能看到半透明暂停菜单下的游戏画面
PopState() // PauseMenu 关闭, InGame.OnResume()

3.6.3 Undo / Redo 系统 (Command Pattern + 双栈)#

地图编辑器、关卡工具、策略游戏中的撤销/重做功能是游戏工具链的核心架构之一:

// 命令接口 —— 每个可撤销的操作都是一个 Command
class Command {
public:
virtual ~Command() = default;
virtual void Execute() = 0; // 执行
virtual void Undo() = 0; // 撤销(执行逆操作)
virtual std::string Description() const = 0;
};
// ===== 具体命令示例:移动实体 =====
class MoveEntityCommand : public Command {
Entity* _entity;
Vec2 _old_pos;
Vec2 _new_pos;
public:
MoveEntityCommand(Entity* e, Vec2 new_pos)
: _entity(e), _old_pos(e->position), _new_pos(new_pos) {}
void Execute() override { _entity->position = _new_pos; }
void Undo() override { _entity->position = _old_pos; }
std::string Description() const override {
return "Move " + _entity->name;
}
};
// ===== Undo/Redo 管理器 =====
class UndoRedoManager {
std::stack<std::unique_ptr<Command>> _undo_stack;
std::stack<std::unique_ptr<Command>> _redo_stack;
public:
void Execute(std::unique_ptr<Command> cmd) {
cmd->Execute();
_undo_stack.push(std::move(cmd));
// 关键:执行新操作后,redo 历史作废
while (!_redo_stack.empty()) _redo_stack.pop();
}
void Undo() {
if (_undo_stack.empty()) return;
auto cmd = std::move(_undo_stack.top());
_undo_stack.pop();
cmd->Undo();
_redo_stack.push(std::move(cmd));
}
void Redo() {
if (_redo_stack.empty()) return;
auto cmd = std::move(_redo_stack.top());
_redo_stack.pop();
cmd->Execute();
_undo_stack.push(std::move(cmd));
}
bool CanUndo() const { return !_undo_stack.empty(); }
bool CanRedo() const { return !_redo_stack.empty(); }
};

为什么用栈而不是 std::list

第 2 章中提到了用 std::list + 迭代器实现 Undo/Redo(支持在中间位置操作)。两种方案各有侧重:

维度双栈list + 迭代器
复杂度更简单,代码量少更灵活,可以跳到任意历史点
内存栈弹出即释放需要额外管理截断
适用标准 Undo/Redo需要历史分支的高级编辑器

3.6.4 递归转迭代 —— 防止 Stack Overflow#

游戏引擎中,很多算法天然是递归的(DFS、场景树遍历、AI 行为树评估)。但线程的调用栈空间有限(Windows 默认 1MB,Linux 默认 8MB),递归深度过大会直接崩溃

解决方案:用堆上的 std::stack 模拟调用栈,手动管理”栈帧”。

示例:DFS 递归版 vs 迭代版#

// ========== 递归版 DFS(简洁但有栈溢出风险)==========
void dfs_recursive(const std::vector<std::vector<int>>& graph,
int node,
std::vector<bool>& visited) {
visited[node] = true;
// process(node);
for (int neighbor : graph[node]) {
if (!visited[neighbor]) {
dfs_recursive(graph, neighbor, visited); // 递归调用
}
}
}
// ========== 迭代版 DFS(手动栈,无栈溢出风险)==========
void dfs_iterative(const std::vector<std::vector<int>>& graph,
int start,
std::vector<bool>& visited) {
std::stack<int> st; // 堆上分配,几十 GB 空间
st.push(start);
while (!st.empty()) {
int node = st.top();
st.pop();
if (visited[node]) continue;
visited[node] = true;
// process(node);
// 注意:如果需要和递归版访问顺序完全一致,
// 要逆序压栈(让第一个邻居在栈顶)
for (auto it = graph[node].rbegin(); it != graph[node].rend(); ++it) {
if (!visited[*it]) {
st.push(*it);
}
}
}
}

示例:场景树遍历#

struct SceneNode {
std::string name;
std::vector<SceneNode*> children;
};
// 递归版——如果场景树有 10000 层就爆栈了
void traverse_recursive(SceneNode* node) {
if (!node) return;
// process(node);
for (auto* child : node->children) {
traverse_recursive(child);
}
}
// 迭代版——安全
void traverse_iterative(SceneNode* root) {
if (!root) return;
std::stack<SceneNode*> st;
st.push(root);
while (!st.empty()) {
SceneNode* node = st.top();
st.pop();
// process(node);
// 逆序压栈以保持遍历顺序
for (int i = static_cast<int>(node->children.size()) - 1; i >= 0; --i) {
st.push(node->children[i]);
}
}
}

💡 经验法则:在游戏引擎中,任何涉及树/图遍历的代码,如果递归深度可能超过几百层,就应该改成迭代版。UE5 的渲染管线、Unity 的 Transform 层级遍历,底层都是迭代实现。


3.7 本章小结#

核心要点#

概念要点
LIFO后进先出,只操作栈顶,push/pop/top 全 O(1)
std::stack容器适配器,默认底层 deque,也支持 vector/list
为什么默认 deque不像 vector 扩容全搬、不像 list 缓存差,折中最优
pop() 不返回值C++ 异常安全设计:避免拷贝构造抛异常导致数据丢失
单调栈栈内保持单调,O(n) 解决”找两侧第一个更大/小元素”问题
双栈两个栈可以模拟队列(LC 232),也可以实现 Undo/Redo
递归的本质系统调用栈 = 隐式的栈。手动栈 = 显式的栈,可防栈溢出

面试 30 秒速答#

Q:什么是栈?

A:受限的 LIFO 线性表,只允许在栈顶操作。Push / Pop / Top 都是 O(1)。核心应用:函数调用栈、括号匹配、DFS、单调栈。

Q:std::stack 底层是什么?为什么选 deque?

A:std::stack 是适配器,默认底层是 deque。选 deque 因为它分段连续,扩容时只需分配新 Buffer 块而不搬老数据vector 会全量拷贝);同时空闲块可以释放(vector 不主动释放),且比 list 缓存友好。

Q:什么是单调栈?时间复杂度为什么是 O(n)?

A:单调栈要求栈内元素保持递增或递减。当新元素破坏单调性时循环弹栈。虽然有嵌套循环,但每个元素最多入栈一次、出栈一次,总操作 2n 次,所以是 O(n)。常用于解决”找到每个元素左/右边第一个比它大/小的元素”类问题。


📖 下一章:第四章 队列:先来后到的公平 —— 从 FIFO 到双端队列,从 BFS 层序遍历到滑动窗口最大值,再到游戏引擎的事件系统与渲染命令缓冲区。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

第三章 栈:后进先出的秩序
https://firefly-7a0.pages.dev/posts/data_structure/03_stack/
作者
lonelystar
发布于
2026-04-04
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
LonelyStar
Hello, I'm LonelyStar.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
119
分类
11
标签
346
总字数
226,548
运行时长
0
最后活动
0 天前

目录