☰
队列与约瑟夫环:顺序队列、链式队列及环形逻辑的C++实现
2026/10/20 21:07:40 网站建设 项目流程

1. 队列与约瑟夫环:从线性结构到环形逻辑的完整实现

队列这个结构,很多人第一次学的时候觉得“这不就是排队吗”,然后写了个数组加两个下标就交差了。但真到用的时候,比如做任务调度、消息缓冲、BFS 遍历,才发现边界处理、假溢出、内存管理这些坑一个比一个深。这篇文章把顺序队列、链式队列和约瑟夫环三个东西串在一起讲,因为它们本质上是一条线上的:前两个解决的是“先进先出”的存储问题,约瑟夫环则是队列思想在环形逻辑上的经典应用。C++ 实现,代码可以直接抄,但更重要的是把每个设计决策背后的原因说清楚。

先说说这三个东西各自解决什么问题。顺序队列用一段连续内存存数据,靠队头和队尾两个指针控制出入,优点是访问快、缓存友好,缺点是容量固定,而且朴素实现会有“假溢出”——明明前面有空位,但因为队尾到头了就不能再插入。链式队列用节点串联,动态扩容,不存在假溢出,但每个节点多一个指针开销,缓存局部性差一些。约瑟夫环是一个数学游戏:n 个人围成一圈,从第 k 个人开始报数,报到 m 的人出列,然后从下一个人继续,直到所有人出列。用队列模拟这个过程非常直观——出队的人如果没报到 m 就重新入队,报到 m 就彻底出队。

适合谁看?如果你正在学数据结构,课本上的代码跑不通或者看不懂,这篇文章能帮你把每个细节补齐。如果你已经工作了,需要手写一个无锁队列或者环形缓冲,顺序队列的循环数组实现就是基础。如果你在准备面试,约瑟夫环是高频题,但面试官更想听的是你为什么用队列而不是链表直接模拟,以及时间复杂度怎么分析。

我见过太多人学队列的时候只记住“先进先出”四个字,结果写出来的代码在队空、队满、循环回绕这些地方全是 bug。下面从设计思路开始,一步步把这三个结构拆开揉碎。

2. 顺序队列的设计与实现细节

2.1 为什么需要循环队列

朴素顺序队列用front和rear两个下标,front指向队头元素,rear指向队尾元素的下一个位置。入队时rear++,出队时front++。问题很快就来了:假设容量是 5,依次入队 5 个元素,rear变成 5,此时队满。然后出队 2 个元素,front变成 2,rear还是 5。这时候队列里实际只有 3 个元素,但rear已经到达数组末尾,再入队就会越界。这就是假溢出——数组前面明明有 2 个空位,但因为rear不能回绕,空间被浪费了。

解决办法就是把数组在逻辑上弯成一个环。rear到达容量上限后,通过取模运算回到下标 0。这样只要队列里元素个数小于容量,就一定能插入。循环队列的核心公式就两个:入队时rear = (rear + 1) % capacity,出队时front = (front + 1) % capacity。

但取模带来一个新问题:怎么判断队空和队满?如果不加处理,front == rear既可能是空也可能是满。常见方案有三种:一是牺牲一个存储单元,当(rear + 1) % capacity == front时认为队满,这样队列实际最多存capacity - 1个元素;二是额外维护一个size变量记录当前元素个数,size == 0为空,size == capacity为满;三是用一个flag标记最后一次操作是入队还是出队。我推荐第二种,因为size变量不仅解决了判空判满,还能 O(1) 时间返回队列长度,非常实用。

2.2 循环队列的 C++ 实现

下面是一个完整的循环队列模板类,用size变量管理状态,支持泛型。

template <typename T> class CircularQueue { private: T* data; int capacity; int front; int rear; int count; public: explicit CircularQueue(int cap = 10) : capacity(cap), front(0), rear(0), count(0) { data = new T[capacity]; } ~CircularQueue() { delete[] data; } bool empty() const { return count == 0; } bool full() const { return count == capacity; } int size() const { return count; } bool enqueue(const T& value) { if (full()) return false; data[rear] = value; rear = (rear + 1) % capacity; ++count; return true; } bool dequeue(T& out) { if (empty()) return false; out = data[front]; front = (front + 1) % capacity; --count; return true; } bool peek(T& out) const { if (empty()) return false; out = data[front]; return true; } };

这段代码有几个关键点值得展开。第一,构造函数用explicit防止隐式转换,比如CircularQueue<int> q = 10;这种写法会被编译器拒绝,必须写成CircularQueue<int> q(10);,避免误用。第二,enqueue和dequeue返回bool而不是void,调用方可以判断操作是否成功,这在生产代码里很重要——你永远不知道调用方会不会在队满时还硬塞数据。第三,peek是const成员函数,因为它不修改队列状态,这样const CircularQueue&也能调用。

