ARTICLE DETAIL

资讯详情

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

C++栈与队列从原理到工程:手写实现、STL容器与算法实战

C++栈与队列从原理到工程:手写实现、STL容器与算法实战 队列、栈这两个名字在C数据结构与算法的学习路径里几乎永远是先出场的主角。随手翻开一本《数据结构》教材前半部分是顺序表、链表到了队列和栈这里很多人会觉得“不就是两个线性结构嘛一个先进后出一个先进先出”然后把代码抄一遍就翻过去了。但真正到了写工程项目、刷算法题、准备面试的时候才会发现这两个结构是无数高级特性的底盘函数递归背后的调用栈、编辑器里的撤销操作、操作系统的中断栈帧、消息队列、线程池里的阻塞队列、广度优先搜索、表达式求值……全都长在栈和队列这两棵树上。这篇文章我不会给你念教材而是以实际使用的角度把栈和队列在C里“为什么会这样设计”“代码到底怎么写”“踩过哪些坑”一次讲清楚。内容对刚学到数据结构的同学、正在准备408统考或蓝桥杯等竞赛的人、以及工作中想在工程里正确使用C容器的人都适用。读完你应该能独立手写一个循环队列能看懂单调栈和括号匹配的套路也知道在工程里该用std::stack还是自研队列。1. 先搞清楚栈和队列到底解决了什么问题1.1 两种存取规则两种思维模式栈和队列本质上都是线性表数据一个挨一个排着。它们的特别之处不在于“怎么存”而在于“怎么取”。栈是后进先出LIFO就好比食堂里一摞盘子你总是拿最上面那个先放上去的盘子反而最后被拿走。队列是先进先出FIFO就像排队买奶茶先到的人先拿到后来的必须排在队尾。这个“限制访问顺序”的设计乍一看是限制了自由度实际却是把“到底先处理谁”这个决策权从调用方手里收走了。什么时候需要这种限制答案是当“处理顺序”本身就是核心逻辑的时候。函数调用必须一层层返回所以要用栈任务到达先后必须公平处理所以用队列。顺序本身就是一种信息栈和队列把这种信息天然地保存了下来。很多初学者纠结一个问题明明数组和链表都能存数据为什么还要搞出栈和队列因为数组和链表关心的是“怎么把数据放进去”而栈和队列关心的是“以什么顺序把数据拿出来”。这属于两种维度的设计。后续学习树、图的深度优先和广度优先遍历时这种思维差异会被进一步放大。1.2 为什么C学习者必须把这两个结构吃透从考试角度说数据结构408统考里栈和队列几乎年年不缺席。循环队列的判空判满、栈与递归的关系、表达式求值都是选择题和算法题的重灾区。期末复习也好、考研冲刺也好这两个结构一定是重点中的重点。从竞赛和面试角度说单调栈、用两个栈实现队列、用队列实现栈、括号匹配、BFS模板这些题目在蓝桥杯、力扣、牛客上出现频率极高。而这类题目最大的特点就是“解法极其固定”你只要真正理解一次就能套用到大量变种题上。从工程角度说C里所有和“任务调度”“调用回溯”“缓存缓冲”相关的代码背后几乎都是栈和队列的思想。认知上有没有把这两个结构吃透直接决定了你写出来的代码是“能跑的玩具”还是“可维护的工程”。1.3 栈和队列的工程应用地图我整理了一张非常朴素的应用对照表建议初学者先记下来之后学到操作系统、网络编程这些课程时再回来对照着看场景对应结构原因函数调用与返回栈调用栈后调用的函数先返回天然匹配LIFO递归深度控制栈系统栈/手动栈递归就是隐式压栈手动栈可避免爆栈编辑器撤销/浏览器后退栈最近的操作优先被回退括号匹配、表达式求值栈最近遇到的操作符要先处理任务队列/消息队列/线程池队列FIFO先到先服务公平且稳定BFS广度优先搜索队列逐层扩展先发现的节点先访问音视频帧缓冲、打印缓冲队列数据产生与消费速度不一致时削峰填谷缓冲区溢出攻击防护栈栈帧金丝雀栈方向增长与数组方向不同是漏洞根源这张表看下来你会发现栈和队列不只是“数据结构题”它们是操作系统、编译原理、网络、并发编程这些硬核课程的地基。我见过不少同学在栈和队列这里图快跳过结果学到《操作系统》进程调度时又在补课完全没必要。2. 栈的C实现从自己造轮子到看懂STL2.1 数组栈还是链表栈先看业务场景手写栈的底层容器无非两种选择数组或者链表。数组栈的优点是内存连续、缓存命中率高、扩容简单至少比链表扩容简单缺点是预先占用一段连续内存如果栈大小不可预估会造成一定浪费。链表栈的好处是大小动态随心节点随增随删缺点也很明显每个节点都要额外存储一个指针存在内存碎片和分配开销。实际工程里该选哪个我的经验是90%的通用场景选数组栈。现代CPU对连续内存的访问效率远高于散落各处的链表节点而且栈本身就是一种“只在末尾操作”的结构数组天然适合这种访问模式。链表栈在面试里写一遍证明你懂指针操作就够了工程代码里绝大多数时候用不到它。2.2 动态扩容栈的手写实现含代码我给学生讲栈的时候默认要求能默写一个基于动态数组的模板栈。下面是核心骨架#include stdexcept #include cstddef template typename T class Stack { public: explicit Stack(size_t cap 16) : capacity_(cap), top_(0), data_(new T[cap]) {} ~Stack() { delete[] data_; } void push(const T value) { if (top_ capacity_) { grow(); } data_[top_] value; } void pop() { if (empty()) { throw std::out_of_range(Stack underflow); } --top_; } T top() { if (empty()) { throw std::out_of_range(Stack is empty); } return data_[top_ - 1]; } const T top() const { if (empty()) { throw std::out_of_range(Stack is empty); } return data_[top_ - 1]; } bool empty() const { return top_ 0; } size_t size() const { return top_; } private: void grow() { size_t new_cap capacity_ * 2; T* new_data new T[new_cap]; for (size_t i 0; i top_; i) { new_data[i] data_[i]; } delete[] data_; data_ new_data; capacity_ new_cap; } size_t capacity_; size_t top_; T* data_; };有两个细节值得注意。第一个是扩容倍数。上面用的2倍扩容是工程中常见的折中方案。扩容倍数太小比如1.1倍会导致频繁搬运元素均摊成本高扩容倍数太大比如10倍内存浪费严重。2倍是一个写入均摊复杂度O(1)且空间浪费可接受的经验值。第二个是这句Stack(const Stack)和赋值运算符没写。这段代码只是一个“能跑的演示”如果要用在生产环境必须补齐拷贝构造、拷贝赋值、移动构造、移动赋值否则两个Stack对象浅拷贝会让同一块内存被delete两次。这就是C的Rule of Three/Five问题面试时很容易被追问。提示手写容器时拷贝控制是必考细节。写之前先问自己一句如果别人把这个对象复制了一份两个对象能否互不干扰地独立使用2.3 函数调用栈与backtrace栈回溯到底怎么回事系统层面的栈和数据结构教材里的栈是同一个东西。每次函数调用系统会分配一段栈帧stack frame里面存放局部变量、寄存器上下文、返回地址等信息。函数返回时这段栈帧被弹出控制权交还给调用者。这种“最后一个被调用的函数最先返回”的行为和栈的LIFO完全一致。当程序崩溃时调试器之所以能告诉你“出错位置的调用链”靠的也是栈。gdb里执行bt或backtrace命令它会沿着栈帧里的返回地址一层层往上走把整条调用链打印出来。这就是“栈回溯backtrace”的核心原理。中断处理程序在执行时会临时使用独立的中断栈保存被打断现场的寄存器形成一组称为中断栈帧的结构避免与用户态栈互相污染。这些机制的名字里都带“栈”但本质都是同一个先进后出的思想在不同层级上的复现。初学者最常见的误区是觉得栈只能存整数、字符串。实际上栈存的是“上下文”和“状态”。递归函数为什么容易爆栈因为每递归一层就压入一份新的栈帧如果递归10万层栈空间就被撑爆了。想解决这个问题思路就两句话要么把递归改成循环并用显式栈模拟要么限制递归深度。2.4 C STL里的栈长什么样C标准库里提供了一个适配器std::stack它并不自己管理数据而是包装了一个底层容器。默认底层是std::deque你也可以传std::vector或std::list进去std::stackint s1; // 默认底层 deque std::stackint, std::vectorint s2; // 底层 vector为什么默认不选std::vector而选std::deque我个人的理解是deque支持两端高效插入删除扩容时不需要搬运全部元素而vector扩容会整体搬迁对栈来说deque的空间浪费和vector相差不大但某些场景下deque性能更稳定。当然如果你知道栈的最大深度显式指定vector并reserve性能往往更好。std::stack只提供push、pop、top、empty、size这5个操作看起来非常简陋但这是刻意为之。因为栈的核心语义就是“只能从一端操作”一旦提供迭代器你就能从中间遍历栈的约束就被破坏了。这也是我在教学时反复强调的接口越窄语义越安全。3. 队列的C实现循环队列一个细节不能马虎3.1 朴素顺序队列的“假溢出”问题如果用普通数组实现队列你会立刻遇到一个尴尬的问题。设队列为数组q[m]用front指向队头、rear指向队尾下一个位置。入队时rear往后走出队时front往后走。很快rear就撞到了数组末尾可数组前半段明明还有很多空位。这就是经典的“假溢出”物理存储没满但逻辑上再也无法插入新元素。解决办法有两种一是每次出队都把元素整体前移这样入队是O(1)出队最坏O(n)效率太差二是把数组首尾相接让rear从m-1位置继续走到0也就是循环队列。循环队列在逻辑上是一个环物理上还是一段连续数组。唯一要小心的是从那头绕回来的路上怎么判断队列是空还是满。3.2 用 rear 和 length 设计循环队列队空队满不再纠结很多教材里循环队列的经典做法是浪费一个存储单元约定“队空时front等于rear队满时(rear 1) % m front”。这个方法能用但理解起来绕而且白白浪费一格空间。我更喜欢另一种方案也是很多数据结构习题里默认采用的用rear指示队尾下一个位置用length记录当前元素个数。核心公式就两个队空条件length 0队满条件length m队头位置front (rear - length m) % m为什么这个方案更好因为判空和判满的条件完全对称不需要纠结“为什么浪费一格”也不需要额外的tag标志位。所有操作都建立在length这一个变量上边界情况清晰。下面是一个可直接运行的C实现#include stdexcept #include cstddef template typename T class CircularQueue { public: explicit CircularQueue(size_t m) : capacity_(m), data_(new T[m]), rear_(0), length_(0) {} ~CircularQueue() { delete[] data_; } bool empty() const { return length_ 0; } bool full() const { return length_ capacity_; } size_t size() const { return length_; } void enqueue(const T x) { if (full()) { throw std::out_of_range(Queue is full); } data_[rear_] x; rear_ (rear_ 1) % capacity_; length_; } void dequeue() { if (empty()) { throw std::out_of_range(Queue is empty); } --length_; } T front() { if (empty()) { throw std::out_of_range(Queue is empty); } size_t front_index (rear_ capacity_ - length_) % capacity_; return data_[front_index]; } private: size_t capacity_; T* data_; size_t rear_; size_t length_; };这里计算队头位置为什么是(rear - length m) % m因为在循环队列里队头在物理位置上总是在队尾的“前面”两者相隔的距离就是队列长度length。如果rear已经绕回了数组头部附近减出的结果是负数取模前先加一个m修正即可。dequeue操作只减length不动队头指针因为队头位置是通过公式现算的。这个设计看着省事实际会让代码更清晰你不需要额外维护一个front_成员变量也就不可能出现front_和rear_不一致的 bug。注意手写循环队列最常栽的跟头是front计算公式取反了或者忘了考虑rear小于length时结果为负数的情况。我的建议是写完后用长度为3的队列手动走一遍入队、出队、绕圈的流程再提交或上线。3.3 从循环队列到阻塞队列线程池为什么要选它简单队列只能在单线程里玩。多线程环境下多个生产者往队列里放任务、多个消费者从队列里取任务这时队列的“先进先出”语义依然有效但需要外加两样东西锁和条件变量。所谓阻塞队列就是当队列为空时消费者取元素会被挂起等待直到生产者放入新数据后唤醒当队列满时生产者也会被阻塞直到消费者取走元素腾出空间。线程池的任务队列就是这个模型一堆工作线程不断从阻塞队列里取任务执行主线程不断往里扔任务。用C实现一个简化版阻塞队列核心逻辑也就几十行#include queue #include mutex #include condition_variable template typename T class BlockingQueue { public: explicit BlockingQueue(size_t cap) : capacity_(cap) {} void push(const T item) { std::unique_lockstd::mutex lock(mtx_); not_full_.wait(lock, [this] { return queue_.size() capacity_; }); queue_.push(item); not_empty_.notify_one(); } T pop() { std::unique_lockstd::mutex lock(mtx_); not_empty_.wait(lock, [this] { return !queue_.empty(); }); T item queue_.front(); queue_.pop(); not_full_.notify_one(); return item; } private: std::mutex mtx_; std::condition_variable not_full_; std::condition_variable not_empty_; std::queueT queue_; size_t capacity_; };这里最关键的是wait的用法wait(lock, predicate)会在条件不满足时自动释放锁并挂起当前线程被唤醒后再重新抢到锁。很多新手自己实现时喜欢先lock再while循环判断稍不注意就会一边持有锁一边睡眠直接死锁。有界容量为什么重要因为无界队列会让任务无限堆积内存最终被耗尽。线程池里选择阻塞队列时通常优先选有界版本配合拒绝策略保护系统。至于消息队列的“重复消费问题”那是另一个层面的语义问题内存队列是拿走即删消息中间件为了保证可靠性会保留消息网络抖动或消费端超时导致同一条消息被多次投递消费端需要通过幂等操作兜底。3.4 std::queue、std::deque、std::priority_queue 怎么选C标准库给队列家族提供了三个容易混淆的容器我先用一张表把它们的区别说清楚容器语义典型底层适用场景std::queueFIFO队列只从尾部入、头部出std::deque普通任务队列、BFSstd::deque双端队列头尾都可进出分段连续数组滑动窗口、双端操作std::priority_queue优先级队列每次弹出最大/最小元素std::vector 堆任务按优先级调度、TopK问题std::queue和std::deque的区别在于约束的松紧。记住一个原则能用std::queue表达的语义就不要用std::deque去表达。接口越窄后续维护的人就越不容易用错。std::priority_queue的底层是堆它不是把元素排好序而是只保证堆顶是最大/最小这个性质在很多场景里比“完全有序”更高效。一些框架和技术栈里经常说的“任务队列”本质也是队列思想。比如写小程序后端时用到消息队列客户端处理帧时用帧缓冲队列它们和我们在C里写的std::queue在抽象上是一致的只不过换成分布式组件或硬件环境。数据结构学得好的人看这些技术往往一眼就能看穿本质。4. 栈和队列的经典算法应用题目练手与思维升级4.1 括号匹配与表达式求值栈的经典入门题是括号匹配。思路非常直接遇到左括号就入栈遇到右括号就尝试与栈顶匹配匹配成功则弹出匹配失败或栈已空则判定非法。扫描完整个字符串后栈必须为空才是合法序列。#include string #include stack bool isValid(const std::string s) { std::stackchar 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 ! {)) { return false; } st.pop(); } } return st.empty(); }这个题的价值在于训练一种直觉什么时候需要用栈当“最近的某个东西需要先处理”的时候。表达式求值也是如此。中缀表达式3 4 * 2如果从左到右硬算会得到14而不是11因为*优先级更高且“更靠后出现却要先算”。用两个栈操作数栈、操作符栈或者转成后缀表达式再求值本质上都是在利用栈把“最近的、最紧急的”运算先做完。4.2 单调栈只注意“下一个更大元素”的人路有点窄单调栈是栈这个结构最漂亮的演化。所谓单调栈就是栈内元素保持单调递增或单调递减。别小看这个“单调”修饰语它让原本O(n²)的暴力枚举变成了O(n)。最经典的场景是“下一个更大元素”给定数组要求每个元素右边第一个比它大的数。暴力做法是对每个元素往右扫描复杂度O(n²)。单调栈的做法是维护一个从栈底到栈顶递减的栈遍历数组时只要当前元素比栈顶大就说明栈顶的“下一个更大元素”找到了可以弹出并记录答案。#include vector #include stack std::vectorint nextGreaterElement(const std::vectorint nums) { std::vectorint res(nums.size(), -1); std::stackint st; // 存下标不是存值 for (int i 0; i (int)nums.size(); i) { while (!st.empty() nums[st.top()] nums[i]) { res[st.top()] nums[i]; st.pop(); } st.push(i); } return res; }为什么存下标而不存值因为答案数组需要按下标填充而且后续可能需要用下标之间的距离做更多计算。这是一个非常实用的编码细节。单调栈的本质是“延迟决策”当前元素入栈后并不急着决定它的答案而是等右边出现更大元素时一次性清算。这种“延迟处理直到条件满足时集中结算”的思路在接雨水、柱状图最大矩形、每日温度这类题目里反复出现。会了单调栈你就掌握了一类高频题的通用解法。热搜里的“暴力枚举算法”和“剪枝算法”更多是另一条路线但说到底都是想减少无效计算量单调栈是其中把“剪枝”做到极致的形式之一。4.3 BFS与任务调度队列在搜索算法里的“待办清单”广度优先搜索BFS是队列在算法题里最典型的存在。BFS的模板其实非常固定把起点入队标记已访问循环取出队首节点把它的所有未访问邻居入队。#include queue #include vector // 假设 graph 是邻接表start 是起始节点编号 void bfs(const std::vectorstd::vectorint graph, int start) { std::vectorbool visited(graph.size(), false); std::queueint q; q.push(start); visited[start] true; while (!q.empty()) { int cur q.front(); q.pop(); // 处理 cur 节点 for (int next : graph[cur]) { if (!visited[next]) { visited[next] true; q.push(next); } } } }为什么BFS用队列而不是栈因为BFS要求“先发现的节点先扩展”这正好是FIFO的语义。如果用栈做深度优先就是另一套模板了。队列在这里充当的角色是“待办清单”保证每个状态按发现顺序被处理从而让搜索按“层”推进。在这个层面队列解决的是“调度顺序”问题和线程池里任务队列的职责高度一致。还有一类基于队列的拓扑排序Kahn算法也是先把入度为0的节点入队然后不断处理并更新后继节点入度本质同样是把“当前能处理的事”按顺序排好。4.4 用栈实现队列、用队列实现栈面试为什么会考这类题目看起来很绕但它的真正考点不是容器本身而是“如何在约束下维持另一种语义”。用两个栈实现队列的思路是准备in和out两个栈。入队时压入in出队时如果out不为空直接弹出否则把in里的所有元素全部倒入out再从out弹出。整个过程每个元素最多被移动两次摊还复杂度O(1)。用队列实现栈稍微trick一点。核心动作是入栈时把新元素放到队尾然后把前面的所有元素依次出队再重新入队这样新元素就跑到队头了。出栈时直接出队即可。也可以用两个队列来回倒腾思路与两栈版对称。这类题之所以高频是因为它逼着你剥离“结构表象”去思考“操作语义”栈和队列的差异只在弹出的位置不同通过一个中间缓冲区就能互相模拟。我建议读者把这两个题的代码都亲手写一遍然后思考一个问题为什么能互相模拟因为栈和队列都只是“线性结构限定端点操作”它们的表达能力本质上是等价的。5. 实操环境与工程建议把代码跑起来才是硬道理5.1 VS Code配置C/C环境的常见坑理论说得再多代码不跑起来等于白学。我在帮读者和同事排查环境问题时遇到最多的就是VS Code写C代码不知道怎么编译调试。VS Code本身只是个编辑器真正干活的是编译器。Windows上一般装MinGW-w64Linux上系统自带的g就能用。一个最小可用配置只需要两个文件tasks.json负责告诉VS Code怎么编译launch.json负责怎么调试。tasks.json里关键的配置项长这样{ tasks: [ { type: cppbuild, label: C 编译, command: g, args: [ -g, -stdc17, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: build } ] }-g必须带上否则调试器看不到变量信息-stdc17开启现代C特性。很多初学者输出了一堆乱码报错其实只是忘记指定标准编译器默认用了老旧的C98规则。Windows上还有一个高频报错程序一运行就提示缺少vcruntime140.dll或类似动态库。这通常是系统缺少对应的运行库组件。简单说Visual C Redistributable 是运行C/C程序所需的公共运行库集合安装对应版本即可解决不用自己手动去系统目录里复制DLL。提示先跑通一个“hello world”再开始写栈和队列的代码。环境问题越早解决后面学习越顺畅。我不止一次见过有人把时间浪费在折腾环境上结果数据结构本身反而没时间练。5.2 给队列和栈写单元测试留几个断言心里有底手写的数据结构没有测试你永远不知道它是碰巧能跑还是真的正确。我建议至少用一个简单的测试函数覆盖边界情况。#include cassert void test_stack() { Stackint st; assert(st.empty()); st.push(1); st.push(2); st.push(3); assert(st.top() 3); st.pop(); assert(st.top() 2); assert(st.size() 2); st.pop(); st.pop(); assert(st.empty()); } void test_circular_queue() { CircularQueueint q(3); assert(q.empty() !q.full()); q.enqueue(1); q.enqueue(2); q.enqueue(3); assert(q.full()); assert(q.front() 1); q.dequeue(); assert(q.front() 2); q.enqueue(4); // 绕圈4 应该填到数组第0格 assert(q.front() 2); q.dequeue(); q.dequeue(); q.dequeue(); assert(q.empty()); }这些测试用例不是随便写的。q.enqueue(4)这一步是在验证“绕圈”行为也就是3.2节里循环队列最核心的部分。如果公式写错这个用例立刻暴露问题。对于阻塞队列测试还要验证“阻塞”本身先启动一个消费者线程去取数据确认它挂起再放入数据确认它被唤醒。这种并发测试用纯断言不好写可以借助std::future和超时机制来做但那是后话。至少单元测试层面的边界逻辑上面几段代码已经够了。5.3 性能对比什么时候该用什么别用错容器我经常被问std::stack和自写的数组栈哪个快答案是绝大多数业务场景下差异可以忽略真正影响性能的是“是否频繁分配内存”和“是否直通缓存”。std::stack默认底层是deque它的分段连续设计在随机访问上不如vector但栈几乎不随机访问所以问题不大。以下几个经验值我实测下来比较靠谱已知栈的最大规模优先std::vector作为栈底并提前reserve扩容次数基本为0。队列频繁入队出队且数据量很大时std::queue默认底层deque表现稳定手写循环队列需要你自己管理容量和扩容收益在特定场景才明显。优先级队列选std::priority_queue底层堆操作是O(log n)不要自己手写堆排序替代除非有特殊定制需求。多线程环境下条件变量的阻塞队列适合“任务粒度不均、需要背压”的场景如果追求极致吞吐可以考虑无锁队列但实现复杂度高一个量级先别碰。工程里还有一类场景值得注意写数据库客户端时批量写入经常借助缓冲区队列把多条请求攒在一起再提交。比如用C绑定TDengine这类时序数据库时把采集数据先放进本地队列定时批量执行taos_stmt_prepare等接口能明显减少写入次数。这个“攒一批再干活”的思路本质上就是队列在充当削峰填谷的缓冲层。6. 常见报错与排查技巧实录6.1 栈空栈弹出、栈溢出、边界越界手写栈和std::stack用多了最典型的报错我归纳成三类。第一类是空栈操作。pop一个空栈或读取top时栈内没有元素在未定义行为里属于“看着能跑但偶尔崩溃”的一种调试时非常难受。我自己的习惯是封装一个类而不是直接裸用数组并在pop和top里显式检查并抛出异常。虽然有一点性能开销但换来的是错误可定位。第二类是递归导致的栈溢出。函数递归层数太深系统栈空间耗尽程序直接崩溃。出现这个问题时gdb里执行bt看回溯信息如果发现反复出现同一个调用链基本就能定位到无终止条件的递归。解决办法要么修复终止条件要么把递归改写成显式栈加循环。第三类是数组栈越界。手写数组栈时top_自增超过capacity_写入越界内存。这种问题可能不会立即崩溃而是悄悄破坏相邻数据。排查时建议打开AddressSanitizer编译选项g -fsanitizeaddress它会把越界读写直接变成明确的报错信息。6.2 队列假溢出、判满条件弄错、并发死锁队列的经典问题也很集中我整理了一份速查表症状可能原因排查思路队列还能入队却报满判满条件写错或未考虑循环回绕核对队满公式用3格数组手动模拟出队后队头位置不对front计算公式错误检查(rear - length m) % m是否正确入队数据被覆盖循环队列容量设置过小且未处理满检查是否先判满再入队多线程程序卡死条件变量wait前锁未释放确认wait(lock, pred)传的是unique_lock队列内存持续增长无界队列且消费者速度跟不上改成有界阻塞队列加入容量限制消息被重复处理消费语义是“至少一次”失败重试导致重复消费端做幂等处理业务侧兜底判断循环队列“满还是空”一定要先看用的是哪种方案。浪费一格、加tag标志、用length计数三套方案各有各的公式。最忌讳的是把三套方案揉在一起写比如明明用length计数还去判断front rear这会让逻辑彻底混乱。并发死锁这个问题非常阴险。条件变量wait的正确方式是先持有unique_lock然后调用wait它在挂起时会释放锁。新手容易写成先mutex.lock()再在循环里wait等待时锁没释放消费者永远拿不到锁程序就僵死了。遇到这类问题第一件事就是检查锁的生命周期。6.3 避坑心得最后聊聊我自己踩过几年坑之后总结的几条习惯仅供参考。草稿上先画图再写代码。数组栈画一个竖着的数组循环队列画一个环形队列空和满的状态各画一遍再动手写代码。逻辑在纸上理顺了代码自然一遍过。判断空和满写成两个独立函数别散落在每个操作里。操作多了以后如果每个方法里都自己判断一旦条件变化就要到处改容易漏。封装成empty()和full()两个函数相当于把正确答案统一管理起来。所有涉及下标计算的地方优先使用size_t。负数取模、负数下标这类问题用无符号类型能少一多半。但也别忽略隐患size_t做减法可能下溢所以循环队头公式里(rear - length m)一定要记得加一个m再取模。给手写容器做好拷贝控制。C里手写动态数组类如果不管拷贝、移动、析构“浅拷贝双delete”几乎是必然事故。写完了先问自己一句这个类复制一份会不会出问题不会才算真正写完。对std::stack和std::queue底层不熟悉时先翻开容器源码或者文档确认默认容器不要瞎猜。很多人以为std::queue的底层是链表实际上是deque。这种细节虽然在日常开发中不致命但被问到就露馅了。我在实际使用中还有一个体会栈和队列的很多问题表面是代码问题本质是抽象问题。当你对“谁先处理谁”这件事想清楚了代码怎么写都不会太差。比如线程池该用有界还是无界队列、BFS为什么要逐层扩展、括号嵌套为什么天然适合栈这些问题想通后写代码只是在表达你已经理解的事实而已。最后再分享一个学习方法每学完一种数据结构就写一篇自己的总结画图加示例代码。别小看这个动作把一句话能看懂的知识用自己的语言完整复述一遍记忆深度完全不一样。栈和队列只是起点后面还有树、图、哈希、排序每走一步都把这个习惯保持下去数据结构这条路会越走越顺。
返回列表