欢迎光临
我们一直在努力

数据结构学习之路(二)----循环链表与双向循环链表

一、前言

上一章学习中,我们学习了顺序表与单链表,单链表是最基础的形式,但它在某些场景下存在局限性:无法从任意结点访问其前驱,尾结点无法快速回到头部。为了解决这些问题,循环链表和双向链表应运而生。本文将详细介绍循环链表(包括带头结点的单向循环链表)、约瑟夫环问题(经典应用)以及双向循环链表的原理与C语言实现,代码均经过整理与测试。

二、循环链表

2.1 什么是循环链表

循环链表是单链表的一种变形:将最后一个结点的 next 指针从 NULL 改为指向头结点(或第一个结点),使整个链表形成一个环。

结构示意(带头结点):

┌→ [H] → [A] → [B] → [C] ─┐
└──────────────────────────┘

2.2 核心区别

特性单链表循环链表
尾结点 next 指向 NULL 指向头结点
遍历终止条件 p != NULL p != head(或 p->next != head)
从尾到头 不可能(除非反向遍历) 可以顺着环回到起点
判断空链表 head->next == NULL head->next == head

2.3 C语言实现(带头结点的单向循环链表)

#include <stdio.h>
#include <stdlib.h>

typedef int Elemtype;

// 结点结构
typedef struct _node {
Elemtype val;
struct _node* next;
} node_t;

// 链表管理结构(带头结点)
typedef struct {
node_t head; // 头结点(不是指针,直接占用空间)
node_t* rear; // 尾指针,方便尾插
int num; // 结点个数(不含头结点)
} LinkList_t;

// 初始化:头结点的next指向自己,rear指向头结点,计数为0
void creatLoopLink(LinkList_t* link_loop) {
link_loop->num = 0;
link_loop->head.next = &link_loop->head;
link_loop->rear = &link_loop->head;
}

// 头插法:在头结点之后插入新结点
int InsertLoopLinkHead(LinkList_t* link_loop, Elemtype val) {
node_t* new_node = malloc(sizeof(node_t));
if (new_node == NULL) {
return -1; // 内存分配失败
}
new_node->val = val;
new_node->next = link_loop->head.next;
link_loop->head.next = new_node;

// 如果插入前链表为空,则更新rear指向新结点
if (link_loop->rear == &link_loop->head) {
link_loop->rear = new_node;
}
link_loop->num++;
return 0;
}

// 尾插法:在rear之后插入新结点,并更新rear
int GoBackLoopLink(LinkList_t* link_loop, Elemtype val) {
node_t* new_node = malloc(sizeof(node_t));
if (new_node == NULL) {
return -1;
}
new_node->val = val;
// 新结点的next指向头结点(保持循环)
new_node->next = link_loop->rear->next;
link_loop->rear->next = new_node;
link_loop->rear = new_node;

// 如果插入前链表为空,则头结点的next也要指向新结点
if (link_loop->head.next == &link_loop->head) {
link_loop->head.next = new_node;
}
link_loop->num++;
return 0;
}

// 遍历并打印所有结点
void showLoopLink(LinkList_t* link_loop) {
node_t* p = link_loop->head.next;
// 循环结束条件:p回到头结点
while (p != &link_loop->head) {
printf("%d\\t", p->val);
p = p->next;
}
printf("\\n");
}

// 删除第一个值为val的结点
void deleteLoopLink(LinkList_t* link_loop, Elemtype val) {
node_t* p = &link_loop->head;
// 寻找待删除结点的前驱
while (p->next != &link_loop->head && p->next->val != val) {
p = p->next;
}
if (p->next->val == val) {
node_t* tmp = p->next;
p->next = tmp->next;
free(tmp);
link_loop->num–;
} else {
printf("no %d Element\\n", val);
}
}

三、约瑟夫环问题

3.1 问题描述

约瑟夫环(Josephus Problem) 是一个经典的数学应用问题:

n 个人编号 1~n 围成一圈,从第 1 个人开始报数,每数到 m 的人出列,然后从下一个人重新报数,直到所有人出列。求最后的幸存者编号(或出列顺序)。

