数据结构与算法——第2章-循环链表-约瑟夫环(2.8.1)

一 概述

1
2
3
本文介绍了循环链表的概念及其在约瑟夫环问题中的应用,以及如何通过遍历判断单链表是否存在环。
约瑟夫环问题展示了如何在循环链表中计数并删除节点,
而有环链表检测则是通过对比节点地址来确定链表结构特征

二 循环链表

1
2
3
4
5
无论是静态链表还是动态链表,有时在解决具体问题时,需要对其结构进行稍微调整。
比如,可以把链表的两头连接,使其成为循环链表。

虽然循环链表成环状,但本质上还是链表,因此在循环链表中依然能够找到头指针和首元节点等。
循环链表和普通链表相比唯一的不同就是:循环链表首尾相连。

图示

三 约瑟夫环

3.1 什么是约瑟夫环

1
2
3
4
5
6
约瑟夫环问题是一个经典的循环链表问题:已知 n 个人(分别用编号 1,2,3,…,n 表示)围坐在一张圆桌周围,
从编号为 k 的人开始顺时针报数,数到 m 的那个人出列;
他的下一个人又从 1 开始,还是顺时针开始报数,数到 m 的那个人又出列;
依次重复下去,直到圆桌上剩余一个人。

假设此时圆周周围有 5 个人,要求从编号为 3 的人开始顺时针数数,数到 2 的那个人出列

图示

3.2 约瑟夫环出列顺序

1
2
3
4
5
6
7
8
9
出列顺序依次为:
编号为 3 的人开始数 1,然后 4 数 2,所以 4 先出列
4 出列后,从 5 开始数 1,1 数 2,所以 1 出列
1 出列后,从 2 开始数 1,3 数 2,所以 3 出列
3 出列后,从 5 开始数 1,2 数 2,所以 2 出列
最后只剩下 5 自己,所以 5 胜出

约瑟夫环问题有多种变形,比如顺时针转改为逆时针等,虽然问题的细节有多种变数,
但解决问题的中心思想一样,即使用循环链表。

3.3 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <stdio.h>
#include <stdlib.h>

typedef struct node{
int number;
struct node * next;
}person;

person * initLink(int n){
person * head=(person*)malloc(sizeof(person));
head->number=1;
head->next=NULL;
person * cyclic=head;
for (int i=2; i<=n; i++) {
person * body=(person*)malloc(sizeof(person));
body->number=i;
body->next=NULL;
cyclic->next=body;
cyclic=cyclic->next;
}
// 首尾相连
cyclic->next=head;
return head;
}

void findAndKillK(person * head,int k,int m){
person * tail=head;
// 找到链表第一个结点的上一个结点, 为删除操作做准备
while (tail->next!=head) {
tail=tail->next;
}
person * p=head;
// 找到编号为 k 的人
while (p->number!=k) {
tail=p;
p=p->next;
}

// 从编号为 k 的人开始, 只有符合 p->next==p 时, 说明链表中除了 p 结点, 所有编号都出列了
while (p->next!=p) {
// 找到从 p 报数 1 开始, 报 m 的人, 并且还要知道数 m-1 的人的位置 tail,方便
// 做删除操作
for (int i=1; i<m; i++) {
tail=p;
p=p->next;
}
// 从链表上将p结点摘下来
tail->next=p->next;
printf("出列人的编号为:%d\n",p->number);
free(p);
// 继续使用 p 指针指向出列编号的下一个编号, 游戏继续
p=tail->next;
}
printf("出列人的编号为:%d\n",p->number);
free(p);
}

int main() {
printf("输入圆桌上的人数n:");
int n;
scanf("%d",&n);
person * head=initLink(n);
printf("从第k人开始报数(k>1且k<%d):",n);
int k;
scanf("%d",&k);
printf("数到m的人出列:");
int m;
scanf("%d",&m);
findAndKillK(head, k, m);
return 0;
}

/*
输入圆桌上的人数n:5
从第k人开始报数(k>1且k<5):3
数到m的人出列:2
出列人的编号为:4
出列人的编号为:1
出列人的编号为:3
出列人的编号为:2
出列人的编号为:5
*/

四 判断单链表是否为有环链表

4.1 分析

1
2
注意:有环链表并不一定就是循环链表。
循环链表指的是“首尾相连”的单链表,而有环链表则指的是单链表中存在一个循环子链表

图示

4.2 实现方案1

一、分析

1
2
3
4
5
6
最直接的实现思想:从给定链表的第一个节点开始遍历,每遍历至一个节点,都将其和所有的前驱节点进行比对,
如果为同一个节点,则表明当前链表中有环;
反之,如果遍历至链表最后一个节点,仍未找到相同的节点,则证明该链表中无环。

注意:如果一个单链表为有环链表,基于单链表中各节点有且仅有 1 个指针域的特性,则该链表是没有尾结点的。
即:有环链表的遍历过程是无法自行结束的,需要使用 break 语句手动结束遍历

二、示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 自定义 bool 类型
typedef enum bool
{
False=0,
True=1
}bool;

// H 为链表的表头
bool HaveRing(link * H) {
link * Htemp = H;
// 存储所遍历节点所有前驱节点的存储地址, 64 位环境下地址占 8 个字节, 所以这里
// 用 long long 类型
long long addr[20] = { 0 };
int length = 0, i = 0;
// 逐个遍历链表中各个节点
while (Htemp) {
// 依次将 Htemp 和 addr 数组中记录的已遍历的地址进行比对
for (i = 0; i < length; i++) {
// 如果比对成功, 则证明有环
if (Htemp == addr[i]) {
return True;
}
}
// 比对不成功, 则记录 Htemp 节点的存储地址
addr[length] = Htemp;
length++;
Htemp = Htemp->next;
}
return False;
}

三、时间复杂度

1
当函数的返回值为True,表示该链表有环。该方案的时间复杂度为O(n²)。

4.3 实现方案2

一、分析

1
2
3
4
5
6
7
8
9
10
11
相比上一种实现方案,这里介绍一种时间复杂度为O(n)的算法。

在一个链表中,如果2个指针(H1和H2)都从表头开始遍历链表,
其中H1每次移动2个节点的长度(H1=H1->next->next),而H2每次移动1个节点的长度(H2=H2->next),
如果该链表为有环链表,则H1、H2最终必定会相遇。

假设有环链表中的环包含n个节点,则第一次遍历,H1和H2相差1个节点;
第二次遍历,H1和H2相差2个节点;
第三次遍历,H1和H2相差3个节点...,
最终经过多次遍历,H1和H2会相差n-1个节点,
此时就会在环中重合,此时H1和H2相等。

二、示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// H 为链表的表头, 该函数会返回一个枚举的 bool 类型数据
bool HaveRing(link * H) {
link * H1 = H->next;
link * H2 = H;
while (H1)
{
if (H1 == H2)
{
// 链表中有环
return True;
}
else
{
H1 = H1->next;
if (!H1)
{
// 链表中无环
return False;
}
else
{
H1 = H1->next;
H2 = H2->next;
}
}
}
// 链表中无环
return False;
}

三、时间复杂度

1
当函数的返回值为True,表示该链表有环。该方案的时间复杂度为O(n)。

五 参考

  • CSDN—循环链表(约瑟夫环)、判断单链表是否有环