尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++栈与队列深度剖析:从手写实现到STL底层与进阶应用

C++栈与队列深度剖析:从手写实现到STL底层与进阶应用 栈和队列是数据结构里被误解最深的一对结构。很多初学者觉得它们“太简单了”不就是先进后出和先进先出嘛但真正用起来才发现函数调用的底层依赖栈、消息队列在分布式系统里扛流量、滑动窗口算法靠双端队列跑出O(n)复杂度。这篇内容我想把C视角下的栈和队列从头到尾拆开讲一遍包含手写实现、STL源码层面的取舍、循环队列的满空判断陷阱、以及进阶的单调栈和并发队列问题适合正在学数据结构、准备面试或者想补一补C容器底层原理的同学。1. 从“底层视角”看清栈和队列的本质1.1 栈一条只允许从一端进出的死胡同栈的本质是用一个线性表操作限制来描述“后进先出”的规律。你可以把它想象成一条死胡同车只能从胡同口进出最先开进去的车必然最后才能倒出来。所有操作都被约束在栈顶这一端入栈push、出栈pop、取栈顶top。这个约束听起来像是“限制”实际上却是很多算法问题的核心逻辑。函数调用时系统用调用栈保存返回地址和局部变量括号匹配、表达式求值、撤销操作都是“最近发生的事情最先处理”的天然场景。C里的std::stack就是对这个逻辑的封装但我们后面会讲到它的底层容器其实是std::deque这一点很多人没注意。为什么栈的效率那么高因为所有操作都发生在末端数组也好链表也好末端操作要么是O(1)的尾插尾删要么是O(1)的指针操作根本不需要遍历。这也是它能在各种算法题里频繁出场的原因——很多看似复杂的题目抽象到最后就是“维护一个栈”。1.2 队列窗口前排队的服务模型队列的规律刚好反过来先进先出。队尾入队队头出队新来的排后面先来的先走。这个模型在生活中太多了银行排队、打印机任务排队、操作系统的进程调度全是队列的思想。C里对应的容器是std::queue和std::deque。但值得多说一句的是队列如果用普通数组实现会遇到“假溢出”问题元素不断出队后队头指针向后移动数组前面明明空着一大片新元素却因为队尾指针到了末尾而无法入队。所以工程上常用的是循环队列把数组头尾连成一个环让队尾能“绕回”到数组开头去。队列在系统设计里还有一层更宏大的角色消息队列。生产者往队列里丢消息消费者从队列里取消息解耦、削峰、异步——三个关键词全在这里面体现。理解队列的“缓冲”本质是理解分布式架构的起点之一。1.3 栈和队列的典型应用清单我整理了一张表把这两个结构的典型使用场景列在一起对照着看会很清楚场景使用结构核心原因函数调用/递归栈嵌套调用需要“后进先出”地恢复现场表达式求值栈运算符优先级本质是后处理高层级运算符撤销/重做栈用两个栈来回倒括号匹配栈最近的左括号决定当前的右括号是否匹配任务调度队列按到达顺序公平处理消息队列/线程池队列解耦生产者和消费者滑动窗口最大值双端队列两端都能进出的特性恰好匹配窗口移动深度优先搜索栈后访问的先扩展天然匹配递归或显式栈2. 从零手写实现把栈和队列写进代码里2.1 数组栈最简单也最容易翻车的一版先来一版基础的数组栈。栈底固定在下标0top指向栈顶元素的下一个位置。top 0表示空栈top capacity表示栈满。#include iostream #include stdexcept template typename T class ArrayStack { private: T* data; int top; // 指向栈顶元素的下一个位置 int capacity; void resize() { int newCap capacity * 2; T* newData new T[newCap]; for (int i 0; i top; i) { newData[i] data[i]; } delete[] data; data newData; capacity newCap; } public: ArrayStack(int cap 8) : capacity(cap), top(0) { data new T[capacity]; } ~ArrayStack() { delete[] data; } void push(const T val) { if (top capacity) { resize(); } data[top] val; } void pop() { if (empty()) { throw std::underflow_error(Stack underflow); } --top; } T topValue() { if (empty()) { throw std::underflow_error(Stack empty); } return data[top - 1]; } bool empty() const { return top 0; } int size() const { return top; } };手写数组栈绝大多数坑都在扩容这一块。resize里必须先分配新空间、拷贝元素、再释放旧空间顺序不能反拷贝元素时要按旧的元素个数拷贝而不是按新容量。如果写成全量拷贝数组中未初始化的部分会被读出来遇上非平凡类型可能直接出问题。这是我在课程设计里见过最多的翻车写法。2.2 链栈不扩容也能一直push数组栈的扩容机制其实也简单但如果你想要“永不满”的体验可以写链栈。链栈的本质是单链表的头插法链表头部就是栈顶入栈就是头插出栈就是删头节点。因为栈操作只发生在头部链栈天然没有“满了”的概念每个节点都是动态分配的。#include iostream #include stdexcept template typename T class LinkedStack { private: struct Node { T data; Node* next; Node(const T val, Node* nxt nullptr) : data(val), next(nxt) {} }; Node* head; int count; public: LinkedStack() : head(nullptr), count(0) {} ~LinkedStack() { while (head) { Node* tmp head; head head-next; delete tmp; } } void push(const T val) { head new Node(val, head); count; } void pop() { if (!head) { throw std::underflow_error(Stack empty); } Node* tmp head; head head-next; delete tmp; --count; } T topValue() { if (!head) { throw std::underflow_error(Stack empty); } return head-data; } bool empty() const { return head nullptr; } int size() const { return count; } };链栈的唯一缺点是内存开销大每个节点都要多存一个next指针而且节点在堆上零散分配缓存不友好。数组栈则相反内存连续访问速度快但扩容时要搬数据。实际项目里大部分场景用数组栈就够了链栈更多地用来理解链表结构本身。2.3 循环队列用rear和length判断满空循环队列是笔试和课设里的“常客”最经典也最容易写错的版本是用rear和length两个变量管理。rear指向下一个元素要插入的位置length记录当前元素个数。数组下标为(rear - length m) % m的位置就是队头这个公式我建议直接背下来。入队arr[rear] val; rear (rear 1) % m; length;出队队头位置是front (rear - length m) % m; --length;因为出队只是逻辑上的移除不真正清空那个位置所以不需要移动元素。#include iostream #include stdexcept template typename T class CircularQueue { private: T* arr; int front; // 队头下标逻辑上通过 rear 和 length 计算 int rear; // 下一个插入位置 int length; // 当前元素个数 int m; // 容量 public: CircularQueue(int cap 10) : m(cap), rear(0), length(0), front(0) { arr new T[m]; } ~CircularQueue() { delete[] arr; } void enqueue(const T val) { if (full()) { throw std::overflow_error(Queue full); } arr[rear] val; rear (rear 1) % m; length; } void dequeue() { if (empty()) { throw std::underflow_error(Queue empty); } front (rear - length m) % m; arr[front] T(); // 释放元素占用的资源可选 --length; } T frontValue() { if (empty()) { throw std::underflow_error(Queue empty); } return arr[(rear - length m) % m]; } bool empty() const { return length 0; } bool full() const { return length m; } int size() const { return length; } };这里最关键的是不要用front rear判断满。因为如果不浪费一个存储单元当队列满时front和rear恰好相等和空队列完全一样程序根本分不清。用length记录长度是最直观的解决办法也让“满”和“空”的判断变得完全直白——推荐在课设里就用这个方案。2.4 链式队列队列的链表形态链式队列维护两个指针front指向队头节点rear指向队尾节点。入队在rear这一步出队在front这一步两个方向恰好是链表的尾插和头删。因为只操作两端复杂度全是O(1)非常简单。#include iostream #include stdexcept template typename T class LinkedQueue { private: struct Node { T data; Node* next; Node(const T val, Node* nxt nullptr) : data(val), next(nxt) {} }; Node* front; Node* rear; int count; public: LinkedQueue() : front(nullptr), rear(nullptr), count(0) {} ~LinkedQueue() { while (front) { Node* tmp front; front front-next; delete tmp; } } void enqueue(const T val) { Node* newNode new Node(val); if (rear) { rear-next newNode; } else { front newNode; } rear newNode; count; } void dequeue() { if (!front) { throw std::underflow_error(Queue empty); } Node* tmp front; front front-next; if (!front) { rear nullptr; } delete tmp; --count; } T frontValue() { if (!front) { throw std::underflow_error(Queue empty); } return front-data; } bool empty() const { return front nullptr; } int size() const { return count; } };链式队列出队前要先判断front是否为空。如果空了还继续操作rear指针可能变成野指针另外链表删除节点后要及时把rear置空不然会出现“front为空但rear还指向旧节点”的脏状态。3. STL容器与手写实现的取舍vector/deque/stack/queue3.1 std::stack和std::queue的底层是deque不少初学者以为std::stack底层是std::vectorstd::queue底层是std::list。实际上C标准库的默认实现都是std::deque。为什么不用vector当stack的底层因为stack只需要在末端操作vector完全够用。但deque在双端操作上都能保持O(1)这让std::stack和std::queue共用同一个底层容器成为可能——标准库厂商只需要实现一个deque然后封装成两种适配器就行。deque的另一个优势是不需要像vector那样整体搬迁。vector扩容时要复制所有元素到新内存deque用分段存储规避了这个问题。这种设计让deque在很多场景下表现非常稳定代价是随机访问比vector慢一些因为它要算“落在哪一段、段内偏移多少”。3.2 deque分段连续内存的“双端都能动”的容器deque的内部结构值得稍微展开讲一下。它把数据分成若干个连续缓冲区block再用一个中心化的映射表map记录各个block的地址。当两端容量不够时只需要在map上扩展指针数组或者在头部/尾部新申请一个block不动已有数据。这也是为什么deque两端都能O(1)操作的原因两端插入在Map数组里添加一个block地址或者往已有block的空位写入。随机访问operator[]需要先通过map找到block再算block内偏移多了一层间接所以比vector慢。std::stack和std::queue的默认底层选deque还有一个隐藏好处它不像list那样每个元素都分配单独节点而是以block为单元批量分配缓存命中率远高于list。所以即便只是当stack用deque往往也比list作为底层更高效。3.3 手写还是STL我的选择标准我自己做项目时有一套判断标准能用std::stack和std::queue就直接用手写只在三种情况出现算法题或练习需要理解内部实现原理手写一遍记忆才深刻。课设或面试老师或面试官明确要求实现底层这时STL是禁止的。特殊需求比如固定容量、无锁并发版本、需要监控队列水位等标准库容器不提供这些接口。还有一点要提醒生产代码里千万不要用std::stack的迭代器——标准库明确不提供stack的迭代器因为stack语义上就不应该允许遍历。真想遍历栈内容要么直接用deque要么把元素逐个弹出再压回去这是很多人会踩的隐坑。3.4 VS Code下C开发环境的最简配置热词里反复出现“vscode配置C/C环境”说明很多读者还在为开发环境头疼。我推荐一套“零插件负担”的最简配置安装编译器Windows建议用MinGW-w64MSYS2里搜mingw-w64-x86_64-gcc安装macOS直接用clangLinux用g。VS Code安装三个扩展C/C、C/C Extension Pack、Code Runner。别装一堆花里胡哨的三个足够。基础配置在.vscode/tasks.json里设置编译任务关键参数写上g -g -Wall -stdc17。-g是生成调试信息-Wall把警告全开-stdc17指定标准。调试的时候断点配合“调用堆栈”面板可以直观看到函数调用栈的每一层——这恰好就是栈的实时运行形态。4. 进阶玩法单调栈、双端队列与并发队列4.1 单调栈下一个更大元素的利器单调栈是栈这个基础结构最经典的“升级形态”。它维护一个栈内元素单调递增或递减的序列通常用于解决“左边/右边第一个比当前元素大或小的元素”这类问题。最经典的题目是“每日温度”或“下一个更大元素”。思路是遍历数组当当前元素比栈顶大时说明栈顶元素的下一个更大元素就是当前元素弹出栈顶并记录答案否则继续把当前元素下标压入栈。#include vector #include stack std::vectorint nextGreater(const std::vectorint nums) { int n nums.size(); std::vectorint result(n, -1); std::stackint st; // 存下标 for (int i 0; i n; i) { while (!st.empty() nums[i] nums[st.top()]) { result[st.top()] nums[i]; st.pop(); } st.push(i); } return result; }单调栈的核心洞察是每个元素最多进栈一次、出栈一次所以总复杂度是O(n)。比暴力双循环的O(n²)整整降了一个量级。这个“以空间换时间、用线性扫描保持有序性”的思路在很多算法题里都能复用。为什么单调栈能保持O(n)因为入栈出栈的总次数有上限——每个元素一旦出栈就不会再进来。所以即便有嵌套的while循环所有while的总执行次数也不会超过n这才是O(n)的来源。面试时把这个说清楚往往是加分项。4.2 双端队列滑动窗口问题的灵魂双端队列std::deque在两端的O(1)插入删除让它成为滑动窗口问题的天然工具。经典问题给定数组和一个大小固定的窗口窗口每次向右滑动一格求每个窗口中的最大值。朴素的思路是每次扫描窗口内元素O(n×k)。用双端队列可以把复杂度降到O(n)队列中始终保持窗口内候选最大值的下标并且从队头到队尾按数值递减排序。#include vector #include deque std::vectorint maxSlidingWindow(const std::vectorint nums, int k) { std::vectorint result; std::dequeint dq; for (int i 0; i (int)nums.size(); i) { // 队头元素已经滑出窗口 if (!dq.empty() dq.front() i - k) { dq.pop_front(); } // 保持队列递减队头永远是当前窗口最大 while (!dq.empty() nums[dq.back()] nums[i]) { dq.pop_back(); } dq.push_back(i); if (i k - 1) { result.push_back(nums[dq.front()]); } } return result; }这里有两个容易忽略的细节第一dq.front() i - k时才能判定队头滑出窗口边界条件是而不是第二新元素入队前要把所有“比它小且比它老”的元素弹出否则它们永远不可能成为最大值留着纯属浪费空间。双端队列在C里也承担了“栈和队列都能当”的角色。你可以直接把std::deque当成一个“两头都能操作的动态数组”用只是要记住它牺牲了一点随机访问性能。4.3 阻塞队列与无锁队列生产者消费者视角进阶到并发领域栈和队列的角色会发生质的转变。阻塞队列是线程池、消息中间件里的核心组件。当队列为空时消费者线程会被阻塞挂起直到有生产者投递消息当队列满时生产者也会被阻塞直到消费者取走元素。这个“等待—唤醒”的交互是线程安全队列的骨架。标准实现的思路是互斥锁 条件变量。#include queue #include mutex #include condition_variable template typename T class BlockingQueue { private: std::queueT q; std::mutex mtx; std::condition_variable cv; size_t capacity; public: explicit BlockingQueue(size_t cap 100) : capacity(cap) {} void push(const T val) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this]() { return q.size() capacity; }); q.push(val); cv.notify_one(); } T pop() { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this]() { return !q.empty(); }); T val q.front(); q.pop(); cv.notify_one(); return val; } };条件变量配unique_lock是必须的因为wait需要在等待期间释放锁、被唤醒后重新抢回锁。用lock_guard会直接死锁——这是个高频面试考点。更高阶的无锁队列则用原子操作实现核心思想是用一个原子变量做头指针一个原子变量做尾指针插入时用CAS把尾指针“试着往后挪”若期间有别的线程也插入就重新读取尾指针再试。无锁队列的最大意义是避免线程被挂起在低竞争场景下延迟很低但它实现的复杂性也高得多——“ABA问题”“内存回收”“伪共享”全是坑。我个人的建议是项目没有明确性能瓶颈就别自己写无锁队列先用BlockingQueue跑起来真有性能问题再上无锁。自己造轮子的代价往往比想象中大。4.4 函数调用栈程序运行时每天都在用的栈你写的每个C函数运行时都会对应一个“栈帧”。函数被调用时系统把返回地址、参数、局部变量压进调用栈函数返回时系统弹出栈帧恢复现场。递归为什么容易栈溢出就是因为每次递归都会产生新栈帧栈空间有限压得太深就爆了。调试时打开VS Code的“调用堆栈”面板你能看到的就是一串函数调用历史记录——从最早的入口一路到当前断点的那一层。这串记录本身就是一条栈最后调用的函数在最上面最先调用的在最下面。排查栈溢出的常规做法是缩小测试数据规模二分定位是哪一层递归出问题检查递归终止条件看是否漏了return考虑把深度递归改成迭代。理解“函数的调用过程就是栈的入栈出栈过程”会让很多晦涩的运行时问题变得很好理解。5. 踩坑实录与调试经验5.1 栈溢出排查思路我在课设里遇到过一个很典型的案例一个同学的快速排序在十万级数据上直接崩溃。原因很简单他选用的递归快排在最坏情况下递归深度等于数组长度每次递归都要消耗栈帧内存十万层的栈帧直接撑爆了默认栈空间。排查栈溢出的三个步骤可以作为通用模板确认是栈溢出报错信息带stack overflow、Segmentation fault或者调试器显示调用栈非常深。统计递归深度在递归入口打印或记录深度观察是否超过1万层。改写法能用循环的改循环或者像快排那样对短区间改用插入排序限制递归深度。栈空间在Windows/Linux默认大概只有几MB而堆空间随随便便能申请几百MB。别把大数组放在函数内部局部变量里——那是在栈上分配非常容易爆。改成std::vector或者动态申请就把存储挪到了堆上。5.2 循环队列满空判断的经典陷阱循环队列最经典的坑就是“用front rear判断满”和“直接用rear front判断空”混淆。如果你采用“浪费一个存储单元”的经典教材方案规则是队空条件front rear队满条件(rear 1) % m front这种写法下队列实际能存m-1个元素不是m个。很多同学交代码时没发现这个m-1的细节测试用例少放一个元素时一切正常一放满就死循环。用rear length方案则没有这个烦恼length 0就是空length m就是满存满m个元素也不会出问题。两种方案都能用但混着写的人最多——一会儿用reaf得front rear判断空一会儿又用(rear1)%m判断满前后逻辑不一致调试起来极其痛苦。建议选定一种方案就全程用同一套判断。5.3 手写链表容器时最容易犯的内存错误手写链栈、链队列时C初学者最常见的内存错误有三类忘记删除节点pop只改指针不delete节点导致内存泄漏。释放后再访问先delete tmp再去访问tmp-data属于悬空指针。头尾指针不同步链式队列出队后如果front变空rear还指向旧节点下一次入队时判断rear是否正确就很关键。我调试这类错误的方法很土但很有效在关键操作里临时加打印每次push/pop后打印出整个链表的内容和所有指针地址。指针问题往往对比“逻辑地址”和“实际地址”时立刻暴露。5.4 高频错误速查表错误典型症状原因解决方案栈顶指针越界数据写入错乱push前没判断满加top capacity检查或扩容栈空时pop抛出异常/崩溃没判空就操作pop前调用empty()循环队列假溢出队列明明有空间却无法入队用线性数组的思路实现循环队列用循环下标(rear1)%m循环队列满空不分存取结果错乱没有用length或浪费一格统一一套满/空判断方案内存泄漏程序跑多次后内存飙升链表节点没delete析构函数遍历释放全部节点悬空指针偶尔崩溃、数据看似正确释放后继续访问释放后立即把指针置nullptr死锁程序卡住不动lock_guard配condition_variable改用unique_lock配合wait6. 结尾一点个人经验最后分享一个我做项目时的习惯凡是需要“最近状态”的地方先想栈凡是需要“按顺序处理”的地方先想队列。这个朴素的判断标准帮我省下了大量思考时间。比如浏览器回退、编译器的符号表、撤销操作第一反应就应该是栈而打印机任务、网络请求排队、消息推送第一反应就应该是队列。另一个经验是别怕手写一遍这些基础结构。我教过的学生里凡是能独立手写栈和队列并说清楚循环队列为什么那样判断满空的人后面学二叉树、图、哈希表时普遍轻松很多。数据结构就像搭积木栈和队列是最底下那两层这两层稳了往上垒什么都不会太慌。如果把栈和队列再往下延伸下一步值得研究的就是std::deque的内存布局、以及基于原子的无锁队列实现。这些内容的门槛不低但基础打牢之后读源码和写高性能组件都会顺畅很多。
返回列表