ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

循环链表实战:约瑟夫环C语言实现与指针避坑指南

循环链表实战:约瑟夫环C语言实现与指针避坑指南

简介:这份资源面向C语言数据结构初学者与课程设计者,围绕经典的约瑟夫死亡游戏问题,提供完整代码实现与设计思路讲解,帮助读者理解循环链表在实际问题中的建模与操作。压缩包内共1个docx文档,约18KB,以文字形式系统梳理了从元素类型定义、链表结点结构到环形链表创建、按计数删除结点、输出剩余人员等完整算法流程,并附有可直接参考的C语言源码。内容涵盖结构体ElemType与LNode的定义、带头结点循环链表的构建与释放、DeleteLinkList删除指定位置元素、ThrowPerson循环淘汰直至剩余指定人数等关键环节,对指针操作与边界处理有较细致的说明。已有3145人学习该资源,适合需要掌握循环链表增删逻辑、完成类似课程作业或准备数据结构实验的读者对照研读,也可作为理解约瑟夫环算法思想的入门材料。

1. 约瑟夫死亡游戏:为什么一圈人报数,最后活下来的位置能被算出来

一圈人围坐,从某个人开始报数,报到某个数字的人出局,下一个人重新从 1 开始报,如此往复,直到剩下指定人数。这个场景听起来像游戏,但它其实是循环链表最经典的教学案例——约瑟夫问题。很多人第一次写这段代码时,觉得逻辑很简单:数到就删,删完接着数。但真正跑起来,指针指向谁、删除后头指针怎么挪、什么时候该停,处处是坑。这份 C 语言代码把整个流程拆成了创建环形链表、删除第 i 个结点、循环淘汰、输出剩余人员四个动作,每个动作都对应一个具体的指针操作。搞懂它,你就能理解循环链表为什么能在 O(1) 时间内把尾结点接回头结点,以及删除结点时那个容易被忽略的“头指针重定向”到底在解决什么问题。约瑟夫问题的底层不是数学公式,而是一套指针在环上移动的规则。

2. 环形链表的结构设计:为什么尾结点要指回头结点

2.1 元素类型与结点类型的分离

这份代码首先定义了两个结构体:ElemType和LNode。ElemType里放的是业务数据——位置编号position和姓名name[8];LNode里放的是链表骨架——数据域data和指针域next。这种分离不是多此一举,而是链表实现里常见的分层思路:数据域负责“存什么”,指针域负责“怎么连”。把两者拆开之后,如果以后要把姓名换成学号或者身份证号,只需要改ElemType,链表操作函数一行都不用动。

