☰
彻底搞懂队列:从FIFO到环形缓冲、STL与并发应用
2026/10/8 20:00:08 网站建设 项目流程

1. 为什么要单独写一篇队列的学习笔记

刷题和写业务代码的时候,队列几乎无处不在——BFS、滑动窗口、任务调度、消息削峰,哪个都能见到它的身影。但说实话,我学数据结构的时候,链表和栈都挺好上手的,唯独队列一开始有点绕:一会儿先进先出,一会儿环形数组,一会儿又冒出来个双端队列。加上C++里既有STL封装好的std::queue,又有一堆底层实现细节,很容易让人学了后面忘了前面。

这篇笔记是我自己重新整理队列知识时的记录。我默认你已经掌握了基本的数组和链表操作,也用过std::vector这类容器,但如果你对队列的理解还停留在"排队买东西"的程度,那完全没关系——这篇就是从最朴素的直觉开始,一路讲到手写实现、STL源码思路和实战应用。目标只有一个:让你看完之后,自己能写队列、能选队列、能排查队列相关的Bug,而不是只会调包。

队列的核心认知就一句话:先进先出(FIFO,First In First Out)。数据从一端进入(队尾),从另一端离开(队头),就像水管里的水,先流进去的先流出来。这个特性决定了它在"顺序处理"场景下的不可替代性。但为什么这么简单的逻辑,在实际工程里会牵扯出阻塞队列、环形队列、优先队列这些变种?因为"先进先出"只是最朴素的规则,真实世界往往还需要加上"满了怎么办""空了怎么办""谁优先走"这类附加条件。

2. 队列的本质:先进先出到底意味着什么

2.1 从排队场景理解FIFO的约束力

设想一个最普通的场景:食堂打饭。先来的人先打菜,后来的排后面,这就是天然队列。计算机里的队列就是这个模型的数字化:新来的任务插到队尾,处理完的任务从队头离开。与栈(后进先出)相比,队列强调的是"公平"和"顺序保持"——每个元素等待的时间与它到达的先后顺序一致。

这个约束在工程中特别重要。比如网络请求的处理,如果后发的请求先被响应,体验就会很混乱;再比如打印任务,总不能后提交的文档反而先打出来。凡是要求"顺序不能被打破"的场景,队列就是最简单可靠的结构。

但这里有一个值得思考的点:为什么不用数组这种更底层的结构直接实现?如果用数组存一列数据,每次取出队头元素后要把后面的所有元素往前挪,代价是O(n)。队列的价值就在于,它把"入队"和"出队"两个操作都压到了O(1)——这才是它存在的真正理由。如果每次出队都要搬移全部数据,那它和一个普通数组没有任何区别。

2.2 队列的三种基础操作与两种实现路线

一个最小可用的队列,只需要三个操作,缺一不可:

  • 入队(push/enqueue):把元素放到队尾
  • 出队(pop/dequeue):把队头元素移除并返回
  • 判空(empty):判断队列里还有没有元素

C++的std::queue还提供了front()和back()用于访问队头和队尾元素,size()返回元素个数。仅此而已,STL的队列容器适配器本身提供的接口非常精简。

实现路线则有两条:顺序(数组)实现和链式(链表)实现。数组实现需要注意空间的循环利用,链表实现则要处理节点的动态分配。两条路线各有优劣,我在后面的章节分别展开。

3. 手写一个顺序队列:环形缓冲区是核心难点

3.1 朴素数组队列为什么会"假溢出"

如果你直接用一段固定长度的数组来实现队列,大概率会写成这样:一个front指针指向队头,一个rear指针指向队尾的下一个空位。入队时rear++,出队时front++。

问题来了:出队只是移动了front的位置,数组前面那些已经出队的空间就永远空着了。随着入队出队次数增多,rear很快顶到数组末尾,哪怕数组前半段全是空的,程序也会告诉你"队列已满"。这就是典型的"假溢出"。

我刚开始学的时候在这里纠结了很久:明明还有空间,为什么就是不能入队?后来想明白,问题的根源在于线性增长的下标无法回头。要解决它,唯一的办法是让下标在到达末尾时跳回开头——也就是把数组首尾相接,做成一个环。

3.2 环形队列的head与tail怎么设计才不出错

环形队列的核心是把逻辑上的线性数组首尾相连。具体操作是:rear和front在抵达数组末端时,通过取模运算回到数组开头。

