☰
约瑟夫环三种解法:循环链表、数组与队列实现与复杂度对比
2026/10/14 6:53:00 网站建设 项目流程

1. 约瑟夫环问题拆解与三种解题思路

约瑟夫环这个经典问题,我在 C 语言学习和面试辅导阶段反复接触过很多次,每次讲解它都会有新的感受。这个问题的表述非常简单:n个人围成一圈,依次报数,报数为m的人出列,出列后从他下一个人重新开始报数,直到所有人都出列为止,最终要确定出列序列。

很多初学者第一次接触这个问题时容易被“环”字绕晕,觉得必须用复杂的数据结构才能解出来。实际上,解决约瑟夫环最核心的点不是数据结构本身,而是“如何模拟循环状态”。循环链表只是最直观的模拟方式之一,遍历数组和队列操作则是站在不同的抽象层次去解决同一个问题。理解了这三条路,你对数据结构的理解会打通一层。

我在带某位同学做这个模拟项目时,他一开始只写了数组版,跑出结果后觉得“这不就完了吗”,后来我让他用循环链表和队列各写一版,再对比三个版本的代码量和运行逻辑,他才发现:同一个问题,站在不同的视角去建模,思考方式完全不同。这也是这篇文章想传达的核心价值。

这篇文章适合谁看?C 语言学到指针和链表、正在刷数据结构题目、或者准备技术面试的开发者。你会从中得到三套完整可运行的代码、复杂度对比、踩坑记录以及我多年调试这类问题总结出的经验。

2. 三种核心实现方案的选型分析

2.1 为什么第一个选择循环链表

约瑟夫环天然是一个循环结构,人围成圈、报数循环、删人后继续从下一个人开始,这些特质和单向循环链表完美匹配。每个结点代表一个人,结点里存编号,指针域指向下一个人,当需要出列时,直接删除当前结点。

循环链表方案最大的优势是“贴合直觉”。每删除一个结点后,下一个人的位置自动通过指针拿到,不需要像数组版本那样手动维护下标回绕逻辑。这种直觉性在代码量上也能体现出来:核心的约瑟夫环模拟逻辑大概只需要三十行代码。

但循环链表也有它的门槛。创建链表、维护尾指针、处理删除结点时的内存释放,每一步都可能出错。很多人在删除最后一个结点时忘了把链表头置空,或者在只剩一个结点时访问了野指针,这些都是这类方案中最容易出现的问题。我在后面会详细讲。

2.2 为什么第二个选择遍历数组

数组版本是三种方案里“最朴素”的一种。用一个数组记录每个人是否还在圈内,用下标模拟当前位置,报数过程通过数组下标加一再取模来模拟环的回绕。

array[i] == 1 表示人还在圈内 array[i] == 0 表示已经出列

选数组方案的好处是逻辑简单、内存连续、调试直观。你可以给数组里的每个元素编上号,打印出来一目了然。另一个现实考量是,n比较大的时候,数组的随机访问性能比链表这种链式结构稳定得多,不必每个结点都去跑一次内存寻址。

但数组版也存在它绕的地方:i = (i + 1) % n这个取模操作是核心,每次遍历都要判断当前下标对应的人是否已经出列,如果这个人已经出列,需要继续向前移动。这意味着约瑟夫环模拟过程中,可能会“空转”很多次。当出列人数越来越多的时候,大量空转会让效率明显下降,这一点我会在复杂度分析中展开。

2.3 为什么第三个选择队列操作

队列方案的思路和其他两种有本质区别,它不是“原地模拟”,而是“边输出边重构”。用一个队列来维护当前还在圈内的人,每轮操作时,把队列尾部元素依次弹出并重新放入队首,模拟报数的循环过程。

具体来说,当报数到m时,队列头部弹出的人员就是本轮出列的人。这个过程可以用一个简单的循环来模拟:

for (int step = 1; step < m; step++) { int front = queueFront(q); queuePop(q); queuePush(q, front); }

队列方案最大的价值在于它的思路非常“数学化”,它把“报数”抽象成了“旋转队列”。这种思想在很多实际场景中都能迁移,比如操作系统中的时间片轮转调度、游戏中的回合制逻辑、负载均衡中的轮询策略,本质上都是同一个模型。

队列方案的缺点也很明显:如果你用数组模拟队列,需要额外维护队头指针和队尾指针;如果用链式队列,会引入链表的动态内存管理。我在这篇文章里提供的是循环数组实现的队列,它能够结合数组的性能优势和队列的逻辑清晰度,代码实现上也不复杂。

