简介:一份以约瑟夫死亡游戏为背景的C语言实现资料,面向正在学习数据结构与算法的学生及编程爱好者,清晰示范了如何运用循环链表完成约瑟夫环的问题建模与求解。压缩包内仅含1个docx文档,体积约18KB,文档中不仅完整收录了可运行的C语言源代码,还详细整理了从结构体定义到循环链表初始化、插入、删除、遍历的全过程设计思路。内容从定义人员结构体、创建带头结点的环形链表开始,逐步说明如何借助尾指针完成结点插入、如何按报数位置定位并删除结点、如何更新头指针以及如何循环至剩余指定人数,每一步都配有清晰的逻辑说明,能帮助读者理解创建、遍历、删除与连接等核心操作。这份资源已有3145人学习下载,既适合作为《数据结构》课程设计或实验报告的参考,也适合作为链表专题的强化练习,帮助读者进一步体会动态内存管理和指针操作在实际算法中的灵活运用。
1. 约瑟夫环:循环链表让淘汰过程不再“翻车”
约瑟夫环的经典描述是一群人围成一圈报数,数到固定数字的人出局,直到剩下指定人数为止。很多第一次手写这个算法的人都会踩同一个坑:用数组模拟删除时,每删一个人就要把后面的元素整体前移,或者给已删除的位置打标记,然后每次报数还要反复跳过那些“死了却没被清走”的坑位。逻辑上能跑通,代码却越写越绕,报数指针和数组下标之间经常错位,调试起来非常痛苦。而循环链表方案把“下一个活人”直接固化在指针里——死亡的人从链上摘除之后就再也影响不到遍历,报数指针天然指向上一个活着的人的下一个。理解清楚这个资源里的CreateLinkList、DeleteLinkList和DieLinkList三段逻辑,就能看懂约瑟夫环最推荐的底层实现方式,也能把单链表删除结点的经典套路彻底吃透。
2. 数据建模:为什么约瑟夫环必须用“结构体 + 单链表”
2.1 从ElemType看数据分层:位置与姓名为什么要绑定
项目里定义了一个结构体ElemType,包含int position和char name[8]两个字段。位置字段存的是初始入队序号,姓名字段存的是每个参与者的人名。很多人会问:既然position就是从1到TotalCount的连续编号,直接用数组下标不行吗,为什么还要单独存一份?因为删除操作会打乱顺序——第5个人被扔下海之后,第6个人会变成新的“队首”,但他在原来排队时的编号仍然是6。如果只靠数组下标,删除后下标和编号就会错位,你需要在数组里搬移元素或者维护一张映射表。把position和name打包进结构体,数据随结点一起移动,删除后依然能输出正确的原始编号和姓名。这是数据建模层面的第一层取舍:用空间换语义的完整性。
2.2 LinkList的指针别名:为什么typedef LNode * LinkList能简化代码
C语言里LNode是结构体标签,ElemType是它的数据域类型,struct LNode *next是指向下一结点的指针域。而typedef LNode * LinkList这行把“指向LNode的指针”定义成了一个独立类型。这个别名的意义在于,链表的本质不是一个含有多元素的容器,而是“指向第一个结点的指针”加上结点之间的next链接。把LinkList当成一种类型传参,函数的签名读起来更接近“我对一个链表做操作”,而不是“我对一个结点指针做操作”。常见的实现思路是,函数内部所有移动都用LinkList类型的临时变量p、q、r,而链表头L本身始终代表整条链的入口。理解这一点之后,再看代码里的malloc、结点赋值和free,都是在LinkList这个抽象层面上完成的。
typedef struct ElemType { int position; /* 人员在初始队列中的编号 */ char name[8]; /* 人员姓名,注意长度限制 */ } ElemType; typedef struct LNode { ElemType data; /* 数据域:一个完整的人员信息 */ struct LNode *next; /* 指针域:指向下一个结点 */ } LNode; typedef LNode * LinkList; /* 链表类型就是结点指针 */这段声明体现了三个层次:ElemType描述元素长什么样,LNode描述元素如何串起来,LinkList则是整个链表的入口类型。三者各自独立,又通过typedef逐层关联。如果将来要把姓名改成更长的字符串,只需要改ElemType里的name;如果要把单链表改成双向链表,只需要在LNode里增加一个prev指针,上层调用方几乎不需要感知差异。这种分层正是链表设计里最值得借鉴的部分——数据形态和链接形态分离。
2.3 静态数组与循环链表的本质差异
数组方案的问题是删除和避开“死人”的成本。假设用一个int数组标记每个人是否存活,每次报数都要判断当前位置是否已被淘汰,如果淘汰就要跳过,等于是把逻辑判断混进了遍历过程。而循环链表在删除结点时直接free释放内存,未删除的结点之间保持紧密的next连接,遍历时永远不需要检查“这个结点是否还活着”。从时间复杂度上看,数组标记法删除一次是O(1),但报数跳过死人最坏情况下每次要额外遍历一圈;链表删除一次也是O(1),但它的O(1)是真正摘除结点,而不是打标记。从代码可读性上看,链表的指针关系与游戏规则一一对应:结点就是人,next就是下一个活人,删除就是死亡。
3. CreateLinkList:带头结点构建环形链表的完整动作
3.1 头结点的双重角色:占位与最终释放
创建环形链表时,代码先分配了一个头结点L,并让L->next = NULL。这个头结点不存储任何人员信息,它只是一个占位符,作用是让尾插法的初始状态干净:尾指针r可以先指向L,然后逐个往L后面挂新结点。常见实现的思路是,头结点避免在插入第一个结点时单独写一套分支逻辑——如果没有头结点,第一次插入要特殊处理“链表为空”的情况;有了头结点,所有插入都统一走“找到尾结点,把新结点接上去”这条路径。值得注意的是,这个头结点在成环的瞬间被free掉了,原因是循环链表本身不需要一个空头来标记起始位置,L最终被重新指向第一个实际结点。头结点的生命周期只有搭建过程这一小段,它的使命是让建链代码保持简洁,使命完成后立即释放,避免内存泄漏。
3.2 尾插法与r尾指针:为什么p->next必须先置NULL再接
尾插法的关键是维护一个尾指针r,r永远指向当前链表的最后一个结点。处理一个新结点p时,先填写数据域,然后把p->next置为NULL,再执行p->next = r->next和r->next = p。因为r->next在正常情况下是NULL,所以p->next先置NULL再赋值给r->next,逻辑上是等价的。这里的习惯性写法是在为后续成环做铺垫:如果r->next不是NULL,说明r不是真正的尾结点,那尾指针的维护就出错了。把p->next先置空,相当于先把新结点的链接关系初始化干净,再接进链表,这是一种安全编码习惯。最后r = p,让r跟上新结点,形成新的尾部。每次只操作尾指针和它的next,头指针L在整个插入过程中完全不需要变动。
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; /* L指向第一个人 */ }代码里的关键是理解了尾指针r的推进节奏。for循环每执行一次,链表的数据结点就多一个,r始终指向最新加入的结点。等n个结点全部挂完,r指向最后一个结点,这时执行r->next = L->next,让最后一个结点的next越过已释放的头结点、直接指向第一个数据结点,环就闭合了。free(L)释放的是头结点占用的内存,紧接着L = r->next让链表指针指向第一个参与者,之后的报数、删除都从第一个结点开始。这个顺序不能颠倒:必须先成环再释放头结点,因为r->next = L->next这一步要用到头结点的next域。
3.3 成环的瞬间:r->next = L->next的隐藏含义
从逻辑上看,r->next = L->next做的事是“把尾结点的后继指向首结点”。但由于L->next是一个指针表达式,它取的是头结点的next字段,而不是头结点本身。在free(L)之后,L->next已经不再安全,所以必须在free之前完成这个指针赋值。另一个隐含细节是,free(L)后并没有把L置为NULL,而是让L = r->next重新指向了第一个数据结点。这意味着L的类型虽然是LinkList,但它的语义已经从“头结点指针”变成了“首结点指针”。很多初学者在这里会疑惑:为什么创建时先分配一个头结点,最后又把它释放掉?因为环形结构只需要一个“入口”,不需要链表通常意义上的空头结点。这个设计既保证了构建过程的统一,又避免了环形链表里残留一个不参与报数的空结点干扰后续逻辑。
4. DeleteLinkList:删除结点时指针交接的底层逻辑
4.1 为什么要停在目标结点的前一个位置
单链表的删除和数组不同,数组删除后是“后面的元素补上来”,链表删除则是“前一个结点的next跳过目标结点”。因为目标是单向链表,你无法从目标结点往前找它的前驱,所以必须用一个指针p先走到目标的前一个位置,然后通过p->next操作目标。这里找前驱的方式是让p从链表头L出发,用while循环移动,直到p指向第i个人前面的那个人。项目里是让p = L,j = 1,只要j < i - 1就移动p,最终p停在i-1号结点。如果i是3,p从1号开始,j先等于1,j < 2成立,移动到2号;再判断j = 2,j < 2不成立,停在2号。此时p->next正是3号,删除p->next就删掉了3号。这个“找前驱”的过程是单链表删除的统一套路,也可以被认为是头结点存在的另一种价值:如果要删除的就是第一个结点,头结点可以让p的初始位置找到统一起点。而在这个循环链表实现里,L本身就充当了那个“假想的0号结点”,所以删除首个参与者也可以走同一套逻辑。
4.2 三步指针交接:保存数据、跳过目标、释放内存
项目里的DeleteLinkList函数用三个关键动作完成一次删除。第一步e = p->next->data,把被删结点的数据域拷贝到引用参数e里,这样删除后还能输出这个人的编号和姓名。第二步p->next = p->next->next,让前驱的next直接指向被删结点的后继,逻辑上把目标结点从环中摘除。第三步虽然在这个代码片段里没有显式写free(q),但资源的设计说明明确提到了要先q = p->next保存地址,再p->next = q->next,最后free(q)。q的作用是保留被删结点的地址,因为p->next一旦被重新赋值,原来的结点就没有指针引用了,不提前存下来就无法释放内存。
void DeleteLinkList(LinkList &L, int i, ElemType &e) { LinkList 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。删除目标结点之后,下一轮报数必须从被删者的下一个活人开始,所以L被重新定位为目标的后继。这样做保证了DieLinkList主循环里,下一次DeleteLinkList调用天然地从正确的位置开始报数。如果忘记了这行,L会一直指向旧的首结点,删除的次序就会错乱,甚至可能把幸存者重复删除。另外注意这里没有free,实际补充完整的代码中应该用q保存待删结点地址,最后调用free(q),否则每删除一个人就泄漏一个结点的内存。对于TotalCount很小的课程作业来说泄漏不致命,但换成大规模场景,每轮泄漏一个结点就是灾难。
4.3 L的指针漂移是循环报数的核心引擎
L的更新并不只是“保持链表可用”,它本身就是报数状态的载体。每一次DeleteLinkList执行完,L指向的是“下一个应该开始报数的人”。下一次调用时p = L就是从这个后继开始数1,数到i时再删除。这种设计让循环逻辑变得极其简洁:DieLinkList只需要反复调用DeleteLinkList,每次删一个,直到剩余人数达标。可以形象地理解为,L不是一个固定不变的链表头,而是一个不断旋转的“当前报数起点指针”。环形链表在这里发挥了无可替代的作用——正因为链表是环形的,L无论漂移到哪个结点,都可以通过next无限地走下去,永远不会碰到NULL。
5. 常见问题与排查:边界参数和指针操作的四个坑
5.1 坑一:PersonCount等于1时,删除的却不是当前位置
现象:把报数步长设为1,程序输出的第一个被扔人员不是当前队首,而是队首之后的那个人。原因:DeleteLinkList的定位逻辑是让p停在i-1号位置,删除p->next。当i = 1时,while(j < 0)根本不执行,p仍然指向L也就是当前队首,p->next是队列里的第二个结点,于是第二个结点被删了。解决:在DeleteLinkList开头加一个特殊情况判断,如果i等于1,直接把L指向的结点作为删除目标,并让L = L->next。这属于典型的单链表删除“找前驱”逻辑在处理首结点时的边界缺陷。
5.2 坑二:剩余人数小于报数步长时,指针绕环多圈
现象:当LeftTotal设置得比较小,活着的人已经少于PersonCount时,DeleteLinkList里的while循环会一直pr指向的结点绕圈,直到j再次达到i-1。这在功能上可能歪打正着,但语义已经不对了:报数过程中经过了已经被删除的结点的位置吗?不会,因为被删结点已经从环上摘掉了,所以绕圈经过的都是活人。真正的问题是,如果PersonCount和剩余人数之间存在公因数关系,报数起点会在多个起点之间循环,删除顺序可能和预期不一致。解决:要么在进入删除循环前检查剩余人数是否小于PersonCount,必要时把PersonCount对剩余人数取模;要么明确约定“数到PersonCount”中的PersonCount永远指从当前活人开始重新计数的步长。在游戏中常数步长是合理的,但必须清楚它与取模步长的差异。
5.3 坑三:显示幸存者时用p->next != L判断循环结束
现象:DisplayLinkList从L开始打印一个结点后,用while(p->next != L)作为是否继续打印的条件。这个条件看似没问题,但当链表只剩下两个或更少结点时容易让人困惑。原因:显示函数先打印L指向的结点,然后进入循环判断p->next是否等于L。如果p->next恰好等于L,说明当前只有一个结点,循环不执行。如果p->next指向另一个结点,循环执行并打印它,然后p移动到这个结点,此时p->next又指回L,循环终止。这个判断逻辑在环上结点数大于等于1时都能正确输出所有幸存者,但如果L本身是悬垂指针,比如删除到最后一个结点后L又被free了,那p = L读取的就是已释放内存,表现就是随机崩溃或乱码。解决:确保DeleteLinkList里free(q)之后,L要么指向一个真实存在的幸存结点,要么在没有任何幸存者时被显式置为NULL,显示函数进入前先判空。
5.4 坑四:name[8]的scanf输入越界
现象:提示输入姓名时,如果用户输入一个超过7个字符的名字,程序在运行到printf输出阶段会显示乱码,或者直接崩溃。原因:char name[8]最多容纳7个字符加一个结束符,scanf("%s", p->data.name)没有任何长度限制,超长输入会越界写坏堆内存,破坏相邻结点的数据甚至next指针。这是C语言里最常见的缓冲区越界问题。解决:把scanf改成scanf("%7s", p->data.name),强制截断输入;同时输出时用%-7s保证对齐。这个改动只需要一行,就能让程序在任意姓名长度下都稳定运行。很多课程代码不写长度限制是因为测试数据简单,但从工程视角看这是必须堵住的漏洞。
5.5 坑五:删除剩余最后一个结点时的自环指针
现象:当LeftTotal设为0,或者循环条件写成while(LeftCount > 0),最后删除到只剩一个结点时,程序不报错但显示结果完全不对。原因:最后一个结点的next指向它自己,删除它时p->next = p->next->next等价于p->next = p,free掉这个结点后,L因L = p->next->next而指向了自己已释放的内存,形成悬垂引用。解决:在DieLinkList的循环条件里,把LeftTotal至少设为1;如果必须支持删光所有人,删除最后一个结点后要将L置为NULL,并在显示函数里增加空指针判断。自查时也可以打印删除每轮后的L指向和剩余人数,确认L始终是活人而不是已free的地址。
6. 设计取舍:过程与结果,链表与数学公式之间的一次选择
循环链表解决约瑟夫环的复杂度是O(TotalCount × PersonCount):每次删除要沿着链走PersonCount步才能定位到目标,总共要删除TotalCount - LeftTotal个人,所以整体是两者的乘积。这样的复杂度放在几万人规模下并不好看,但它的优势在于能按顺序输出完整的淘汰名单,也就是“过程可回溯”。如果只要求最后剩下谁,有一个更轻的数学递推解法:从最后一步往前倒推幸存者的原始位置,公式是f(i) = (f(i-1) + PersonCount) % i,i从1递增到TotalCount。以10个人、数到3为例,递推得到的最后幸存者是4号,这与链表方案删到只剩一人时输出的结果完全一致。
def josephus_survivor(total, step): survivor = 0 for i in range(2, total + 1): survivor = (survivor + step) % i return survivor + 1 print(josephus_survivor(10, 3)) # 输出4这个O(n)解法非常优雅,但它只能告诉你活到最后的那个人的编号,无法回答“第5个被扔下去的是谁”这类过程性问题。链表方案虽然在复杂度上吃亏,却可以把每一步删除都完整记录成名单。真实需求里两种都有用武之地:统计幸存者用递推,模拟游戏过程用链表。还有一条中间路线是数组标记法,空间上少存两个指针,但每次报数都要跳过死人的坑位。三种方案放在一起看,本质上是在“时间、空间、过程信息”三者之间做权衡。我自己的习惯是,这类题目上手先把链表版本写通,因为它的每个指针动作都把数据结构的基础操作练了一遍;等确认逻辑无误后,再根据需求决定是否换成数学解。从那以后遇到任何“环形结构淘汰”问题,我都会先问自己一句:我要的是结果还是全过程?如果答案是后者,就老老实实把循环链表画一遍,指针走位永远比脑子记下标可靠。每当我手痒想用数组下标硬顶时,都会想起那次报数指针和实际活人位置错位的惨痛调试经历。希望这些原理和踩坑记录,也能让你在写约瑟夫环时少走几步弯路。
本文还有配套的精品资源,点击获取