☰
C语言循环链表实现约瑟夫环:从建环到淘汰的完整代码解析
2026/10/18 1:30:20 网站建设 项目流程

简介:这份资源围绕经典的约瑟夫死亡游戏展开,面向正在学习C语言数据结构与算法的初学者及课程设计者,帮助理解循环链表在实际问题中的建模与操作。压缩包内共1个docx文档,约18KB,内容涵盖结构体元素类型定义、链表结点类型设计、循环链表创建、按指定间隔删除结点以及剩余人员输出等完整算法思路,并附有可运行的C语言代码示例。文档从ElemType与LNode的声明讲起,逐步拆解带头结点链表的构建、尾指针维护、首尾相连成环、删除第PersonCount个元素及重置头指针等关键步骤,最后通过ThrowPerson函数循环淘汰直至剩余指定人数。已有3143人学习该资源,适合需要掌握循环链表增删操作、理解约瑟夫环递推逻辑或完成相关实验作业的读者参考,可据此梳理算法流程并动手实现调试。

1. 约瑟夫死亡游戏:用循环链表把“数到谁谁出局”写成可运行的 C 代码

约瑟夫环这个经典问题,很多人第一次见是在数据结构课上,但真让你手写一个能跑、能输入姓名、能输出淘汰顺序的版本,不少人还是会卡在指针操作上。这份 C 语言代码就是一个完整可运行的约瑟夫死亡游戏实现,它把“轮船上的人围成一圈,每数到第 N 个人就扔进大海,直到剩下指定人数”这个场景,用带头结点的循环链表从头到尾串了起来。代码里定义了ElemType结构体存位置和姓名,用LNode做链表结点,通过CreateLinkList建环、DeleteLinkList删人、DieLinkList驱动整个淘汰流程,最后DisplayLinkList输出幸存者名单。适合正在学数据结构链表章节、需要一份能直接编译运行的参考实现,或者想拿它当课程设计底稿的开发者。

2. 核心数据结构拆解:从 ElemType 到循环链表的完整定义

2.1 元素类型 ElemType:位置和姓名的绑定

代码里第一件事就是定义元素类型。约瑟夫环的每个结点需要携带两类信息:这个人在圈里的原始编号,以及他的名字。编号用于输出淘汰顺序时定位,姓名用于直观展示谁出局了。

typedef struct ElemType // 声明结构体为元素类型 { int position; // 人员在圈中的原始编号 char name[8]; // 人员姓名,最长7个字符+结束符 } ElemType;

这里position是int类型,name是固定长度 8 的字符数组。为什么用数组不用指针?因为这是教学级代码,固定长度省去了动态内存分配的麻烦,输入时用scanf("%s", p->data.name)直接写入即可。实际项目中如果姓名可能超长,常见做法是改成char *name配合strdup,但那样就要在删除结点时多一步free,对初学者来说容易漏。

2.2 链表结点 LNode 与链表类型 LinkList

结点类型把数据域和指针域包在一起,链表类型则直接是指向结点的指针。这种三层定义(元素类型 → 结点类型 → 链表类型)是严蔚敏版教材的经典风格,好处是后续函数签名统一用LinkList,语义清晰。

typedef struct LNode // 声明结构体为结点型 { ElemType data; // 数据域:存放人员信息 struct LNode *next; // 指针域:指向下一个结点 } LNode; typedef LNode * LinkList; // 声明结点型的指针为链表

注意struct LNode *next里必须写struct LNode而不能只写LNode,因为此时typedef还没生效。这是 C 语言里自引用结构体的标准写法,漏掉struct关键字编译器会直接报错。

2.3 创建循环链表:带头结点建环再释放头结点

CreateLinkList是整个项目里最值得细看的函数。它先用一个头结点把链表建起来,尾插法逐个添加人员,最后把尾结点的next指向第一个数据结点形成环,再释放头结点,让L直接指向环的入口。

