☰
循环链表解决约瑟夫问题:C语言完整实现与避坑指南
2026/10/22 6:39:43 网站建设 项目流程

简介:约瑟夫死亡游戏是数据结构课程中经典的循环链表应用问题,这份资源面向学习C语言和数据结构的学生、准备算法实验的初学者,围绕“数到指定人数便移出”的规则,完整展示问题分析、算法设计到编码实现的全过程。包内仅含1个文档,约18KB,却整合了设计思路、分步逻辑说明和完整可运行的C源码,无需额外文件即可对照阅读。文档先定义结构体用于保存位置与姓名,再介绍创建环形链表、删除指定序号结点、循环剔除直至剩余目标人数等核心函数的写法,并对动态分配、尾指针链接成环、临时指针释放等关键细节作出解释,可帮助读者避开链表操作中的常见指针错误,深入理解循环链表的整体算法思想。目前已有3145人学习下载,尤其适合在课程设计或算法作业中需要快速掌握约瑟夫问题解法的读者,作为可直接参考和改写的学习材料。

1. 约瑟夫死亡游戏:一份能跑通的C语言循环链表作业

约瑟夫死亡游戏这个题目,几乎每个学数据结构的人都会遇到一次。它模拟的场面是:一群人围成一圈,从某个人开始报数,数到固定数字的人出局,下一个人重新从 1 报数,直到圈里只剩指定数量的人。这个过程的麻烦点在于“围成一圈”和“随时有人离场”,用数组写要反复标记和跳过,而循环链表天然就是首尾相接的结构,删人只需要改两个指针。这份资源给了一套完整的 C 语言实现:结构体定义、创建环形链表、删除指定节点、循环淘汰、输出幸存者,每一步都有对应的函数和设计思路说明,拿到手可以直接编译运行。适合正在做数据结构课程设计、或者想找一个能对照理解的参考实现的人。

2. 数据结构选型与结点设计:为什么循环链表比数组顺手

2.1 数组方案为什么别扭:删除和重新报数都要做额外计算

直接用数组模拟约瑟夫问题时,最常见的做法是维护一个数组,里面存每个人的编号和姓名。删除一个人的时候,要么把后面所有元素整体前移,要么给这个位置打上“已淘汰”标记。整体前移的问题很明显,每删一个人平均要移动几十上百个元素,总人数一大,运行时间肉眼可见地变慢。打标记的做法省了移动,但每次报数都要跳过已淘汰的人,而且下一轮从哪个人开始数,要自己维护一个游标索引,报数跨越末尾时还得取模。这两个方案都不算错,但代码写起来会很绕。

循环链表把“围成一圈”直接做进了数据结构里。每个节点存一个人,尾节点的 next 指向首节点,报数就是沿着 next 指针走;删除一个人就是把他的前驱节点的 next 跨过他,直接指向他的后继。下一轮从哪里开始数?被删节点的后继就是起点,这个信息在删除时顺手就得到了。所以这个资源选择循环链表不是因为它高级,而是因为它在逻辑上和题目描述一一对应。

2.2 结构体 ElemType:位置和姓名绑在一个元素里

先看数据是怎么定义的:

typedef struct ElemType { int position; char name[8]; } ElemType;

这个结构体是链表节点数据域的类型。position 存编号,从 1 开始递增;name 存姓名。typedef 的作用是给 struct ElemType 起了一个别名,后面声明变量或写函数参数时直接写 ElemType,不用每次带 struct 关键词。C 语言里这种写法很常见,目的就是让类型名短一点、语义清楚一点。

这里有两个细节值得注意。第一,position 用 int 类型,对应总人数不超过几万的场景绰绰有余;如果你要模拟几十万人的情况,可以换成 unsigned int。第二,name 数组开的是 8 字节,也就是说姓名最多存 7 个字符加一个字符串结束符。输入姓名超过这个长度就会越界,这个问题后面避坑章节专门说。

2.3 LNode 与 LinkList:从结点到链表的三层抽象

再看结点类型和链表类型的定义:

typedef struct LNode { ElemType data; struct LNode *next; } LNode; typedef LNode * LinkList;

LNode 是结点结构体,data 字段存一个人(ElemType 结构体),next 字段存下一个结点的地址。注意 struct LNode *next 这种写法:在结构体内部引用自身类型时,必须带上 struct 关键词,因为 typedef 别名 LNode 此时还没有生效。这是 C 语言里一个容易卡住初学者的点,但其实记住“结构体内部只能用 struct 原名”就够了。