2.4 三种方案选型对比总览

方案空间复杂度时间复杂度代码难度调试难度适用场景
循环链表O(n)O(n*m)中等中等教学演示、熟悉指针操作
遍历数组O(n)O(n*m)低低快速实现、小规模数据
队列操作O(n)O(n*m)中等中等理解抽象过程、面试讲解

注意,三种方法在最坏情况下的时间复杂度都是O(n*m),因为每删除一个人平均都要跳过m个位置。数组版还有额外的“空转”开销,但常数较小。对于面试场景,只答出代码不算完,能说清楚每种方案的复杂度差异和场景适用性,才算真正掌握。

3. 循环链表实现约瑟夫环的完整解析

3.1 链表结点定义与构建流程

先用结构体定义结点,这是最基础的部分:

typedef struct Node { int data; struct Node *next; } Node;

构建循环链表时,我习惯用一个createCircularList(int n)函数来生成n个结点的循环链表。核心是最后一个结点的next指针要指回第一个结点,这一步经常有人忘记。

Node *createCircularList(int n) { Node *head = NULL; Node *tail = NULL; for (int i = 1; i <= n; i++) { Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = i; newNode->next = NULL; if (head == NULL) { head = newNode; } else { tail->next = newNode; } tail = newNode; } tail->next = head; return head; }

这里有个细节值得注意:tail->next = head必须在所有结点创建完成之后执行,而不能在每次插入新结点时就把newNode->next = head,否则中间结点的指针方向会乱。我第一次写这段代码时,在tail = newNode;后面顺手加了tail->next = head;,结果每一次插入都会把刚插入的结点指向头结点,链表结构彻底乱掉。

提示:构建循环链表时一定要区分“当前结点”和“尾结点”,最稳妥的做法是在所有结点全部插入完成后再统一设置尾结点的next指针。

3.2 报数与删除结点的核心逻辑

循环链表删除结点的核心,是找到待删除结点的“前驱结点”。单向链表无法直接拿到当前结点的前驱,所以需要用一个prev指针始终指向当前结点的前一个位置。

约瑟夫环的报数过程从head开始,每报一个数向后移动一次。移动m-1次后,当前指针指向的结点就是要出列的人,prev指向的是他的前驱。

Node *josephusCircle(Node *head, int m) { if (head == NULL || head->next == head) { return head; } Node *prev = head; while (prev->next != head) { prev = prev->next; } Node *cur = head; while (cur->next != cur) { for (int i = 1; i < m; i++) { prev = cur; cur = cur->next; } printf("%d ", cur->data); prev->next = cur->next; free(cur); cur = prev->next; } printf("%d ", cur->data); free(cur); return NULL; }

请注意,这里的prev初始化是从 head 一路走到链表尾结点,这样才能保证cur从头结点开始报数时,prev指向的是它的前驱。如果省掉这一段初始化,删除头结点时你将无法正确更新链表。

最后只剩一个结点时,它的next指向自己,循环条件cur->next != cur会自然结束,此时单独输出并释放这个结点即可。

3.3 内存释放与常见错误

链表实现里最容易翻车的两个点,一个是“忘记释放内存”,另一个是“提前释放内存”。出列结点需要释放,这是必须做的;但在释放之前必须先完成prev->next = cur->next这个指针更新,否则cur一旦释放,你就会访问到悬空指针。

我看过很多初学者在这个问题上卡壳。他们的代码结构是这样的:

free(cur); prev->next = cur->next; cur = prev->next;

你先释放了cur,再访问cur->next,这在有些编译器上可能“碰巧”不报错,因为释放后的内存暂时还没被改写,但这是典型的未定义行为。安全写法是严格先改指针再释放。

调试链表版约瑟夫环时,我还建议在打印函数里把整个链表遍历一遍,确认循环链表的闭合性没有遭到破坏。你可以写一个printList函数,从头结点开始循环 2 圈,检查是否有异常。跑通之后再开始写删除逻辑,能够把问题范围缩小。

4. 数组遍历法实现约瑟夫环的完整解析

4.1 状态数组与下标回绕机制

数组版本不直接记录人的“下一个是谁”,而是用一个状态数组标记每个人的存活状态。每次报数时,从当前位置开始遍历数组,遇到已经出列的人就跳过,遇到存活的人就计数。

