一、双向链表
1.1 什么是双向链表
双向链表(Doubly Linked List)的每个节点包含三个部分:
-
指向前驱节点的指针(prev)
-
数据域(data)
-
指向后继节点的指针(next)
text
head ↔ [prev|data|next] ↔ [prev|data|next] ↔ [prev|data|next] ↔ NULL
优点:
-
可以从前往后遍历,也可以从后往前遍历
-
删除节点时,不需要知道前驱节点(通过prev直接找到)
-
插入操作更灵活
缺点:
-
每个节点多了一个指针,占用更多内存
-
操作时多维护一个指针,代码稍复杂
1.2 节点结构定义
c
typedef struct DNode {
int data;
struct DNode *prev; // 指向前驱
struct DNode *next; // 指向后继
} DNode, *PDNode;
typedef struct {
PDNode head; // 头结点(哑结点)
int size;
} DList;
和单链表一样,我们继续使用带头结点的方式,让操作更统一。
1.3 初始化与销毁
c
void initDList(DList *list) {
list->head = (PDNode)malloc(sizeof(DNode));
if (list->head == NULL) {
printf("初始化失败\n");
exit(1);
}
list->head->prev = NULL;
list->head->next = NULL;
list->size = 0;
}
void destroyDList(DList *list) {
PDNode cur = list->head;
while (cur != NULL) {
PDNode temp = cur;
cur = cur->next;
free(temp);
}
list->head = NULL;
list->size = 0;
}
1.4 创建新节点
c
PDNode createDNode(int value) {
PDNode newNode = (PDNode)malloc(sizeof(DNode));
if (newNode == NULL) return NULL;
newNode->data = value;
newNode->prev = NULL;
newNode->next = NULL;
return newNode;
}
1.5 插入操作
双向链表的插入比单链表简单,因为可以同时操作前后指针。
在指定位置插入:
c
int insertAt(DList *list, int pos, int value) {
if (pos < 0 || pos > list->size) {
printf("插入位置不合法\n");
return -1;
}
PDNode newNode = createDNode(value);
if (newNode == NULL) return -1;
// 找到要插入位置的原节点(pos位置的节点)
PDNode cur = list->head;
for (int i = 0; i < pos; i++) {
cur = cur->next;
}
// 此时cur是要插入位置的前一个节点(头结点或上一个节点)
PDNode next = cur->next; // 原来pos位置的节点
// 连接新节点
newNode->prev = cur;
newNode->next = next;
cur->next = newNode;
if (next != NULL) {
next->prev = newNode;
}
list->size++;
return 0;
}
头插法(在第一个有效节点前插入):
c
void insertAtHead(DList *list, int value) {
insertAt(list, 0, value);
}
尾插法:
c
void insertAtTail(DList *list, int value) {
insertAt(list, list->size, value);
}
1.6 删除操作
双向链表删除节点时,不需要遍历找前驱,直接通过当前节点的prev就能拿到。
c
int deleteAt(DList *list, int pos) {
if (pos < 0 || pos >= list->size) {
printf("删除位置不合法\n");
return -1;
}
// 找到要删除的节点
PDNode cur = list->head->next;
for (int i = 0; i < pos; i++) {
cur = cur->next;
}
PDNode prev = cur->prev;
PDNode next = cur->next;
// 断开连接
prev->next = next;
if (next != NULL) {
next->prev = prev;
}
int value = cur->data;
free(cur);
list->size--;
return value;
}
1.7 遍历(正向和反向)
c
void printForward(DList *list) {
PDNode cur = list->head->next;
printf("正向 size=%d, [", list->size);
while (cur != NULL) {
printf("%d", cur->data);
if (cur->next != NULL) printf(" <-> ");
cur = cur->next;
}
printf("]\n");
}
void printBackward(DList *list) {
// 先找到尾节点
PDNode cur = list->head;
while (cur->next != NULL) {
cur = cur->next;
}
printf("反向 [");
while (cur != list->head) {
printf("%d", cur->data);
if (cur->prev != list->head) printf(" <-> ");
cur = cur->prev;
}
printf("]\n");
}
1.8 完整演示
c
int main() {
DList list;
initDList(&list);
insertAtTail(&list, 10);
insertAtTail(&list, 20);
insertAtTail(&list, 30);
printForward(&list); // [10 <-> 20 <-> 30]
insertAtHead(&list, 5);
printForward(&list); // [5 <-> 10 <-> 20 <-> 30]
insertAt(&list, 2, 15);
printForward(&list); // [5 <-> 10 <-> 15 <-> 20 <-> 30]
deleteAt(&list, 2);
printForward(&list); // [5 <-> 10 <-> 20 <-> 30]
printBackward(&list); // [30 <-> 20 <-> 10 <-> 5]
destroyDList(&list);
return 0;
}
二、循环链表
2.1 什么是循环链表
循环链表(Circular Linked List)是首尾相连的链表:
-
单向循环链表:尾节点的next指向头结点
-
双向循环链表:尾节点的next指向头结点,头结点的prev指向尾节点
text
单向循环链表:
head → [节点1] → [节点2] → [节点3] → ... → [节点n] ↘
↑_______________________________________________↙
双向循环链表:
head ↔ [节点1] ↔ [节点2] ↔ ... ↔ [节点n] ↔ head
特点:
-
从任意节点出发都能遍历整个链表
-
适合需要循环处理的场景
2.2 单向循环链表的实现
c
typedef struct CNode {
int data;
struct CNode *next;
} CNode, *PCNode;
typedef struct {
PCNode head; // 头指针(带头结点)
int size;
} CList;
初始化(头结点的next指向自己,形成空循环):
c
void initCList(CList *list) {
list->head = (PCNode)malloc(sizeof(CNode));
if (list->head == NULL) {
printf("初始化失败\n");
exit(1);
}
list->head->next = list->head; // 指向自己,表示空链表
list->size = 0;
}
插入(注意尾节点要指向头结点):
c
void insertAtTail(CList *list, int value) {
PCNode newNode = (PCNode)malloc(sizeof(CNode));
newNode->data = value;
// 找到尾节点(next指向头结点的节点)
PCNode cur = list->head;
while (cur->next != list->head) {
cur = cur->next;
}
cur->next = newNode;
newNode->next = list->head;
list->size++;
}
遍历 (判断结束条件是 cur->next != list->head):
c
void printCList(CList *list) {
if (list->size == 0) {
printf("空链表\n");
return;
}
PCNode cur = list->head->next;
printf("[");
while (cur != list->head) {
printf("%d", cur->data);
if (cur->next != list->head) printf(" -> ");
cur = cur->next;
}
printf("] -> (回到头)\n");
}
三、经典应用:约瑟夫环
3.1 问题描述
约瑟夫环问题:n个人围成一圈,从第一个人开始报数,数到m的人出列,然后从下一个人重新报数,直到所有人出列。求出列顺序。
3.2 思路分析
用循环链表非常合适:
-
创建一个包含n个节点的循环链表
-
从某个节点开始遍历,每数到m就删除当前节点
-
继续从下一个节点数,直到链表为空
3.3 代码实现
c
#include <stdio.h>
#include <stdlib.h>
typedef struct JosephNode {
int num; // 编号
struct JosephNode *next;
} JosephNode;
// 创建约瑟夫环
JosephNode* createJosephus(int n) {
if (n <= 0) return NULL;
JosephNode *head = (JosephNode*)malloc(sizeof(JosephNode));
head->num = 1;
head->next = NULL;
JosephNode *tail = head;
for (int i = 2; i <= n; i++) {
JosephNode *newNode = (JosephNode*)malloc(sizeof(JosephNode));
newNode->num = i;
newNode->next = NULL;
tail->next = newNode;
tail = newNode;
}
tail->next = head; // 形成环
return head;
}
// 约瑟夫环求解
void josephus(JosephNode *head, int m) {
if (head == NULL) return;
JosephNode *cur = head;
JosephNode *prev = NULL;
// 找到尾节点(作为prev的初始值)
prev = cur;
while (prev->next != head) {
prev = prev->next;
}
int count = 1;
while (cur->next != cur) { // 只剩一个节点时停止
if (count == m) {
// 删除当前节点
printf("%d ", cur->num);
prev->next = cur->next;
free(cur);
cur = prev->next;
count = 1; // 重置计数
} else {
prev = cur;
cur = cur->next;
count++;
}
}
// 输出最后一个
printf("%d\n", cur->num);
free(cur);
}
int main() {
int n = 7, m = 3;
printf("%d个人,数到%d出列,顺序:", n, m);
JosephNode *head = createJosephus(n);
josephus(head, m);
return 0;
}
运行结果:
text
7个人,数到3出列,顺序:3 6 2 7 5 1 4
四、双向链表 vs 单链表
| 操作 | 单链表 | 双向链表 |
|---|---|---|
| 头插 | O(1) | O(1) |
| 尾插 | O(n) | O(1)(维护尾指针) |
| 中间插入 | O(n) | O(n)(但不用找前驱) |
| 删除已知节点 | O(n)(需要前驱) | O(1) |
| 反向遍历 | 不支持 | O(n) |
| 内存占用 | 1个指针/节点 | 2个指针/节点 |
适用场景:
-
单链表:简单、省内存,适合单向遍历
-
双向链表:需要双向遍历、频繁删除已知节点的场景
五、小结
这一篇我们讲了:
| 结构 | 特点 | 应用 |
|---|---|---|
| 双向链表 | 每个节点有prev和next,删除更高效 | 需要双向遍历的场景 |
| 循环链表 | 首尾相连,从任意点可遍历 | 约瑟夫环、环形缓冲区 |
重点:
-
双向链表的插入删除要同时维护prev和next
-
循环链表的遍历结束条件不再是NULL,而是回到头结点
-
约瑟夫环是循环链表的经典应用
下一篇我们会讲栈和队列,它们是受限的线性表,但在很多场景下非常有用。
六、思考题
-
双向链表的插入操作中,如果插入位置是末尾,需要注意什么?
-
用双向循环链表实现约瑟夫环,和用单向循环链表有什么区别?
-
为什么双向链表删除节点时不需要找前驱节点?
-
尝试实现一个双向链表的反转函数。