LinkList 本质上是 LNode *,也就是“指向链表结点的指针”。把它单独 typedef 成 LinkList,主要是为了语义区分:看到一个 LinkList 参数,就知道这个函数操作的是链表本身;看到一个 LNode *,就知道可能只是临时遍历用。在这个资源里,函数签名普遍写 LinkList &L,这个 & 是 C++ 的引用语法,不是标准 C 的写法,编译时后缀名用 .cpp 就能过。如果非要用 .c 后缀编译,得把引用改成二级指针 LinkList *L,调用处再传 &L。这一点也是后面避坑章节的重点。

3. 创建与删除:循环链表两个核心操作的逐行拆解

3.1 CreateLinkList:带头结点创建,尾插法连接成环

创建链表的函数是这个资源里信息密度最高的一段,完整代码如下:

void CreateLinkList(LinkList &L, int n) { LinkList r, p; int i; L = (LinkList)malloc(sizeof(LNode)); L->next = NULL; r = L; for(i = 1; i <= n; i ++) { p = (LinkList)malloc(sizeof(LNode)); p->next = NULL; p->data.position = i; 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; }

拆开来看,第一步是创建带头结点的链表:malloc 分配一个 LNode 大小的内存,L 指向它,L->next 置为 NULL。这里的头结点不存有效数据,只作为链表的起始锚点,方便后面统一处理插入逻辑。第二步是尾插法:r 是尾指针,永远指向当前最后一个结点。循环里每拿到一个新结点 p,先给它的数据域填上编号 i,再让用户输入姓名存进 p->data.name,然后把 p 挂到 r 后面。这里 p->next = r->next 这一步,因为 r 是尾结点时 r->next 本来就是 NULL,所以等价于 p->next = NULL,写成 r->next 只是为了保持“新结点插在尾结点后面”这个统一的插入语义。

第三步是成环和释放头结点。所有结点插入完成后,r->next = L->next,让尾结点的 next 指向第一个有效结点,链表就此闭合成环。然后 free(L) 释放头结点,L = r->next 让 L 指到第一个有效结点上。这一步很关键:释放之后 L 如果还指向原来的头结点地址,就成了野指针;重新赋值后 L 才代表“当前环的首个人员”。这个函数跑完后,链表是没有头结点的纯循环链表,L 直接指向编号为 1 的那个人。参数 n 就是总人数,调用时对应 TotalCount。

3.2 DeleteLinkList:数到第 i 个人,跨过他的前驱

删除节点的函数代码段如下:

void DeleteLinkList(LinkList &L, int i, ElemType &e) { LinkList p; p = L; int j = 1; while(j < i - 1) { p = p->next; j ++; } e = p->next->data; L = p->next->next; p->next = p->next->next; }

这个函数做的事是:从 L 指向的第 1 个人开始数起,数到第 i 个人,把他删掉。p 初始指向 L,也就是第 1 个人。while 循环的条件是 j < i - 1,j 从 1 开始,所以循环体会执行 i - 2 次,每次把 p 往后移一个节点。循环结束后,p 停在要删除节点的前一个节点上。举个例子,i 等于 3 时,循环条件 j < 2,j 从 1 变到 2,循环执行一次,p 从第 1 个人挪到第 2 个人,然后 e = p->next->data 取到的就是第 3 个人的数据。

接下来两行是删除的核心操作。e = p->next->data 先把被删人的信息保存到参数 e 里,这样调用方可以拿到姓名和编号去输出。L = p->next->next 让 L 指向被删节点的后继,相当于把“下一轮报数的起点”设在被删人的下一位。p->next = p->next->next 则是把前驱节点的 next 跨过被删节点,直接连接后继,从链表结构上把这个节点摘除。这个函数里没有写 free(q),也就是说被删节点虽然从链表上摘下来了,但内存还占着,严格来说有泄漏。课程设计的代码里这种省略很常见,但你自己写的时候最好补上,第 6 章会给修正版。

3.3 头指针 L 的转移:下一轮从被删者的后继开始

约瑟夫游戏能连续进行,依靠的是 L 在每次删除后转移到正确的位置。删除前的 L 是这一轮报数的起点,删除后 L 变成被删节点的下一个节点。比如现在环里有 5 个人,编号 1 到 5,L 指向 1。假设数到 3 的人出局,那么删除后 L 就指向编号 4。下一轮从编号 4 开始数 1,接下来被删的是编号 1 还是编号 2,取决于 PersonCount 的取值。

这个设计最巧妙的地方在于,它根本不需要重新计算“下一个人是谁”。因为删除时已经知道被删节点的后继地址,直接把这个地址赋给 L 就行。如果 L 不更新,下一轮又会从原来的起点开始数,游戏就永远在同一个圈子里打转。写代码时容易漏掉 L = p->next->next 这一行,漏掉之后表面上看链表也正常,但输出结果会错得离谱。判断循环链表程序对不对,先盯住 L 的转移路径,这一步对了,游戏流程基本就对了。

4. 游戏主流程:三个参数怎么驱动整个淘汰过程

4.1 DieLinkList:循环删人直到剩下 LeftTotal

淘汰过程由 DieLinkList 函数统一控制,代码很短:

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); } }