void CreateLinkList(LinkList &L, int n) { LinkList r, p; int i; L = (LinkList)malloc(sizeof(LNode)); // 创建头结点 L->next = NULL; r = L; // r始终指向尾结点 for(i = 1; i <= n; i++) { p = (LinkList)malloc(sizeof(LNode)); // 新结点 p->next = NULL; p->data.position = i; // 编号从1开始 printf("请输入%d个人员姓名:", i); scanf("%s", p->data.name); p->next = r->next; // 新结点插入尾部 r->next = p; r = p; // 更新尾指针 } r->next = L->next; // 尾结点指向第一个数据结点,成环 free(L); // 释放头结点 L = r->next; // L指向环中第一个数据结点 }

参数n是总人数,L是引用传递的链表头指针。循环里每次malloc一个新结点,填入编号和姓名,然后挂到尾部。关键在最后两行:r->next = L->next让尾结点回指第一个数据结点,free(L)释放掉临时头结点,L = r->next把链表入口重新定位到第一个数据结点。这样得到的环没有头结点,L直接指向第一个有效人员。

注意:如果忘记free(L),头结点会一直占着内存,虽然程序跑起来看不出问题,但这是内存泄漏。另外L = r->next必须在free(L)之后,否则L->next就成了野指针访问。

3. 淘汰逻辑实现:DeleteLinkList 与 DieLinkList 的配合

3.1 删除第 PersonCount 个结点:定位前驱再断链

DeleteLinkList负责从环中删除第i个结点,并把被删结点的数据通过引用参数e带出来。它的做法是先走到第i-1个结点,然后操作next指针跳过第i个结点。

void DeleteLinkList(LinkList &L, int i, ElemType &e) { LinkList p; p = L; int j = 1; while(j < i - 1) // 走到第i-1个结点 { p = p->next; j++; } e = p->next->data; // 保存被删人员信息 L = p->next->next; // 更新链表入口,指向被删结点的下一个 p->next = p->next->next; // 断链 }

这里有个细节值得注意:L = p->next->next在p->next = p->next->next之前执行。为什么要先更新L?因为如果被删结点恰好是L指向的结点,不先移动L的话,后面p->next断链后L就悬空了。这个顺序不能反。

参数i是要删除的位置(从当前L算起的第几个),e是输出参数,把被淘汰者的编号和姓名传回调用方。函数没有释放被删结点的内存,因为L已经指向了下一个结点,被删结点的地址丢失了,这里存在内存泄漏。常见做法是在断链前用临时指针q保存p->next,断链后free(q)。

3.2 驱动淘汰流程:DieLinkList 的循环控制

DieLinkList把创建链表、循环删除、输出淘汰名单三件事串在一起。它维护一个LeftCount变量表示当前剩余人数,每删一个减一,直到等于LeftTotal为止。

void DieLinkList(LinkList &L, int TotalCount, int PersonCount, int LeftTotal) { ElemType e; int LeftCount = TotalCount; CreateLinkList(L, TotalCount); // 先建环 printf("跑到大海中人员名单\n 编号\t 姓名\n"); while(LeftCount > LeftTotal) // 剩余人数大于目标就继续淘汰 { DeleteLinkList(L, PersonCount, e); LeftCount--; printf("%d\t%s\n", e.position, e.name); } }

参数含义:TotalCount总人数,PersonCount每次数到几淘汰,LeftTotal最终要剩下几人。循环条件是LeftCount > LeftTotal,每轮调用DeleteLinkList删掉从当前L起数的第PersonCount个结点。注意DeleteLinkList内部会把L更新为被删结点的下一个,所以下一轮数数是从被删者的下一个人开始,这正好符合约瑟夫环的规则。

3.3 输出幸存者:DisplayLinkList 的环遍历终止条件

淘汰结束后,L指向环中某个结点,DisplayLinkList从L开始遍历,直到回到L为止。

void DisplayLinkList(LinkList L) { LinkList p; p = L; printf("轮船上剩余人员名单:\n 编号\t 姓名\n"); printf("%d\t%s\n", p->data.position, p->data.name); while(p->next != L) // 绕一圈回到起点就停 { p = p->next; printf("%d\t%s\n", p->data.position, p->data.name); } }

先输出L指向的第一个幸存者,然后p后移,只要p->next不等于L就继续输出。这个终止条件依赖环的闭合性,如果前面建环或删除时指针操作有误导致环断裂,这里会死循环或崩溃。

4. 避坑与常见问题:指针操作里最容易翻车的几个点

4.1 删除结点后没有 free,内存泄漏

现象:程序运行结果正确,但用 Valgrind 或 AddressSanitizer 检查会发现堆内存只增不减。

原因:DeleteLinkList里p->next = p->next->next之后,被删结点的地址就丢了,没有free掉。

解决:在断链前用临时指针保存被删结点,断链后释放。

LinkList q = p->next; // 保存要删除的结点 e = q->data; L = q->next; p->next = q->next; free(q); // 释放内存

4.2 PersonCount 为 1 时删除逻辑出错

现象:输入PersonCount = 1,程序输出乱码或直接崩溃。

原因:DeleteLinkList里while(j < i - 1)当i=1时条件为j < 0,循环一次都不执行,p停在L上,然后e = p->next->data取的是第二个结点的数据,但实际要删的是L本身。

解决:单独处理i=1的情况,或者把DeleteLinkList改成从L开始数,用p指向要删结点的前驱。更稳妥的做法是在DieLinkList里对PersonCount做取模归一化。

4.3 姓名输入超长导致缓冲区溢出

现象:输入超过 7 个字符的姓名,程序行为异常或覆盖相邻内存。

原因:char name[8]只能存 7 个字符加一个\0,scanf("%s")不检查长度。

解决:把scanf("%s", p->data.name)改成scanf("%7s", p->data.name)限制读入长度,或者把name数组加大到 32 并同样限制。

4.4 总人数为 0 或剩余人数大于总人数

现象:输入TotalCount = 0或LeftTotal > TotalCount,程序建了一个空环或死循环。

原因:CreateLinkList在n=0时r->next = L->next让尾结点指向自己,free(L)后L指向一个已释放的结点。LeftTotal > TotalCount时while条件永远为真。

解决:在main里加输入校验,TotalCount必须大于 0,LeftTotal必须小于等于TotalCount,PersonCount必须大于 0。

4.5 编译时 LinkList &L 报错

现象:用纯 C 编译器(如 gcc)编译时,LinkList &L报语法错误。

原因:&引用语法是 C++ 的特性,C 语言不支持。

解决:把文件后缀改成.cpp用 g++ 编译,或者把引用改成二级指针LinkList *L,函数内用*L访问。这份代码原文用的是&L,说明它实际上是 C++ 风格的 C 代码,用 g++ 编译最省事。

5. 二次开发与改造思路:从教学代码到能用的工具

这份代码跑通之后,如果想拿它做课程设计或者改造成更实用的版本,有几个方向可以下手。第一个方向是把输入输出从控制台搬到文件。现在姓名是逐个scanf输入的,人数多了很痛苦。可以改成从persons.txt读取,每行一个姓名,CreateLinkList里用fgets替代scanf,这样批量测试就方便了。第二个方向是支持多轮游戏。把DieLinkList里的CreateLinkList调用提到main里,让用户可以反复输入PersonCount和LeftTotal而不用重新录入姓名。第三个方向是加上淘汰顺序的完整记录,不只是打印到屏幕,而是写回一个result.txt,每行记录“第几轮淘汰了谁”,方便事后核对。

如果想把删除逻辑写得更健壮,我一般会把DeleteLinkList重构成两个函数:一个FindPrev负责定位第i-1个结点,一个DeleteNode负责断链和释放。这样职责单一,测试的时候可以单独验证定位逻辑对不对。另外PersonCount大于当前剩余人数时,取模运算PersonCount % LeftCount能避免多绕好几圈,虽然结果一样但效率更高。

验证改造是否正确的办法很简单:用一组已知答案的数据跑一遍。比如总人数 5,每次数到 3 淘汰,最后剩 2 人,手动推演淘汰顺序应该是 3、1、5,剩下 2 和 4。把程序输出和手算结果对一遍,指针操作有没有错立刻就能看出来。从那以后我每次改链表相关的代码,都强制先用 3 到 5 个结点的小数据跑一遍手算验证,确认指针指向没问题再上大规模数据。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询