注意:如果 T 是复杂对象,data[rear] = value会触发拷贝赋值。对于大对象,可以考虑用移动语义data[rear] = std::move(value),但要注意value是const引用时不能移动。实际项目中,如果队列存的是大结构体,建议存指针或std::unique_ptr。

2.3 容量选择与性能考量

循环队列的容量怎么定?如果太小,频繁队满导致入队失败;如果太大,浪费内存。一个经验法则是根据生产者和消费者的速率差来估算。假设生产者每秒产生 1000 个任务,消费者每秒处理 800 个,那么队列会以每秒 200 个的速度增长。如果希望队列能缓冲 10 秒的突发流量,容量至少是 2000。但这是理想情况,实际还要考虑内存限制和缓存行大小。

从性能角度看,循环队列的入队和出队都是 O(1),而且因为数据在连续内存上,CPU 缓存命中率高。但取模运算%在有些架构上比较慢,可以用条件判断替代:rear = (rear + 1 == capacity) ? 0 : rear + 1;。现代编译器通常会把% capacity优化成位运算,前提是capacity是 2 的幂。所以如果你对性能有极致要求,把容量设为 2 的幂,比如 1024、4096,这样取模就变成了& (capacity - 1),一条指令搞定。

还有一个细节:front和rear用int还是size_t?如果队列容量不会超过INT_MAX,用int没问题。但如果容量可能很大,用size_t更安全。不过size_t是无符号的,做减法时要小心下溢。我一般用int,因为队列容量超过 20 亿的场景几乎不存在,而且int的运算更快。

3. 链式队列的适用场景与实现

3.1 链式队列 vs 顺序队列:怎么选

链式队列用节点存储数据,每个节点包含数据和指向下一个节点的指针。队头指针front指向第一个节点,队尾指针rear指向最后一个节点。入队在rear后面接一个新节点并更新rear,出队删除front节点并更新front。因为节点是动态分配的,队列长度没有上限(受限于内存),也不存在假溢出。

那什么时候用链式队列?一是队列长度变化很大,事先无法预估最大容量;二是元素本身很大,移动或拷贝成本高,用链式结构只需要改指针;三是需要频繁在队列中间插入或删除(虽然标准队列不支持,但可以扩展)。反过来,如果队列长度稳定,或者对缓存性能要求高,顺序队列更合适。我做过一个网络包处理的模块,包大小平均 1.5KB,队列深度在 1000 左右波动,用循环队列预分配 2048 个槽位,性能比链式队列好 30% 以上,因为链式队列每次入队都要new一个节点,内存分配开销不可忽视。

3.2 链式队列的 C++ 实现

template <typename T> class LinkedQueue { private: struct Node { T data; Node* next; Node(const T& d) : data(d), next(nullptr) {} }; 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; } } bool empty() const { return front == nullptr; } int size() const { return count; } void enqueue(const T& value) { Node* node = new Node(value); if (rear) { rear->next = node; } else { front = node; } rear = node; ++count; } bool dequeue(T& out) { if (empty()) return false; out = front->data; Node* tmp = front; front = front->next; if (!front) rear = nullptr; delete tmp; --count; return true; } bool peek(T& out) const { if (empty()) return false; out = front->data; return true; } };

这段代码里,enqueue不需要判断队满,因为链表理论上可以一直加节点直到内存耗尽。dequeue里有一个容易忽略的细节:当出队后front变成nullptr时,必须把rear也置空,否则rear会指向一个已经被delete的节点,下次入队时rear->next = node就是访问野指针,程序直接崩溃。这个 bug 我在早期项目中踩过,调试了半天才发现。

析构函数里用循环逐个释放节点,不能用递归,否则队列很长时会栈溢出。另外,如果 T 是复杂类型,new Node(value)会调用拷贝构造,可以考虑完美转发和移动语义来优化,但那是另一个话题了。

3.3 内存管理与异常安全

链式队列最大的风险是内存泄漏。每次enqueue都new一个节点,如果dequeue时忘记delete,或者异常路径上没有释放,内存就会慢慢涨上去。C++ 里更现代的做法是用std::unique_ptr<Node>管理节点,这样出队时unique_ptr自动释放,不需要手动delete。但unique_ptr的链式结构在遍历和析构时会有递归问题,需要小心处理。

另一个问题是异常安全。如果new Node(value)抛出std::bad_alloc,队列状态不变,这是强异常安全保证。但如果T的拷贝构造函数抛异常,new表达式会释放已分配的内存,也不会泄漏。所以标准写法是安全的。但如果你在enqueue里做了多步操作,比如先改rear->next再new,那就可能出问题。我的建议是先把所有可能抛异常的操作做完,再修改队列状态。