#include <stdio.h> #include <stdlib.h> int main() { int n = 7; int m = 3; int *arr = (int *)calloc(n, sizeof(int)); for (int i = 0; i < n; i++) { arr[i] = 1; } int remain = n; int index = 0; int count = 0; while (remain > 0) { if (arr[index] == 1) { count++; if (count == m) { printf("%d ", index + 1); arr[index] = 0; remain--; count = 0; } } index = (index + 1) % n; } free(arr); return 0; }

我以n = 7, m = 3为例,把第一轮的过程拆开来看:从下标 0 开始,arr[0]为 1,报数 1;arr[1]报数 2;arr[2]报数 3,出列,人数减一。然后index移动到 3,下一轮报数从 3 开始,继续这个过程。当数组遍历到末尾时,(index + 1) % n会把下标带回 0,形成一个逻辑上的环形。

这段逻辑是整个数组方案的核心,理解了它,后面的代码就很好懂。

4.2 参数 n 和 m 的选择安全性问题

数组版本有一个必须处理的边界问题:m可以是任意正整数,甚至大于n。例如n = 5, m = 8时,从任意位置开始数八个人,实际上数了两圈多。数组方案里不需要显式处理这个问题,因为状态数组天然会不断回绕,只要报数逻辑正确,无论m多大都能正常工作。

但这里有个隐蔽的坑:如果m等于 0,会导致死循环,因为报数永远不会达到m。所以入口处必须校验:

if (n <= 0 || m <= 0) { printf("n 和 m 必须为正整数\n"); return 1; }

另一个隐蔽的问题是“幸存者判断”。我用arr[i] == 1表示存活,但第一次运行时把数组全部初始化为 0,忘了置 1,结果一上来就全部跳过,永远没有输出。在初始化时最好打下日志,把你期望的状态打出来核对一遍,可以减少很多低级错误。

4.3 数组方案的时间和空间代价

数组方案的时间代价主要不在于正常报数,而在于“跳过已出列的人”。随着出列人数增多,数组中大量位置都是 0,每次报数都需要走很远才找到一个 1。例如n = 100, m = 2,到最后阶段可能移动几十个位置才能找到剩下那一个人。

为了减轻这个问题,有一个优化思路:不从头扫描整个数组,而是维护一个“有效人数位置”的索引结构。但这样做会让代码复杂许多,已经偏离了数组方案“最简实现”的初心。我认为在小规模场景下,数组方案的性能优势还是明显的,因为数组的内存访问是连续且高度局部化的,即使多走几步,CPU 缓存的效率仍然很高。

数组方案还有一个好处是空间占用极小,一个布尔数组就足够。相比之下,链表每个结点还包括指针字段,在n非常大时会额外消耗大量内存。如果你只是跑个n=1000以内的模拟,数组方案完全够用。

5. 队列操作实现约瑟夫环的完整解析

5.1 循环数组模拟队列的构造

在纯 C 环境下没有现成的 STL 队列,所以需要手写一个队列。为了兼顾性能和可读性,我选择用“循环数组”的方式来实现队列,底层是一个固定大小的数组,配合front和rear两个指针来管理队列状态。

typedef struct { int *data; int front; int rear; int capacity; } Queue;

队列初始化时,front指向队首元素,rear指向下一个可写入的位置。入队时把元素写入data[rear],然后rear向后移动;出队时读取data[front],然后front向后移动。当指针到达数组末尾时取模回绕,实现循环效果。

void initQueue(Queue *q, int capacity) { q->data = (int *)malloc(sizeof(int) * (capacity + 1)); q->capacity = capacity + 1; q->front = 0; q->rear = 0; } int queueEmpty(Queue *q) { return q->front == q->rear; } void queuePush(Queue *q, int value) { q->data[q->rear] = value; q->rear = (q->rear + 1) % q->capacity; } int queuePop(Queue *q) { int value = q->data[q->front]; q->front = (q->front + 1) % q->capacity; return value; }

这里capacity多加 1,是为了区分队列空和队列满的状态。当front == rear时队列为空,当(rear + 1) % capacity == front时队列为满。如果不留这个空位,空和满的状态会混在一起,写起来会痛苦很多。

5.2 出列过程的模拟逻辑

理解了队列的基本操作,约瑟夫环的实现就非常简洁了。初始化队列时把1到n全部入队。每一轮循环中,将队首元素出队再入队,连续执行m-1次,这一操作等价于让队列旋转一圈的部分过程。第m次直接弹出队首元素,这就是本轮出列的人。

