ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ std::stack 详解:从容器适配器到单调栈与栈溢出实践

C++ std::stack 详解:从容器适配器到单调栈与栈溢出实践 写C的人几乎没有绕开过stack。早些年我接手的第一个正经项目是个表达式计算器里面最核心的部分就是两个栈一个存运算符一个存操作数。当时我对STL还很生疏硬是手搓了一个栈出来后来翻标准库才发现std::stack早就把这些活全包了而且比我写的健壮得多。这篇内容适合三种人刚学完C基础、想知道STL里stack到底怎么用的人准备算法面试、需要掌握单调栈这类高频题型的人以及写业务代码时总在栈和队列之间犹豫、想搞明白性能取舍的人。我会把数据结构原理、STL实现、经典算法场景和实际踩坑记录放在一起尽量给你一套能直接“移植”到项目里的用法。1. 先搞明白C里的stack到底是个什么1.1 从数据结构栈到STL容器适配器栈的基本语义很简单只能从一端进出后进先出。你可以把它想象成一叠盘子洗碗的时候后洗完的盘子放在最上面下次吃饭时先拿的也是这叠盘子最上面那只。往栈里放元素叫入栈从栈顶取元素叫出栈整个过程永远只碰栈顶这一个位置。但在C标准库里std::stack并不是一种独立的内存结构而是一个“容器适配器”。这句话是理解整个库实现的钥匙。标准库先给你准备好了几种底层容器比如deque、vector、list它们各有各的内存布局和增删策略std::stack做的事情就是把其中一种容器包装起来只暴露符合“后进先出”语义的那几个接口push、pop、top、empty、size。底层的其他功能比如随机访问、中间插入、遍历全都被藏起来了。这种设计思路很聪明。它相当于给底层容器立了一道“门禁”外部代码无法通过stack对象直接操作底层容器中间的元素。好处是强制约束了使用规范如果你往代码里塞一个stack读代码的人立刻就知道这里只有栈顶操作没有别的花活。如果直接暴露deque别人可能会不小心用下标访问中间元素逻辑就被破坏了。栈的底层应用场景远比很多人想象的广。函数调用栈、浏览器后退按钮、文本编辑器撤销、深度优先搜索、括号匹配、表达式求值……这些看起来八竿子打不着的场景底子里全是后进先出。所以搞懂std::stack不只是学会一个STL容器而是掌握了这些场景的共同解法。1.2 三个底层容器为什么默认用dequestd::stack的模板声明是长这样的templateclass T, class Container std::dequeT class stack;第二个模板参数就是底层容器默认是deque。很多人一上来就会问为什么不是vectorvector不是最常用、随机访问也最快吗原因得从栈操作模式说起。栈只需要在尾部做两种操作push_back和pop_back这正好和vector的能力重叠。但vector有一个性能隐患扩容。当vector的容量不够时它会重新申请一块更大的内存把旧元素全部拷贝或移动过去再释放旧内存。如果栈的使用量无法提前预估这种反复扩容拷贝会带来明显的抖动。deque的设计不一样它用一小段一小段连续内存拼接起来需要扩展时只新增一段存储区不需要整体搬迁已有元素。所以deque在“不断往尾部插入”的场景下综合开销比vector更平滑。那为什么不用listlist是双向链表节点分散在堆上每个节点还要额外存储两个指针。对栈这种只需要尾部操作的场景list的空间开销大而且节点不连续导致CPU缓存命中率差。链表适合频繁在中间插入删除栈根本没有这个需求。我把三个候选容器的特性整理过一个表方便你按项目需要选底层容器尾部操作开销扩容/内存特点栈场景评价deque默认O(1)常数不大分段连续内存扩展不移动已有元素综合稳妥首选vectorO(1)但扩容时会整体复制连续内存必须预reserve才高效能预估容量时可选listO(1)但节点开销大每次插入分配一个节点缓存不友好一般不建议实践里我见过有人为了性能强行把底层容器改成vector然后提前调用reserve预留容量这种做法在“明确知道栈的最大深度”时是有效的。比如深度优先搜索一棵深度固定的树你可以算出最多同时压入多少个节点reserve之后就省去了deque各分段的管理开销。但如果拿不准老老实实用默认deque稳定的常数开销不会给你惹麻烦。2. std::stack 核心接口与第一段实战代码2.1 最小可用代码入栈、出栈、取栈顶std::stack的核心接口其实少得可怜少到新手很快就能全记住push入栈pop出栈top取栈顶元素empty判断是否为空size取栈内元素数量。还有一个emplaceC11加入的用来原地构造元素。写一个最小示例#include iostream #include stack int main() { std::stackint st; st.push(10); st.push(20); st.push(30); std::cout 栈大小: st.size() \n; // 3 std::cout 栈顶元素: st.top() \n; // 30 st.pop(); // 弹出30 std::cout 弹出后栈顶: st.top() \n; // 20 while (!st.empty()) { std::cout st.top() ; st.pop(); } std::cout \n; return 0; }这段代码虽然短涉及了几乎全部常用操作。注意最后遍历栈的方式先取top再pop直到empty。你没法用迭代器遍历stack因为适配器压根不提供迭代器。这种限制看似不方便实际是好事——逼着你按栈的语义思考。这里要特别强调一个点top()返回的是引用不是拷贝。如果你写auto x st.top(); // 得到一个拷贝 auto y st.top(); // 得到栈顶元素的引用两者用途完全不同。修改y会直接影响栈内元素修改x则不会。如果你只是想读值用auto即可如果你要实现类似“取栈顶元素然后原地修改”的操作就必须用引用st.top() 100;这行代码会直接更新栈顶元素。理解这一点能少犯很多莫名其妙的bug。2.2 为什么top()和pop()要分开设计很多刚从Java或Python转过来的朋友会不习惯为什么不能像int x st.pop();这样一步到位返回被弹出的元素这个设计绝不是什么历史包袱而是经过深思熟虑的。设想一下如果pop直接返回元素那它必须先把栈顶元素拷出来再把元素销毁。假如拷贝过程中抛异常栈的元素已经被移除了吗栈的内部状态是否还一致为了把异常安全性做好标准库设计者选择让pop返回void只负责移除元素而top单独返回引用让你自己决定是拷贝还是读取。这个“先获取再移除”的两步流程给了调用者完全的控制权。还有一个更现实的性能考量如果要弹出的元素是一个复杂对象返回拷贝意味着多一次拷贝构造如果pop返回的是引用那引用悬空的问题又来了。所以“toppop”分离是最稳妥的方案。使用时应记住一个标准动作// 正确先存下需要的值再pop Value v st.top(); st.pop();不要写成// 错误先pop再取top st.pop(); auto v st.top(); // 栈顶已经是另一个元素了这种低级失误在紧张写代码时很容易出现尤其是从其他语言习惯带过来之后。我自己的纠错习惯是脑子里把top和pop当成完全独立的两条指令pop之后绝不立刻再访问top除非你明确想让下一个栈顶元素变成当前栈顶。2.3 自定义类型的栈与emplace直接构造栈里不只能放int任何可拷贝或可移动的类型都可以放。这里有一个从C11开始就值得养成的习惯用emplace代替push当你不想临时构造对象时。#include iostream #include stack #include string struct Task { int id; std::string name; Task(int i, std::string n) : id(i), name(std::move(n)) {} }; int main() { std::stackTask tasks; // push需要构造一个Task临时对象然后将其移动进栈 tasks.push(Task{1, 读取文件}); // emplace直接使用参数构造栈内元素跳过临时对象 tasks.emplace(2, 解析内容); std::cout tasks.top().name \n; return 0; }emplace的底层原理是把参数完美转发给Task的构造函数直接在容器分配好的内存上构造对象省掉了一个临时对象的构造和移动。对于像Task这样的对象移动成本不高差别不大但如果元素是内部持有大量堆内存的复杂类型省下这一次临时构造是肉眼可见的收益。不过用emplace也有个前提你要确保参数和构造函数能精确匹配。参数写错了编译器会报一大串错误初学者看着很容易懵。遇到这种报错先别慌去看第一行提示通常说的是“没有匹配的构造函数”然后把参数个数、类型和构造函数签名逐一比对即可。3. stack的经典应用从括号匹配到算法竞赛3.1 括号匹配栈最直观的用法如果把所有栈的应用排个序括号匹配一定是最容易理解也是最适合练手的入门题。问题是给你一个只包含()[]{}的字符串判断括号是否正确闭合。人工判断很简单写程序却需要记住“最近遇到的左括号”。这不就是栈后进先出的典型场景吗思路是遍历每个字符遇到左括号就入栈遇到右括号时如果栈为空说明没有左括号对应直接判定不合法栈不为空就取栈顶检查栈顶左括号和当前右括号是否匹配。如果匹配就弹出栈顶继续往下如果不匹配直接返回false。遍历结束后栈必须为空才算合法否则说明有左括号没被闭合。#include iostream #include stack #include string #include unordered_map bool isValid(const std::string s) { std::stackchar st; std::unordered_mapchar, char match { {), (}, {], [}, {}, {} }; for (char c : s) { if (c ( || c [ || c {) { st.push(c); } else { if (st.empty() || st.top() ! match[c]) { return false; } st.pop(); } } return st.empty(); } int main() { std::cout std::boolalpha; std::cout isValid((()[])) \n; // true std::cout isValid(([)]) \n; // false return 0; }这个例子里最容易忽略的是st.empty()检查。很多初版代码忘了空栈判断遇到右括号开头或多余右括号的用例就直接崩溃。算法题里这叫“边界条件”在工程里这叫“空指针检查”本质都是一回事。3.2 中缀转后缀与表达式求值表达式求值里栈几乎是不可替代的存在。日常写的中缀表达式3 4 * 2计算机直接处理并不方便因为运算符有优先级。一个标准解法是先把中缀表达式转成后缀表达式逆波兰式然后用一个栈就能轻松求值。中缀转后缀的过程用到了一个运算符栈和一个输出队列。遍历表达式时操作数直接输出遇到左括号入栈遇到右括号则弹出运算符到输出直到遇到左括号遇到运算符时只要栈顶运算符优先级不低于当前运算符就弹出到输出再把当前运算符入栈。扫描结束后把栈里剩余运算符全部弹出。求值过程就简单了遇到操作数入栈遇到运算符则弹出两个操作数计算后把结果入栈最后栈顶就是最终结果。比如3 4 2 * 就是先算4*28再算3811。这也是为什么很多计算器内核都对栈情有独钟。如果想要一套能直接跑通的代码我建议你第一步只做“整数四则运算允许括号”。写的时候有三个关键细节一是处理连续数字时要把整个数读完别把123读成1、2、3二是减法、除法要注意弹栈顺序栈顶是右操作数栈顶下面是左操作数三是除数为0时要提前拦截否则运行时直接中断。3.3 单调栈下一个更大元素算法竞赛和面试里单调栈是stack应用的高级形态。所谓单调栈就是维护栈内元素从栈底到栈顶保持单调递增或递减。它最常见的用途是解决“找数组里每个元素右边第一个比它大的元素”这类问题。暴力解法对每个元素都往右扫描复杂度O(n²)。单调栈可以把复杂度压到O(n)。思路是从左往右遍历数组栈里存的是下标保持栈顶元素对应的数组值递减从栈底到栈顶。当遇到一个新元素时不断把值小于当前元素的栈顶下标弹出每弹出一个下标说明这个下标对应的“右边第一个更大的元素”就是当前元素之后把当前下标入栈。#include iostream #include vector #include stack std::vectorint nextGreaterElement(const std::vectorint nums) { std::vectorint result(nums.size(), -1); std::stackint st; // 存下标 for (int i 0; i (int)nums.size(); i) { while (!st.empty() nums[st.top()] nums[i]) { result[st.top()] nums[i]; st.pop(); } st.push(i); } return result; } int main() { std::vectorint nums {4, 1, 2, 5, 3}; auto res nextGreaterElement(nums); for (int v : res) std::cout v ; // 5 2 5 -1 -1 return 0; }看懂这段代码的关键在于理解“什么时候出栈”。每次新元素就是压在未出栈元素头上的“天花板”只要栈顶元素小于当前元素它的下一个更大元素就锁定了。这个过程里每个元素最多入栈一次、出栈一次所以总复杂度是线性。单调栈虽然名字唬人拆开看就是“用栈维护一个候选序列”候选里那些注定被更大元素压制的元素提前出栈腾位置。3.4 用显式栈实现DFS代替递归递归天然就是栈驱动的每次递归调用都会在系统调用栈上压入一个栈帧。但很多场景下系统调用栈是有容量限制的递归深度一高就栈溢出。解决办法之一是把递归改成显式栈把“待访问节点”压入std::stack或自定义栈循环处理。比如二叉树的先序遍历递归版本很简洁void dfs(TreeNode* node) { if (!node) return; visit(node); dfs(node-left); dfs(node-right); }改成显式栈void dfs(TreeNode* root) { if (!root) return; std::stackTreeNode* st; st.push(root); while (!st.empty()) { TreeNode* node st.top(); st.pop(); visit(node); if (node-right) st.push(node-right); if (node-left) st.push(node-left); } }注意这里要先压右孩子再压左孩子这样出栈顺序才是先左后右。这和递归的先序遍历顺序保持一致。如果压栈顺序写反遍历结果的顺序就会不同。不过显式栈也不是万能的。它虽然避开了系统调用栈的深度限制但如果数据量同样海量std::stack本身也会消耗堆内存。我的经验是当递归深度可能超过几千层或者你明确感觉到递归版本在特定数据上崩了再改成显式栈如果只是十几个节点的树不用自找麻烦。4. 深入底层调用栈、栈溢出和性能取舍4.1 调用栈和std::stack不是一回事很多初学者会把“函数调用栈”和std::stack搞混。两者都叫栈但完全不同。函数调用栈是程序运行时由操作系统和编译器管理的底层机制每次函数调用系统分配一段连续内存区域叫栈帧用来保存局部变量、返回地址、寄存器状态函数返回时栈帧被回收。这个栈的地址是自上而下增长的在很多平台上“栈向下增长”也和堆相反。std::stack则是标准库提供的一个数据结构它的元素存储在堆内存上通过底层容器的分配器和系统调用栈没有直接关系。你在代码里定义std::stackint这个对象本身可能占用一点栈空间但它管理的元素数据存储在堆上。理解这层区别很有用。有人以为“用了std::stack就不会栈溢出”其实不一定。如果你在循环里不断push百万个元素消耗的是堆内存不会触发系统调用栈溢出但如果你写了一个递归深度百万层的函数哪怕函数体内不使用std::stack系统调用栈也早就爆了。两个栈一个在你的程序逻辑层一个在运行时底层别混为一谈。4.2 栈溢出到底是怎么发生的又怎么规避系统调用栈的大小是有限的Windows默认约1MBLinux默认通常是8MB。每次函数调用都要消耗一些栈空间局部变量越多、参数越大单个栈帧就越大。递归函数更危险因为每次递归都新分配一个栈帧而且绝不释放直到到达终止条件。看这个经典示例int sum(int n) { return n 1 ? 1 : n sum(n - 1); }n10时没问题n10000时大概率在某个平台上段错误崩溃原因就是调用栈被填满了。这在Linux上常常表现为Segmentation fault在Windows上可能直接异常终止。规避办法有几种一是把递归改成迭代很多递归本质上是可以用循环加显式栈模拟的二是减小栈帧体积不要在大函数里声明超大数组三是适当调整系统栈大小这个在不同平台上有不同的编译器选项比如Linux下的ulimit -sWindows下可以通过编译器链接选项修改但我个人建议不到万不得已别动这个改变运行环境栈大小对程序的可移植性伤害很大。4.3 什么时候别用std::stackstd::stack虽然方便但它不是所有场景的最优解。如果你对性能极度敏感比如游戏引擎里做一个高频调用的帧内临时缓冲std::stack适用的地方也足够但几个问题值得留意。第一个问题是底层容器的分配。std::stack默认使用deque而deque在扩容时虽然不移动已有元素但会有分段管理的额外开销。如果你能提前确定最大元素数量自己用vector预留容量做一个轻量栈能显著减少分配次数。第二个问题是缺少批量操作。std::stack没有clear方法想清空只能不停弹栈。如果栈里对象析构成本高这么做有点亏。有人会用st std::stackint();重新赋值来一次性释放底层容器的所有内存这个trick实测有效但会让栈内元素逐个析构时间成本还在只是代码更简洁。第三个问题是调试体验。std::stack不提供迭代器你无法很方便地查看栈底附近的元素。调试时为了确认某几个元素的状态得挨个弹出来再压回去。如果只是自己调试可以临时取出底层容器容器适配器在标准中没有公开底层容器的接口但很多实现里有个受保护的成员c。想快速看内容可以用编译器扩展或者干脆换个思路暂时改用vector并在代码里只使用尾端操作等调试完再切回std::stack。我自己的习惯是工程代码里优先用std::stack语义清晰维护成本低算法比赛或需要极高性能的嵌套循环里根据场景决定是否裸写数组栈。裸写栈往往就是一个定长数组加一个top下标代码不到十行int st[MAXN]; int top -1; st[top] value; // 入栈 int v st[top--]; // 出栈这种写法没有安全检查适合自己掌握边界的情况不适合作为对外API。5. 避坑指南与排查实录5.1 空栈上调用top()最常见的未定义行为C标准里对空栈调用top()和pop()属于未定义行为。理论上编译器可以做任何事实践中最常见的是读到脏数据、随后崩溃。我见过不止一次线上bug排查到最后就是某段逻辑在栈为空时鬼使神差地调了一次top()。养成两个习惯可以根治这个问题。第一每次取top()前先检查empty()第二把“取栈顶并弹出”封装成一个小工具函数让使用方不直接面对裸接口template typename Stack auto popTop(Stack st) - decltype(st.top()) { // 注意这里不能返回引用 auto value st.top(); st.pop(); return value; }不过这个模板函数有个隐患它返回的是按值拷贝适合int、指针这类轻量类型。对于大型对象直接top加pop两步明确取值再弹栈反而更容易控制拷贝方式。关键还是那句老话任何对top或pop的调用都要问问自己这一行执行时栈为空的可能性是否存在。5.2 不要长期持有栈内元素的引用或指针std::stack的top()返回的是引用这让很多人产生一个错觉既然拿到了引用就能长期保存这个引用下次继续用。这是非常危险的。底层容器可能在某些操作时重新分配内存或移动元素。以vector为例当你push新元素导致容量不足vector会整体搬到新内存旧引用全部失效。deque在两端插入元素时对元素的引用仍然有效但迭代器会失效这比vector好一些但仍然不能让你在入栈操作后继续依赖旧指针。最稳妥的策略是只在取用栈顶的那一刻使用引用用完就丢别指望它一直指向同一个合法对象。如果你确实需要在栈中存储对象的地址以便后续处理那就不要让栈持有对象本身而是让栈持有指针或智能指针。这时候要注意栈里存的是指针的拷贝删除指针时机要自己管理好性能没有白拿的。5.3 编译链接时的静态库/动态库坑这个坑和stack的关系不算直接但C新手十有八九会遇到一段明明编译通过的代码换台机器运行却提示缺少某个DLL或者链接时报一堆LNK2005/LNK2019错误。在Windows上最常见的是和Visual C Redistributable相关的问题。我的建议很简单开发阶段就明确自己用的是动态运行时还是静态运行时。如果是动态运行时部署到未安装运行库的机器时需要带上对应的Redistributable包如果是静态链接就没有DLL缺失问题但生成的exe会大不少。另外Debug和Release的运行时库不能混着链接否则会出现符号冲突。比如你Debug工程里链接了一个Release编译的静态库经常出现already defined之类的重复符号错误别死磕代码先检查各个模块的运行时配置是不是一致。5.4 一个综合小练习十进制转二进制栈的学习到底有没有掌握用一个小练习检验就清楚了把十进制数转成二进制。原理是“除2取余逆序排列”。一直对2取余得到一组余数最先得到的是最低位最后得到的是最高位要逆序输出正好用栈。#include iostream #include stack void toBinary(int n) { if (n 0) { std::cout 0\n; return; } std::stackint st; while (n 0) { st.push(n % 2); n / 2; } while (!st.empty()) { std::cout st.top(); st.pop(); } std::cout \n; } int main() { toBinary(10); // 1010 toBinary(255); // 11111111 return 0; }这个代码把栈的基本接口全串起来了入栈保存顺序、出栈逆序输出。如果你想做得再深一点可以把2改成参数实现任意进制的转换也可以改成支持负数处理补码表示。改动不大但能让你真正理解“后进先出”是怎么变成“逆序输出”的。6. 我用stack几年后留下的几个心法代码写得越多越觉得容器只是工具真正值钱的是建模能力。看到一个问题需要“最近匹配”“深度优先”“撤销恢复”时第一反应应该是能不能用栈。这种敏感度不是背题背出来的是反复用栈解决实际问题练出来的。我自己的体会是新手阶段不要急着嫌std::stack接口少接口少反而逼你思考清楚每一步操作的含义。等你能把top、pop、empty之间的配合烂熟于心再回头看那些“复杂”的算法题会发现很多题的骨架就是一个栈。最后再分享一个小技巧如果你在调试时觉得std::stack太难看清全貌可以用一个很简单的方式临时查看——把它拷贝进一个vector再遍历。适配器本身没有迭代器但拷贝是允许的auto tmp st; // 拷贝 while (!tmp.empty()) { std::cout tmp.top() ; tmp.pop(); }这个办法不会影响原栈几行代码就能临时看清所有元素。等调试完再删掉这部分不影响线上逻辑。栈这个数据结构虽然小用好了能节省大量排查时间希望你也在实践中慢慢体会。
返回列表