数据结构:双链表的全方位解构
双向循环链表像一群人手拉手围成圈:每个人既记得左边是谁、也记得右边是谁,从任意一人出发都能绕完一整圈,加人走人只需改两根手指。
核心思想
单链表最大的痛是找前驱 O(n)。双向链表给每个节点加一个 prev 指针,前驱后继都能 O(1) 拿到。再让链表首尾相连成环(尾节点的 next 指回头,头节点的 prev 指向尾),并加一个不存数据的哨兵头节点 phead,于是:
- 空表也有结构(哨兵自环),不用特判空表。
- 头插、尾插、头删、尾删全部统一为 O(1)——因为哨兵的
prev就是尾节点,哨兵的next就是第一个有效节点,O(1) 可达。 - 已知任意节点
pos,在它前后插入、删除它本身,都是 O(1)。
代价:每个节点多一个指针(空间换时间),且插入/删除时要改 4 个指针,顺序极易写错。
数据结构定义
typedef struct Listnode
{
LTDataType data;
struct Listnode *next; // 后继
struct Listnode *prev; // 前驱
}LTNode;
内存模型(带哨兵头节点 phead,存了 3 个有效数据 1,2,3):
┌──────────────────────────────────────────┐
↓ │
[phead] ⇄ [1] ⇄ [2] ⇄ [3] ⇄──────────────┘
↑ │
└──────────────────────────────────┘
哨兵不存数据,prev 指向尾节点(3),next 指向首节点(1)
尾节点(3) 的 next 指回 phead → 形成环
关键约定:
phead是哨兵位,phead->next是第一个有效节点,phead->prev是最后一个有效节点(尾)。- 空表判断:
phead->next == phead(哨兵自环,没有有效节点)。 - 遍历从
phead->next开始,到再次遇到phead结束。
关键操作实现
创建节点 LTbuynode
LTNode * LTbuynode(LTDataType x)
{
LTNode *node = (LTNode *)malloc(sizeof(LTNode));
if (node == NULL)
{
perror("malloc fail!");
exit(1);
}
node->data = x;
node->next = node->prev = node; // 新节点自环:next 和 prev 都指向自己
return node;
}
逐行解释:新节点初始时 next = prev = self(自环)。这是双向循环链表的「空状态」原子结构——一个自环节点本身就是一条合法的空环。后续插入时只需把它接到链里,不用单独初始化指针。
初始化 LTInit
LTNode * LTInit()
{
LTNode *phead = LTbuynode(-1); // 哨兵位,-1 是占位数据(永不读取)
return phead;
}
逐行解释:初始化就是建一个自环哨兵节点。返回值赋给调用者的头指针。哨兵的 data 用 -1 占位,约定永不读取哨兵的 data,所以放什么都行。
打印 LTprint
void LTprint(LTNode *phead)
{
LTNode *pcur = phead->next; // 从第一个有效节点开始
while (pcur != phead) // 转一圈回到哨兵就结束
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
逐行解释:从 phead->next 出发,顺着 next 走,直到再次回到 phead。这就是循环链表遍历的终止条件——不是 NULL,而是「回到起点」。
尾插 LTPushBack
void LTPushBack(LTNode *phead, LTDataType x)
{
assert(phead);
LTNode *newnode = LTbuynode(x);
// phead phead->prev(原尾) newnode
newnode->prev = phead->prev; // 新节点的前驱 = 原尾
newnode->next = phead; // 新节点的后继 = 哨兵(成环)
phead->prev->next = newnode; // 原尾的后继 = 新节点
phead->prev = newnode; // 哨兵的前驱 = 新节点(更新尾)
}
逐行解释:尾插 O(1),因为哨兵的 prev 直接给出原尾节点。要改 4 个指针:newnode->prev、newnode->next、原尾->next、phead->prev。
修改顺序的核心原则:先改新节点的两个指针(它还没接入,随便改不影响别人),再改旧节点的指针。具体说:
newnode->prev = phead->prev—— 此时phead->prev还指向原尾,安全。newnode->next = phead。phead->prev->next = newnode—— 让原尾指向新节点。必须在这步之前还没动phead->prev,否则原尾就找不到了。所以这步在phead->prev = newnode之前。phead->prev = newnode—— 最后更新哨兵的前驱为新尾。
陷阱:如果先执行 phead->prev = newnode,那 phead->prev->next(第3步)就变成了 newnode->next,原尾节点彻底丢失。所以「先改新、后改旧」「先连原尾、再更新哨兵」是铁律。
头插 LTPushFront
void LTPushFront(LTNode *phead, LTDataType x)
{
assert(phead);
// phead newnode phead->next(原首节点)
LTNode *newnode = LTbuynode(x);
newnode->prev = phead; // 新节点前驱 = 哨兵
newnode->next = phead->next; // 新节点后继 = 原首节点
phead->next->prev = newnode; // 原首节点的前驱 = 新节点
phead->next = newnode; // 哨兵的后继 = 新节点(更新头)
}
逐行解释:头插 O(1),哨兵的 next 直接给原首节点。同样 4 个指针,同样「先改新节点、再改旧节点」「先连原首、再更新哨兵」。
尾删 LTPopBack / 头删 LTPopFront
void LTPopBack(LTNode *phead)
{
assert(phead && phead->next != phead); // 不能删空表,也不能删哨兵
LTNode *del = phead->prev; // 待删的尾节点
// phead del->prev(新尾) del
del->prev->next = phead; // 新尾的后继 = 哨兵
phead->prev = del->prev; // 哨兵的前驱 = 新尾
free(del);
del = NULL;
}
void LTPopFront(LTNode *phead)
{
assert(phead && phead->next != phead);
LTNode *del = phead->next; // 待删的首节点
// phead del del->next(新首)
del->next->prev = phead; // 新首的前驱 = 哨兵
phead->next = del->next; // 哨兵的后继 = 新首
free(del);
del = NULL;
}
逐行解释:删除也是 O(1)。assert(phead->next != phead) 保证不删空表(空表时哨兵自环,next==phead,删了等于删哨兵,链表结构就毁了)。删的过程:让待删节点的前驱和后继直接相连(跨过 del),再 free(del)。
先连后断原则:先让 del 的邻居互连(del->prev->next = del->next 等),再 free(del)。如果先 free,就读不到 del->prev 和 del->next 了。
查找 LTFind
LTNode * LTFind(LTNode *phead, LTDataType x)
{
LTNode *pcur = phead->next;
while (pcur != phead) // 转一圈
{
if (pcur->data == x)
return pcur;
pcur = pcur->next;
}
return NULL;
}
逐行解释:遍历有效节点比较,找到返回节点指针(供 LTInsert/LTErase 用),找不到返回 NULL。
在 pos 之后插入 LTInsert
void LTInsert(LTNode *pos, LTDataType x)
{
assert(pos);
LTNode *newnode = LTbuynode(x);
// pos newnode pos->next(原后继)
newnode->prev = pos; // 新节点前驱 = pos
newnode->next = pos->next; // 新节点后继 = 原后继
pos->next = newnode; // pos 的后继 = 新节点
newnode->next->prev = newnode; // 原后继的前驱 = 新节点
}
逐行解释:在 pos 之后插,O(1)(双向链表前驱后继都在手)。注意第 4 步 newnode->next->prev = newnode:此时 newnode->next 已指向原后继,所以这步是让原后继的 prev 回指新节点。
顺序陷阱:第3步 pos->next = newnode 必须在第4步之后吗?不——第4步用的是 newnode->next(已存了原后继),不是 pos->next,所以即使第3步先改了 pos->next,第4步仍正确。但更稳的写法是先存 pos->next 到临时变量,避免依赖顺序记忆。
删除 pos 节点 LTErase
void LTErase(LTNode *pos)
{
assert(pos);
// pos->prev pos pos->next
pos->next->prev = pos->prev; // 后继的前驱 = pos 的前驱
pos->prev->next = pos->next; // 前驱的后继 = pos 的后继
free(pos); // 邻居互连后再删
pos = NULL; // 只置空形参(见下)
}
逐行解释:删除 O(1)。让 pos 的前驱和后继直接互连(跨过 pos),再 free(pos)。先连后断:两步互连必须都在 free 之前。
重要约定:pos 不能是哨兵 phead,否则删了哨兵整个链表结构就废了。调用者必须保证不传哨兵。函数内 pos = NULL 只置空形参,外部持有的指针仍悬空,需调用者自行置空。
销毁 LTDestroy
void LTDestroy(LTNode *phead)
{
assert(phead);
LTNode *pcur = phead->next;
while (pcur != phead) // 遍历有效节点
{
LTNode *next = pcur->next; // 先存下一个
free(pcur);
pcur = next;
}
free(phead); // 最后释放哨兵本身
phead = NULL; // ⚠️ 只置空形参
}
逐行解释:遍历释放所有有效节点,最后释放哨兵 phead。同样「先存 next 再 free」防 UAF。
陷阱:phead = NULL 只改了形参,调用者手里的头指针仍指向已释放的哨兵内存(悬空指针)。和 LTErase 一样,调用者必须自己 plist = NULL。这是「传一级指针无法改外部指针」的老问题——如果要彻底解决,LTDestroy 应改成传二级指针 LTNode**。
复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插 / 尾插 | O(1) | 哨兵 prev/next 直接给位置 |
| 头删 / 尾删 | O(1) | 同上 |
| 在 pos 处插入 / 删除 pos | O(1) | 双向,前驱后继都在手 |
| 按值查找 | O(n) | 仍需遍历 |
| 销毁 | O(n) | 逐个释放 |
对比单链表:单链表尾插尾删 O(n)(找前驱),双向循环链表全 O(1)。代价是每个节点多 8 字节(一个 prev 指针)。
常见陷阱清单
- 4 个指针修改顺序错 → 断链,最常见也最难调。口诀:「先改新节点两指针,再连旧邻居,最后更新哨兵」。
- 先
free后连邻居 → UAF,读不到前驱后继。 LTErase传了哨兵phead→ 删了哨兵,结构崩塌。LTDestroy/LTErase后不置空外部指针 → 悬空指针。- 空表判断写错 → 应是
phead->next == phead,写成phead == NULL(哨兵永不为空,永远判非空)。 - 哨兵 data 被误读 → 占位值无意义,遍历应跳过哨兵。
我的实现 vs 教科书实现
- 设计正确:哨兵 + 双向 + 循环,结构标准,头尾插删全 O(1)。
- 代码复用不足:
LTPushBack/LTPushFront完全可以调用LTInsert(phead, x)/LTInsert(phead->prev... )复用,当前各写一遍,有重复。 LTDestroy置空无效:传一级指针无法改外部头指针,应改二级指针或要求调用者手动置空。- 缺接口:没有
LTSize、LTEmpty(判空)、LTClear(清空不销毁)。 - vs
std::list:C++ 标准库的list就是这种结构,额外提供迭代器、splice(O(1) 拼接)、size维护。
拓展知识点
变体与进阶
- Linux 内核链表:反向设计——把链表节点(
struct list_head)嵌入到业务结构体里,而非把业务数据放进链表节点。这样一套链表代码能挂任意类型,极致复用。 - LRU Cache:HashMap + 双向链表,O(1) 访问 + O(1) 淘汰。访问一个节点就移到链表头,满了删尾。
- 不带头双向链表:去掉哨兵,但头插删要特判空表,代码稍繁。
常见考点
- 双向链表插入/删除 4 个指针的顺序(口诀见上)。
- 哨兵位的作用:统一边界,免特判。
- 为何循环 + 哨兵能让头尾操作 O(1):哨兵 prev/next 直接定位首尾。
与其他结构的关系
双向链表是单链表的升级版(加 prev),常用来实现队列、栈、LRU、调度队列。std::deque(双端队列)底层用分块的双向链表/数组组合。和单链表、顺序表同属线性表家族。
一句话记忆法
双向循环链表:哨兵是圆心,首尾在隔壁,插删都 O(1),改 4 指针先新后旧、先连后断,空表看 next==phead。
2739



被折叠的 条评论
为什么被折叠?