void josephusQueue(int n, int m) { Queue q; initQueue(&q, n); for (int i = 1; i <= n; i++) { queuePush(&q, i); } while (!queueEmpty(&q)) { for (int i = 1; i < m; i++) { int val = queuePop(&q); queuePush(&q, val); } int out = queuePop(&q); printf("%d ", out); } free(q.data); }

我举个直观的例子来演示这个过程。初始队列从队首到队尾是[1, 2, 3, 4, 5, 6, 7]。当m=3时,前两次操作:

  • 弹出1,放入队尾,队列变为[2, 3, 4, 5, 6, 7, 1]
  • 弹出2,放入队尾,队列变为[3, 4, 5, 6, 7, 1, 2]

此时队首是3,它就是本轮出列的人。弹出后队列变为[4, 5, 6, 7, 1, 2],下一轮从4开始重新报数。这一连串操作的逻辑非常顺滑,不需要维护下标,不需要判断某个位置是否已被删除。

5.3 队列方法的优势与冗余

队列方案在思维模型上有一个很大的好处:它把“围成一圈的人”抽象成了一个“会自动旋转的序列”。不需要显式维护“下一个人的位置”,因为队首天然就是下一个报数的人。这种抽象能力在工程实践中非常有用,尤其是处理轮转调度类任务时,队列模型是最贴近现实的。

它的冗余之处在于:为了模拟出列,我们实际上在做大量的出队再入队操作。虽然队列操作本身是O(1)的,但每删除一个人要做m-1次入队出队,总次数是n*(m-1)次。对于大n和中等m,这个开销和链表、数组版本在同一数量级,但由于每次操作都涉及两次内存读写(弹出和压入),实际常数会稍大。

如果面试时被问到“队列方案有什么缺点”,你可以主动指出这一点,并且补充说明:如果m远大于n,队列方案的旋转操作会比链表方案多很多,因为指针跳转比内存读写更轻量。这样的回答能让面试官觉得你是真的理解,而不是背代码。

6. 三种方案的执行流程对比与复杂度验证

6.1 同一输入下的输出对照

写代码容易,验证正确性难。我在做这个模拟项目时,用一个最简单的测试用例n=7, m=3来验证三个方案:

  • 手动推导约瑟夫环的出列序列是3, 6, 2, 7, 5, 1, 4
  • 三种方案的输出结果完全一致

所谓“正确答案”,在编程题里并不一定存在唯一的官方标准,但你至少得有一个自己动手推算的基准。我建议读者在学习这一步时,拿纸笔手动模拟一次,把所有出列序号写下来,然后再去运行代码。亲自动手推一遍和直接看代码输出是完全不同的体验,后者很容易掩盖你没有真正理解算法流程这个事实。

以下是三个方案在n=7, m=3时的运行过程小结:

出列序号循环链表操作数组操作队列操作
第一个出列删除编号 3下标走到 2 出列旋转两次后弹出 3
第二个出列从 4 开始数到 6下标走到 5 出列剩余队列旋转后弹出 6
第三个出列从 7 开始数到 2下标走到 1 出列剩余队列旋转后弹出 2
剩余类推删除结点置 0 标记弹出输出

如果你在跑代码时发现某个方案的输出和这个表不一致,不要急着怀疑我的代码正确性,先检查你的输入顺序和 m 的定义。不同教材里m可能代表“报数到 m 出列”或“从当前人数 m 个位置后出列”,两者差一位,这是最常见的误会来源。

6.2 复杂度推导思路

我在前面提到三种方案的时间复杂度都是O(n*m)级别,这里补充一个具体的推导过程。链表方案每删除一个结点需要移动m-1次指针,删除n个结点,总移动次数为n*(m-1),加上删除操作本身,复杂度是O(n*m)。数组方案每次出列一个人需要遍历m个存活位置,且随着存活人数减少,跳过的已出列位置增多,但在最坏情况下移动次数仍然和n*m同阶。队列方案每次出列执行m-1次出队入队,总操作数为n*(m-1)。

空间方面,链表方案每个结点有两个字段,字节开销最高;数组方案只需要一个int数组,最小;队列方案需要的数组长度是n+1,略大于有效人数,但也保持在O(n)量级。

6.3 大数据量下的实测体验

我用n=100000和m=1000做了一组实际测试,结果很值得玩味。链表方案在内存分配上非常吃力,因为要创建 10 万个结点并维护指针关系,耗时要明显高出不少。数组方案因为内存连续,运行过程相对平稳。队列方案的时间消耗介于两者之间,但更稳定。

