2 双链表
GESP C++ 五级:双链表精讲
核心目标:理解双链表的逻辑结构与内存模型,掌握节点的定义、创建、遍历、插入、删除等基本操作,重点掌握指针操作的顺序与边界处理,能分析操作的时间复杂度,能解决双链表相关选择题与编程题。
双链表每个节点有两个指针——prev指向前驱,next指向后继;插入和删除时四条指针都要改,顺序不能乱;已知节点指针时插入删除 O(1),代价是多一个指针的内存开销。
一、考纲定位
GESP 五级官方考纲第3条明确要求:“掌握链表的创建、插入、删除、遍历和反转操作,理解单链表、双链表、循环链表的区别”。
双链表在五级考试中的考查分布:
| 考点 | 重要程度 | 典型考法 |
|---|---|---|
| 节点结构体定义 | ★★★★★ | 代码填空、选择题 |
| 双链表与单链表/循环链表的区别 | 选择题高频 | |
| 插入节点(四个指针的更新顺序) | 代码填空、选择题 | |
| 删除节点(非头尾节点的正确操作) | 选择题高频 | |
| 尾部追加节点 | ★★★★☆ | 代码填空 |
| 带哨兵节点的双链表初始化 | 选择题 | |
| 双向遍历 | ★★★☆☆ | 编程题 |
| 复杂度分析 | ★★★★☆ | 判断题、选择题 |
二、双链表基本概念
1. 什么是双链表
双链表(Doubly Linked List)是一种链式数据结构,每个节点包含三个部分:
- 数据域(data) :存放数据
- 前驱指针(prev) :指向前一个节点
- 后继指针(next) :指向下一个节点
相对于单链表,双链表的核心优势是支持双向遍历,在已知节点指针的情况下,插入和删除效率更高;代价是每个节点需要额外的空间存储 prev 指针。
2. 双链表 vs 单链表 vs 循环链表
| 对比项 | 单链表 | 双链表 | 循环链表 |
|---|---|---|---|
| 指针数量 | 1个(next) |
2个(prev、next) |
单/双链表首尾相连 |
| 遍历方向 | 只能从头到尾 | 双向遍历 | 环形遍历 |
| 删除已知节点 | O(n)(需找前驱) | O(1)(直接通过prev) |
取决于单/双 |
| 内存开销 | 最小 | 较大(多一个指针) | 视具体实现 |
| 尾节点指针 | nullptr |
指向头节点 | |
高频易错辨析:
在单链表中,若已知任意结点的指针,删除该结点通常需要知道其前驱结点,找前驱需要从头遍历,时间复杂度为 O(n);而在双链表中,通过 prev 指针可以直接找到前驱,删除已知节点的时间复杂度为 O(1)。
循环双链表的尾节点的 next 指向头结点,而不是 nullptr,这是与普通双链表的关键区别。
3. 节点定义
struct DNode {
int data; // 数据域
DNode* prev; // 指向前一个节点
DNode* next; // 指向下一个节点
// 构造函数(可选,方便初始化)
DNode(int val) : data(val), prev(nullptr), next(nullptr) {}
};
注意:双链表的节点比单链表多一个
prev指针,定义时不能遗漏。
三、双链表的创建与遍历
1. 创建双链表(尾插法)
DNode* createDList(const vector<int>& vals) {
DNode* head = nullptr;
DNode* tail = nullptr;
for (int val : vals) {
DNode* newNode = new DNode(val);
if (!head) {
head = tail = newNode; // 第一个节点
} else {
tail->next = newNode; // 旧尾节点的next指向新节点
newNode->prev = tail; // 新节点的prev指向旧尾节点
tail = newNode; // 更新尾指针
}
}
return head;
}
要点:创建过程中有两个关键操作——tail->next = newNode 和 newNode->prev = tail,两个都要写,缺一不可。
2. 双向遍历
// 正向遍历:从头到尾
void printForward(DNode* head) {
DNode* p = head;
while (p != nullptr) {
cout << p->data << " ";
p = p->next;
}
cout << endl;
}
// 反向遍历:从尾到头
void printBackward(DNode* tail) {
DNode* p = tail;
while (p != nullptr) {
cout << p->data << " ";
p = p->prev;
}
cout << endl;
}
双链表最核心的优势:可以从任意节点出发,向前或向后遍历。
四、双链表的插入操作(重点)
1. 在节点 p 之后插入节点 s
void insertAfter(DNode* p, DNode* s) {
s->next = p->next; // ① 新节点的next指向p的原后继
if (p->next != nullptr) {
p->next->prev = s; // ② 原后继的prev指向新节点
}
s->prev = p; // ③ 新节点的prev指向p
p->next = s; // ④ p的next指向新节点
}
四步操作:
| 步骤 | 操作 | 目的 |
|---|---|---|
| ① | s->next = p->next |
新节点的后继指向p的原后继 |
| ② | p->next->prev = s |
p的原后继的前驱指向新节点 |
| ③ | s->prev = p |
新节点的前驱指向p |
| ④ | p->next = s |
p的后继指向新节点 |
2. 在节点 p 之前插入节点 s
void insertBefore(DNode* p, DNode* s) {
s->next = p; // ① 新节点的next指向p
s->prev = p->prev; // ② 新节点的prev指向p的原前驱
if (p->prev != nullptr) {
p->prev->next = s; // ③ p的原前驱的next指向新节点
}
p->prev = s; // ④ p的prev指向新节点
}
真题示例(2026年3月) :
双向循环链表中要在结点p之前插入新结点s(均非空),以下指针操作正确的是( )。
A.s->next = p; p->prev = s; p->next = s; s->prev = p;
B.s->prev = p; s->next = p->next; p->next->prev = s; p->next = s;
C.s->next = p; s->prev = p->prev; p->prev->next = s; p->prev = s;
D.s->next = p; s->prev = nullptr; p->prev = s;
答案:C
解析:在双链表中结点p之前插入结点s,需要修改四个指针的方向且不能丢失原有的前驱结点引用。首先处理新结点s的两端:s->next = p; 以及 s->prev = p->prev;。接着修改原链表中的指针,必须先修改p的原前驱结点的后继:p->prev->next = s;,最后修改p的前驱指向新结点:p->prev = s;。
3. 在尾部追加节点
void append(int data) {
DNode* newNode = new DNode(data);
if (is_empty()) {
head = tail = newNode;
} else {
tail->next = newNode;
newNode->prev = tail;
tail = newNode;
}
++size;
}
真题示例(2025年6月) :
填入相应代码完善
append(),用于在双向链表尾部增加新节点,横线上应填写( )。
A.tail->next = newNode;
B.newNode->prev = tail; tail = newNode;
C.tail = newNode; newNode->prev = tail; tail->next = newNode;
D.tail->next = newNode; newNode->prev = tail; tail = newNode;
答案:D
解析:D选项表示新节点的前指针指向尾节点,尾节点后指针指向新节点,新节点成为新的尾节点。
五、双链表的删除操作(重点)
1. 删除非头尾节点 p
void deleteNode(DNode* p) {
p->prev->next = p->next; // ① 前驱的后继指向p的后继
p->next->prev = p->prev; // ② 后继的前驱指向p的前驱
delete p; // ③ 释放p
}
三步操作:
| 步骤 | 操作 | 目的 |
|---|---|---|
| ① | p->prev->next = p->next |
前驱节点越过p,指向p的后继 |
| ② | p->next->prev = p->prev |
后继节点越过p,指向p的前驱 |
| ③ | delete p |
释放p的内存 |
真题示例(2025年3月) :
双向链表中每个结点有两个指针域
prev和next,设 p 指向链表中的一个结点,它的前驱结点和后继结点均非空。要删除结点p,则下述语句中错误的是( )。
解析:双链表中删除非头尾结点p,需要将p的前驱结点和p的后继结点相连后释放p结点。p的前驱节点的next需要指向p的后继结点,p的后继结点的prev需要指向p的前驱结点。错误的选项通常是因为指针操作顺序导致信息丢失——先执行了 p->next->prev = p->prev;,之后再用 p->next 时它已经不是原来的值了。
2. 删除时的边界处理
void deleteNode(DNode* p) {
if (p->prev != nullptr) {
p->prev->next = p->next; // 不是头节点
} else {
head = p->next; // 删除的是头节点,更新头指针
}
if (p->next != nullptr) {
p->next->prev = p->prev; // 不是尾节点
} else {
tail = p->prev; // 删除的是尾节点,更新尾指针
}
delete p;
}
六、带哨兵节点的双链表
1. 什么是哨兵节点
哨兵节点(哑结点)是不存储实际数据的虚拟节点,附加在链表头部或尾部,用于统一插入和删除的边界处理,避免对头节点和尾节点的特殊判断。
struct DLinkList {
DNode* head; // 头哨兵
DNode* tail; // 尾哨兵
int size;
DLinkList() {
head = new DNode(0);
tail = new DNode(0);
head->next = tail;
tail->prev = head;
head->prev = nullptr;
tail->next = nullptr;
size = 0;
}
};
2. 空的双向循环链表
真题示例(2025年3月) :
假设双向循环链表包含头尾哨兵结点(不存储实际内容),分别为
head和tail,链表中每个结点有两个指针域prev和next。下面代码实现一个空的双向循环链表,横线上应填的最佳代码是( )。
解析:双向循环链表中,头结点的 next 指针应指向尾结点,尾结点的 prev 指针应指向头结点。理论上,头结点的 prev 指针还要指向尾结点,尾结点的 next 指针指向头结点,以形成完整的循环。
3. 判断链表是否为空
bool is_empty() const {
return head == nullptr; // A选项,可填写
// return tail == nullptr; // B选项,可填写
// return size == 0; // D选项,可填写
// return head.data == 0; // C选项,不可填写
}
真题示例(2025年6月) :横线处不能填写( )。答案:C。解析:A选项表示头节点为空指针,B选项表示尾节点为空指针,C选项表示头节点数据域为0,D选项表示链表大小为0。因此A、B、D选项都可填写,只有C选项不可填写。
七、复杂度分析
| 操作 | 单链表 | 双链表 |
|---|---|---|
| 遍历 | O(n) | |
| 按值查找 | ||
| 已知节点后插入 | O(1) | |
| 已知节点前插入 | O(n) | O(1) |
| 删除已知节点 | ||
| 尾部插入(有尾指针) | O(1) | |
| 空间复杂度 | O(n) | O(n)(多一个指针域) |
关键结论:双链表在已知节点指针的情况下,插入和删除都是 O(1);单链表删除已知节点仍需 O(n) 找前驱。
八、双链表与单链表的核心区别(高频选择题)
真题示例(2026年3月) :
关于单链表、双链表和循环链表,下列说法正确的是( )。
A. 在单链表中,若已知任意结点的指针,则可以在O(1)时间内删除该结点
B. 循环链表中一定不存在空指针
C. 在循环双链表中,尾结点的next指针一定为nullptr
D. 在带头结点的循环单链表中,判定链表是否为空只需判断头结点的next是否指向自身
答案:D
解析:A错误,单链表删除已知节点需要找前驱,O(n);B错误,不带头结点的循环单链表为空时头指针即为空指针;C错误,循环双链表的尾节点 next 指向头结点,不是 nullptr;D正确。
九、易错点汇总
- 插入时指针顺序错误:必须先处理新节点的两端,再修改原链表中的指针。如果先改
p->next,会丢失原来的后继节点引用。 - 删除时指针顺序错误:先执行
p->next->prev = p->prev;后,p->next的值可能已经被改变,再用p->next就会出错。 - 忘记处理头尾边界:删除头节点时要更新
head,删除尾节点时要更新tail。 - 插入时忘记判断
p->next != nullptr:在尾部节点之后插入时,p->next为空,直接访问p->next->prev会崩溃。 - 空链表初始化错误:带哨兵节点的双向循环链表,头哨兵的
next指向尾哨兵,尾哨兵的prev指向头哨兵。 - 混淆单双链表删除复杂度:单链表删除已知节点是 O(n),双链表才是 O(1)。
- 循环双链表尾节点
next不为空:循环双链表的尾节点next指向头结点,不是nullptr。 delete后未置空指针:虽然考试不强制,但好习惯是p = nullptr;。- 内存泄漏:删除节点后忘记
delete。 - 遍历时忘记判空:
while (p != nullptr)不能漏。
十、速查表
| 需求 | 代码片段 |
|---|---|
| 节点定义 | struct DNode { int data; DNode *prev, *next; }; |
| 创建(尾插) | tail->next=newNode; newNode->prev=tail; tail=newNode; |
| 正向遍历 | for (DNode* p=head; p; p=p->next) |
| 反向遍历 | for (DNode* p=tail; p; p=p->prev) |
| 在p后插入s | s->next=p->next; p->next->prev=s; s->prev=p; p->next=s; |
| 在p前插入s | s->next=p; s->prev=p->prev; p->prev->next=s; p->prev=s; |
| 删除p | p->prev->next=p->next; p->next->prev=p->prev; delete p; |
| 空双向循环链表 | head->next=tail; tail->prev=head; |
| 判断为空 | return head == nullptr; 或 return size == 0; |
十一、总结
双链表是 GESP 五级链表模块的核心内容,也是与单链表形成对比的重要考点。
- 双链表每个节点有两个指针:
prev指向前驱,next指向后继。 - 双链表支持双向遍历,这是单链表做不到的。
- 已知节点指针时,双链表的插入和删除都是 O(1)。
- 插入时先处理新节点的两端,再修改原链表的指针,顺序不能乱。
- 删除非头尾节点:
p->prev->next=p->next; p->next->prev=p->prev; delete p; - 删除头节点要更新
head,删除尾节点要更新tail。 - 循环双链表的尾节点
next指向头结点,不是nullptr。 - 单链表删除已知节点是 O(n),双链表是 O(1),这是高频选择题考点。
- 带哨兵节点的双链表可以统一处理边界,简化插入删除。
- 多练“在p前/后插入”“删除p”“哨兵初始化”三类代码填空题。
复习建议:先把双链表的节点定义、尾插法创建、双向遍历写熟;再重点突破插入和删除的指针操作顺序,手动画出每一步的指针变化;最后练习带哨兵节点的初始化和判断空链表的写法。考试中先看操作是在p之前还是之后插入,再看删除的是不是头尾节点,最后逐条检查四个指针是否都更新正确。