这里有一个极其容易出错的点:如何区分队列"空"和"满"。因为环形结构下,rear == front这个条件既能表示空,也能表示满(如果允许队列把最后一个空间也占满的话)。比如数组长度是5,初始时front = 0, rear = 0;入队5个元素后,rear绕一圈回到0,此时rear == front,但你没法判断它到底是空还是满。

常用的解法有三种,我实际写代码时最推荐第一种:

方案做法判断条件代价
牺牲一个存储单元始终让rear指向最后一个元素的下一个位置,且该位置不存数据空:front == rear;满:(rear + 1) % capacity == front空间浪费1个元素
增加计数器用一个count变量记录当前元素个数空:count == 0;满:count == capacity额外一个变量
增加标志位用一个bool标记最后一次操作是入队还是出队空/满时front == rear,结合标志位区分逻辑略绕

我实践中几乎都用"牺牲一个存储单元"的方案,因为它不需要额外的状态变量,取模判断也直观。空和满的条件天然互斥,不会出现二义性。

3.3 完整可运行的环形队列C++实现

下面这份代码我加了详细的注释,方便直接抄去学习和调试。这里的capacity是队列的总容量,实际可存储的元素数量是capacity - 1。

#include <iostream> #include <stdexcept> template <typename T> class CircularQueue { private: T* data; int capacity; int head; // 指向队头元素 int tail; // 指向队尾元素的下一个位置 public: explicit CircularQueue(int cap) : capacity(cap) { data = new T[capacity]; head = 0; tail = 0; } ~CircularQueue() { delete[] data; } bool isEmpty() const { return head == tail; } bool isFull() const { return (tail + 1) % capacity == head; } void push(const T& value) { if (isFull()) { throw std::overflow_error("Queue is full"); } data[tail] = value; tail = (tail + 1) % capacity; } T pop() { if (isEmpty()) { throw std::underflow_error("Queue is empty"); } T value = data[head]; head = (head + 1) % capacity; return value; } T& front() { if (isEmpty()) { throw std::underflow_error("Queue is empty"); } return data[head]; } int size() const { return (tail - head + capacity) % capacity; } };

注意size()的计算方式非常关键:(tail - head + capacity) % capacity。为什么不是直接用tail - head?因为环形结构里tail可能小于head(比如绕了一圈后),直接相减会得负数或错误值。加上capacity再取模,就能统一处理"没绕圈"和"绕了圈"两种情况。

这里我建议你亲手跑一遍,打印入队、出队过程中head和tail的变化。我自己学习时,把下标变化捋清楚的那一瞬间,对取模运算的妙用有了真正深刻的理解——它不是数学技巧,而是环形结构下"下标越界回绕"的天然表达。

3.4 环形队列为什么适合做缓冲区

环形队列最大的工程价值在于"固定内存、无搬移"。生产者写入数据,消费者取走数据,两者互不干扰地绕着圈走。只要生产速度不超过消费速度太多,这个缓冲区就不会溢出。这在嵌入式、网络收发、日志缓冲等场景下非常常用。

相比之下,如果用std::vector模拟队列,每出队一个元素就得erase(begin()),复杂度O(n),大量数据时性能惨不忍睹。环形队列用取模运算把一个O(n)操作降成了O(1),空间上还做到了极致复用。牺牲一个存储单元换来的是实现简洁和性能稳定,这笔账非常划算。

4. 链表实现的队列:动态扩容与内存管理的取舍

4.1 带头尾指针的单向链表结构设计

数组实现虽然高效,但有一个先天限制:容量固定。虽然可以用动态扩容(满了就翻倍复制),但扩容本身有开销。如果队列大小变化剧烈,或者难以预估峰值,链表实现更灵活——需要多少节点就分配多少节点,原则上不存在"满"的概念(只要内存够)。

链表队列的结构设计不复杂:一个单链表,额外维护一个head指针指向队头节点,一个tail指针指向队尾节点。入队就是在tail后面挂新节点,出队就是摘掉head节点。

这里有一个细节值得注意:为什么不直接复用单链表的"头插头删"?因为单链表头插容易,尾插需要遍历;而队列要求一端入、一端出。如果入队和出队都用头节点,那队列就退化成栈了。所以必须双指针:head负责出,tail负责入,两头各干各的,互不干扰。

4.2 完整实现与析构时的坑