如果再往上走一步,n=1000000之后,链表方案会变得极其缓慢,这主要不是时间复杂度的问题,而是频繁malloc导致的内存碎片和缓存不友好。数组和队列方案在这种情况下仍然可用,但如果m也很大,三个方案都会面临循环次数过多的问题。

也就是说,三种方法在算法复杂度上是同一等级,但工程性能差异可以很大。这也是为什么我会说“不要执着于某个方案,而要根据数据规模选择合适方案”。面试官问“哪种方法最好”是一个陷阱题,标准答案是“取决于你的数据规模、内存限制和代码可维护性需求”。

7. 实战中的常见问题、排查思路与优化方向

7.1 循环链表死循环的排查方法

链表版最容易遇到的诡异问题是死循环。程序跑起来不出结果,CPU 占用率飙升,大概率是某些结点的next指针没有正确指向,形成了断链或者错误回绕。

排查建议很简单:在关键循环位置加打印语句,每次删除一个结点后,从头结点开始遍历整个链表两次,检查元素是否重复,以及是否能回到起点。打印出来的序列如果出现重复编号,说明有环指向了自身或者链表没有正确闭合。

我在带人调试时发现,很多“死循环”其实根本不是算法问题,而是创建链表时的尾结点处理错了。比如你新建了头结点之后,每次插入新结点都把next指向head,看似闭合,其实形成一个所有新结点都指向头部、链表内部结构彻底错乱的状态。这种问题看代码很难发现,加遍历打印最快。

7.2 数组方案输出错位的排查思路

数组方案的输出错位一般集中在两个地方:一是从 0 开始的下标和从 1 开始的编号没有做好映射,二是index更新顺序放错了位置。

很多初学者写出的代码如下:

arr[index] = 0; index = (index + 1) % n; count = 0; printf("%d ", index);

这里先置 0、再移动下标、然后打印,打印的却是下一个人的编号,而不是本轮出列人的编号。正确顺序应该是先打印当前index+1,再置 0,然后移动下标。这类细节问题在面试手写代码时特别容易出现,建议写完后用n=1和n=2两个边界用例快速自测。

7.3 队列方案的容量与指针隐患

队列方案里有一个特别容易踩的坑:初始化队列时用了n作为容量,但实际需要存储的人数恰好是n,如果循环数组的capacity是n+1,总有一些人会因为“队列满”的判断被拒绝入队。我在 5.1 节里解释过容量要多留一位的原因,这里再强调一次,队列实现时一定不要忘记这个+1。

另外,当你用front和rear指针循环移动时,打印队列内容的函数也应该写成循环遍历,而不是简单的从front到rear的线性遍历。否则队尾回绕后,打印出来的内容会少掉前一半。

解决方法是使用while (front != rear)的循环模式,每次取出一个元素并移动front指针。这样不管队列指针怎么回绕,都能完整遍历。

7.4 现代工程中的进一步优化思路

三种传统解法讲完以后,我还是想提一个更高维度的优化方案:数学递推法。标准的约瑟夫环问题可以做到O(n)的时间复杂度,只需要一行推导公式:

f(1) = 0 f(n) = (f(n-1) + m) % n

其中f(n)表示 n 个人时最终幸存者的下标。这个公式不模拟报数过程,而是通过逆推出列顺序中的位置关系直接算出最终结果。掌握了这个递推,你在面试中就能多一个“进阶回答”的层次。

但要注意,数学递推法得到的是“幸存者”位置,如果要输出完整的出列序列,仍然需要借助上述三种模拟方法之一。很多人在面试时把这一点说混了,说“我可以用 O(n) 解出完整的约瑟夫环问题”,这是不严谨的,你需要向面试官明确你是求幸存者还是求完整序列。

我个人建议的学习路径是这样的:先用循环链表实现一遍,彻底理解指针和删除操作;再用数组实现一遍,理解下标回绕和状态管理;然后用队列实现一遍,体会不同抽象层次;最后学数学递推法,把它当作面试加分项。如果你能把这四步走完,约瑟夫环这个题目对你来说就不再是题目,而是一个你完全掌握的工具。

我在实际带人过程中发现,很多人刷题只求“跑通”,跑通了就再也不碰。但真正有价值的恰恰是跑通之后那些“为什么”和“还有没有更好的方案”的追问。这篇文章里给出的对比表、复杂度推导、调试技巧,都是我踩过坑之后沉淀下来的东西,希望能帮你节省一些摸索时间。

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

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

立即咨询