这个函数的工作流程分三步。先调用 CreateLinkList 把环形链表建好,这一步会扫描所有人员的姓名。然后进入 while 循环,条件里的 LeftCount 初始为 TotalCount,每删一个人就减 1,直到 LeftCount 等于 LeftTotal 时停住。循环体里,DeleteLinkList 删除第 PersonCount 个人并把他的信息存进 e,随后 printf 输出他的编号和姓名,正好对应“扔入大海”这一事件。

理解这个循环的关键是 LeftCount 和 LeftTotal 的关系。LeftCount 表示当前还活着的人数,LeftTotal 表示最后要剩下的人数。只要活着的人比目标剩余人数多,就继续删。比如总人数 10,目标剩下 3,那 while 循环会执行 7 次,删除 7 个人。这里要注意的是,如果 LeftTotal 等于 TotalCount,循环一次都不会执行,程序会直接输出全部人员名单,逻辑上也算正确。

4.2 main 函数与三个参数:输入顺序和边界条件

入口函数把三个参数串起来:

void main() { int TotalCount, PersonCount, LeftTotal; LinkList L; printf("请输入总人数:"); scanf("%d", &TotalCount); printf("请输入抛入大海时要数到的人数:"); scanf("%d", &PersonCount); printf("请输入轮船安全需要剩余的人数:"); scanf("%d", &LeftTotal); DieLinkList(L, TotalCount, PersonCount, LeftTotal); DisplayLinkList(L); }

三个参数的顺序是:总人数、报数间隔、剩余人数。第一次运行建议用 TotalCount=5、PersonCount=2、LeftTotal=2 来验证,这是经典的约瑟夫参数组合,手算一遍就能核对程序输出。输入时 LeftTotal 必须小于 TotalCount,否则不会有人被淘汰,这一点靠用户自觉,代码里没有做合法性校验。

参数取值对程序行为的影响可以看下面这张表:

参数含义建议取值边界说明
TotalCount总人数大于等于 1为 1 时链表只有一个节点
PersonCount报数到几删除大于等于 2为 1 时删除逻辑有缺陷,见避坑章节
LeftTotal最后留下几人0 到 TotalCount-1等于 TotalCount 时不会执行删除

这张表里的 PersonCount 建议值不是随便写的。原代码 DeleteLinkList 在 i=1 时会删错人,所以如果用户输入 PersonCount=1,程序输出会和预期不一致。对于课程设计来说,通常报数间隔都是 2 或 3,这个边界问题不容易触发,但你在演示程序时可以提前声明“报数间隔请从 2 开始”,免得现场翻车。

4.3 DisplayLinkList:从任意起点遍历整个环

淘汰结束后,剩下的幸存者由 DisplayLinkList 输出:

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 指向的第一个人,然后进入 while 循环,只要 p->next 不等于 L,就继续往后走并输出。这是一种避免 do-while 写法的简化方案。它的好处是:当环里只剩一个人时,p->next 就是 L 自己,while 条件不成立,直接输出一个人就结束,不会重复打印。

要注意的是输出顺序。L 在经过多轮删除后不一定指向编号 1,它指向的是最后存活链表中的某个节点。所以幸存者名单的打印顺序是从那个节点开始顺时针走一圈,而不是按编号从小到大排列。如果你期望的输出是编号有序,那得额外做一次排序或从编号 1 所在位置开始遍历。大多数课程设计对输出顺序没有硬性要求,这里提一下免得你对不上答案。

5. 避坑与常见问题排查:四类翻车现场与修复方式

5.1 PersonCount=1 时删除的是第二个人

现象:输入 PersonCount=1,程序输出的出局名单不是从第 1 个人开始,而是从第 2 个人开始。

原因:DeleteLinkList 里 p 从 L 开始定位,p 指向第 1 个人。删除逻辑总是删 p->next,也就是第 2 个人。当 PersonCount=1 时,应该删 p 自己,但 p 的前驱需要绕一整圈才能找到,代码没有处理这种情况。

解决:最简单粗暴的方式是在输入说明里限定 PersonCount 从 2 开始,这是课程设计的常见做法。如果想让程序更健壮,在 DeleteLinkList 开头加一段 i==1 的处理:先用一个临时指针从 L 开始绕环找到尾节点,让尾节点指针指向 L 的下一个节点,再把 L 更新为下一个节点。第 6 章修正版代码里给了完整的实现。