3.2 循环链表解法

利用循环链表可以完美模拟这个围成一圈的过程:每个结点代表一个人,报数过程相当于遍历链表,删除结点对应人出列。

#include <stdio.h>
#include <stdlib.h>

typedef int Elemtype;

// 结点结构
typedef struct _node {
Elemtype val;
struct _node* next;
} node_t;

// 约瑟夫环管理结构
typedef struct {
node_t* head; // 指向第一个结点
node_t* tail; // 指向最后一个结点
} Joseph_t;

// 初始化:创建包含 n 个结点的循环链表(编号1~n)
int initJosephList(Joseph_t* game, int n) {
game->head = game->tail = NULL;
for (int i = 1; i <= n; i++) {
node_t* new_node = malloc(sizeof(node_t));
if (new_node == NULL) return -1;
new_node->val = i;
if (game->head == NULL) {
game->head = new_node;
game->tail = new_node;
} else {
game->tail->next = new_node;
game->tail = new_node;
}
}
// 尾结点指向头结点,形成循环
game->tail->next = game->head;
return 0;
}

// 打印当前环中所有结点
void showJoseph(Joseph_t* game) {
node_t* p = game->head;
do {
printf("%d\\t", p->val);
p = p->next;
} while (p != game->head);
printf("\\n");
}

// 开始游戏:每数到 m 的结点删除,返回最后幸存者的编号
int startJosephGame(Joseph_t* game, int m) {
node_t* pre = NULL; // 前驱结点
node_t* cur = game->head;
// 当环中不止一个结点时继续
while (cur != cur->next) {
// 报数 1 到 m-1,移动 pre 和 cur
for (int i = 1; i < m; i++) {
pre = cur;
cur = cur->next;
}
// 此时 cur 指向待删除结点(报数到 m 的人)
pre->next = cur->next;
free(cur);
cur = pre->next; // 从下一个人继续报数
}
int survivor = cur->val;
free(cur);
return survivor;
}

// 示例用法
int main() {
Joseph_t game;
int n = 41, m = 3; // 经典约瑟夫问题:41人,数到3出列
initJosephList(&game, n);
printf("初始序列:");
showJoseph(&game);
int survivor = startJosephGame(&game, m);
printf("幸存者编号:%d\\n", survivor);
return 0;
}

四、双向循环链表

4.1 什么是双向循环链表

双向链表的每个结点有两个指针域:prev(前驱)和 next(后继)。将双向链表的头结点的 prev 指向尾结点,尾结点的 next 指向头结点,就构成了双向循环链表。这是实际工程中最常用的链表形态之一(如 Linux 内核链表)。

结构示意:

┌→ [H] ⇄ [A] ⇄ [B] ⇄ [C] ─┐
└──────────────────────────┘
prev 与 next 形成完整闭环

4.2 结点的定义

typedef struct _Dnode {
Elemtype val;
struct _Dnode* next;
struct _Dnode* prev;
} D_nodelist;

4.3 基本操作实现(带头结点的双向循环链表)

#include <stdio.h>
#include <stdlib.h>

typedef int Elemtype;

typedef struct _Dnode {
Elemtype val;
struct _Dnode* next;
struct _Dnode* prev;
} D_nodelist;

// 初始化:头结点的 next 和 prev 都指向自身
void initdloopList(D_nodelist* head) {
head->val = 0; // 头结点不存有效数据
head->next = head;
head->prev = head;
}

// 通用添加结点函数:将 new_node 插入到 prev 和 next 之间
void addNode(D_nodelist* new_node, D_nodelist* next, D_nodelist* prev) {
next->prev = new_node;
new_node->next = next;
new_node->prev = prev;
prev->next = new_node;
}

// 头插:在头结点之后插入新结点
int insertHeadDLink(D_nodelist* head, Elemtype val) {
D_nodelist* new_node = malloc(sizeof(D_nodelist));
if (new_node == NULL) return -1;
new_node->val = val;
// 将新结点插入到 head 和 head->next 之间
addNode(new_node, head->next, head);
head->val++; // 头结点的 val 域用来记录结点个数
return 0;
}

