ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++数据结构核心:从栈与队列到消息队列的工程实践

C++数据结构核心:从栈与队列到消息队列的工程实践 1. 从一道面试题说起为什么所有 C 开发者都躲不开栈和队列我面试过不少候选人也带过很多刚入行的新人发现一个规律凡是能把栈和队列讲清楚的人写代码的思路基本都差不到哪去。凡是支支吾吾、只说得出先进后出、先进先出这八个字的人后面问到函数调用、内存布局、消息处理、缓存设计大概率也是一团浆糊。这不是玄学是因为栈和队列这两个数据结构根本不只是考试题它们就是程序运行机制的骨架。就拿 C 来说你每调用一次函数参数怎么传、局部变量放哪、返回值怎么拿全靠系统栈你每写一行std::vector的代码底层的动态扩容策略也和队列的思想脱不开干系再看现在广泛用到的消息队列、线程池的阻塞队列本质就是队列在分布式和并发场景下的变体。所以我一直觉得学数据结构不能只盯着它是什么而是要问三个问题它解决了什么问题它的代价是什么真实世界里它在哪这篇文章我就用栈和队列这两个最基础的结构把这三个问题彻底聊透。目标读者是两类人一类是刚学完 C 语法、准备入门数据结构的学生另一类是已经工作、想回头补一补基本功的开发者。文章里的代码我会直接放到 VS Code 或者 Visual Studio 里就能跑配置好 C/C 环境的同学复制粘贴就能动手验证。2. 先搞清楚栈和队列到底在解决什么问题2.1 先忘掉教科书看看现实生活中的排队怎么说呢数据结构这东西名字听着吓人但思想全都在生活里。你去食堂打饭后到的人排在队尾先到的人先打到饭这就是队列先进先出FIFOFirst In First Out。你往一个弹簧弹夹里压子弹最先压进去的子弹在最里面最后才会被打出来这就是栈后进先出LIFOLast In First Out。你可能会觉得这不就是两种不同的排队规则嘛有什么好研究的对就是排队规则。但计算机科学里几乎所有涉及顺序的问题本质都是在问一件事你到底要按什么规则来处理一堆任务是先来的先处理还是最新的先处理栈说我永远处理最近的那个队列说我永远处理最早的那个。就这么简单的一句话延伸出去就是整个程序运行的底层逻辑。比如 C 的函数调用调 AA 调 BB 调 C那 C 执行完肯定要先返回给 BB 再返回给 A这是天然的最近调用先返回所以系统栈天然就是栈结构。你再想想浏览器的后退按钮、编辑器的撤销操作全都是后发生的操作先恢复栈结构直接落地。2.2 为什么先进后出和先进先出这么重要往深了说这两个结构反映了两种最基本的内存管理策略和任务调度策略。栈是一种就地处理的策略它允许你在一个线性序列的一端做所有操作插入和删除都是 O(1)而且空间可以紧凑排列缓存友好。代价是什么你只能动最上面的元素中间的元素想动不好意思你得先把上面的都弹出去。这就是受限的力量——有时候限制反而带来了高效和确定性。队列则是一种公平处理的策略它允许你在一端插入、另一端删除同样 O(1) 的复杂度但它保证了先来者先服务。这在任务调度、流量削峰、生产者消费者模型里极其重要。代价是什么队列通常需要两个指针队头和队尾而且如果底层用数组实现还会有假溢出的问题这我在后面会详细讲。所以你看栈和队列的价值并不在于它们能存多少数据而在于它们提供了一种受控的访问方式。复杂系统最怕的就是谁都能随便改数据结构限制了访问方式反而让程序的正确性更容易证明Bug 更少。这就是为什么操作系统、编译器、网络协议栈里到处是栈和队列的身影。2.3 C 标准库里的 std::stack 和 std::queue直接用还是自己写很多初学者会问C STL 里已经有std::stack和std::queue了为什么我还要学怎么实现问得好。我的回答是你确实可以直接用但你必须知道它们底下发生了什么。std::stack默认底层是std::dequestd::queue默认底层也是std::deque。deque 是一个双端队列它能在头尾两端都高效插入删除。这意味着标准库的 stack 其实是一个包装器它把 deque 的接口限制了一下只暴露push、pop、top这些栈操作。这是面向对象里的适配器模式非常优雅。但问题在于如果你不了解数组和链表实现的差异遇到性能问题你会无从下手。比如你写一个实时性要求很高的网络协议解析器每毫秒都要处理几千个数据包你用std::queue没什么问题但如果你误用了std::list作为底层容器由于链表节点是分散分配的缓存命中率低性能可能直接腰斩。这就是为什么我建议每个人至少手动实现两遍栈和队列一遍用数组一遍用链表。这不是为了造轮子是为了建立直觉某个操作快到底快在哪某个操作慢到底慢在哪3. 手写一个能上生产环境的栈3.1 用动态数组实现一个模板栈先看最简单的版本用动态数组实现栈。核心就三个操作push压栈、pop弹栈、top取栈顶。我直接上代码配上详细的注释。#include iostream #include stdexcept template typename T class ArrayStack { public: // 构造函数申请初始容量 explicit ArrayStack(size_t init_cap 8) : capacity_(init_cap), size_(0), data_(new T[init_cap]) {} // 析构函数释放内存 ~ArrayStack() { delete[] data_; } // 禁掉拷贝和赋值避免浅拷贝问题后面会细说 ArrayStack(const ArrayStack) delete; ArrayStack operator(const ArrayStack) delete; // 压栈先检查容量不够就扩容 void push(const T value) { if (size_ capacity_) { resize(capacity_ * 2); // 扩容为原来的两倍 } data_[size_] value; } // 弹栈把栈顶元素移除 void pop() { if (empty()) { throw std::out_of_range(Stack underflow); } --size_; } // 获取栈顶元素不弹出 T top() { if (empty()) { throw std::out_of_range(Stack is empty); } return data_[size_ - 1]; } const T top() const { return top(); } bool empty() const { return size_ 0; } size_t size() const { return size_; } private: void resize(size_t new_cap) { T* new_data new T[new_cap]; for (size_t i 0; i size_; i) { new_data[i] data_[i]; } delete[] data_; data_ new_data; capacity_ new_cap; } T* data_; size_t size_; size_t capacity_; };这里有几个特别重要的细节逐个说。第一resize我为什么用capacity_ * 2而不是加一个固定值因为倍数扩容的时间复杂度是均摊 O(1)而固定增量扩容的均摊复杂度是 O(n)。打个比方你每次发现座位不够了就多买一张椅子那每来一个人都要买一次椅子但如果你每次买的时候直接翻倍那平均下来每来一个人你只需要付很少的椅子费。实际工程里vector 的扩容策略一般就是 1.5 倍或 2 倍你要是笔试题里写固定增量面试官大概率会追问。第二data_[size_] value这一步如果T是一个没有默认构造函数的类型new T[init_cap]这一行就会编译错误。这暴露了裸数组 模板的一个坑。更完善的写法是用std::vectorT作为底层存储但那就失去手动实现的乐趣了所以我保留了这个版本同时你要意识到它的局限性。第三我直接把拷贝构造函数和赋值运算符delete掉了。为什么因为如果不删除编译器生成的默认拷贝构造函数只是做浅拷贝两个栈对象会指向同一块堆内存析构的时候就会 double free。这是 C 新手最容易踩的坑没有之一。3.2 数组栈和链表栈怎么选除了数组实现栈也可以用链表实现每次push就在链表头插入节点pop就删除头节点。两者对比如下对比维度数组栈链表栈随机访问缓存命中率高元素连续存储低节点分散在堆中内存占用有预分配空间可能浪费每个节点多一个指针字段扩容需要搬移整个数组不需要天然动态生长最坏操作耗时扩容时 O(n)每次 O(1)实现复杂度中需要处理容量管理低只操作头指针实际工程里绝大多数情况下用数组栈。为什么因为 CPU 缓存。数组的内存是连续的读取一个元素的时候相邻的元素会被一起加载到缓存行所以后续的 push/pop 操作往往直接命中缓存速度非常快。链表节点是 new 出来散落在堆上的每次访问基本都要走一次内存可能就缓存未命中延迟高出几个数量级。但是链表栈也不是一无是处。如果你需要频繁创建和销毁栈且栈的大小变化剧烈、难以预估链表栈避免了扩容的搬移代价就是一个更好的选择。我记得有一次在嵌入式环境里写一个协议解析器内存只有几十 KB用数组栈就怕爆后来换了链表栈配合对象池稳得很。说一下怎么测写一个循环压入 100 万个整数分别用数组栈和链表栈跑你会看到数组栈在时间上的巨大优势。这个实验很简单5 分钟就能做完强烈推荐你自己试试比看我写一百句话都管用。3.3 栈的经典笔试括号匹配与表达式求值栈的实际练习离不开两个经典题括号匹配和表达式求值。括号匹配的思路是遇到左括号就入栈遇到右括号就检查栈顶是否匹配。如果读到右括号时栈已经空了说明右括号多了如果遍历完栈里还有残留说明左括号多了。这就是栈的就近匹配特性天然用于处理嵌套结构。表达式求值则分为中缀转后缀、后缀求值两步。中缀就是我们平时写的(12)*3后缀是1 2 3 *。中缀转后缀要用一个运算符栈遇到数字直接输出遇到运算符则弹出栈中所有优先级不低于当前运算符的运算符然后压栈遇到左括号直接压栈遇到右括号则弹出直到左括号。后缀求值则简单得多遇到数字压栈遇到运算符弹出两个数字计算再压回栈。我当初学这个的时候最大的感悟是栈并不仅仅是一个存数据的容器它天然地表达了处理完一个子问题再回到上一层的逻辑。括号嵌套是子问题函数调用是子问题表达式递归求值也是子问题。你理解了栈的这一个核心精神再去看递归、回溯、深度优先搜索会豁然开朗。4. 手写一个工业级队列从环形缓冲区到 BlockingQueue4.1 为什么数组队列会有假溢出队列的实现听上去比栈简单入队尾、出队头。但如果你真的是用普通数组做队列很快会发现一个问题每次出队你都得把后面的所有元素往前搬一个位置复杂度 O(n)这性能太差了。于是你换了个思路用两个指针队头指针和队尾指针出队只动队头指针入队只动队尾指针。但这又引出了新问题——数组用着用着队尾指针到头了而队头前面还空着一大片空间这就叫假溢出。那怎么办答案是环形缓冲区Circular Buffer。把数组的头尾连成一个环队尾指针到末尾后回绕到数组开头。这样只要队列没满你永远可以继续入队。用模运算%就能实现回绕tail_ (tail_ 1) % capacity_;这一个模运算就是整个环形队列的灵魂。4.2 实现一个完整的环形队列模板直接上一个能用的版本。这里我留一个经典问题如何区分队空和队满我采用的是牺牲一个存储单元的方法也就是数组长度为 N 时只允许存 N-1 个元素。当(tail_ 1) % capacity_ head_时认为队列已满当head_ tail_时认为队列为空。#include iostream #include stdexcept template typename T class CircularQueue { public: explicit CircularQueue(size_t cap 16) : capacity_(cap 1), // 多留一个位置用于区分空/满 head_(0), tail_(0), data_(new T[capacity_]) {} ~CircularQueue() { delete[] data_; } CircularQueue(const CircularQueue) delete; CircularQueue operator(const CircularQueue) delete; void enqueue(const T value) { if (full()) { throw std::overflow_error(Queue is full); } data_[tail_] value; tail_ (tail_ 1) % capacity_; } void dequeue() { if (empty()) { throw std::out_of_range(Queue is empty); } head_ (head_ 1) % capacity_; } T front() { if (empty()) { throw std::out_of_range(Queue is empty); } return data_[head_]; } const T front() const { return front(); } bool empty() const { return head_ tail_; } bool full() const { return (tail_ 1) % capacity_ head_; } size_t size() const { return (tail_ capacity_ - head_) % capacity_; } private: T* data_; size_t head_; size_t tail_; size_t capacity_; };注意几点。第一size()的计算公式(tail_ capacity_ - head_) % capacity_是环形队列的经典写法必须加capacity_再取模否则 tail 小于 head 时会变成负数。第二这个版本没有扩容功能满就不能再入队了。实际工程怎么做两种方案一是环形队列配合动态扩容扩容时把元素搬到新数组并按顺序重新摆放二是干脆用链式队列天然没有容量上限。第三data_[tail_] value需要T有赋值能力如果 T 是只读类型就要用移动语义或指针存储这里不展开。这个实现能直接用于串口驱动的数据接收缓冲、单片机里的按键事件缓冲、日志模块的异步写入队列等场景。我自己在嵌入式项目里就常年用这种环形队列只是把容量配成 2 的幂次方这样取模运算可以用 (capacity_ - 1)代替效率更高。这是一个非常实用的优化技巧你如果知道队列容量必然满足 2 的 N 次方优先使用位运算。4.3 从环形队列到消息队列阻塞队列与生产者消费者光有基本队列还不够真实场景里的队列往往要解决线程安全和阻塞等待的问题。这就是线程池的阻塞队列、系统中间件消息队列的雏形。我这里给一个基于std::mutex和std::condition_variable的生产者消费者阻塞队列。核心逻辑就一句话队列空时消费者等待队列满时生产者等待。#include queue #include mutex #include condition_variable template typename T class BlockingQueue { public: explicit BlockingQueue(size_t max_size 100) : max_size_(max_size) {} void push(const T value) { std::unique_lockstd::mutex lock(mutex_); not_full_.wait(lock, [this]() { return queue_.size() max_size_; }); queue_.push(value); not_empty_.notify_one(); } T pop() { std::unique_lockstd::mutex lock(mutex_); not_empty_.wait(lock, [this]() { return !queue_.empty(); }); T value std::move(queue_.front()); queue_.pop(); not_full_.notify_one(); return value; } private: std::queueT queue_; std::mutex mutex_; std::condition_variable not_empty_; std::condition_variable not_full_; size_t max_size_; };这个类在 C 并发编程里是个很好的练习。wait的第二个参数——一个 lambda 谓词——是防止虚假唤醒的关键。所谓虚假唤醒就是线程被唤醒时条件并不成立所以必须用谓词再检查一次。这是面试高频考点也是实际开发里很容易忽略的坑。消息队列的三大作用——解耦、异步、削峰——在这个小类里其实已经体现大半了。生产者和消费者不需要知道对方的存在这就是解耦生产者 push 后立即返回消费者在后台 pop 处理这是基础异步用一个有界队列限制积压任务数量这是削峰。以后接触到 RabbitMQ、Kafka或者 C 里的并发队列库你会发现核心思想都是这个 30 行代码能讲清楚的东西。4.4 全栈项目里队列到底用在了哪里聊到全栈很多人觉得队列是后端中间件才用得上的东西。其实不对。一个全栈项目从前端到后端从浏览器到数据库队列无处不在。先说前端。你做一个点击按钮提交表单的页面如果用户快速点了十次你不可能发十个请求吧常见的方案是用一个任务队列只保留最后一个或合并相同请求。再比如前端的状态管理库内部也是用队列/调度器来管理更新的顺序。再说后端。Web 服务器的请求处理、日志上报、邮件发送、订单超时处理几乎全靠队列。业界常说的消息队列三大作用——异步、削峰、解耦我可以举一个最简单的例子你的系统要发一万封邮件如果同步发用户要等几分钟如果先丢进队列接口立刻返回后台慢慢发用户一秒不到就拿到了响应。这就是异步和解耦。数据库层面也有队列。MySQL 的 redo log 就是先写日志再落盘InnoDB 的写入缓冲密密麻麻排着队。Redis 的 list 本身就是队列LPUSHBRPOP组合可以快速实现一个可用的任务队列。我刚学全栈那会儿特意用一个阻塞队列实现了在线人数统计和聊天室消息广播做完之后就再也不觉得消息队列是什么高不可攀的东西了。它就是一个存放消息的容器放在合适的位置起到缓冲和调度作用。5. 栈和队列的高级玩法从 C 八股到算法实战5.1 为什么 C 面试永远离不开栈和队列每年的 C 八股文、软考、数据结构期末复习栈和队列一定占有一席之地。如果你去搜栈和队列相关的面试题出现频率最高的大概是这几个方向栈的经典应用括号匹配、表达式求值、最小栈、队列的变种双端队列、优先队列、单调队列、以及如何用两个栈实现一个队列用两个队列实现一个栈先说两个栈实现队列。思路是入队直接 push 到 stack1出队时如果 stack2 为空就把 stack1 的所有元素依次弹出并压入 stack2然后从 stack2 弹出栈顶。这样 stack2 的栈顶就是最早入队的元素。均摊复杂度 O(1)。反过来两个队列实现栈的思路是入栈时把元素先 push 到非空队列出栈时把队列前 n-1 个元素搬到另一个空队列剩下的那个就是要弹出的元素。这个题看上去像是脑筋急转弯但做一遍你对这两个数据结构的特性就会印象深刻栈擅长逆序队列擅长保序。再说单调栈和单调队列这是进阶算法里的大杀器。单调栈常用于找下一个更大元素这类问题维护一个栈内元素单调递增或递减的序列。比如给定数组要找出每个元素右边第一个比它大的元素用单调栈从右往左扫描每个元素最多入栈出栈一次复杂度 O(n)。暴力解法是 O(n^2)这就是数据结构的价值——用空间换时间用单调性干掉无效的比较。单调队列则常用于滑动窗口最值问题。你在一个数组中找一个长度为 k 的窗口里的最大值如果每次都遍历窗口复杂度 O(nk)太慢。用单调队列维护一个从队头到队尾递减的序列队头就是当前窗口的最大值每次窗口滑动时把新元素从队尾加入并弹出所有比它小的元素同时把已经滑出窗口的队头元素弹出。每个元素入队出队一次总的复杂度 O(n)。这一类题目到了 LeetCode 上都是热门题也最能体现数据结构 算法 程序这句话的分量。所以我建议每一个学 C 的人栈和队列至少要做五十道题尤其是单调栈和单调队列刷完再回头看你的工程代码眼界完全不一样。5.2 双端队列 deque为什么 STL 默认选它前面提到std::stack和std::queue的底层默认是std::deque。那你有没有想过为什么不是 vector不是 list这其实是个很精彩的设计问题。dequedouble-ended queue双端队列可以在头尾两端以 O(1) 复杂度插入删除。它内部采用分段连续存储map 的指针数组指向多个连续缓冲区所以它既不像 vector 那样头部插入需要搬移元素也不像 list 那样每个节点都要单独分配内存导致缓存不友好。用它做 stack 或 queue 的底层容器等于两头兼顾随机访问不如 vector但两端操作快内存连续性不如 vector但比 list 强得多。这里我补充一个真实踩过的坑。早年我在一个网络模块里用std::deque存上行数据包跑着跑着发现内存碎片严重。后来分析才发现deque 在存储小对象时分段缓冲区的开销被放大了。如果包都很小比如几十字节每个缓冲区浪费的空间比数据本身还多。后来我换成std::vector手动管理一个环形逻辑内存占用直接下降了一倍。这告诉我们STL 的默认选择是一个平均最优解但你的业务永远不会是平均的。不要盲目迷信默认容器。5.3 深挖一层栈空间、函数调用和递归C 里提到栈绕不开栈空间这个概念。每个线程都有自己的栈区存放函数调用的局部变量、参数和返回地址。默认大小通常只有几 MBLinux 下可以ulimit -s查看Windows 下一般是 1MB 到 8MB远小于堆空间。所以你在函数里定义一个大的局部数组或者写一个没有出口的递归函数很快就会出现栈溢出stack overflow。有个很经典的实验void blow_stack() { char buffer[1024 * 1024]; // 什么也不做仅仅是定义一个大数组 }如果这个函数里开一个 1MB 的局部数组Linux 默认线程栈 8MB 的话连续嵌套几层就爆了。这不是危言耸听我见过不少线上事故就是因为在回调函数里声明了大结构体变量导致栈溢出程序崩溃。正确做法是大块数据放堆上用new/std::vector管理递归尽量改成迭代加显式栈。说到函数调用C 的函数调用栈其实就是一个天然的栈应用每次调用把返回地址、参数、局部变量压栈函数返回时弹栈恢复现场。你在调试器里看到的调用堆栈就是这样一个结构。理解了这点你会对栈有一种亲切感——它是你写的每一行代码都在依赖的东西。5.4 实战小项目用栈实现一个带括号的简易计算器到此为止理论说得不少了我推荐一个能马上动手做的小项目写一个命令行简易计算器支持加减乘除和括号。输入(12)*34程序输出13。这个项目会用到两个栈一个数字栈一个运算符栈。灵感来源于中缀转后缀的思想但我们可以直接在中缀表达式上用双栈求值。规则是遇到数字压数字栈遇到运算符时如果运算符栈栈顶的优先级不低于当前运算符先弹出栈顶运算符计算再把当前运算符压栈遇到左括号直接压栈遇到右括号则一直弹出运算符计算直到遇到左括号最终输出数字栈的栈顶。这个项目写下来大概 200 行足够你把栈的操作、优先级处理、边界条款全部练一遍。更重要的是做完之后你会明白为什么说数据结构是程序设计的骨架——计算器的核心骨架就是两个栈什么词法分析、AST、递归下降都是锦上添花的东西。初学者把这个项目做上两遍栈这一关基本就稳了。6. 踩坑录我见过的栈和队列七大问题6.1 栈溢出不只是递归惹的祸提到栈溢出很多人第一反应是递归写炸了。其实不然。我见过一个案例某程序反复深层次调用回调函数每一层都定义一个中等大小的局部 std::string实际使用中并没有递归但嵌套层次一深栈空间照样被耗尽。所以排查栈溢出时不要只盯着递归要关注调用链深度 × 每层局部变量大小这个乘积。工具方面Linux 下可以用valgrind --toolmemcheck检查栈相关错误也可以用gdb查看崩栈现场。如果你用的是 VS Code配置好 C/C 调试环境后Debug 模式下程序崩溃调用堆栈窗口会直接显示你当前卡在哪一层函数非常直观。6.2 环形队列的空/满判断之争环形队列最容易写错的不是入队出队而是空和满的判断。除了牺牲一个存储单元的做法还有两种常见方案一种是用一个额外的计数器count_记录当前元素个数另一种是加一个flag_标记最后一次操作是入队还是出队。三种方案各有千秋。计数器方法最直观但要多维护一个字段flag 方法需要额外判断写操作牺牲一个单元的方法代码最简洁但容量少了一个。实际产品里我看用计数器的居多因为可读性好出 bug 的概率低。6.3 队列的 ABA 问题与并发安全并发环境下队列的线程安全比想象中复杂。我见过一个生产者消费者模型生产者入队时先检查队列是否满满就等待消费者出队时检查队列是否空空就等待。看起来没问题但如果没有锁保护多个消费者同时出队时队头指针可能被多个线程同时移动导致元素漏出、数据错乱。解决方式就是前面 BlockingQueue 里引入互斥锁和条件变量。还有一个更隐蔽的和 ABA 相关的问题无锁队列lock-free queue中指针被线程 A 读取后线程 B 可能出队入队多次导致内存地址被复用线程 A 再次比较时发现指针没变但内容已经换过了。这就是无锁结构的 ABA 问题。基础解法是给指针加上一个递增的版本号一起比较。这个话题比较深但如果你的目标是 C 后端方向值得花时间研究。6.4 内存泄漏C 手写数据结构最大的坑手写栈和队列最大的成就感来源于我不用 STL 也能写出能用的容器最大的噩梦则是内存泄漏和浅拷贝。前面我把拷贝构造和赋值操作 delete 了这是最省心的方案。如果你确实需要拷贝必须实现深拷贝或者用智能指针std::unique_ptrT[]管理堆数组那就安全得多。实际工程里我更推荐用std::vectorT做底层存储把内存管理的风险交给标准库自己只专注于栈/队列的逻辑。6.5 用 VSCode 跑 C 代码的配置要点最后说一个实操层面的问题因为这些代码要跑起来才能有感觉。VS Code 配置 C/C 环境这件事劝退了很多新手。核心就是三步装编译器Windows 用 MinGW-w64 或 Visual Studio Build ToolsmacOS 用 Xcode Command Line ToolsLinux 装 g装 C/C 扩展然后配置好tasks.json和launch.json。网上教程很多我不重复啰嗦了只说一个关键点确保你安装的 MinGW 版本和 VS Code 里的编译器路径一致。我见过有人装了多个编译器导致 VS Code 调用了错误版本编译时报出一堆莫名其妙的错误。所以先配置编译器再写代码宁可多花十分钟把环境一次配好也别边写边调环境非常浪费时间。6.6 常见问题速查表问题现象可能原因解决方法程序弹栈时报 Stack underflowpop 调用次数超过 push在 pop/top 前加 empty 判断环形队列永远显示满空/满判断逻辑写反画图模拟入队出队仔细检查 head/tail 移动多线程下数据错乱队列未加锁使用 mutex condition_variable内存溢出/重复释放浅拷贝导致同一内存被析构两次delete 拷贝构造或实现深拷贝程序跑起来死循环条件变量缺失导致忙等或永久等待正确使用 wait 谓词嵌套调用深时崩溃栈空间耗尽加大线程栈或用堆存储大型局部变量这个表我建议你收藏一下写代码的时候对照着查尤其是前两条是初学者最经常遇到的问题。7. C 数据结构其实是一通百通的最后想和你分享一点个人的体会。很多人把精力花在学新框架、追新语言特性上结果一遇到算法题或者线上性能问题就心里发慌。我见过太多这样的开发者了——他们知道怎么调库但不理解库底下的数据结构和算法一旦库不满足需求就只能靠瞎试很难有根据地做技术选型。栈和队列虽然是最基础的数据结构但你看函数调用靠栈、消息队列靠队列、线程池阻塞队列、环形缓冲区、单调栈、双端队列……这些东西其实都围绕秩序和访问规则在展开。只要你把先进后出和先进先出这两个基本模型吃透再去看树、图、堆、哈希表会发现它们都不过是如何组织数据、如何限制访问的不同答案。树是递归结构的表达堆是优先级的队列哈希表是字典的映射。数据结构从来没有脱离生活它只是把生活里的排队规则搬进了计算机。有一句话我一直很认同算法 数据结构 程序。而栈和队列是这等式最朴素的注脚。这篇文章不算短但如果你能跟着代码亲手敲一遍把环形队列画到纸上推演一遍再去刷十几道相关题目你就已经把这个基础打牢了。后面无论学全栈项目还是深入底层系统都会顺畅得多。根据我个人经验学数据结构和做菜很像。菜谱看一百遍不如自己动手做一遍。栈和队列是那盘番茄炒蛋——做法简单但火候、咸淡、翻锅的手法里全是功夫。动手吧把代码敲起来把队列画起来这比收藏十篇教程都有用。
返回列表