提示:如果队列会被多个线程同时访问,链式队列需要加锁。但加锁的粒度很关键——锁整个队列最简单,但并发度低。可以尝试锁分离:入队锁rear,出队锁front,但要注意空队列时的竞争条件。无锁队列是另一个层次的话题,需要原子操作和内存屏障,这里不展开。

4. 约瑟夫环:队列思想的环形应用

4.1 问题建模与队列模拟

约瑟夫环的描述是:n 个人围成一圈,编号 1 到 n。从第 1 个人开始报数,报到 m 的人出列,然后从下一个人重新报数,直到所有人出列。要求输出出列顺序。

用队列模拟的思路非常直接:先把 1 到 n 依次入队。然后循环执行:从队头取出一个人,如果当前报数不是 m,就把他重新入队;如果是 m,就输出并彻底出队。每处理一个人,报数计数器加一,报到 m 后重置为 1。

这个过程用循环队列或链式队列都可以。循环队列的好处是不需要频繁分配内存,但需要预先知道 n 的大小。链式队列更灵活,但每次重新入队都要new节点,n 很大时性能差。实际测试下来,n=100000、m=3 时,循环队列比链式队列快 5 倍以上,因为链式队列的new/delete开销太大了。

4.2 约瑟夫环的 C++ 实现

#include <iostream> #include <queue> void josephus(int n, int m) { std::queue<int> q; for (int i = 1; i <= n; ++i) { q.push(i); } int count = 1; while (!q.empty()) { int person = q.front(); q.pop(); if (count == m) { std::cout << person << " "; count = 1; } else { q.push(person); ++count; } } std::cout << std::endl; }

这段代码用std::queue实现,逻辑清晰。但有一个细节:count从 1 开始,每次取出一个人后判断是否等于 m。如果等于 m,输出并重置count为 1;否则重新入队并++count。注意重新入队时count已经加过了,所以下一个人报的是count+1。这个顺序不能搞反,否则报数会错位。

用std::queue的底层是std::deque,性能比手写循环队列差一些,但代码简洁,适合面试手写。如果追求性能,可以用循环数组实现:

void josephus_fast(int n, int m) { std::vector<int> q(n); for (int i = 0; i < n; ++i) q[i] = i + 1; int front = 0, rear = n, count = 1; int remaining = n; while (remaining > 0) { int person = q[front]; front = (front + 1) % n; if (count == m) { std::cout << person << " "; count = 1; --remaining; } else { q[rear] = person; rear = (rear + 1) % n; ++count; } } std::cout << std::endl; }

这里用vector模拟循环队列,front和rear在[0, n)范围内循环。注意rear初始为 n,因为前 n 个位置已经被初始数据占满了。每次重新入队时,把元素写到rear位置,然后rear后移。因为出队和入队操作交替进行,rear永远不会追上front,所以不需要判满。这个实现比std::queue快很多,因为没有动态内存分配。

4.3 数学解法与队列解法的对比

约瑟夫环有一个著名的数学递推公式:f(n, m) = (f(n-1, m) + m) % n,其中f(1, m) = 0。这个公式可以在 O(n) 时间内算出最后幸存者的编号,但只能求最后一个人,不能输出完整的出列顺序。如果你只需要最后幸存者,数学解法是最快的,n=1亿也能秒出结果。但如果要输出所有人出列的顺序,就必须用模拟,时间复杂度 O(n*m),因为每个人可能被重新入队多次。

队列模拟的时间复杂度分析:每轮出队一个人,如果没报到 m 就重新入队,相当于这个人被处理了 m 次(最坏情况)。总共有 n 个人,所以总操作次数是 O(n*m)。当 m 很大时,这个算法会很慢。优化思路是用数学方法直接计算出下一个出列的人的位置,跳过中间的重新入队操作。比如当前队列有 k 个人,从当前位置开始报数,报到 m 的人出列,那么出列位置就是(current + m - 1) % k。这样每次出列只需要 O(1) 时间,总时间复杂度降到 O(n)。但用数组实现时,删除元素需要移动数据,可以用链表或者标记数组来避免移动。

注意:约瑟夫环的变种很多,比如从第 k 个人开始报数、报数方向交替、每次报数上限不同等。队列模拟的框架不变,只需要调整初始入队顺序和报数逻辑。面试时如果遇到变种,先问清楚规则,再动手写。

5. 常见问题与排查技巧实录

5.1 循环队列的假溢出与边界 bug

循环队列最容易出错的地方是判空和判满。如果用front == rear判空,那判满就不能也用这个条件。我见过有人写(rear + 1) % capacity == front判满,但忘了队列实际容量是capacity - 1,结果入队到最后一个位置时判断失误,要么丢数据要么越界。