// 尾插:在头结点之前(即最后一个结点之后)插入新结点
int GoBackDLink(D_nodelist* head, Elemtype val) {
D_nodelist* new_node = malloc(sizeof(D_nodelist));
if (new_node == NULL) return -1;
new_node->val = val;
// 将新结点插入到 head 和 head->prev 之间
addNode(new_node, head, head->prev);
head->val++;
return 0;
}

// 正向遍历:从头结点的下一个结点开始,直到回到头结点
void showDListV1(D_nodelist* head) {
D_nodelist* pos = head->next;
while (pos != head) {
printf("%d\\t", pos->val);
pos = pos->next;
}
printf("\\n");
}

// 反向遍历:从头结点的前一个结点(即尾结点)开始,直到回到头结点
void showDListV2(D_nodelist* head) {
D_nodelist* pos = head->prev;
while (pos != head) {
printf("%d\\t", pos->val);
pos = pos->prev;
}
printf("\\n");
}

// 通用删除函数:将 next 和 prev 两个结点直接相连
void deDList(D_nodelist* next, D_nodelist* prev) {
next->prev = prev;
prev->next = next;
}

// 删除第一个值为 e 的结点
void deleteDList(D_nodelist* head, Elemtype e) {
D_nodelist* pos = head->next;
// 查找值为 e 的结点
while (pos != head) {
if (pos->val == e) break;
pos = pos->next;
}
if (pos != head) {
// 将 pos 的前驱和后继直接相连
deDList(pos->next, pos->prev);
free(pos);
head->val–;
} else {
printf("NO FIND THIS ELEMENT!\\n");
}
}

// 释放整个链表(所有数据结点)
void release(D_nodelist* head) {
D_nodelist* pos = head->next;
while (pos != head) {
deDList(pos->next, pos->prev);
free(pos);
head->val–;
pos = head->next;
}
}

五、总结对比

5.1 时间复杂度对比

将上一章学习的顺序表与单向链表拿来做对比,我们可以清晰的看见时间复杂度

操作顺序表单向链表循环链表双向链表
按下标访问 O(1) O(n) O(n) O(n)
按值查找 O(n) O(n) O(n) O(n)
头部插入 O(n) O(1) O(1) O(1)
尾部插入 O(1) O(n) O(n) O(1)
中间插入 O(n) O(n) O(n) O(n)
删除(已知结点) O(n) O(n) O(n) O(1)
删除(已知前驱) O(n) O(1) O(1) O(1)

5.2 空间对比

结构存储密度说明
顺序表 ~100% 只存数据,无指针开销
单向链表 ~50% 每个结点多一个指针
循环链表 ~50% 与单链表相同
双向链表 ~33% 每个结点多两个指针

存储密度 = 数据域大小 / 结点总大小,以 int 数据 + 32位指针为例估算。

5.3 选型指南

场景:需要频繁按下标访问?
└── 是 → 顺序表(数组)

场景:需要频繁在头部插入/删除?
└── 是 → 链表(单/双/循环均可)

场景:需要频繁在尾部操作 + 需要从尾向头遍历?
└── 是 → 双向链表

场景:需要环形遍历(如轮转调度、约瑟夫环)?
└── 是 → 循环链表

场景:需要双向遍历 + 环形结构 + 任意位置O(1)删除?
└── 是 → 双向循环链表(工程首选)

场景:数据量未知或变化剧烈?
└── 是 → 链表
└── 否 → 顺序表

场景:内存紧张,数据量可预估?
└── 是 → 顺序表

5.4 一句话总结

结构核心特点
顺序表 连续存储,随机访问 O(1),增删需要移动元素
单向链表 指针串联,头插/删除 O(1),单向遍历
循环链表 尾结点回头,适合环形场景(约瑟夫环、轮转调度)
双向链表 双指针,支持双向遍历,删除不需要找前驱

以上就是关于循环链表、约瑟夫环以及双向循环链表的详细讲解与代码实现。

赞(0)
未经允许不得转载:171主机测评 » 数据结构学习之路(二)----循环链表与双向循环链表
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址