ARTICLE DETAIL

资讯详情

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

C语言实现约瑟夫环:单向循环链表解决报数出列问题

C语言实现约瑟夫环:单向循环链表解决报数出列问题 1. 项目概述从“报数出列”到循环链表实战“报数出列”这个问题但凡学过一点数据结构或者参加过算法面试的朋友应该都不陌生。它听起来像是个游戏比如小时候玩的“击鼓传花”或者“数到7就拍手”但在程序员的眼里这其实是一个经典的约瑟夫环问题。我第一次接触它是在大学的数据结构课上当时用数组硬解代码写得又臭又长边界条件处理得焦头烂额。后来在工作中尤其是在处理一些需要循环调度或顺序淘汰的场景时才发现这个问题的模型其实非常实用。这次我们就用C语言结合最贴切的单向循环链表来彻底搞懂它。整个过程我会带你从最朴素的思路开始一步步优化最后得到一个健壮、高效的解决方案。无论你是正在啃数据结构的学生还是想重温基础的在职开发者相信这篇都能给你带来一些实实在在的收获。简单来说我们要实现的功能是有n个人围成一圈从第一个人开始报数数到m的人出列然后从他的下一个人开始重新报数直到所有人都出列为止。我们需要按出列顺序输出每个人的编号。这个“圈”就是数据结构的关键而循环链表天生就是为了模拟“圈”而生的。2. 核心数据结构选型为什么是单向循环链表面对“围成一圈”和“连续淘汰”这两个核心需求我们有几个候选数据结构数组、双向链表、单向循环链表。我们来逐一分析看看为什么单向循环链表是最优解。2.1 数组方案的笨重与低效最直观的想法可能是用数组。我们创建一个长度为n的数组people[]初始值设为1表示在圈内0表示已出列。然后我们用一个大循环来模拟报数过程。int people[MAX_SIZE] {0}; int remain n; // 剩余人数 int count 0; // 当前报的数 int index 0; // 当前指向的人 while (remain 0) { if (people[index] 1) { // 如果这个人还在圈内 count; if (count m) { // 数到m了 people[index] 0; // 标记出列 printf(%d , index 1); // 输出编号 count 0; // 重置报数 remain--; // 剩余人数减一 } } index (index 1) % n; // 移动到下一个人模拟环形 }这个方案可行但效率很低。问题在于当很多人已经出列后people[index] 0的情况会越来越多循环不得不一次次地跳过这些空位做了大量无效的if判断。时间复杂度在最坏情况下会接近O(n*m)当n和m都很大时性能是难以接受的。2.2 双向循环链表的过度设计既然链表能解决动态删除的问题那双向循环链表呢它当然可以完美实现每个节点有prev和next指针删除节点时修改前后节点的指针即可。但是对于“报数出列”这个问题我们只需要单向遍历。删除一个节点时我们只需要知道它的前一个节点是谁以便将前一个节点的next指向被删除节点的下一个节点。这个“前一个节点”的信息在遍历过程中是很容易维护的不需要为每个节点额外存储一个prev指针。使用双向链表意味着每个节点多消耗一个指针的内存空间这在数据量巨大时是不小的开销属于“杀鸡用牛刀”。2.3 单向循环链表的优雅契合最终我们选择单向循环链表。它的优势非常明显天然成环最后一个节点的next指针直接指向头节点物理结构上就是一个环完美模拟了“围成一圈”。删除高效一旦找到待删除节点及其前驱节点修改指针的耗时是常数级O(1)。不像数组需要移动元素也不像普通单向链表删除头节点需要特殊处理循环链表处理起来很统一。空间适中每个节点只比数组多存储一个next指针比双向链表节省一个指针的空间。逻辑清晰整个算法的逻辑与问题描述几乎一一对应“移动m-1次”对应“报数”“删除当前节点”对应“出列”代码可读性极高。注意这里有一个初学者极易混淆的点。当我们说“报数到m的人出列”在链表遍历中如果从当前节点开始报“1”那么我们需要走m-1步才能到达应该出列的节点。我最初就曾错误地走了m步导致结果完全不对。记住这个“差一”原则是正确实现的关键。3. 链表节点的定义与创建定好了数据结构我们就从最基础的环节开始定义节点和创建初始的循环链表。3.1 结构体定义在C语言中我们用结构体来表示链表节点。每个节点需要存储两个信息编号代表这个人的ID和指向下一个节点的指针。#include stdio.h #include stdlib.h typedef struct Node { int id; // 人的编号从1开始 struct Node* next; // 指向下一个节点的指针 } Node;这里使用typedef为struct Node起了别名Node这样后面声明变量时直接写Node*即可更简洁。3.2 创建循环链表接下来我们需要一个函数根据总人数n来创建这个初始的循环链表。链表创建后最后一个节点的next要指向第一个节点形成闭环。Node* createCircularList(int n) { if (n 0) { return NULL; // 处理非法输入 } Node* head NULL; Node* prev NULL; Node* current NULL; for (int i 1; i n; i) { current (Node*)malloc(sizeof(Node)); if (current NULL) { perror(内存分配失败); exit(EXIT_FAILURE); // 内存分配失败直接退出程序 } current-id i; current-next NULL; if (head NULL) { head current; // 第一个节点作为头节点 } else { prev-next current; // 将新节点连接到链表尾部 } prev current; // 更新prev指针指向当前新节点 } // 循环结束此时current指向最后一个节点 if (current ! NULL) { current-next head; // 最后一个节点指向头节点形成环 } return head; // 返回头节点指针 }实操心得内存检查每次malloc后一定要检查返回的指针是否为NULL。这是编写稳健C程序的基石尤其是在嵌入式等资源受限的环境下。闭环时机一定要在所有节点都创建并连接好后再让尾节点指向头节点。如果在循环体内就试图闭环逻辑会变得非常混乱。头节点的意义在这个循环链表中“头节点”的概念被弱化了因为任何一个节点都可以作为起点。但我们通常还是保留head指针指向第一个创建的节点方便初始化和一些边界情况处理。4. 报数出列的核心算法实现链表创建好后就进入最核心的算法部分。这个过程可以分解为两个关键操作定位要删除的节点以及安全地删除它。4.1 算法流程与指针操作假设我们有n5个人m2。初始链表为1-2-3-4-5-(回到1)。 我们的目标是依次输出出列顺序2, 4, 1, 5, 3。算法步骤如下初始化两个指针current指向当前报数起点初始为headprev指向current的前一个节点。在循环链表中prev可以通过遍历找到但更聪明的做法是让prev一开始就指向head的前一个节点即尾节点。当链表中不止一个节点时prev ! current循环执行 a.报数定位让prev和current同步移动m-1次。prev始终跟在current后面一步。 b.删除节点此时current指向要出列的人。执行prev-next current-next。然后输出current-id。 c.释放内存free(current)防止内存泄漏。 d.更新起点将current更新为prev-next即出列者的下一个人作为下一轮报数的起点。循环结束后链表中只剩下一个节点。输出该节点的编号并释放其内存。4.2 核心代码实现下面是核心函数josephus的实现它接收链表头节点head和报数上限m。void josephus(Node** headRef, int m) { if (*headRef NULL || m 0) { printf(参数无效\n); return; } if (m 1) { // 特殊情况每次报1相当于依次输出所有人 Node* current *headRef; Node* start *headRef; printf(出列顺序: ); do { printf(%d , current-id); Node* toFree current; current current-next; free(toFree); } while (current ! start); *headRef NULL; printf(\n); return; } Node* prev *headRef; // 让prev指向头节点的前一个节点即尾节点 while (prev-next ! *headRef) { prev prev-next; } Node* current *headRef; // 从第一个人开始报数 printf(出列顺序: ); while (prev ! current) { // 当不止一个人时 // 1. 报数定位移动m-1次 for (int i 1; i m; i) { prev current; current current-next; } // 2. 删除并输出当前节点 prev-next current-next; // 将前驱节点指向后继节点 printf(%d , current-id); Node* toFree current; // 保存要释放的节点指针 current current-next; // current移到下一个节点作为新起点 free(toFree); // 释放出列节点的内存 } // 3. 处理最后剩下的一个人 printf(%d\n, current-id); free(current); *headRef NULL; // 将头指针置为NULL表示链表已空 }代码关键点解析双指针策略使用prev和current这一对指针是精髓。prev始终是current的前驱这使得删除操作prev-next current-next可以在O(1)时间内完成无需再次遍历链表寻找前驱。循环条件while (prev ! current)是判断是否只剩一个节点的巧妙方法。在循环链表中如果只剩一个节点那么这个节点的next指向自己且prev和current会指向同一个节点。特殊情况处理当m1时算法会退化为依次删除每个节点。上面的代码单独处理了这种情况避免了在for (int i 1; i m; i)循环中因i 1条件不成立而导致的逻辑错误。这是一个非常重要的边界条件检查。头指针的传递函数参数是Node** headRef指向头指针的指针。因为我们在函数内部会修改头指针本身最终置为NULL所以需要传递指针的地址。如果只传递Node* head那么函数内部的修改无法影响调用者。4.3 内存管理的艺术C语言编程内存管理是绕不开的坎。在这个算法中我们动态分配了每个节点的内存也必须在节点出列后立即释放。Node* toFree current; // 先保存指针 current current-next; // 移动current到安全位置 free(toFree); // 再释放内存这个顺序很重要。如果先free(current)那么current-next就变成了访问已释放内存的“野指针”程序会崩溃。先移动current再释放原节点是安全的做法。注意在整个链表操作完成后务必确保*headRef NULL。这是一个好习惯可以防止后续代码误操作一个已经被释放的链表这种错误通常被称为“悬挂指针”非常难以调试。5. 完整可运行代码与测试将以上所有部分组合起来并加上主函数进行测试我们就得到了一个完整的程序。#include stdio.h #include stdlib.h typedef struct Node { int id; struct Node* next; } Node; Node* createCircularList(int n) { if (n 0) return NULL; Node* head NULL; Node* prev NULL; Node* current NULL; for (int i 1; i n; i) { current (Node*)malloc(sizeof(Node)); if (!current) { perror(malloc failed); exit(1); } current-id i; current-next NULL; if (!head) head current; else prev-next current; prev current; } if (current) current-next head; return head; } void josephus(Node** headRef, int m) { if (!headRef || !(*headRef) || m 0) { printf(Invalid input.\n); return; } if (m 1) { // Special case Node* cur *headRef; Node* start *headRef; printf(出列顺序: ); do { printf(%d , cur-id); Node* tmp cur; cur cur-next; free(tmp); } while (cur ! start); *headRef NULL; printf(\n); return; } Node* prev *headRef; while (prev-next ! *headRef) prev prev-next; Node* cur *headRef; printf(出列顺序: ); while (prev ! cur) { for (int i 1; i m; i) { prev cur; cur cur-next; } prev-next cur-next; printf(%d , cur-id); Node* tmp cur; cur cur-next; free(tmp); } printf(%d\n, cur-id); free(cur); *headRef NULL; } int main() { int n, m; printf(请输入总人数n: ); if (scanf(%d, n) ! 1 || n 0) { printf(输入错误\n); return 1; } printf(请输入报数上限m: ); if (scanf(%d, m) ! 1 || m 0) { printf(输入错误\n); return 1; } Node* head createCircularList(n); josephus(head, m); // 此时head应为NULL if (head NULL) { printf(链表已正确清空。\n); } return 0; }测试与验证 我们可以用几组经典数据来测试程序的正确性。输入 (n, m)预期输出 (出列顺序)程序输出结果(5, 2)2, 4, 1, 5, 32 4 1 5 3✅(5, 3)3, 1, 5, 2, 43 1 5 2 4✅(1, 5)11✅(7, 4)4, 1, 6, 5, 7, 3, 24 1 6 5 7 3 2✅手动推算一下n5, m2的过程可以帮你彻底理解算法初始1-2-3-4-5。第一轮从1开始报数1报12报2出列。剩下1-3-4-5从3开始报数3报14报2出列。剩下1-3-5从5开始报数5报11报2出列。剩下3-5从3开始报数3报15报2出列。最后剩下3。顺序正是2, 4, 1, 5, 3。6. 常见问题与深度排查指南在实际编写和调试这个程序的过程中我踩过不少坑。下面把这些典型问题及其解决方案整理出来希望能帮你快速排雷。6.1 程序崩溃段错误 (Segmentation Fault)这是最常见的问题根本原因几乎都是非法内存访问。可能原因1访问已释放的内存。在josephus函数中如果先free(current)再执行current current-next就会触发段错误。务必遵循“先移动后释放”的顺序。可能原因2链表未正确闭环。在createCircularList函数中如果忘记执行current-next head;链表就不是循环的。那么在josephus函数的while (prev-next ! *headRef)循环中prev会一直找到NULL导致后续对prev-next的访问失败。排查方法使用调试器如GDB单步运行在free语句和指针解引用如current-next处设置断点。或者添加大量printf语句打印每个关键步骤的指针值观察其变化是否符合预期。6.2 输出结果错误或陷入死循环可能原因1报数移动步数错误。这是最经典的“差一错误”。记住如果从当前节点开始报“1”那么需要移动m-1步到达第m个节点。我的for循环写的是for (int i 1; i m; i)条件i m确保了循环执行m-1次。如果写成i m就会多移动一次。可能原因2循环结束条件错误。while (prev ! current)是判断只剩一个节点的正确条件。如果错误地使用了while (current-next ! current)在只剩两个节点且current指向后一个节点时这个条件可能提前为真导致逻辑错误。可能原因3未处理m1的特殊情况。当m1时for (int i 1; i m; i)根本不会执行prev和current不会移动算法会卡在第一个节点陷入死循环或错误删除。这就是为什么代码中需要单独处理m1。排查方法用很小的n和m比如n3, m2手动模拟代码执行在纸上画出每一步的链表状态和指针位置与你的程序输出进行对比。6.3 内存泄漏 (Memory Leak)内存泄漏在小型程序里可能看不出影响但在长期运行或大规模数据的服务中会是灾难。可能原因每个malloc的节点在出列后都必须有对应的free。确保在josephus函数的所有退出路径上都释放了内存。包括正常循环结束、特殊情况处理m1以及函数开头的错误返回处。排查方法在Linux/macOS下可以使用valgrind工具来检测。编译时加上-g选项然后运行valgrind --leak-checkfull ./your_program。如果输出中显示“All heap blocks were freed”则表示没有内存泄漏。6.4 输入处理与鲁棒性一个健壮的程序必须能处理各种奇葩输入。问题用户输入了n0或m0甚至输入了非数字字符。解决在主函数的scanf后检查返回值。scanf返回成功匹配的参数个数。如果输入nscanf(“%d”, n)成功则返回1。同时在createCircularList和josephus函数开头对参数进行合法性检查如if (n 0) return NULL;并给出明确的错误提示。7. 方案对比与扩展思考虽然单向循环链表是这个问题的最佳实践但了解其他方案的优缺点能加深我们对数据结构的理解。7.1 不同实现方案对比方案数据结构时间复杂度 (出列一人)空间复杂度优点缺点方案一数组标记O(n) (最坏)O(n)实现简单易于理解删除效率低大量无效遍历方案二双向循环链表O(m)O(n)删除操作方便可双向遍历空间开销大问题本身不需要双向性方案三单向循环链表O(m)O(n)结构契合问题空间效率高逻辑清晰需要仔细处理指针避免出错方案四数学递推公式O(n) (总时间)O(1)时间复杂度最优无需模拟过程推导复杂无法获得中间出列序列只适合求最后幸存者数学递推公式这里提一下因为它展示了算法优化的另一个维度。约瑟夫环问题有一个著名的递推公式用于直接计算最后幸存者的编号f(1) 0f(i) (f(i-1) m) % i (i 1)其中f(i)表示i个人时最后幸存者的编号从0开始计数。如果想得到从1开始计数的结果只需result f(n) 1。这个算法时间复杂度是O(n)空间复杂度是O(1)非常高效。但它只能求出最后剩下谁无法得到完整的出列顺序。我们的链表模拟法虽然慢一些O(n*m)但能提供完整的序列信息适用场景不同。7.2 项目扩展与应用场景理解了这个基础模型你可以尝试很多有趣的扩展这能极大锻炼你的编程和设计能力可变报数规则不是固定的报数m而是提供一个数组int rule[]第i轮出列的人由rule[i]决定。这需要动态调整每轮的步数。双向约瑟夫环报数可以顺时针也可以逆时针交替进行。这可能需要用到我们之前排除的双向循环链表因为需要向前遍历。幸存者特权最后剩下的人幸存者有特殊属性或触发不同的事件。这只需要在算法结束后对剩下的那个节点进行额外处理即可。图形化模拟用图形库如简单的字符动画或更复杂的GUI动态展示报数和出列的过程非常直观。应用场景方面约瑟夫环问题不仅仅是算法题操作系统进程调度算法中轮询调度Round Robin与约瑟夫环的循环遍历思想类似。游戏开发很多回合制游戏、淘汰制游戏的逻辑底层就是约瑟夫环的变种。密码学一些古老的加密算法曾利用过类似的循环位移思想。最后关于链表操作我个人的体会是画图。在纸上或者白板上把节点和指针的每一步变化画出来是理解链表算法、调试链表代码最有效、最直观的方法没有之一。尤其是处理prev和current这种双指针关系时画图能让你一眼看出指针移动和节点删除的逻辑是否正确。把这个基础打牢了后面遇到更复杂的链表问题比如反转链表、检测环、合并有序链表时你才会觉得游刃有余。
返回列表