news 2026/10/1 3:21:14

栈:从后进先出到单调栈,一文讲透算法与系统底层

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
栈:从后进先出到单调栈,一文讲透算法与系统底层

如果说数组和链表是数据结构里的基础积木,那栈就是那个“说明书上一句话就能说完,实战里却最容易认不出来”的零件。后进先出,四个字,人人都会背;但真正在看题的时候,我们想到它了吗?去年我面试一位候选人,让他实现一个最小栈,push 和 pop 两分钟就写完了,getMin 要求 O(1) 时整个人卡住,绕了一大圈都没想起来“再开一个辅助栈”这种解法。我问他平时写代码有没有主动用过栈,他想了想说:好像编译器在用。

这就是大多数人的状态。栈从不冷门——函数调用、递归回溯、表达式求值、浏览器后退按钮,背后全是它。但正因为它太底层、太理所当然,我们反而在需要主动用它的时候认不出它。这期是优选算法专题的第十二篇,我想换个讲法:先从“为什么会有栈”说起,再手写两种栈实现,接着拆括号匹配和单调栈这两类最高频题型,最后落到栈帧、堆与栈这些系统级概念上。适合刷题遇瓶颈想回头补数据结构的人,也适合准备算法工程师面试、想系统过栈考点的同学。

1. 为什么栈是最简单也最容易认不出的数据结构

1.1 后进先出到底是一种什么能力

栈的规则确实简单:只能在栈顶插入和删除,最新进来的最先出去。用一摞盘子来想就行——你往最上面放盘子,也先从最上面拿盘子。但我想说的是另一个层面:后进先出本质上是在维护一种“最近的历史”。

这句话才是栈的灵魂。当一个问题需要优先处理“最近发生的事”时,它就指向栈。举个例子,浏览器的后退按钮:你访问 A、B、C 三个页面,回到上一页时回到的是 C 的前一页 B,而不是 A。为什么?因为浏览器把访问历史压进了一个栈,后退就是 pop。再比如编辑器里的 Undo:你依次做了三次操作,撤销时先撤销最后一次。栈结构天然适合这类“时间倒流”的用法。

这里顺便区分一个概念:栈和队列是一对好兄弟。队列先进先出,适合处理“按顺序等待”的任务,比如打印队列;栈后进先出,适合处理“最近优先”的任务。学算法到后面你会发现,很多题就是在两个极端之间选边站。

1.2 为什么很多人刷题时认不出栈

有个很反直觉的现象:大家学栈的时候都学得很快,但刷题时一遇到栈题就懵,或者绕远路用其他方法硬解。原因在于——认出题目需要栈,靠的不是背概念,而是对“数据流动方向”的敏感度。

举个例子,DFS(深度优先搜索)其实就是用栈来做的。递归版本靠的是系统调用栈,显式版本则是我们自己开一个栈。但很多题解只写“深度优先遍历”,几乎不提栈。同样,Tarjan 算法找强连通分量、回溯法做排列组合,底层全是栈。你如果只盯着“栈”两个字去找题,永远只能做那种题干里明说“用栈实现”的题;真正的考点,是那些题面里根本不说栈,但解题非它不可的问题。这种“识别能力”会在后面几节里反复练习。先记一句话:任何需要“回到最近一个未完成状态”的操作,都在栈的射程范围内。

顺带说一句,全栈工程师那个“栈”是技术栈的意思,和今天这个后进先出的数据结构不是一回事。别在面试时把这两个概念搞混,真的有人这么干过。

2. 手写两种栈:数组栈的扩容细节与链表栈的指针开销

刷题时直接用 STL 的std::stack就够了,但自己手写一遍实现,会对“栈为什么快”“栈为什么容易爆”有更真实的体感。这里给出两种最常见的实现。

2.1 数组栈:扩容策略是核心

数组栈的核心是维护一个连续数组和栈顶下标。我用 C++ 模板写一个精简版:

#include <stdexcept> template<typename T> class ArrayStack { private: T* data; int capacity; int topIndex; // 栈顶元素下标,空栈时为 -1 public: explicit ArrayStack(int cap = 16) : capacity(cap), topIndex(-1) { data = new T[capacity]; } ~ArrayStack() { delete[] data; } void push(const T& val) { if (topIndex + 1 == capacity) { expand(); } data[++topIndex] = val; } void pop() { if (empty()) { throw std::runtime_error("stack underflow"); } --topIndex; } T& top() { if (empty()) { throw std::runtime_error("stack underflow"); } return data[topIndex]; } bool empty() const { return topIndex == -1; } int size() const { return topIndex + 1; } private: void expand() { int newCap = capacity * 2; T* newData = new T[newCap]; for (int i = 0; i <= topIndex; ++i) { newData[i] = data[i]; } delete[] data; data = newData; capacity = newCap; } };

两个关键点。第一,topIndex初始为 -1,push 时先加一再写入,pop 时直接减一,逻辑最省事。第二,扩容为什么选二倍而不是“容量 + 固定值”?因为二倍扩容能保证均摊代价是 O(1):每扩容一次,新容量至少能容纳之前已有的全部元素,之后的 n 次 push 都不用再扩容,把一次 O(n) 的拷贝摊到 n 次操作里,每次均摊下来还是常数时间。固定增量则会让扩容频率过高,整体均摊到 O(n)。

2.2 链表栈:不需要扩容,但每个节点都付了指针钱

链表栈的思路是用链表头当栈顶,每次 push 在头部插入新节点:

template<typename T> class LinkedStack { private: struct Node { T val; Node* next; Node(const T& v, Node* n) : val(v), next(n) {} }; Node* head; // 链表头即栈顶 int count; public: LinkedStack() : head(nullptr), count(0) {} ~LinkedStack() { while (head) { Node* cur = head; head = head->next; delete cur; } } void push(const T& val) { head = new Node(val, head); ++count; } void pop() { if (empty()) { throw std::runtime_error("stack underflow"); } Node* cur = head; head = head->next; delete cur; --count; } T& top() { if (empty()) { throw std::runtime_error("stack underflow"); } return head->val; } bool empty() const { return head == nullptr; } int size() const { return count; } };

注意这里有个隐藏开销:每次 push 都是一次new,也就是一次堆内存分配。频繁的小分配在真实系统里可能成为性能瓶颈,而且每个节点都要额外存一个 next 指针,64 位系统下就是 8 字节。如果栈里存的是 int,那光是指针开销就抵得上一个元素了。

2.3 选型对比,以及一个 STL 冷知识

对比维度数组栈链表栈
内存布局连续内存,缓存友好节点分散,缓存命中率低
扩容行为容量满时倍增,需要拷贝已有元素,单次最坏 O(n)无需扩容,每次 push 新建节点
单次操作均摊O(1)O(1)
空间开销预分配容量可能浪费少量内存每个节点多一个 next 指针
典型场景高频读写、资源受限环境容量完全不可预估的场景

我的建议很直接:算法题里直接用std::stack;真实项目里除非有明确理由,否则默认数组栈。这里有个很多人不知道的小细节——C++ 的std::stack默认底层容器其实是std::deque,不是std::vector。deque 是分段连续的结构,头部和尾部插入都是 O(1),所以std::stack的 push/pop/top 都稳定是常数时间。想知道为什么,往下看到栈帧那一节就明白了:栈这种“只在尾部操作”的结构,用 deque 实现最省心。

3. 括号匹配这类“对称性”题目:配对与嵌套是栈的直觉信号

3.1 为什么计数法解决不了括号问题

很多人在初学字符串处理时,遇到括号先想到计数:统计左右括号数量相等就合法。这个思路在只有一种括号时能通过一部分用例,但一旦括号类型变多,立刻失效。看这个反例:([)]。左括号两个、右括号两个,数量完全对得上,但它并不是一个合法的括号序列,因为[还没闭合,)就出现了。

为什么会这样?因为括号合法性要求的不是“总数匹配”,而是“关闭顺序和打开顺序严格反向”。最近打开的括号必须最先关闭,这恰恰就是后进先出。所以括号匹配这类题,从见题的第一秒就应该往栈上想:遍历字符串,遇到左括号就压栈,遇到右括号就弹栈检查是否匹配。栈天然保存了“还没闭合的左括号,按什么顺序等着被关闭”。

3.2 完整解法与代码走读

这是 LeetCode 20 题 Valid Parentheses,算是栈题的入门标配:

bool isValid(string s) { stack<char> st; for (char c : s) { if (c == '(' || c == '[' || c == '{') { st.push(c); } else { if (st.empty()) { return false; // 没有左括号可配,直接失败 } char top = st.top(); if ((c == ')' && top == '(') || (c == ']' && top == '[') || (c == '}' && top == '{')) { st.pop(); } else { return false; } } } return st.empty(); }

复杂度很漂亮:每个字符最多压栈一次、弹栈一次,时间 O(n),空间 O(n)。走一遍[()]:遇到[压栈,遇到(压栈,遇到)弹出(匹配成功,遇到]弹出[匹配成功,最后栈空,合法。再看([)]:遇到(压栈,遇到[压栈,遇到)时栈顶是[,不匹配,直接返回 false。栈顶状态就是“最近一个还没闭合的左括号”,判断起来干净利落。

3.3 同类变体:路径简化与标签校验

括号题只是这类“配对嵌套”问题最朴素的形态,同一套思想能延伸到很多看起来不像括号的题。比如路径简化(LeetCode 71),把/a/./b/../../c/简化为/c:用栈记录路径片段,遇到.忽略,遇到..就弹出上一级,最后把栈里剩下的片段拼起来。这不就是“最近访问的路径优先被撤销”吗。

再比如 HTML 标签嵌套校验:<div><p>text</p></div>合法,<div><p></div></p>不合法。标签的开启和闭合天然是一对配对关系,而且后开的标签必须先闭,和括号一模一样。以后看到任何“外部元素包裹内部元素、内部先闭合”的题,都默认往栈上想。

4. 单调栈:把“下一个更大元素”从暴力优化成模板

4.1 先看暴力解法为什么慢

“找出数组中每个元素的下一个更大元素”,听起来很简单。暴力思路也直接:对每个位置 i,从 i+1 往后扫描,找到第一个比 nums[i] 大的数。但最坏情况,比如一个严格递减的数组,每个位置都要扫到最后,总复杂度 O(n²)。题目一卡到十万级数据量,直接超时。

关键在于,暴力解法浪费了大量重复扫描。你扫描后面的元素时,前面已经扫过的比较关系没有保存下来,导致同一对元素被反复比较。单调栈干的事情,就是把“谁比谁大”这种关系用一趟线性扫描全部记下来。

4.2 单调栈模板的构造逻辑

单调栈的模板我建议直接背下来,再理解原理:

vector<int> nextGreaterElement(vector<int>& nums) { int n = nums.size(); vector<int> res(n, -1); stack<int> st; // 存下标,栈底到栈顶单调递减 for (int i = 0; i < n; ++i) { while (!st.empty() && nums[i] > nums[st.top()]) { res[st.top()] = nums[i]; // 当前元素就是栈顶的下一个更大元素 st.pop(); } st.push(i); } return res; }

核心逻辑是这样:栈里维护的是一串单调递减的下标序列。遍历到新元素时,只要它比栈顶元素大,就说明栈顶元素“下一个更大元素”找到了,于是弹出并记录答案。这个新元素继续和新的栈顶比,直到栈顶比它大或者栈空,然后把自己压进去。每个元素最多进栈一次、出栈一次,所以整体 O(n)。

走一遍[2, 1, 5, 6, 2, 3]:2 和 1 依次入栈(递减)。遇到 5,比栈顶的 1 大,弹出 1 记录答案 5;再比栈顶的 2 大,弹出 2 记录答案 5;压入 5。遇到 6,弹出 5 记录答案 6。后面 2 入栈,遇到 3 弹出 2 记录答案 3。最后栈里剩下 6 和 3,没有更大元素,答案保持初始化的 -1。结果[5, 5, 6, -1, 3, -1],和暴力算出来的一模一样。

4.3 三个高频变体与最小栈彩蛋

单调栈的变体非常多,但底层都是同一个模板换皮:

变体栈的单调性弹出时记录什么
下一个更大元素递减栈当前元素值
每日温度递减栈当前下标与栈顶下标之差
柱状图最大矩形递增栈以弹出柱子为高的矩形面积
接雨水递减栈弹出位置可接的雨水量

每日温度(LeetCode 739)就是“下一个更大元素”的孪生题,只是答案从“更大的值”变成“等了多少天”,也就是下标差,模板几乎不用改。柱状图最大矩形(LeetCode 84)稍微绕一点:维护单调递增栈,当当前柱子比栈顶矮时,说明以栈顶柱子为高的矩形右边界已经确定,弹出并计算宽度和面积;为了处理末尾还留在栈里的柱子,通常会在 heights 末尾补一个高度为 0 的哨兵。核心转化一句话:找“左右第一个更矮的柱子”,这个动作就是单调栈的专长。

最后回头解决文章开头那位候选人卡住的最小栈。要求 push、pop、getMin 都 O(1),常规思路是 getMin 时遍历整个栈,太慢。答案其实一句话:额外维护一个辅助栈,每次 push 时把“当前最小值”也压进辅助栈,pop 时同步弹出。原理很简单,但面试里能想到的人不多。这揭示了一个更通用的技巧:一个栈不够用,就再加一个栈。“栈 + 辅助栈”的组合在算法题里出现频率不低。

5. 栈帧与递归:函数调用栈到底怎么工作

5.1 一次函数调用在栈上发生了什么

讨论完算法里的栈,必须回到系统层面看看“栈”这个字的本体。假设 main 调用 funcA(3),编译器眼中大致是这么一套流程:

  1. 调用方把实参按约定方式压栈(具体顺序由 ABI 决定,这里按最经典的栈式调用理解);
  2. 压入返回地址——也就是 funcA 执行完后,main 要从哪条指令继续往下走;
  3. 进入 funcA 后,先保存 main 的栈帧指针(frame pointer),这样才能在返回时恢复 main 的栈现场;
  4. 栈指针继续往低地址方向移动,为 funcA 的局部变量腾出空间。

这几样东西合在一起,就是一次函数调用的“栈帧”。funcA 再调用 funcB,就再压一层新栈帧。函数返回时,按完全相反的顺序清理:销毁局部变量、恢复旧帧指针、弹出返回地址、跳回调用点继续执行。

你调试崩溃时看到的 backtrace(调用栈回溯),就是沿着栈帧里保存的地址链一级一级往回找现场。崩溃时打印调用栈,是排查问题最快的手段;ARM 等嵌入式平台上的回溯规则由 ABI 定义,但本质都是跟着返回地址走。理解栈帧,调试器的“调用堆栈”窗口就不再是黑魔法了。

5.2 递归为什么天生依赖栈

递归代码看着简单,底层靠的其实就是这套栈帧机制。每次递归调用自身,就压入一层新栈帧,参数、局部变量、返回地址都留在各自独立的帧里;递归返回,则逐帧弹出。所谓“回溯”,不过是系统把之前手动保存的状态自动恢复了一遍。

这一点对算法学习特别重要。你写一个二叉树的 DFS,递归版本由系统栈自动帮你记录“当前走到哪个节点、左边有没有访问”,完全不用手动管理;一旦改成迭代版本,你就得自己开一个栈来模拟这个过程。很多人觉得“用栈写 DFS”很别扭,就是因为没想明白:你其实是在复刻系统栈帧的行为。

5.3 栈溢出的真相与应对

Linux 下主线程栈默认一般是 8MB,嵌入式或 MCU 平台可能只有几 KB。无限递归,或者递归深度大到超过栈容量,就会栈溢出,程序直接崩溃。这里的“栈”就是函数调用栈,不是算法题里你自己定义的那个栈。

应对方式有几个:限制递归深度;把递归改写成显式栈加循环;某些语言支持尾递归优化,编译器会把尾调用优化成跳转,不再压新栈帧。面试时聊到“递归的缺点”,能答出“每一层调用都保存完整上下文、占用栈空间、深度过大会栈溢出”,就算过了这一题。

6. 堆和栈的内存之争:变量到底存在哪

6.1 先分清两个“堆”

这是我在面试里问过很多次的问题:“堆和栈有什么区别?”回答五花八门,其中有一个高频误区——把算法里的堆(二叉堆、优先队列)和内存里的堆(malloc/new 分配的区域)混为一谈。这两个概念只是恰好同名:

  • 数据结构中的堆:一种完全二叉树,用来实现优先队列,典型操作是 push 和取最值;
  • 内存区域中的堆:进程地址空间里一块自由管理的内存,动态分配的对象的家。

讨论算法题时说“用堆做”,多半指优先队列;讨论内存布局时说“对象在堆上”,指动态分配。两者不是一回事,面试时先说清楚你问的是哪个,能避免很多尴尬。

6.2 局部变量、全局静态变量、堆对象都放在哪

变量类型存放位置生命周期
局部变量(栈变量)栈帧函数调用期间
全局变量 / 静态变量静态存储区程序整个运行期
new / malloc 产生的对象堆从分配直到释放(或由 GC 回收)

函数里定义一个“很大的局部数组”,其实就压在栈帧里,8MB 的栈很容易被几十 MB 的数组直接打穿。算法题里见过有人直接在函数里开int a[1000000],本地跑得好好的,换到在线评测环境直接崩溃,就是因为栈空间不够。大块数据要么放全局/静态区,要么用 new 放堆上,这是实际做题时应该避开的坑。

6.3 “栈比堆快”的真相,以及它对算法题的影响

栈的分配就是移动一下栈指针,一条指令完成;堆分配要搜索空闲块、维护元信息、处理并发锁,确实慢不少。另一个原因是缓存局部性——栈上的局部变量往往挨得近,访问友好;堆对象散落各处,cache miss 概率高。但话说回来,这只是普遍现象,不是铁律。如果你一次性申请一大块连续堆内存,访问速度同样能拉满。所以别只背“栈快堆慢”的结论,面试追问的时候要能讲清楚背后这两个原因。

这一节对算法题的直接影响是:所有高频状态都尽量压在栈上,比如用局部变量而不是反复 new 对象;但容量太大的数据结构,比如图的邻接表、大数组,就老老实实放堆里。这个取舍在写工程代码的时候比刷题时更明显。

7. 栈题识别清单与五个高频翻车现场

7.1 三个“该用栈”的信号

我把压箱底的识别方法整理成三句话,刷题时可以直接对照:

  1. 配对与嵌套关系。关闭顺序和打开顺序相反,比如括号、HTML 标签、嵌套作用域。看到这类题,默认栈。
  2. 需要回退到最近状态。撤销操作、路径简化、浏览器后退、DFS 回溯。这类题本质是“回到最近一个未完成状态”,栈是天然的容器。
  3. 与“下一个更大/更小”有关。求某个元素右侧第一个比它大(或小)的元素,或者等价的距离、面积问题。这是单调栈的主场。

判断标准很简单:拿一张纸模拟数据流,如果某个元素处理完后,后面再需要它时会用到“它是最近被处理的”,那就是栈。

7.2 五个高频翻车现场

这些坑我见过太多次了,自己在初学阶段也都踩过,写在这里当警示牌。

  1. 弹空栈。C++ 的std::stack::top()和pop()在空栈上是未定义行为,不是抛异常。所有弹栈操作前先判空,这是最基本的防御。
  2. 单调栈遗留元素。遍历结束后,栈里剩下的元素都没有“下一个更大/更小”的答案。很多人忘记处理,或者不知道初始化答案数组时就要预设好默认值。模板里res(n, -1)就是在给这部分兜底。
  3. 存值还是存下标。单调栈到底存元素值还是存下标?需要记录位置信息时(每日温度、柱状图面积)必须存下标,访问值再用nums[st.top()]取。只比较大小不关心位置的简化问题,才可以只存值。
  4. 表达式求值的顺序。中缀表达式转后缀、或者用两个栈直接求值时,数字栈和符号栈谁先弹、括号处理完再弹哪一层,顺序错了结果全错。我的经验:遇到右括号,先把括号内的符号栈全部清空,再弹数字栈;遇到运算符,先弹出栈里优先级不低于当前运算符的所有符号,再压当前符号。
  5. 递归转迭代的深度切换。有些题的递归解法看着优雅,但输入一大就把系统栈打爆。要么限制递归深度并逐层剪枝,要么改成显式栈加循环。判断标准很简单:递归深度是否可能超过几千层,会的话就趁早转迭代。

最后分享一个我自己的习惯:拿到一个新题,先不急着看标签,而是问一句“这里存不存在一种最近优先的关系”。如果存在,哪怕题面里没有“栈”这个字,也基本可以往栈的方向想。栈这东西看着小,但它其实是很多看似复杂问题的底层骨架。能把这一层想通,括号匹配、单调栈、递归转迭代这些题目,也就不再是背模板,而是变成一种顺手的直觉了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:20:37

journalctl日志查询实战:从入门到持久化配置与磁盘控制

我用journalctl查个日志&#xff0c;本来以为要翻小半天老文件&#xff0c;结果一条命令三秒定位问题。这事儿搁五年前根本没法想——那时候排查问题全靠grep /var/log/messages&#xff0c;日志一轮就被logrotate切走&#xff0c;想找三天前的报错简直大海捞针。现在systemd成…

作者头像 李华
网站建设 2026/10/1 3:20:11

ZCode全开源:Agent运行时架构设计与三端部署实践

1. 从313MB争议说起&#xff1a;ZCode到底在解决什么问题智谱把ZCode全开源这件事&#xff0c;在Agent开发圈子里炸开锅的直接导火索&#xff0c;其实是一个很具体的数字——313MB。当时有开发者发现某个Agent运行时在后台悄悄传输了这个量级的数据&#xff0c;社区里立刻分成两…

作者头像 李华
网站建设 2026/10/1 3:19:27

招聘系统毕设全攻略:SpringBoot+Vue+MySQL从部署到答辩

又到了毕设季&#xff0c;每年这个时候都会有一大批同学抱着“毕业设计招聘系统”这个选题来找我。说实话&#xff0c;招聘系统确实是SpringBootVueMySQL这个技术栈最经典的落地场景之一&#xff0c;业务逻辑清晰、角色划分明确、功能扩展空间大&#xff0c;不管是做开题、写论…

作者头像 李华
网站建设 2026/10/1 3:18:18

RPM包管理从入门到实践:命令、依赖与rpmbuild打包

拿到一台新的 Linux 服务器&#xff0c;尤其是 CentOS、Rocky 或者 RedHat 这类基于 RPM 体系的系统&#xff0c;你总要跟rpm这个命令打交道。不管是装个 MySQL、部署个 Java 环境&#xff0c;还是排查某个文件到底属于哪个软件包&#xff0c;都绕不开它。但说实话&#xff0c;…

作者头像 李华
网站建设 2026/10/1 3:18:18

C#上位机通过OPCAutomation连接KEPServerEX 6实现曲线监控

简介&#xff1a;一份完整的C# OPC通信示例工程&#xff0c;演示通过OPC自动化接口连接KEPServerEX 6服务器&#xff0c;并借助Windows窗体与图表控件将实时数据绘制成动态曲线。内容涵盖OPC服务器连接、数据项订阅、定时刷新、异常重连与图表优化等关键环节&#xff0c;适合工…

作者头像 李华
网站建设 2026/10/1 3:17:17

企业AI转型四步法:从场景选择到规模化落地的避坑指南

我们部门去年搞了一场AI转型动员会&#xff0c;各部门负责人都到了。讲台上厂商顾问放了一段特别炫酷的演示&#xff0c;大模型在屏幕上秒答问题、自动生成报表&#xff0c;台下几位老总眼里都在放光。三个月后我再去回访&#xff0c;发现那套系统除了在汇报PPT里出现过&#x…

作者头像 李华