typedef struct ElemType { int position; // 人员在原始序列中的编号 char name[8]; // 人员姓名,固定长度便于演示 } ElemType; typedef struct LNode { ElemType data; // 数据域:存放具体人员信息 struct LNode *next;// 指针域:指向下一个结点 } LNode; typedef LNode *LinkList; // 链表类型就是结点指针

这里LinkList被定义为LNode *,意味着链表操作函数接收的都是指向结点的指针。C 语言里没有引用传递,但代码里用了LinkList &L,这是 C++ 的引用语法。如果严格用 C 编译器,需要改成LinkList *L或者返回新的头指针。这个细节在后面删除操作里会直接影响头指针能不能被正确更新。

2.2 带头结点建环,再释放头结点

创建循环链表的过程分两步:先建一条带头结点的单链表,再把尾结点的next指向第一个数据结点,最后释放头结点。头结点在这里的作用是“占位”,让插入操作不用特判空链表。等所有结点都插完了,头结点的使命就结束了。

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; printf("请输入%d个人员姓名:", i); scanf("%s", p->data.name); r->next = p; // 尾结点接上新结点 r = p; // 新结点成为新的尾 } r->next = L->next; // 尾结点指回头结点后的第一个数据结点 free(L); // 释放头结点 L = r->next; // L 指向第一个数据结点 }

关键在最后三行。r->next = L->next把尾结点接到了第一个数据结点上,形成环;free(L)释放的是最初那个空头结点;L = r->next让链表指针指向环的入口。如果漏掉free(L),头结点会泄漏;如果先free(L)再访问L->next,就是典型的释放后使用。顺序不能乱。

2.3 尾指针 r 的移动逻辑

r的角色是“永远指向最后一个结点”。每插入一个新结点p,先让r->next = p把新结点挂到尾部,再让r = p把尾指针前移。这个模式在单链表尾插法里很常见,区别只在于最后一步要把r->next指回起点而不是置NULL。循环链表的所有遍历都必须有一个终止条件,这里用的是“回到起点”,所以后面DisplayLinkList里判断的是p->next != L,而不是p != NULL。

3. 删除第 i 个结点:指针重定向与头指针漂移

3.1 找到第 i-1 个结点

删除操作的核心是找到目标结点的前驱。代码里用p = L起步,j = 1计数,循环条件是j < i - 1。这意味着当循环结束时,p停在第i-1个结点上,p->next就是待删除的第i个结点。

void DeleteLinkList(LinkList &L, int i, ElemType &e) { LinkList p, q; p = L; int j = 1; while (j < i - 1) { // 走到第 i-1 个结点 p = p->next; j++; } q = p->next; // q 指向待删除结点 e = q->data; // 保存被删人员信息 L = q->next; // 头指针后移,指向被删结点的下一个 p->next = q->next; // 前驱跳过 q free(q); // 释放 q }

这里有一个容易被忽略的动作:L = q->next。为什么删除一个结点要动头指针?因为在约瑟夫环里,每一轮报数都从“当前存活圈子的某个位置”开始,删除之后,下一轮报数的起点变成了被删结点的下一个。如果L不跟着走,下一轮DeleteLinkList还会从旧的头结点开始数,数出来的位置就全错了。这个头指针漂移是约瑟夫环和普通单链表删除最大的区别。

3.2 删除后环是否还完整

p->next = q->next让前驱直接跳过q,环的连通性没有被破坏。但要注意,如果i = 1,循环条件j < 0不成立,p停在L上,q = L->next,删除的是第二个结点而不是第一个。这份代码的PersonCount在实际调用时至少为 2,所以没有暴露这个问题。如果要把PersonCount = 1也支持上,需要单独处理i = 1的情况,让p先绕一圈找到尾结点。

3.3 被删结点的信息保存

e = q->data在free(q)之前执行,把位置和姓名拷贝到输出参数里。这个顺序不能反,free之后q指向的内存已经归还给堆,再读就是未定义行为。后面DieLinkList里每次删除后立刻printf输出e.position和e.name,靠的就是这份拷贝。

4. 循环淘汰的主循环:剩余人数怎么控制

4.1 LeftCount 与 LeftTotal 的边界

DieLinkList是淘汰过程的主控函数。它先调用CreateLinkList建环,然后进入while (LeftCount > LeftTotal)循环,每删一个人LeftCount--,直到剩余人数等于安全人数。

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

循环条件用的是>而不是>=。如果写成>=,当剩余人数刚好等于安全人数时还会再删一个,最后剩的人就少了一个。这个边界在测试时很容易翻车,尤其是LeftTotal = 1的时候,多删一次直接导致环里没人,后面的DisplayLinkList访问空指针。

4.2 每轮报数的起点如何延续

DeleteLinkList里的L = q->next保证了下一轮报数从被删者的下一个人开始。假设总人数 5,报数到 3,第一轮删掉 3 号,L指向 4 号;第二轮从 4 号开始数 1、2、3,删掉 1 号(因为环是 4→5→1→2),L指向 2 号。这个延续逻辑完全靠头指针在删除时同步移动来实现,不需要额外的“当前起点”变量。

4.3 输出顺序与淘汰顺序的一致性

printf放在DeleteLinkList调用之后,输出的是刚被删掉的人。因为e在删除函数里已经被赋值,主循环里直接打印即可。淘汰名单的顺序就是实际报数出局的顺序,和position编号没有必然关系。这也是约瑟夫问题的一个特点:编号是固定的,出局顺序是动态的。

5. 避坑与排查:环形链表最容易翻车的几个地方

5.1 遍历不终止,程序卡死

现象:DisplayLinkList一跑就卡住,终端没有任何输出或者疯狂刷屏。原因:循环链表的遍历没有用“回到起点”作为终止条件,而是习惯性地写了while (p != NULL)。环里没有NULL,指针会一直转。解决:遍历循环链表必须用do-while或者先记录起点再判断p != L。这份代码里DisplayLinkList先打印L的数据,然后while (p->next != L)逐個打印,最后停在尾结点,逻辑是对的。

5.2 删除后头指针没更新,下一轮数错人

现象:淘汰名单里出现重复编号,或者某个人明明应该出局却一直活着。原因:DeleteLinkList里漏了L = q->next,头指针还指着已经被free的结点或者旧起点。下一轮p = L从一个错误的位置开始数。解决:每次删除后强制让L指向被删结点的下一个,并且在free(q)之前完成赋值。

5.3 PersonCount 大于当前剩余人数

现象:报数报到一半,指针绕了一圈还没数到目标,while (j < i - 1)可能多转好几圈,甚至因为i太大导致逻辑混乱。原因:约瑟夫环里报数是循环的,PersonCount可以大于当前人数,但这份代码的DeleteLinkList只走i-1步,没有对i取模。解决:在调用删除之前,把PersonCount对当前剩余人数取模,或者让删除函数内部用(i - 1) % LeftCount来控制步数。否则当PersonCount大于剩余人数时,删除位置会偏移。

5.4 释放头结点后继续使用 L

现象:CreateLinkList执行完,L指向的地址看起来正常,但偶尔打印出乱码。原因:free(L)释放的是头结点,如果后面还有代码通过旧L访问L->next,就是访问已释放内存。这份代码在free(L)之后立刻L = r->next,把L重新指向有效结点,顺序是对的。但如果把free(L)和L = r->next调换,就会出问题。

5.5 姓名数组越界

现象:输入较长姓名时程序崩溃或者输出乱码。原因:char name[8]最多存 7 个字符加结尾符,scanf("%s")不检查长度。解决:限制输入长度,比如scanf("%7s", p->data.name),或者在结构体里把数组开大一点。这个坑在演示代码里很常见,实际使用时需要根据姓名长度调整。

6. 从约瑟夫环看循环链表的设计取舍

约瑟夫环把循环链表的特性用到了极致:尾结点指回头结点让“绕圈”成为天然操作,删除时同步移动头指针让“起点延续”不需要额外状态。但这份代码也暴露了几个设计上的取舍。第一,头指针L同时承担“环的入口”和“下一轮报数起点”两个角色,删除时必须同步更新,漏一步就错。如果换成“当前指针 + 前驱指针”的双指针方案,删除逻辑会更直观,但需要多维护一个变量。第二,DeleteLinkList的步数控制没有对剩余人数取模,导致PersonCount大于剩余人数时行为不符合约瑟夫环的循环报数语义。第三,用 C++ 引用语法写 C 代码,在纯 C 编译器下需要改成指针的指针,这是教学代码常见的混用。

我一般会这么理解:约瑟夫环的难点不在链表操作本身,而在“删除之后,下一轮从哪里开始”这个状态怎么传递。这份代码选择用头指针漂移来传递,优点是变量少、逻辑紧凑,缺点是任何一次删除后忘记更新L,整个淘汰顺序就全乱。从那以后我每次写环形链表的删除,都会在free之前强制检查一遍L是否已经指向q->next,并且用一个小规模用例(比如 5 人报 3 留 1)手工推一遍指针走向,确认淘汰名单和预期一致再扩大规模。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进