#include <iostream> #include <stdexcept> template <typename T> class LinkedQueue { private: struct Node { T data; Node* next; explicit Node(const T& val) : data(val), next(nullptr) {} }; Node* head; Node* tail; int count; public: LinkedQueue() : head(nullptr), tail(nullptr), count(0) {} ~LinkedQueue() { while (head != nullptr) { Node* temp = head; head = head->next; delete temp; } } void push(const T& value) { Node* newNode = new Node(value); if (tail != nullptr) { tail->next = newNode; } else { head = newNode; } tail = newNode; ++count; } T pop() { if (head == nullptr) { throw std::underflow_error("Queue is empty"); } Node* temp = head; T value = head->data; head = head->next; if (head == nullptr) { tail = nullptr; } delete temp; --count; return value; } bool isEmpty() const { return head == nullptr; } int size() const { return count; } };

写这份代码时,我最想强调的就是析构函数。刚开始学链表的时候,我经常忘了释放内存,直到用内存检测工具才发现泄漏。链表队列的每个节点都是new出来的,析构时必须遍历整个链表逐个delete。如果你用LinkedQueue存的是自定义类型,节点析构时还会自动调用自定义类型的析构函数,所以不用担心内层资源的释放问题——但要确保自定义类型的析构逻辑是正确且不抛异常的(C++析构函数默认noexcept,抛异常会直接终止程序)。

另一个坑是pop()里"删完最后一个节点"的情况。如果删掉的是最后一个节点,head变成nullptr,但tail还指向已经被删除的节点——这就是悬垂指针。所以必须有if (head == nullptr) tail = nullptr;这步处理。我见过不少初学代码漏掉这个分支,程序跑着跑着就段错误,排查半天才发现是tail成了野指针。

4.3 数组版与链表版怎么选

直接给结论:能预估容量上限的,首选环形数组;容量变化剧烈或无法预估的,选链表。如果是LeetCode刷题,基本无脑用STL的std::queue就够了,自己手写反而容易出错。如果是嵌入式或实时性要求高的场景,数组版更好,因为链表节点分配涉及new/delete,时间不可控。如果是写业务代码,std::queue底层默认用std::deque(双端队列),已经兼顾了性能和灵活性,不必过早优化。

5. STL queue源码中隐藏的设计思路

5.1 queue其实是一个容器适配器

很多初学者会误以为std::queue是一个独立的容器,实际上它只是一个容器适配器——默认基于std::deque封装,把双端队列暴露的全部接口裁剪成队列需要的几个。换句话说,它不自己管理内存,而是"站在别人的肩膀上"做减法。

这意味着你可以替换底层容器。std::queue的第二个模板参数可以传std::deque(默认)、std::list,甚至自定义的容器(只要满足front()、back()、push_back()、pop_front()等接口要求)。

#include <queue> #include <list> // 换用list作为底层容器 std::queue<int, std::list<int>> q1; // 默认使用deque std::queue<int> q2;

这个设计思路非常值得学习:适配器模式。它不重新发明轮子,而是定义一组合适的接口约束,让既有容器"适配"成新结构。std::stack同理,底层也是std::deque。

5.2 为什么默认选deque而不是vector

这里藏着C++标准库设计者一个精妙的选择。理论上std::queue也可以用std::vector作为底层容器,vector也支持push_back和front(),但它不支持在头部高效删除元素——pop_front需要在vector头部搬移所有元素,O(n)的开销。

而std::deque采用分段连续空间的设计,本质是"多个连续缓冲区拼成的一个逻辑连续序列"。它在头部和尾部都可以高效插入删除,O(1)均摊复杂度。deque的随机访问能力虽然不如vector,但队列本来就不需要随机访问。所以deque几乎是queue的完美底座。

我见过有人为了"性能"手动把queue的底层容器改成vector,结果入队没问题,出队瞬间卡成PPT。这就是没搞清适配器底层需求导致的典型事故。

5.3 访问队头队尾:front和back的细节

std::queue提供了front()和back()两个只读/可写接口,分别返回队头和队尾元素的引用。这里有个容易踩的坑:在空队列上调用front()或back()是未定义行为,标准库不保证会抛出异常,直接崩溃也是可能的。用之前一定要先empty()判断。

std::queue<int> q; if (!q.empty()) { int x = q.front(); }

很多人从std::vector的经验迁移过来,以为越界访问会像at()一样抛异常,结果queue不抛异常就段错误了。标准库容器对性能的执着意味着它默认你"不会在非法状态调用接口"。

6. 队列在真实项目里的四种典型应用

6.1 BFS遍历:队列让层次遍历变得理所当然

图或树的广度优先搜索是队列最经典的应用。BFS的核心思想是"一层一层地处理",这天然就是FIFO顺序。二叉树层序遍历,简单到不需要思考:

#include <queue> #include <vector> struct TreeNode { int val; TreeNode* left; TreeNode* right; }; std::vector<std::vector<int>> levelOrder(TreeNode* root) { std::vector<std::vector<int>> result; if (!root) return result; std::queue<TreeNode*> q; q.push(root); while (!q.empty()) { int levelSize = q.size(); std::vector<int> level; for (int i = 0; i < levelSize; ++i) { TreeNode* node = q.front(); q.pop(); level.push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } result.push_back(level); } return result; }

这里的巧妙之处在于用q.size()固定本层节点数,即使入队新节点也不影响本次循环的次数。每次while循环开始时的size()就是当前层的宽度,然后for循环处理完这一层,新入队的节点留给下一轮。这个模式在BFS中极其常用,建议背下来。

6.2 滑动窗口:用队列维护一个"会移动的区间"

LeetCode高频题型"滑动窗口最大值"是队列应用的另一个经典场景。核心思路是维护一个单调队列——队列内元素保持单调递减,队头是当前窗口的最大值。新元素入队时,把队尾所有比它小的元素全部弹出,因为它比这些旧元素大且更靠右,在新窗口里这些旧元素永远不可能成为最大值。

#include <deque> #include <vector> std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) { std::deque<int> dq; // 存下标,保持对应数值单调递减 std::vector<int> result; for (int i = 0; i < 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; }

注意这里用的是std::deque而不是std::queue,因为单调队列需要在队尾也执行弹出操作(不是简单的先进先出)。这提醒我们:选容器时不要被"队列"这个名字绑住,关键是看你对两端是否需要控制权。标准std::queue隔离了队尾弹出能力,所以需要灵活操作时就得用deque。

6.3 线程池的任务调度:阻塞队列解决"生产-消费"难题

热搜词里出现了"线程池的阻塞队列选择"这个搜索,非常典型。线程池本质就是一个生产者-消费者模型:主线程不断往任务队列里塞任务,工作线程从队列里取任务执行。这里的关键问题是:队列空时,线程该干什么?队列满时,生产者该干什么?失去阻塞特性的普通队列无法优雅地回答这两个问题。

阻塞队列的本质是"队列 + 同步机制":为空时,消费者线程阻塞等待,直到有生产者唤醒它;为满时,生产者线程阻塞,直到有消费者腾出空间。C++标准库提供了std::condition_variable和std::mutex来实现这种阻塞逻辑,也可以直接用无锁并发队列(如基于环形CAS的实现)。Java里有现成的LinkedBlockingQueue,C++就得多写几行。

一个简化版的C++实现思路是这样的:

#include <queue> #include <mutex> #include <condition_variable> template <typename T> class BlockingQueue { private: std::queue<T> q; std::mutex mtx; std::condition_variable cvNotEmpty; std::condition_variable cvNotFull; size_t capacity; public: explicit BlockingQueue(size_t cap) : capacity(cap) {} void push(const T& value) { std::unique_lock<std::mutex> lock(mtx); cvNotFull.wait(lock, [this] { return q.size() < capacity; }); q.push(value); cvNotEmpty.notify_one(); } T pop() { std::unique_lock<std::mutex> lock(mtx); cvNotEmpty.wait(lock, [this] { return !q.empty(); }); T value = q.front(); q.pop(); cvNotFull.notify_one(); return value; } };

这里condition_variable::wait的第二个参数是谓词,用于防止"虚假唤醒"——线程可能在没有真正被通知的情况下醒来,所以必须用条件再次确认状态。这是并发编程里极容易踩的坑,不能省略。

6.4 消息队列:跨进程/跨服务的队列思想延伸

热搜词里还有"消息队列重复消费问题"、"消息队列"这类词。消息队列(如RabbitMQ、Kafka)本质上是把"进程内队列"的概念扩展到"跨进程、跨机器"层面。为什么需要它?因为生产者和消费者之间多了网络通信、持久化、集群容错等诉求,单机的数据结构无法满足。

队列层面的启示是:消息队列的核心依然是FIFO(在分区内),但引入了消费位点的概念——消费者记录自己读到哪条消息了,重启后可以从位点继续,而不是简单地从队头重新读。这跟单机队列"弹出即删除"的行为截然不同。

再说"重复消费"问题:这在消息队列里几乎无法彻底避免(网络超时后重试、消费端处理成功但还没来得及提交位点就宕机等都会导致重复投递)。业界通用办法是消费幂等性——把"消费一次"和"消费两次"的结果做成一模一样。实现手段包括数据库的唯一键去重、Redis的SETNX、或者业务状态机的幂等判断。理解了队列的生产-消费模型,再去理解消息队列的重复消费问题,底层逻辑就完全通了。