排查方法:在enqueue和dequeue里加断言,检查count和front/rear的关系。比如enqueue后count应该加 1,dequeue后count应该减 1。如果count和实际元素个数对不上,说明状态管理有问题。另外,用一个小容量(比如 3)手动模拟入队出队序列,把每一步的front、rear、count打印出来,很快就能定位。

还有一个隐蔽的 bug:front和rear的初始值。如果初始化为 0,第一个元素入队后rear变成 1,front还是 0,这没问题。但如果初始化为 -1,取模运算(-1 + 1) % capacity在 C++ 里结果是 0,看起来也对。但(-1) % capacity在 C++ 里是 -1,不是 capacity-1,因为 C++ 的取模对负数向零取整。所以如果front初始为 -1,出队时front = (front + 1) % capacity第一次变成 0,没问题,但后续如果front变成 -1 就会出错。建议统一用 0 作为初始值,避免负数取模的坑。

5.2 链式队列的野指针与内存泄漏

链式队列的dequeue操作里,如果出队后队列为空,必须把rear置空。这个 bug 的表现是:出队最后一个元素后,再入队一个新元素,程序崩溃或者行为异常。原因是rear还指向已经被delete的节点,rear->next = node访问了已释放的内存。

排查方法:在dequeue里加日志,打印front、rear和count。如果count变成 0 但rear不是nullptr,就是这个问题。修复很简单,在dequeue里加一行if (!front) rear = nullptr;。

内存泄漏的排查可以用 Valgrind 或者 AddressSanitizer。编译时加-fsanitize=address,运行后如果有泄漏,会直接报出泄漏的调用栈。我习惯在开发阶段一直开着 ASan,虽然性能会降一些,但能提前发现很多内存问题。

5.3 约瑟夫环的报数错位与性能瓶颈

约瑟夫环最常见的错误是报数错位。比如count初始化为 0 还是 1,count == m的判断是在出队前还是出队后,重新入队时count有没有加。这些细节差一个,结果就全错。

我的建议是先用小数据手动验证。n=5,m=3,正确出列顺序是 3, 1, 5, 4, 2(从 1 开始报数)。写完后用这个用例跑一遍,如果不对,打印每一步的队列状态和count值,很快就能找到问题。

性能瓶颈方面,如果 n 很大(比如 100 万),m 也很大(比如 1000),队列模拟会非常慢。这时候可以考虑用数学方法优化,或者用链表加步进计算。但面试中一般 n 不会超过 1000,队列模拟足够。如果面试官追问优化,可以提数学递推和链表步进两种思路。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
循环队列入队失败但队列不满判满条件错误打印front、rear、count用count判满,或牺牲一个单元
循环队列出队后数据错乱front或rear未取模检查取模运算是否遗漏所有移动都加% capacity
链式队列出队最后一个元素后崩溃rear未置空检查dequeue后front是否为nullptr加if (!front) rear = nullptr;
链式队列内存持续增长节点未释放用 Valgrind 或 ASan 检测确保每个new都有对应的delete
约瑟夫环出列顺序错误报数计数器逻辑错误用小数据手动验证明确count初始值和判断时机
约瑟夫环 n 很大时超时队列模拟 O(n*m)分析时间复杂度用数学递推或链表步进优化

提示:调试队列问题时,最有效的方法是把队列状态可视化。对于循环队列,画一个环形图,标出front和rear的位置;对于链式队列,画出节点和指针。纸上画一遍,比盯着代码看半小时都管用。

6. 从队列到环形缓冲:实际项目中的扩展

队列的思想在实际项目中远不止教科书上的入队出队。比如音频处理里的环形缓冲,本质上就是一个循环队列,但生产者和消费者是异步的,需要处理读写指针的竞争。视频解码里的帧队列,通常用链式队列加锁实现,因为帧的大小不固定。任务调度器里的就绪队列,可能是优先级队列,但底层还是队列的变体。

约瑟夫环的环形逻辑在分布式系统里也有影子,比如一致性哈希的环、令牌桶的循环发放。理解队列的环形回绕和状态管理,是理解这些复杂结构的基础。

如果你想把这三个结构真正吃透,我的建议是:先手写一遍循环队列,不加count变量,只用front和rear判空判满,感受一下边界条件的处理。然后写链式队列,故意制造一次野指针崩溃,再用调试器定位。最后用队列模拟约瑟夫环,把 n 和 m 调大,观察性能变化。踩过这些坑之后,队列对你来说就不再是课本上的几行伪代码了。

我在实际项目中用循环队列做过一个日志缓冲,容量 8192,生产者是多个线程,消费者是单个写盘线程。一开始没加锁,日志偶尔丢失,后来用原子操作加内存屏障解决了。但那是另一个话题了,队列本身的设计才是根基。把今天这三个结构写熟,后面遇到再复杂的变体,你都能一眼看穿它的本质。

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

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

立即咨询