1. 什么是双向链表?
双向链表是一种链式存储结构,每个结点包含三个部分:存储数据的域、指向下一个结点的指针、指向上一个结点的指针。与单链表相比,双向链表可以双向遍历,在任意位置插入和删除更加灵活。
而我们今天要实现的是 带头双向循环链表,它有三个特点:
-
带头:有一个哨兵位结点(head),它不存储有效数据,只作为“头结点”方便操作。
-
双向:每个结点有 next 和 prev 指针。
-
循环:尾结点的 next 指向头结点,头结点的 prev 指向尾结点,形成一个环。
这种结构的最大优势是:不管在哪个位置插入或删除,都不需要移动元素,只需修改指针。
2. 结点结构定义
typedef int LTDataType; // 方便修改存储的数据类型
typedef struct ListNode {
LTDataType data; // 数据域
struct ListNode* next; // 指向下一个结点
struct ListNode* prev; // 指向上一个结点
} LTNode;
示意图:
+——–+ +——–+ +——–+
| prev |<—->| prev |<—->| prev |
| data | | data | | data |
| next |—->| next |—->| next |
+——–+ +——–+ +——–+
head tail
^ |
+——————————-+
3. 创建新结点
每个新结点被创建时,我们需要手动分配内存,并让它的 next 和 prev 先指向自己(循环初始状态)。
LTNode* LTBuyNode(LTDataType x) {
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL) {
perror("malloc fail!");
exit(1);
}
newnode->data = x;
newnode->next = newnode->prev = newnode; // 自己指向自己,形成循环
return newnode;
}
⚠️ 坑点1:一定要检查 malloc 是否成功,否则后续对空指针操作会崩溃。
4. 初始化链表
有两种常见的初始化方式,我们对比一下。
方式一:传二级指针
void LTInit(LTNode** pphead) {
assert(pphead);
*pphead = LTBuyNode(-1); // 哨兵位,数据任意
}
调用时:LTInit(&plist);
方式二:返回头结点(推荐,保持接口一致性)
LTNode* LTInit() {
LTNode* phead = LTBuyNode(-1);
return phead;
}
调用时:LTNode* plist = LTInit();
⚠️ 坑点2:为什么推荐方式二? 因为链表其他操作(增删改查)都只需要传一级指针(头结点地址不会改变)。如果初始化用二级指针,而销毁时可能又用一级指针,导致接口风格不一致,增加学习成本。
5. 打印链表
注意:哨兵位不存储有效数据,所以从 phead->next 开始打印,直到回到 phead 停止。
void LTPrint(LTNode* phead) {
LTNode* pcur = phead->next;
while (pcur != phead) {
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("\\n");
}
图解: 假设链表有 1,2,3 三个结点:
phead -> [哨兵] <-> [1] <-> [2] <-> [3] <-> [哨兵]
^ |
+——————————-+
打印结果:1 -> 2 -> 3 ->
6. 尾插(LTPushBack)
在哨兵位的前一个位置(即最后一个有效结点)后面插入新结点。
步骤:
创建新结点 newnode。
新结点的 prev 指向当前尾结点 phead->prev。
新结点的 next 指向头结点 phead。
原尾结点的 next 指向新结点。
头结点的 prev 指向新结点。
void LTPushBack(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->prev = phead->prev; // 新结点的prev指向原尾结点
newnode->next = phead; // 新结点的next指向头结点
phead->prev->next = newnode; // 原尾结点的next指向新结点
phead->prev = newnode; // 头结点的prev指向新结点
}
示意图(空链表时):
插入前:
phead -> [哨兵] <-> [哨兵] (自己指向自己)
插入1后:
[哨兵] <-> [1] <-> [哨兵]
^ |
+—————+
⚠️ 坑点3:修改指针的顺序很重要!如果先把 phead->prev 改了,就会丢失原尾结点的地址。正确的顺序是:先让新结点连接旧链表,再断开旧链表的连接。
7. 头插(LTPushFront)
在哨兵位之后(第一个有效结点之前)插入新结点。
步骤:
新结点的 next 指向原第一个结点 phead->next。
新结点的 prev 指向头结点 phead。
原第一个结点的 prev 指向新结点。
头结点的 next 指向新结点。
void LTPushFront(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
8. 判空
链表为空时,只有哨兵位自己循环:phead->next == phead。
bool LTEmpty(LTNode* phead) {
assert(phead);
return phead->next == phead;
}
9. 尾删(LTPopBack)
删除最后一个有效结点。注意:链表不能为空(哨兵位不能删)。
步骤:
找到尾结点 del = phead->prev。
将倒数第二个结点 del->prev 的 next 指向头结点。
头结点的 prev 指向倒数第二个结点。
释放 del。
void LTPopBack(LTNode* phead) {
assert(!LTEmpty(phead)); // 空链表不能删
LTNode* del = phead->prev;
del->prev->next = phead;
phead->prev = del->prev;
free(del);
del = NULL;
}
⚠️ 坑点4:释放结点后一定要将局部指针置 NULL,虽然函数结束后指针会销毁,但良好的习惯能避免误用。
10. 头删(LTPopFront)
删除第一个有效结点。
void LTPopFront(LTNode* phead) {
assert(!LTEmpty(phead));
LTNode* del = phead->next;
del->next->prev = phead;
phead->next = del->next;
free(del);
del = NULL;
}
11. 查找
遍历链表,返回第一个匹配数据的结点地址,找不到返回 NULL。
LTNode* LTFind(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead) {
if (pcur->data == x)
return pcur;
pcur = pcur->next;
}
return NULL;
}
12. 在指定位置之后插入(LTInsert)
这是最通用的插入函数,可以替代头插和尾插。
步骤(在 pos 之后插入):
新结点的 next 指向 pos->next。
新结点的 prev 指向 pos。
pos->next->prev 指向新结点。
pos->next 指向新结点。
void LTInsert(LTNode* pos, LTDataType x) {
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->next = newnode;
}
如何使用:
-
尾插:LTInsert(phead->prev, x); // 在尾结点之后插入(实际上是在头结点之前?注意循环)
-
头插:LTInsert(phead, x); // 在哨兵位之后插入
⚠️ 坑点5:pos 不能为 NULL。另外,如果要在指定位置 之前 插入,可以用 LTInsert(pos->prev, x) 实现,但一般不单独提供函数。
13. 删除指定位置结点(LTErase)
删除 pos 位置的结点,pos 不能是哨兵位。
步骤:
pos->prev->next 指向 pos->next。
pos->next->prev 指向 pos->prev。
释放 pos。
void LTErase(LTNode* pos) {
assert(pos);
pos->next->prev = pos->prev;
pos->prev->next = pos->next;
free(pos);
pos = NULL;
}
14. 销毁链表
销毁所有结点(包括哨兵位)。这里有两种接口设计。
方式一:传二级指针(销毁后自动置NULL)
void LTDesTroy(LTNode** pphead) {
LTNode* pcur = (*pphead)->next;
while (pcur != *pphead) {
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(*pphead);
*pphead = NULL;
}
方式二:传一级指针(需要调用者手动置NULL)—— 推荐
void LTDesTroy(LTNode* phead) {
LTNode* pcur = phead->next;
while (pcur != phead) {
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
free(phead);
// 注意:这里无法将外部 plist 置为 NULL,需要调用者手动做
}
调用方式:
LTNode* plist = LTInit();
// … 各种操作
LTDesTroy(plist);
plist = NULL; // 必须手动置NULL,否则变成野指针
⚠️ 坑点6:为什么推荐传一级指针? 因为链表的其他所有操作都只需要一级指针(头结点地址不变),如果销毁突然要求二级指针,接口不统一,容易混淆。传一级指针,让调用者负责置 NULL,是一种更清晰的约定。
15. 常见坑点总结
| 空指针访问 | 未初始化就操作链表 | 总是 assert(phead) |
| 内存泄漏 | 删除结点后没有 free | free 后置 NULL |
| 指针修改顺序错误 | 先断开原链接导致丢失地址 | 先接上新结点,再断开旧链接 |
| 误删哨兵位 | LTErase(phead) 导致崩溃 | 在删除函数中检查 pos != phead |
| 销毁后野指针 | 只释放内存,不置 NULL | 调用者手动 plist = NULL |
| 空链表删除 | 对空链表进行 Pop 操作 | 调用 LTEmpty 检查 |
16. 顺序表 vs 双向链表 对比
| 存储空间 | 物理连续 | 物理不连续,逻辑连续 |
| 随机访问 | O(1) | O(n) |
| 插入/删除 | 需要移动元素,O(n) | 只需改指针,O(1)(已知位置) |
| 空间利用率 | 需要预分配,可能浪费 | 按需申请,无浪费 |
| 缓存友好 | 高 | 低 |
| 适用场景 | 频繁访问、元素高效存储 | 频繁插入删除、大小动态变化 |
17. 完整代码示例
List.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int LTDataType;
typedef struct ListNode {
LTDataType data;
struct ListNode* next;
struct ListNode* prev;
} LTNode;
// 初始化
LTNode* LTInit();
// 销毁
void LTDesTroy(LTNode* phead);
// 打印
void LTPrint(LTNode* phead);
// 判空
bool LTEmpty(LTNode* phead);
// 尾插 / 头插
void LTPushBack(LTNode* phead, LTDataType x);
void LTPushFront(LTNode* phead, LTDataType x);
// 尾删 / 头删
void LTPopBack(LTNode* phead);
void LTPopFront(LTNode* phead);
// 查找
LTNode* LTFind(LTNode* phead, LTDataType x);
// 在 pos 之后插入 / 删除 pos
void LTInsert(LTNode* pos, LTDataType x);
void LTErase(LTNode* pos);
List.c(前面已给出,略)
test.c(示例)
#include "List.h"
int main() {
LTNode* plist = LTInit();
LTPushBack(plist, 10);
LTPushBack(plist, 20);
LTPushFront(plist, 5);
LTPrint(plist); // 5 -> 10 -> 20 ->
LTNode* pos = LTFind(plist, 10);
if (pos) LTInsert(pos, 15);
LTPrint(plist); // 5 -> 10 -> 15 -> 20 ->
LTErase(pos);
LTPrint(plist); // 5 -> 15 -> 20 ->
LTPopBack(plist);
LTPopFront(plist);
LTPrint(plist); // 15 ->
LTDesTroy(plist);
plist = NULL;
return 0;
}
18.最后
带头双向循环链表是数据结构中“最复杂也最简单”的结构:结构定义复杂,但一旦写好插入删除的核心逻辑,其他操作都是对它的简单包装。理解指针的指向变化和循环特性是关键。
如果你能手动画出每一步的指针连接图,调试时就能轻松定位问题。希望这篇文章能帮你彻底搞懂双向链表!
更多链表刷题推荐:
-
LeetCode 链表专题
-
牛客网 链表专项


![第5章,[Win32 章节] :边框绘制函数(六)-171主机测评](https://www.171host.com/wp-content/uploads/2026/08/20260822144238-6a89b55eb002b-220x150.png)