约瑟夫问题与C语言循环链表:从原理到代码实现 简介这是一份约瑟夫死亡游戏亦称约瑟夫环的C语言实现资料面向需要掌握循环链表与经典算法设计的编程学习者与课程设计学生。文档从问题拆解入手依次讲解结构体与结点类型定义、带头结点循环链表的创建、按指定步长删除元素、以及重复淘汰直至剩余人数的完整逻辑其间明确说明尾指针回连首结点形成环、删除时用临时指针保存被删人员信息、并重连前后结点等关键步骤还给出 CreateLinkList、DeleteLinkList、DieLinkList、DisplayLinkList 等函数的实现思路同时提供可直接运行或对照学习的完整 C 语言代码。包内文件共 1 个为 docx 格式文档压缩包大小约 18KB内容聚焦代码与算法说明轻量便于快速查阅。目前已有 3145 人学习下载适合初学者通过其逐步讲解理解指针操作、链表删除与环形遍历等知识点也可为约瑟夫环相关课设提供直接可用的参考实现。1. 约瑟夫死亡游戏一圈人、一个环、四种链表操作约瑟夫问题把一段古代故事变成了一组纯数据结构操作N 个人围成一圈按固定步长报数数到的人离开圈子直到剩下指定人数。很多人看到这类题第一反应是套数学递推公式几行代码就能算出幸存者编号。但这份 C 语言代码走的是另一条更适合练手的路——用循环链表把淘汰过程完整模拟一遍。从建环、按序号删人、循环淘汰到输出幸存者每一步对应一个独立函数等于把循环链表的创建、遍历、查找、删除四个基础操作全部过了一遍。适合两类读者一种是数据结构学到链表想找真实场景动手验证的人另一种是 C 语言课设没方向、想找一个既能演示又能讲清楚原理的题目的人。下面按数据结构、核心逻辑、避坑、改造的顺序拆开讲。2. 项目结构拆解从结构体定义到循环链表建成2.1 数据层设计用结构体描述“人”再用结构体描述“结点”这份代码最先值得看的是它把“人”和“链表结点”分成了两层抽象而不是把所有字段塞进一个大结构体。typedef struct ElemType { // 元素类型描述“人”是谁 int position; // 人员编号从 1 开始 char name[8]; // 人员姓名定长 8 字节 } ElemType; typedef struct LNode { // 结点类型描述链表里的一格 ElemType data; // 数据域存一个完整的人 struct LNode *next; // 指针域指向下一个结点 } LNode; typedef LNode * LinkList; // 链表类型本质是结点指针逻辑说明ElemType 描述业务数据LNode 描述存储结构。以后如果想在人员信息里加年龄、性别字段只改 ElemTypeLNode 和所有函数签名都不需要动。数据与结构分离这个习惯在项目变大之后会非常值钱。name 用定长 8 字节是一个明显取舍省去了字符串动态分配和释放的麻烦但姓名稍长就会被截断第 4 章会专门讲这个坑。参数说明position 是编号建链时按 1 到 TotalCount 自动生成不需要手动维护name 是字符数组输入时直接用 scanf(%s, p-data.name) 写入。typedef LNode * LinkList 这行定义了整个链表的抽象类型。之后函数签名里写 LinkList L等价于 LNode **L也就是“指向指针的引用”。因为引用的是指针变量本身函数内部执行 L xxx外面的指针变量也会被重新赋值。2.2 建环核心CreateLinkList 的尾插法与头结点释放void CreateLinkList(LinkList L, int n) { LinkList r, p; // 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; // 尾插法新结点的 next 置空 p-data.position i; // 编号自动生成 printf(请输入第%d个人员姓名, i); scanf(%s, p-data.name); // 读入姓名 r-next p; // 尾结点接住新结点 r p; // 新结点成为新的尾结点 } r-next L-next; // 尾结点指回第一个真实结点环闭合 free(L); // 头结点没用了释放 L r-next; // L 指向第一个真实结点 }逻辑说明这里用的是尾插法每次把新结点挂在链表尾部保证输入顺序和围坐顺序一致。如果换成头插法最后链表顺序会反转还要额外处理。循环里那句 p-next r-next 在尾插场景下等价于 p-next NULL写出来是为了保持结构对称。真正关键的是循环结束后的三行r-next L-next 让最后一个结点指回第一个真实结点链表闭合成环free(L) 释放占位头结点L r-next 让 L 成为新的起点。从此之后链表里没有哑结点每个结点都代表一个真实的人。参数说明n 是总人数 TotalCount由 main 读入后传入。这个函数把输入姓名和建链放在一起好处是代码短坏处是输入逻辑和数据结构耦合二次开发时想改成从文件读入姓名会有点别扭第 5 章会给出解耦方向。如果自己做不带头结点的版本差别在第一次插入时要单独处理 L 为 NULL 的情况反而多一个分支。头结点版本把复杂度均匀摊到每一轮循环这也是它适合作为教学实现的原因。2.3 三层代码结构与 main 里的三个参数整份代码虽然是单文件但逻辑上可以分成三层读代码时按这个层次走会清楚很多。层次对应代码职责数据层ElemType、LNode定义人员数据和链表结点的存储格式操作层CreateLinkList、DeleteLinkList、DieLinkList、DisplayLinkList建链、删除、淘汰总控、输出幸存者入口层main读取参数串联完整流程main 里真正传给淘汰逻辑的是三个整数它们决定了整个游戏的行为。参数变量名含义合法范围总人数TotalCount环形链表结点个数 1报数步长PersonCount每数到第几个人扔下海 2 时这段代码表现稳定边界见第 4 章幸存人数LeftTotal最终保留人数0 LeftTotal TotalCount这三个值的含义要先读懂后面所有函数都围绕它们做文章。main 调用 DieLinkList 时把三个参数原样传入DieLinkList 内部再调 CreateLinkList 建链。注意 CreateLinkList 只需要调用一次如果在 main 里重复调用会建出两条独立环前一条环的堆内存全部泄漏。这一章是从类型定义到建环完成的完整链路下一章看三类核心操作怎么按序号删人、怎么控制淘汰收敛、怎么输出幸存者。3. 核心逻辑拆解删除、收敛与幸存者输出的指针细节3.1 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; // 前驱跨过被删结点 }逻辑说明当步长大于等于 2 时这段代码是准确的。p 从当前起点 L 出发while(j i - 1) 把 p 移动到待删结点的前驱。比如 i 3p 从第一个结点走两步到第二个结点然后删除 p-next也就是第三个结点。这里最容易被忽略的是 L p-next-next 这一行p-next 是被删结点p-next-next 是它的后继删除完成后把 L 重置为后继正好对应规则里“被扔下海的人离开圈子下一个人成为新的报数起点”。如果少了这行L 停留在被删结点的前驱上下一轮报数就数错人。参数说明i 是报数步长 PersonCount每次由 DieLinkList 传入同一个值e 是引用类型输出参数被删结点的完整数据通过它带出去。还需要提醒两点先重置 L 再改 p-next这个顺序不能换否则 L p-next-next 取到的会是错误的结点原始代码没有释放被删结点的堆内存只是靠指针跨越把结点从环上摘下来功能正确但存在泄漏第 4 章会给出修复方案。3.2 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); // 数到第 PersonCount 个人就删 LeftCount--; // 剩余人数减一 printf(%d\t%s\n, e.position, e.name); } }逻辑说明这个函数是整段代码的总控。先调 CreateLinkList 建环然后进入 while (LeftCount LeftTotal) 循环。每一轮做三件事按步长删一个人、剩余人数减一、打印被删者的编号和姓名。收敛性不需要担心每次循环 LeftCount 严格减 1循环必定结束。真正决定结果的是“每一步删谁”这完全依赖 DeleteLinkList 里 L 的重置逻辑。正因为 L 在删除后被移到被删结点的后继下一轮 DeleteLinkList 才能从正确位置开始数数。参数说明TotalCount 是建环人数PersonCount 是删除步长LeftTotal 是安全剩余人数。三个参数从 main 原样传入函数内部没有任何合法性校验。如果 LeftTotal 大于等于 TotalCount循环一次都不会进入屏幕上只输出一个空的“被抛入大海的人员名单”然后直接跳到幸存者输出。3.3 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); } }逻辑说明环形链表遍历的难点在于没有 NULL 可判断只能判断是否回到起点。这段代码先输出起点 p然后 while(p-next ! L) 继续输出这样起点不重不漏。很多学习者第一次写环形遍历会写成 while(p ! L)由于 p 的初始值就是 L循环一次都不会执行只打印第一个结点如果写成 while(p-next ! NULL)因为环里每个 next 都有值程序会一直输出甚至野指针崩溃。判断环形遍历的正确姿势只有一种先输出起点再用“next 是否回到起点”做终止判断。3.4 运行示例五个人的船上该剩下谁把完整流程串起来用一个具体输入验证逻辑是否正确。终端交互效果大致如下。请输入总人数5 请输入抛入大海时要数到的人数2 请输入轮船安全需要剩余的人数2 被抛入大海的人员名单 编号 姓名 2 b 4 d 1 a 轮船上剩余人员名单 编号 姓名 3 c 5 e逻辑说明这里的三个输入参数分别是 5、2、2对应总人数、步长、幸存人数。手动推演一遍第一轮从 1 开始数 1、2把 2 扔下海L 移到 3第二轮从 3 开始数 3、4把 4 扔下海L 移到 5第三轮从 5 开始数 5、1把 1 扔下海L 移到 3。环里剩下 3 和 5输出顺序先从当前起点 3 开始再输出 5。推演结果与程序输出一致说明起点重置逻辑写对了。4. 常见问题与避坑记录环形链表最容易被骗的五个细节提示第 4 章的五条记录来自实际调试这类环形链表代码时的高频翻车点每一条都可以在拿到代码后立刻验证。4.1 幸存者输出死循环终止条件写错位置现象运行程序第一个人员信息输出后控制台不断刷屏只能强制终止进程。原因把环形链表的遍历终止条件写成了 while(p ! L) 或 while(p-next ! NULL)。前者因为 p 初始值就是 L一次都不进后者因为环内每个 next 都不为空永远出不来。解决牢记环形遍历两句话——先输出起点再从 p-next 开始判断是否回到起点。写代码时把条件限定为 while(p-next ! L)并且提前在纸上画一个三个结点的环标注 p 的移动路线基本不会再错。4.2 删除结点后没有释放堆内存成了孤儿现象程序功能完全正常结果也正确但用内存检测工具扫描会报告泄漏长时间循环调用时内存只增不减。原因DeleteLinkList 里只改了 p-next 的指向被跨过的那块 LNode 内存没有任何指针再指向它既无法访问也无法释放。解决在指针跨越之前先用临时变量接住待删结点。LinkList q p-next; // 先接住待删结点 e q-data; // 取出数据交给输出 p-next q-next; // 前驱跨过待删结点 free(q); // 归还堆内存这段修复不改变原来的删除结果只是补上内存回收。从那以后我把“动过 next 就检查有没有 free”当成链表操作的默认检查项。4.3 scanf 读姓名空格截断与缓冲区残留现象输入姓名时如果有空格比如 Zhang San程序要么只保存了 Zhang要么随后读入的参数变成乱码甚至闪退。原因scanf(%s, ...) 遇到空白字符就停止读取空格后面的 San 残留在输入缓冲区被接下来的 scanf(%d, ...) 当作整数解析类型不匹配产生未定义行为。解决项目场景下最省事的办法是约定姓名不带空格想更稳一点就把姓名读入换成 fgets 再手动去除换行符。char buf[32]; fgets(buf, sizeof(buf), stdin); buf[strcspn(buf, \n)] 0; // 去掉末尾换行 strcpy(p-data.name, buf);注意不要把 scanf 和 fgets 混在同一个输入流里要么统一用 fgets 解析要么全程保持 scanf否则缓冲区状态很难排查。4.4 步长等于 1 时删除错位隐藏的差一 Bug现象设置 PersonCount 为 1按规则每轮应该把当前起点扔下海但程序实际扔的是当前起点的下一个人最终幸存编号和手算结果对不上。原因CreateLinkList 结束时已经释放了头结点L 指向第一个真实结点。而 DeleteLinkList 的逻辑是 p 从 L 出发找待删结点的前驱然后删除 p-next。步长大于等于 2 时没问题步长等于 1 时p 停在 L 上删除的是 L-next也就是第二个结点整体向后错了一位。解决给 DeleteLinkList 加边界分支或者改成“从起点前驱出发”的通用写法。我一般用后者统一处理步长为 1 和其他所有情况。void DeleteLinkList(LinkList L, int i, ElemType e) { LinkList p L, q; // 先让 p 退到起点前一个结点也就是环的最后一个结点 while (p-next ! L) { p p-next; } // 从起点开始走 i-1 步p 停在待删结点的前驱上 for (int k 1; k i; k) { p p-next; } q p-next; // q 是被删结点 e q-data; if (q L) { // 如果删的正好是起点重置 L L q-next; } p-next q-next; free(q); }这段代码把“找前驱”和“删结点”解耦了不管步长是 1 还是 100逻辑都一致。while(p-next ! L) 让 p 绕到环的最后一个结点获得起点前驱再从那里走 i-1 步就能安全处理任何步长。4.5 输入参数越界不校验的结果是行为不可预期现象输入 LeftTotal 大于等于 TotalCount屏幕上直接输出空名单输入 TotalCount 为 0程序还没输入数据就异常结束。原因main 读入三个整数后没有做合法性检查。LeftTotal 大于等于 TotalCount 时DieLinkList 的循环条件一开始就不成立TotalCount 为 0 时 CreateLinkList 会创建一个只有头结点的环后续删除和遍历都会踩空指针。解决在 main 读入后加一段校验给使用者明确提示。if (TotalCount 1 || PersonCount 1 || LeftTotal 0 || LeftTotal TotalCount) { printf(参数不合法总人数1步长1剩余人数0且小于总人数\n); return; }这类校验在课设代码里经常被省略但真正拿给别人演示时它是避免“输入一怪就崩”的第一道防线。5. 二次开发建议把单文件 C 代码改造成可复用的小模块5.1 输入校验先行main 里对三个参数先做检查不合法直接退出。这个改动要放在任何业务逻辑之前让程序对使用者的输入保持“宁可拒绝不可乱跑”的态度。校验代码可以直接用第 4 章那段不需要额外封装但要注意校验条件和实际业务规则保持同步。5.2 定长姓名改成长字符串把 char name[8] 改成 char name[32]只改结构体定义就可以解决大部分演示场景下的截断问题。typedef struct ElemType { int position; char name[32]; // 普通姓名足够用 } ElemType;如果以后要读任意长度姓名再考虑 char *name 搭配 strdup 或 malloc。注意改成指针后删除结点 free 的时候要先 free(name)再 free(q)避免二次泄漏。5.3 提炼一个环形链表小 API把第 4 章的通用删除实现提取成独立函数 ring_erase输入是当前起点、步长、输出参数返回值是新的起点。这样代码就从“约瑟夫问题的作业”变成可以复用到其他环形场景的小模块。// 从 start 开始数 step 个人删除命中者返回新的报数起点 LinkList ring_erase(LinkList start, int step, ElemType *out) { LinkList p start, q; while (p-next ! start) p p-next; // 走到起点前驱 for (int k 1; k step; k) p p-next; // 停在待删结点前驱 q p-next; if (out) *out q-data; if (q start) start q-next; // 删除原点则重置起点 p-next q-next; free(q); return start; // 显式返回新起点 }返回新的 start 是这次提炼的关键。调用方不需要关心内部指针怎么换只需要把返回值赋给当前起点变量下一轮删除就能从正确的人开始数。这个函数把原代码里 L 的隐式重置变成了显式返回值读起来舒服很多。5.4 性能提醒海量数据时考虑换算法如果要模拟十万人、步长五万这种极限场景链表每删一个结点都要线性走 step 步整体复杂度是 O(TotalCount * PersonCount)开销非常吓人。那时候应该用数学递推直接算幸存者编号链表版本更适合作为理解环形数据结构的教学工具而不是高性能计算方案。这段代码我拆完最大的感受是环形链表最坑的地方不在建环而在“起点”这个概念。头结点可以 free指针可以跨越但只要每次删除后没有把“下一次从哪里开始数”这个状态显式维护好输出就对不上手算结果。从那以后我每次写这类环形结构都强制自己先在纸上把“起点前驱、待删结点、后继”三个指针画一遍再动代码。希望帮到你。本文还有配套的精品资源点击获取