双向循环链表详解(C 语言手写实现):一个头结点,怎么同时解决头插、尾插和反向遍历
本文代码来自我的数据结构练习工程
dslearn/List,Visual Studio 2022 + C 语言实现。源码文件用 GBK 编码保存,本文展示时已转为 UTF-8。
一、先说问题:单链表卡在哪
先看一眼单链表尾插的实现(List.cpp):
cpp
//尾插
void ListPushBack(LNode* L, LDataType x)
{
LNode* tail = L;
while (tail->next != NULL) // ← 必须一路走到尾
{
tail = tail->next;
}
tail->next = BuyListNode(x);
}
想往末尾加一个元素,得从头开始一步步走到尾。链表有 n 个元素,这次插入就是 n 次指针跳转,O(n) 。尾删(ListPopBack)同理,也是 O(n)。
单链表还有两个更硬的限制:
- 没法反着走 。每个结点只有
next,一旦走到尾就是NULL,你没有任何线索回到头。想反向遍历只能从头再走一遍,或者把链表反转(这需要 O(n) 的额外空间)。 - 已知结点地址也没法在它前面插 。你手里拿着
p,想把新结点插到p前面,你不知道p的前驱是谁。
这两个问题的根源是同一个:单链表的结点只知道"我后面是谁",不知道"我前面是谁",也不知道"我是不是最后一个"。
二、双向循环链表的结构
解决办法是给每个结点装两个指针:prev(前驱)和 next(后继)。
cpp
typedef int DCLDataType;
typedef struct DCListNode {
DCLDataType data; // 存储数据元素的值
struct DCListNode* prev; // 存放前驱结点的指针
struct DCListNode* next; // 存放后继结点的指针
}DCListNode;
再在前面加一个不存业务数据的头结点 L,然后把链表首尾接起来,形成一个环。
空表长什么样
关键点来了 ------ 空的时候,L 的 prev 和 next 都指向它自己:
空表(整条链上只有头结点自己):
┌─────────────┐
│ 头结点 L │ data = -1(哨兵值,不参与业务)
└─────────────┘
↺ ↺
L->prev L->next 都指回 L 自己
这就是"循环 "二字的含义:链表没有 NULL 结尾,绕一圈会回到头结点。
为什么空表要让指针指回自己?因为这样空表和非空表就长得一模一样了,代码里不需要写"如果空表就特判"这种分支。你看初始化:
cpp
//初始化
DCListNode* DCListInit()
{
DCListNode* L = BuyDCListNode(-1);
assert(L);
//空的循环链表:头结点的后继和前驱都指向自己
L->next = L;
L->prev = L;
return L;
}
头结点一初始化成自环,后面所有操作都只有一套代码。这叫哨兵结点技巧。
三个结点时是什么样
非空表(元素依次是 1, 2, 3):
┌─────┐ ┌───┐ ┌───┐ ┌───┐
│ L │ ⇄ │ 1 │ ⇄ │ 2 │ ⇄ │ 3 │
└─────┘ └───┘ └───┘ └───┘
头结点
L->next → 1 1->prev → L ← 正向走
L->prev → 3 3->next → L ← 反向走
1->next → 2 2->prev → 1
2->next → 3 3->prev → 2
读这张图时请注意两件事:
- 头结点 L 的
next指向第一个数据结点(1) 。所以第一个数据结点的前驱是头结点,不是NULL。 - 头结点 L 的
prev指向最后一个数据结点(3) 。所以尾结点的后继是头结点,不是NULL。
头结点站在环的两端,它既是"1 号的前面",也是"3 号的后面"。
三、本文的核心:头结点是一个万能锚点
现在来看整篇文章最值钱的地方 ------ DCListInsert 只有一份代码,却能表达"在任意结点后面插入":
cpp
// 在pos位置后插入值为x的结点
void DCListInsert(DCListNode* pos, DCLDataType x)
{
assert(pos);
DCListNode* newNode = BuyDCListNode(x);
assert(newNode);
newNode->prev = pos;
newNode->next = pos->next; //① 先记住pos原来的后继
pos->next->prev = newNode; //② 此时pos->next还是原后继,把它回指给新结点
pos->next = newNode; //③ 最后才改pos的后继
}
就这四行。把 pos 换成不同的结点,同一段代码就自动变成不同语义:
传入的 pos |
插入位置 | 实际效果 |
|---|---|---|
L(头结点) |
头结点的后面 | 头插 |
L->next |
第 0 个数据结点后面 | 在第 1 位插入 |
某个数据结点 p |
p 的后面 |
在 p 位后面插入 |
L->prev(尾结点) |
尾结点的后面 | 尾插 |
因为环是闭的,"尾结点的后面"就是"头结点的前面",这两句话说的是同一个位置。指针操作天然就兼容了这两种特例,一行 if 都不用加。
所以 DCListPushFront 就退化成了薄薄一层包装:
cpp
// 头插
void DCListPushFront(DCListNode* L, DCLDataType x)
{
assert(L);
//头结点之后插入 == 头插
DCListInsert(L, x);
}
对比一下单链表里头插和尾插是两段完全不同的代码 ------ 头插 O(1),尾插要一个 while。这里统一成了"在头结点后面插入",也就是"头插"。
尾插 O(1):双链表真正值钱的地方
尾插虽然也能用 DCListInsert(L->prev, x) 一行搞定,但为了让"循环结构"更直白,工程里是手写了这三行:
cpp
// 尾插
void DCListPushBack(DCListNode* L, DCLDataType x)
{
assert(L);
DCListNode* newNode = BuyDCListNode(x);
assert(newNode);
newNode->next = L; //① 循环的"出口"永远回到头结点
newNode->prev = L->prev;
L->prev->next = newNode; //② 让原来的尾结点后继指向新结点
L->prev = newNode; //③ 让头结点的前驱指向新结点
}
没有任何循环 。因为 L->prev 一直就是尾结点,尾插就是"把新结点挂到 L->prev 和 L 之间",一次定位,零次遍历。这就是 O(1)。
对比单链表尾插的 4 行代码 ------ 一边是"一路 while 走到尾",一边是"直接取 L->prev",这就是双链表相对单链表最直接的性能提升。
四、插入的三步,顺序绝对不能换
这是新手最容易踩的坑,我第一次写也写反过。上面 DCListInsert 里那三行注释标了 ①②③,顺序是有讲究的。
先看正确的效果。假设链表是 L → A ⇄ B(B 是尾),现在要在 A 后面插入 x:
插入前:
┌───┐ ┌───┐ ┌─────┐
│ L │ ⇄ │ A │ ⇄ │ B │
└───┘ └───┘ └─────┘
插入后:
┌───┐ ┌───┐ ┌───┐ ┌─────┐
│ L │ ⇄ │ A │ ⇄ │ x │ ⇄ │ B │
└───┘ └───┘ └───┘ └─────┘
对照代码逐行看,每一行执行后 pos->next 是什么值:
| 步骤 | 执行 | pos->next 的值 |
|---|---|---|
| 没执行 | --- | B(原后继) |
| ① | newNode->next = pos->next; |
还是 B(只是被复制了一份给新结点) |
| ② | pos->next->prev = newNode; |
还是 B(此时 pos->next 依然是原后继 B,所以这行等于 B->prev = newNode) |
| ③ | pos->next = newNode; |
变成 newNode(此刻才改) |
② 和 ③ 换一下会怎样? 假设写成 pos->next = newNode 在前:
-
pos->next = newNode;→A->next = x,B 被顶掉了 -
newNode->next = pos->next;→ 这时pos->next已经是x了,所以x->next = x,新结点自己指自己 -
pos->next->prev = newNode;→x->prev = x(本来就是x)写反了的后果:
┌───┐ ┌─────┐ ┌─────┐ │ L │ ⇄ │ B │ + │ x │ B 完全脱离链表 └───┘ └─────┘ └─────┘ ↺ x 的 next 和 prev 都指向自己 从 L 走到 x 就再也走不出去了 ------ 死循环,同时 B 内存泄漏
所以记住这条规律:"记住原值"和"覆盖原值"这两类操作,原值必须在覆盖之前取出来。 这跟单链表 ListInsert 里的 newNode->next = i_1Node->next; i_1Node->next = newNode; 是同一个道理,只是双链表多了一步"改回去",所以顺序更敏感。
五、遍历终止:没有 NULL,就用"走回头结点"
这是循环链表和普通链表差别最大的地方。
单链表遍历靠 cur != NULL:
cpp
while (cur != NULL) // 单链表:撞到 NULL 就停
循环链表永远撞不到 NULL(除非链表被写坏了),所以只能靠 cur != L 判断"我是不是绕回起点了":
cpp
// 获取链表的下标i的结点
// 位序从0开始;空链表或下标越界返回NULL
DCListNode* DCListGetElem(DCListNode* L, int i)
{
assert(L);
assert(i >= 0);
int j = 0;
DCListNode* cur = L->next; //从头结点的后继开始走
while (j < i && cur != L) // 走出圈了就停
{
cur = cur->next;
j++;
}
if (cur == L)
{
//两种情况都到这里:链表本来是空的,或者下标i超出了实际长度
return NULL;
}
return cur;
}
注意 while 的条件是 j < i && cur != L,两个条件缺一不可:
j < i------ 还没数够下标,继续走cur != L------ 还没绕回头结点,说明链里还有数据
只要任意一个 条件不成立就停下来。停下来之后 cur == L 这个检查巧妙地合并了两种失败原因:链表本来是空的(下标 0 就撞上 L),或者下标太大(走完全部数据又撞回 L)。两种情况一律返回 NULL,调用方不用区分。
销毁链表也是同样的原理 ------ 从 L->next 出发,一直走到撞回 L 为止:
cpp
void DCListDestroy(DCListNode* L)
{
assert(L);
DCListNode* cur = L->next; // 必须从第一个结点开始,不然循环条件进不去
while (cur != L)
{
DCListNode* next = cur->next; // 存下下个结点地址,再释放当前结点
free(cur);
cur = next;
}
free(L); // 头结点最后单独释放
}
注释里那句"必须从第一个结点开始"也是踩过坑才写上的 ------ 如果写成 cur = L,那 while (cur != L) 一开始就不成立,循环体一次都不执行,整个链表直接内存泄漏。
六、删除:为什么禁止传入头结点
cpp
// 删除pos位置的结点
void DCListDelete(DCListNode* pos)
{
assert(pos);
pos->prev->next = pos->next; // 让前驱和后继互相跨过 pos
pos->next->prev = pos->prev;
free(pos);
}
两行摘链,一行释放,都很干净。但是它的参数只有 pos,没有 L。
假设你手滑,把头结点传进去了(DCListDelete(L)):
pos->prev就是L自己,pos->next也是L自己- 于是执行成
L->next = L; L->prev = L;------ 链表被清空成"空表"状态 - 然后
free(L)把头结点释放了
但调用方的 L 指针还指向那块已经被释放的内存。 下一次调 DCListPrint(L) 就会访问野指针。这是实打实的 UB,不是"报错但还能跑"的那种。
更麻烦的是,接口内部没有任何办法自己校验 ------ 它只拿到一个 pos,无从判断这个指针是不是头结点。
那怎么保证调用方不传错?靠两个接口的配合。回到 DCListGetElem:它在越界时返回 NULL,永远不可能返回头结点 L (因为判断条件是 cur != L)。所以菜单层拿到的一定是个数据结点:
cpp
// DCListDelete禁止传入头结点,而DCListGetElem永远不会返回头结点
//(越界时返回NULL),所以这里的pos一定是个数据结点,可以放心传
DCListNode* pos = DCListGetElem(L, i);
if (pos == NULL) { /* 越界,直接退出 */ }
DCListDelete(pos);
这种"一个接口的失败输出正好落在另一个接口的危险输入之外"的配合,比在每个调用点写 if 判断更可靠,也更省事。
顺带说,头删尾删就变成了一行:
cpp
// 头删
void DCListPopFront(DCListNode* L)
{
assert(L);
assert(L->next != L); // 空链表不能头删(L->next == L 说明是空表)
DCListDelete(L->next);
}
// 尾删
void DCListPopBack(DCListNode* L)
{
assert(L);
assert(L->prev != L); // 空链表不能尾删
DCListDelete(L->prev);
}
判空的条件又变成了一句 L->next != L ------ 空表的定义就是"L 自己指向自己",不需要额外维护 length 或 empty 标志位。
七、正反打印:一个免费的校验手段
cpp
// 打印链表中的元素(正向 + 反向,便于验证前驱指针是否正确)
void DCListPrint(DCListNode* L)
{
assert(L);
DCListNode* cur = L->next;
printf("正向:头结点");
while (cur != L)
{
printf("->%d", cur->data);
cur = cur->next;
}
printf("->头结点\n");
//借助prev指针从尾结点倒着走一遍,这是单链表做不到的
cur = L->prev;
printf("反向:头结点");
while (cur != L)
{
printf("->%d", cur->data);
cur = cur->prev;
}
printf("->头结点\n");
}
这里有个很实用的技巧:prev 指针写对了没有,不用靠脑子判断,看两行输出对不对得上就行。
假设链表内容是 1, 2, 3:
- 正向输出
头结点->1->2->3->头结点 - 反向输出必须镜像对称 ,即
头结点->3->2->1->头结点
两行放一起对不对称,直接就能定位是哪个结点的前驱没接对。反过来,单链表想这样自检是不可能的 ------ 它压根没有 prev,反向遍历这个动作不存在。
八、完整代码
DCList.h
cpp
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int DCLDataType;
typedef struct DCListNode {
DCLDataType data; // 存储数据元素的值
struct DCListNode* prev; // 存放前驱结点的指针
struct DCListNode* next; // 存放后继结点的指针
}DCListNode;
// 创建一个新结点(申请失败返回NULL)
DCListNode* BuyDCListNode(int data);
// 链表初始化
DCListNode* DCListInit();
// 销毁链表(连头结点一起释放)
void DCListDestroy(DCListNode* L);
// 获取链表的位序i的结点:位序从0开始,空链表或下标越界返回NULL
DCListNode* DCListGetElem(DCListNode* L, int i);
// 在pos位置后插入值为x的结点
// 传入pos为头结点L则等价于头插,传入pos为L->prev则等价于尾插
void DCListInsert(DCListNode* pos, DCLDataType x);
// 删除pos位置的结点
// 禁止传入头结点L:本接口拿不到L,无法自行校验,误传会破坏循环使后续遍历死循环
void DCListDelete(DCListNode* pos);
// 头插
void DCListPushFront(DCListNode* L, DCLDataType x);
// 尾插(O(1),直接从L->prev接上)
void DCListPushBack(DCListNode* L, DCLDataType x);
// 头删(空链表不可调用)
void DCListPopFront(DCListNode* L);
// 尾删(空链表不可调用)
void DCListPopBack(DCListNode* L);
// 打印链表中的元素(正向 + 反向)
void DCListPrint(DCListNode* L);
DCList.cpp
cpp
#define _CRT_SECURE_NO_WARNINGS
#include "DCList.h"
//创建一个新结点
DCListNode* BuyDCListNode(int data)
{
DCListNode* newNode = (DCListNode*)malloc(sizeof(DCListNode));
if (newNode == NULL)
{
//申请失败后就直接返回,绝不能再往下走,否则下一行就是对NULL解引用
printf("BuyDCListNode:申请空间失败\n");
return NULL;
}
//申请成功之后,对数据域和指针域进行初始化
newNode->data = data;
newNode->next = NULL;
newNode->prev = NULL;
return newNode;
}
//初始化
DCListNode* DCListInit()
{
DCListNode* L = BuyDCListNode(-1);
assert(L);
//空的循环链表:头结点的后继和前驱都指向自己
L->next = L;
L->prev = L;
return L;
}
// 销毁链表
void DCListDestroy(DCListNode* L)
{
assert(L);
DCListNode* cur = L->next;//我们必须是从第一个结点开始,不然循环条件进不去
while (cur != L)
{
//存下一个结点的地址
DCListNode* next = cur->next;
free(cur);
cur = next;
}
free(L);
}
// 获取链表的下标i的结点
DCListNode* DCListGetElem(DCListNode* L, int i)
{
assert(L);
assert(i >= 0);
int j = 0;
DCListNode* cur = L->next;//从头结点的后继开始走
//循环链表没有NULL作为结束标志,只能靠"走回头结点"来发现越界
while (j < i && cur != L)
{
cur = cur->next;
j++;
}
if (cur == L)
{
return NULL;
}
return cur;
}
// 在pos位置后插入值为x的结点
void DCListInsert(DCListNode* pos, DCLDataType x)
{
assert(pos);
DCListNode* newNode = BuyDCListNode(x);
assert(newNode);
//把新结点挂到 pos 和 pos->next 之间,三步的顺序不能颠倒:
//第②步要用到pos->next的"原值",所以必须排在第③步之前
newNode->prev = pos;
newNode->next = pos->next; //① 先记住pos原来的后继
pos->next->prev = newNode; //② 此时pos->next还是原后继,把它回指给新结点
pos->next = newNode; //③ 最后才改pos的后继
}
// 删除pos位置的结点
void DCListDelete(DCListNode* pos)
{
assert(pos);
//摘链:让pos的前驱和后继互相跨过pos
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
// 头插
void DCListPushFront(DCListNode* L, DCLDataType x)
{
assert(L);
//头结点之后插入 == 头插,与单链表"遍历到尾再插"相比是O(1)
DCListInsert(L, x);
}
// 尾插
void DCListPushBack(DCListNode* L, DCLDataType x)
{
assert(L);
DCListNode* newNode = BuyDCListNode(x);
assert(newNode);
//双链表的优势:直接从L->prev(尾结点)接上,不需要像单链表那样先遍历一遍
newNode->next = L; //① 循环的"出口"永远回到头结点
newNode->prev = L->prev;
L->prev->next = newNode; //② 让原来的尾结点后继指向新结点
L->prev = newNode; //③ 让头结点的前驱指向新结点
}
// 头删
void DCListPopFront(DCListNode* L)
{
assert(L);
assert(L->next != L);// 空链表不能头删
DCListDelete(L->next);
}
// 尾删
void DCListPopBack(DCListNode* L)
{
assert(L);
assert(L->prev != L);// 空链表不能尾删
DCListDelete(L->prev);
}
// 打印链表中的元素(正向 + 反向)
void DCListPrint(DCListNode* L)
{
assert(L);
DCListNode* cur = L->next;
printf("正向:头结点");
while (cur != L)
{
printf("->%d", cur->data);
cur = cur->next;
}
printf("->头结点\n");
//借助prev指针从尾结点倒着走一遍,这是单链表做不到的
cur = L->prev;
printf("反向:头结点");
while (cur != L)
{
printf("->%d", cur->data);
cur = cur->prev;
}
printf("->头结点\n");
}
九、跑起来看看
工程的 Test.cpp 是个二级菜单交互程序,主菜单选 2 进入双向循环链表部分:
=========== 链表操作菜单 ===========
1. 单链表
2. 双向循环链表
0. 退出
===================================
请选择:2
========= 双向循环链表测试菜单 =========
1. 打印链表(正向 + 反向)
2. 求链表长度
3. 判断链表是否为空
4. 按位查找(返回结点地址)
5. 头插
6. 尾插
7. 在第i个结点后插入值x
8. 头删
9. 尾删
10. 删除第i个结点
11. 销毁链表
0. 退出
=======================================
依次尾插 1、2、3:
请选择:6
请输入尾插的值:1
尾插成功:插入了值 1
正向:头结点->1->头结点
反向:头结点->1->头结点
请选择:6
请输入尾插的值:2
尾插成功:插入了值 2
正向:头结点->1->2->头结点
反向:头结点->2->1->头结点
请选择:6
请输入尾插的值:3
尾插成功:插入了值 3
正向:头结点->1->2->3->头结点
反向:头结点->3->2->1->头结点
在第 0 个结点(即值 1)后面插入 0:
请选择:7
请输入位置i和插入的值x:0 0
插入成功:在第 0 个结点后插入了值 0
正向:头结点->1->0->2->3->头结点
反向:头结点->3->2->0->1->头结点
连着头删 4 次,一直删空:
请选择:8
头删成功
正向:头结点->0->2->3->头结点
反向:头结点->3->2->0->头结点
请选择:8
头删成功
正向:头结点->2->3->头结点
反向:头结点->3->2->头结点
请选择:8
头删成功
正向:头结点->3->头结点
反向:头结点->3->头结点
请选择:8
头删成功
正向:头结点->头结点
反向:头结点->头结点
注意最后一行 ------ 空表打印出来是两行 头结点->头结点。空表没有崩溃、没有死循环,说明 L->next == L->prev == L 这个闭环自始至终是成立的。每一步的正反两行输出都严格镜像,这也验证了 prev 指针每一步都接对了。
十、单链表 vs 双向循环链表
| 操作 | 单链表 List.h |
双向循环链表 DCList.h |
|---|---|---|
| 头插 | O(1) ListPushFront |
O(1) DCListPushFront |
| 尾插 | O(n),需 while 遍历到尾 | O(1) ,直接接 L->prev |
| 头删 | O(1) ListPopFront |
O(1) DCListPopFront |
| 尾删 | O(n),需遍历到尾 | O(1) ,直接摘 L->prev |
已知 p,在 p 后插 |
O(1) | O(1) |
已知 p,在 p 前插 |
做不到 | O(1) ,DCListInsert(p->prev, x) |
| 反向遍历 | 做不到 | O(n) ,从 L->prev 倒着走 |
| 判空 | L->next == NULL |
L->next == L |
| 遍历终止标志 | NULL |
走回头结点 |
| 每个结点开销 | 1 个指针 | 2 个指针 |
双链表的代价 :每个结点多一个指针的内存;插入删除要维护两处引用,出错的面翻倍。双链表的收益:尾插尾删 O(1)、支持前插、支持反向遍历。
所以不是所有场景都该上双链表 。如果只需要从头往后遍历,堆一个多余的 prev 指针纯属浪费(缓存局部性变差)。双链表真正不可替代的地方是:
- LRU 缓存 ------ 访问过的结点挪到链表头部(后移是删除+头插),淘汰尾部是尾删,两者都是 O(1)
- 浏览器前进/后退历史 ------ 天生是双向的
- 文本编辑器光标上下移动 ------
prev就是往上一行 - 内存分配器的空闲链表 ------ 按地址或大小排序时需要双向遍历和摘除
十一、进阶:当前实现里几个真实存在的隐患
上面这套实现能跑,但对得更深一点还能看到几个问题,一并记下来。
1. 缺 DCListSize / DCListEmpty,判空退化成 O(n)
接口层没有提供长度和判空函数,导致菜单层只能自己写一个遍历:
cpp
static int DCListLength(DCListNode* L)
{
int n = 0;
DCListNode* cur = L->next;
while (cur != L) { n++; cur = cur->next; }
return n;
}
菜单里的"判断链表是否为空"就变成了 if (DCListLength(L) == 0),每次判空都要走完整条链。虽然判空本身有 L->next != L 这个 O(1) 写法,但既然是循环带头结点链表,在结点里加一个 size 字段就能让长度和判空都变成 O(1),代价只是每个结点多几个字节。
2. assert 在 Release 下会被禁用,空表尾删直接 free 掉头结点
cpp
void DCListPopBack(DCListNode* L)
{
assert(L);
assert(L->prev != L); // Release 下这行被编译掉了
DCListDelete(L->prev);
}
Debug 下 assert 拦得住。Release 下 assert 是空操作 ,空链表调 DCListPopBack 会让 L->prev == L,于是 DCListDelete(L) 执行 L->next = L; L->prev = L; 然后 free(L) ------ 头结点被释放,调用方的 L 变野指针,之后任何操作都是 UB。
这是 assert 做参数校验的固有缺陷:它只保护调试期 。真要健壮,判空应该走返回值(返回 int 表示成功与否),或者在 DCListDelete 里额外传 L 让它能自校验。
3. DCListInsert 只能后插,但前插只差一行
想在 pos 前面插入,其实就是 DCListInsert(pos->prev, x)。接口文档里没把这个用法点出来,容易让人以为"双链表只能后插",白白浪费了 prev 的价值。
4. 头结点 data = -1,和业务数据有潜在冲突
目前 DCListGetElem 永远不会返回头结点,所以就算链表里真有一个 -1 的元素也不会被误认。但这个安全性是依赖于"没有任何接口会返回头结点"这个约定 的。哪天加一个"取头结点"或者"按值查找"的接口,就得重新审视这个 -1。
5. 单元测试只覆盖了单链表,双向循环链表一个用例都没有
工程里有个 ListTest 工程(Visual Studio 自带的 Google Test 框架),给单链表的 14 个接口写了 40 多个用例,测得很细。但 DCList 这 10 个接口完全没被测到。至少该补上这几个:
- 空表初始化后
L->next == L && L->prev == L DCListGetElem越界返回NULL- 正向遍历与反向遍历结果镜像一致
DCListPushBack之后L->prev确实指向新结点(这是尾插最容易写错的地方)- 连删到空表不崩溃
十二、小结
双向循环链表的核心,其实就三句话:
- 循环 + 带头结点 ,让
L->prev永远指向尾、L->next永远指向头,空表时两者都指向自己。 - 因为 1,"在 pos 后面插入"一个接口就够用了 ------
pos传头结点就是头插,传尾结点就是尾插,特例不需要写分支。 - 因为有
prev,尾插尾删变成 O(1),前插和反向遍历从"做不到"变成"一行搞定"。
代价是每个结点多一个指针,以及维护两处引用的难度翻倍。
写这类链表代码时,最该养成的两个习惯是:插入/摘链的语句顺序要想清楚"谁在用原值" ,以及用正反双向遍历的输出来自动校验指针接得对不对。