7. 学习队列时最常踩的四个坑

7.1 队列为空时调用front()导致的未定义行为

前面提过,std::queue的front()在空队列上调用是未定义行为。但数组版的自定义队列不同——我在3.3节的实现里加了异常抛出,这是为了友好调试而做的主动防护,STL为了性能不这么做。所以当你从手写代码切到STL时,一定要改掉"出错了会抛异常"的思维惯性。

7.2 混淆queue和deque/priority_queue的区别

这是一个特别常见的概念混淆。std::queue是标准FIFO,只能一端进一端出;std::deque是双端队列,两端都能进能出;std::priority_queue是优先队列(堆),出队顺序由优先级决定,跟入队顺序无关。

我见过有人想用priority_queue做"先进先出",因为"反正都是queue",结果输出的顺序全乱套。排序规则一旦传错,整个队列行为就会变得完全不可预测。我用一个表格总结三者的核心区别:

容器底层实现出队顺序典型应用
std::queuestd::deque(默认)严格FIFOBFS、任务顺序执行
std::deque分段连续内存两端均可进出滑动窗口、工作队列的灵活操作
std::priority_queue堆(vector)优先级最高者先出Dijkstra、TopK

7.3 取模运算带来的边界条件错误

手写环形队列时,取模运算的边界条件是最容易出Bug的地方。比如(tail + 1) % capacity == head判断队列满时,如果容积是1(虽然不合理),那初始状态head == tail == 0,(0 + 1) % 1 == 0会错误地认为"满"了。所以实际工程中capacity至少要取2,更严格的设计会在构造时断言容量大于1。

另一个常见错误是忘记处理负数取模。(tail - head + capacity) % capacity能正确工作是因为tail和head都在[0, capacity)区间内,差值范围是[-(capacity - 1), capacity - 1],加上capacity后变成[1, 2*capacity - 1],取模后落在[0, capacity - 1]。这个数学正确性值得亲手验证一遍,理解之后就能应对任何环形数组的下标变换。

7.4 new和delete的不配对导致内存泄漏

链表队列的每个节点都要new,但pop时忘记delete、或者析构时没有完整释放整条链,都会造成内存泄漏。这些Bug不会立刻暴露,但程序长期跑下来内存会缓慢上涨,最终被系统杀掉。

我在本地测试链表队列时,会开地址消毒器(AddressSanitizer)编译:

g++ -fsanitize=address -g test.cpp -o test ./test

这样就能在运行时直接检测出内存泄漏和野指针访问,省去大量手动排查的精力。这是调试C++内存相关Bug的第一利器,强烈建议所有C++学习者尽早养成这个习惯。

8. 队列学习的进阶路线与个人建议

学完基础队列和STL用法之后,我建议你按这个顺序做深度练习:

第一,用队列解决三道经典LeetCode题。比如"用队列实现栈""用栈实现队列""设计循环队列",这三道题分别考察了队列的组合运用、接口设计和底层实现细节,做题过程能倒逼你把本章的知识串起来。

第二,自己动手实现一个线程安全的阻塞队列,然后用它改造一个简单的生产者-消费者demo。这一步能让你体会到"队列 + 多线程"的真实场景,也是面试高频考点。热搜词里"线程池的阻塞队列选择"相关的问题,就在这一步得到解答。

第三,阅读编译器标准库中deque的实现原理。std::deque的分段连续结构是C++STL中比较有特色的设计之一,理解它如何兼顾"头部插入O(1)"和"随机访问O(1)",对后续学习std::list、std::vector的性能差异会有本质的认识。

我自己在实际学习中的体会是,队列虽然结构简单,但它几乎是所有"解耦"问题的通用解法——生产者消费者解耦、网络层和应用层解耦、耗时操作和主流程解耦,底层都是队列思想。你把基础FIFO吃透了,再去看分布式消息中间件、线程池、事件循环这些高级话题,会发现它们只是"队列 + 某种策略 + 某种同步机制"的组合。

最后再分享一个实用小技巧:在任何项目里使用std::queue之前,花十秒钟确认它的底层容器和你的使用模式是否匹配。如果你的场景需要在队尾弹出或者遍历队列内部元素,那std::queue就不是最佳选择,换std::deque能省掉你后续无数次的类型转换和接口绕行。选对容器的成本远低于改掉错误架构的成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询