5.2 被删节点没有 free,内存泄漏

现象:程序运行结果正确,但用内存检测工具跑一遍,报出若干“lost block”。

原因:DeleteLinkList 里把节点从链表摘除后,没有调用 free。在一次性运行的课程设计程序里,进程退出后系统会回收全部内存,所以肉眼看不到问题。但如果你把这个函数复用到长时间运行的程序里,每删一个人就漏一块内存,删几万次就会吃光内存。

解决:在摘除节点之后补上释放操作。定义一个临时指针 q 保存被删节点地址,执行 free(q)。这里要注意顺序:必须先保存 e = p->next->data,再重新设置 p->next,最后 free(q)。e 里的数据是结构体拷贝,释放节点不影响已经保存到 e 里的姓名和编号。

5.3 LinkList &L 在 .c 后缀下编译报错

现象:代码保存为 .c 文件,编译器报错,错误位置指向函数签名里的 &L。

原因:引用类型是 C++ 的语法,标准 C 没有这个特性。很多教材为了演示方便混用了 C 和 C++ 语法,实际拿到机器上编译就会出现这种问题。

解决:最简单的处理方案是把文件后缀改成 .cpp 再用 C++ 编译器编译,代码一行不用改。如果老师指定要用纯 C 编译,那需要把几处 LinkList &L 改成 LinkList *L 或 LinkList **L,函数内部访问时用 (*L) 或者先在开头取一次值。对于课程设计来说,通常编译环境允许 C++ 语法,改后缀是最省事的办法。

5.4 姓名输入超过 7 个字符导致栈溢出

现象:输入一个较长的姓名后,程序崩溃或者后续输出出现乱码。

原因:name 数组只有 8 字节,scanf("%s", ...) 不做长度检查,会一直写入直到遇到空白字符。超过数组边界的内存被破坏,首当其冲受影响的是相邻变量,表现就是程序行为突然异常。

解决:把输入格式改成 scanf("%7s", p->data.name),限制最多读 7 个字符,剩下 1 个字节留给字符串结束符。这是 C 语言里控制字符串长度最直接的写法,代价是超出部分会被丢弃。另一个更稳的方案是改用 fgets 读取,配合 stdin 流和长度参数,但对这个程序的体积来说,%7s 已经够用。

6. 进阶:把课程设计改成健壮的通用实现

6.1 补全删除逻辑的修正版

把 DeleteLinkList 补成既能处理 i=1、又能正确释放内存的版本,改动量不大:

void DeleteLinkList(LinkList &L, int i, ElemType &e) { LinkList p, q; if (i == 1) { p = L; while (p->next != L) { p = p->next; } q = L; L = q->next; p->next = L; } else { p = L; int j = 1; while (j < i - 1) { p = p->next; j++; } q = p->next; L = q->next; p->next = q->next; } e = q->data; free(q); }

修正版的核心变化有三个。第一,i==1 时先让 p 绕环走到尾节点,q 保存当前头节点,L 更新为第二个节点,尾节点指针跳过 q 直连新 L,这样删除第一个节点后环结构依然完整。第二,非 i==1 的分支和原来逻辑一样,但用 q 显式保存被删节点地址。第三,分支汇合后统一执行 e = q->data 和 free(q),数据已经拷贝走了,释放内存不影响输出。这段代码可以直接替换原函数,其他函数都不用改。

6.2 推荐验证流程:手算一遍再上机跑

拿到这个资源后,我建议先做一次手动验证再跑程序。用 TotalCount=5、PersonCount=2、LeftTotal=2 这组参数,手算结果是:第一轮删 2,第二轮从 3 开始数,删 4,第三轮从 5 开始数,删 1,剩下 3 和 5。程序输出应该和这个结果一致。然后再测一组边界:TotalCount=1、PersonCount=2、LeftTotal=0,此时只有一个节点,i==1 的分支会触发,删除唯一节点后 L 指向被删节点自身,所以这组参数只适合验证删除逻辑,不适合再调用 DisplayLinkList,否则会访问已经 free 的节点。

6.3 一个值得养成的小习惯

从那以后,我每次拿到这类链表作业代码,都会先盯三件事:删除分支里有没有 free、头指针在删除后有没有重新赋值、以及输入边界会不会触发未处理的逻辑。这三件事检查完再上机,基本不会跑出离谱的结果。这份资源的价值在于它把循环链表的创建、删除、遍历完整串了一遍,逻辑主干是清晰的,上面说的几个坑也是所有同类代码都会遇到的。希望帮到你。

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

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

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

立即咨询