Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
Docker系列文章目录
数据结构与算法系列文章目录
01-【数据结构与算法-Day 1】程序世界的基石:到底什么是数据结构与算法?
02-【数据结构与算法-Day 2】衡量代码的标尺:时间复杂度与大O表示法入门
03-【数据结构与算法-Day 3】揭秘算法效率的真相:全面解析O(n^2), O(2^n)及最好/最坏/平均复杂度
04-【数据结构与算法-Day 4】从O(1)到O(n²),全面掌握空间复杂度分析
05-【数据结构与算法-Day 5】实战演练:轻松看懂代码的时间与空间复杂度
06-【数据结构与算法-Day 6】最朴素的容器 - 数组(Array)深度解析
07-【数据结构与算法-Day 7】告别数组束缚,初识灵活的链表 (Linked List)
08-【数据结构与算法-Day 8】手把手带你拿捏单向链表:增、删、改核心操作详解
09-【数据结构与算法-Day 9】图解单向链表:从基础遍历到面试必考的链表反转
10-【数据结构与算法-Day 10】双向奔赴:深入解析双向链表(含图解与代码)
文章目录
摘要
在掌握了单向链表之后,我们自然会思考:数据之间的链接只能是单向的吗?如果我们需要频繁地在链表中前后移动,或者在删除一个节点时能快速找到它的前驱节点,单向链表似乎就显得有些力不从心。为了解决这些问题,“双向链表”应运而生。本文将深入探讨双向链表的结构、核心优势,并通过图解和详细的代码示例,手把手教你实现其增删查改等核心操作。最后,我们会对比单向与双向链表的应用场景,助你理解其在实际开发中的价值。
一、什么是双向链表 (Doubly Linked List)
在之前的学习中,我们知道单向链表的每个节点都像火车车厢,只有一个指向下一个节点的“挂钩”。而双向链表则为每个车厢都配备了前后两个“挂钩”,使其既能连接下一节车厢,也能连接前一节车厢。
1.1 定义与结构
双向链表(Doubly Linked List)是链表的一种,与单向链表相比,它的每个节点不仅包含一个指向后继节点的指针(next),还增加了一个指向前驱节点的指针(prev)。
一个标准的双向链表节点通常由三部分组成:
- 数据域 (Data): 存储节点的实际数据。
- 前驱指针 (prev): 指向链表中的上一个节点。
- 后继指针 (next): 指向链表中的下一个节点。
头节点的 prev 指针通常为 null,尾节点的 next 指针也通常为 null。
1.1.1 结构图示
我们可以使用 Mermaid 流程图来直观地展示一个双向链表的结构:
从上图可以看出,从任意一个节点出发,我们都可以轻松地向前或向后遍历整个链表。
1.2 双向链表的优势
引入一个额外的 prev 指针,必然会占用更多的内存空间。那么,我们为什么要这样做呢?这种“空间换时间”的策略带来了显著的优势:
- 双向遍历: 这是最直观的优点。我们可以从头到尾遍历,也可以从尾到头遍历,这在某些特定场景下非常有用。
- 高效的删除操作: 在单向链表中,要删除一个节点(非头节点),我们必须先从头遍历找到它的前一个节点,时间复杂度为
O
(
n
)
O(n)
O(n)。而在双向链表中,如果我们已经定位到要删除的节点
p,可以直接通过p.prev访问其前驱节点,从而在 O ( 1 ) O(1) O(1) 的时间内完成指针的修改。 - 高效的插入操作: 同样,在某个已知节点
p的前面插入新节点时,单向链表需要从头遍历找到p的前驱,而双向链表可以直接通过p.prev完成操作。
当然,其缺点也显而易见:
- 更大的空间开销: 每个节点都需要额外存储一个
prev指针。 - 更复杂的插入和删除操作: 相比单向链表,插入和删除节点时需要多维护一个
prev指针,代码逻辑相对复杂一些。
二、双向链表的核心操作与实现
理论讲完,我们开始进入实战环节。我们将以 Java 为例,完整实现一个双向链表。
2.1 节点定义
首先,定义双向链表的节点 DoublyNode。
/**
* 双向链表节点定义
* @param <T> 泛型,表示节点存储的数据类型
*/
class DoublyNode<T> {
public T data; // 数据域
public DoublyNode<T> prev; // 指向前驱节点的指针
public DoublyNode<T> next; // 指向后继节点的指针
public DoublyNode(T data) {
this.data = data;
this.prev = null;
this.next = null;
}
}
接着,我们定义双向链表的主类,并维护 head(头节点)和 tail(尾节点)两个引用。
public class DoublyLinkedList<T> {
private DoublyNode<T> head;
private DoublyNode<T> tail;
private int size;
public DoublyLinkedList() {
// 初始化时,创建一个虚拟头尾节点,简化边界处理(哨兵节点法)
// 也可以不使用哨兵,但操作时需处理更多 head/tail 为 null 的情况
this.head = null;
this.tail = null;
this.size = 0;
}
// ... 后续操作将在此类中实现
}
2.2 插入操作
插入操作是双向链表最复杂的部分之一,因为它需要同时维护 prev 和 next 两个指针的正确性。
2.2.1 头部插入 (Insert at Head)
在链表头部插入一个新节点,需要处理两种情况:链表为空和链表不为空。
操作步骤:
- 创建新节点
newNode。 - 如果链表为空 (
head == null),则head和tail都指向newNode。 - 如果链表不为空,则:
newNode的next指向原来的head。- 原来
head的prev指向newNode。 - 更新
head为newNode。
图解:
代码实现:
// 在头部插入节点
public void addFirst(T data) {
DoublyNode<T> newNode = new DoublyNode<>(data);
if (head == null) {
// 链表为空
head = newNode;
tail = newNode;
} else {
// 链表不为空
newNode.next = head; // 1. 新节点的 next 指向旧 head
head.prev = newNode; // 2. 旧 head 的 prev 指向新节点
head = newNode; // 3. 更新 head
}
size++;
}
2.2.2 尾部插入 (Insert at Tail)
与头部插入类似,尾部插入也相对简单。
操作步骤:
- 创建新节点
newNode。 - 如果链表为空,处理方式同上。
- 如果链表不为空,则:
tail的next指向newNode。newNode的prev指向tail。- 更新
tail为newNode。
代码实现:
// 在尾部插入节点
public void addLast(T data) {
DoublyNode<T> newNode = new DoublyNode<>(data);
if (tail == null) {
// 链表为空
head = newNode;
tail = newNode;
} else {
// 链表不为空
tail.next = newNode; // 1. 旧 tail 的 next 指向新节点
newNode.prev = tail; // 2. 新节点的 prev 指向旧 tail
tail = newNode; // 3. 更新 tail
}
size++;
}
2.2.3 在指定节点后插入 (Insert After a Specific Node)
这是更通用的插入情况,也更能体现双向链表指针操作的细节。假设我们要在节点 p 之后插入 newNode。
操作步骤:
- 找到节点
p。 - 创建新节点
newNode。 - 设
p的后继节点为p_next。 - 将
newNode插入p和p_next之间:newNode.next = p_next(新节点指向p的后继)newNode.prev = p(新节点指向p)p.next = newNode(p指向新节点)- 如果
p_next不为null,则p_next.prev = newNode(p的后继指向新节点) - 如果
p是尾节点,则更新tail为newNode。
图解:
代码实现 (以在指定索引处插入为例):
// 在指定索引处插入节点
public void insert(int index, T data) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
if (index == 0) {
addFirst(data);
return;
}
if (index == size) {
addLast(data);
return;
}
// 找到要插入位置的前一个节点
DoublyNode<T> current = head;
for (int i = 0; i < index - 1; i++) {
current = current.next;
}
DoublyNode<T> newNode = new DoublyNode<>(data);
DoublyNode<T> nextNode = current.next;
// 核心四步,顺序很关键
newNode.next = nextNode; // 1
current.next = newNode; // 2
newNode.prev = current; // 3
nextNode.prev = newNode; // 4
size++;
}
2.3 删除操作
双向链表的一大优势就是删除操作。给定一个节点的引用,可以在 O ( 1 ) O(1) O(1) 时间内删除它。
2.3.1 删除指定节点 (Deleting a Specific Node)
假设我们要删除节点 p。
操作步骤:
- 设
p的前驱为p_prev,后继为p_next。 - 要将
p从链表中“绕开”,只需:p_prev.next = p_next(让p的前驱指向p的后继)p_next.prev = p_prev(让p的后继指向p的前驱)
- 需要处理边界情况:
- 如果
p是头节点 (p_prev为null),则更新head为p_next。 - 如果
p是尾节点 (p_next为null),则更新tail为p_prev。 - 删除后,如果链表为空,
head和tail都设为null。
- 如果
图解:
代码实现 (以删除指定索引的节点为例):
// 删除指定索引的节点
public T remove(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
if (index == 0) {
return removeFirst();
}
if (index == size - 1) {
return removeLast();
}
// 找到要删除的节点
DoublyNode<T> toRemove = head;
for (int i = 0; i < index; i++) {
toRemove = toRemove.next;
}
DoublyNode<T> prevNode = toRemove.prev;
DoublyNode<T> nextNode = toRemove.next;
// 绕过 toRemove 节点
prevNode.next = nextNode;
nextNode.prev = prevNode;
// 清空被删除节点的引用,帮助GC
toRemove.next = null;
toRemove.prev = null;
size--;
return toRemove.data;
}
// 辅助方法:删除头节点
public T removeFirst() {
if (head == null) return null;
DoublyNode<T> oldHead = head;
if (head == tail) { // 只有一个节点
head = null;
tail = null;
} else {
head = head.next;
head.prev = null; // 新头的 prev 指向 null
}
oldHead.next = null; // 帮助 GC
size--;
return oldHead.data;
}
// 辅助方法:删除尾节点
public T removeLast() {
if (tail == null) return null;
DoublyNode<T> oldTail = tail;
if (head == tail) { // 只有一个节点
head = null;
tail = null;
} else {
tail = tail.prev;
tail.next = null; // 新尾的 next 指向 null
}
oldTail.prev = null; // 帮助 GC
size--;
return oldTail.data;
}
三、应用场景与对比
理解了双向链表的原理和实现后,我们来看看它在现实世界中的用武之地。
3.1 何时选择双向链表?
当你的应用需要满足以下一个或多个条件时,双向链表是一个绝佳的选择:
- 需要双向导航: 最典型的例子是网页浏览器的“前进”和“后退”功能。当前页面就是一个节点,
prev指向历史记录,next指向“前进”的页面。 - 需要高效删除: 在一个列表中,如果需要频繁删除任意位置的元素,双向链表的 O ( 1 ) O(1) O(1) 删除能力(在拿到节点引用的前提下)远胜于单向链表和数组。
- 实现某些高级数据结构: 许多复杂的数据结构都基于双向链表实现,例如:
- LRU (Least Recently Used) 缓存淘汰算法: 使用哈希表和双向链表结合,可以实现 O ( 1 ) O(1) O(1) 时间复杂度的查询和更新。最近访问的元素被移动到链表头部,淘汰时从链表尾部删除。
- 文本编辑器: 文本的每一行可以看作一个节点,用户的光标上下移动、插入和删除行,都可以在双向链表上高效完成。
3.2 单向链表 vs. 双向链表
为了更清晰地对比,我们用一个表格来总结它们的差异。
| 特性 | 单向链表 (Singly Linked List) | 双向链表 (Doubly Linked List) |
|---|---|---|
| 节点结构 | data, next | data, prev, next |
| 内存开销 | 较小 | 较大(多一个指针) |
| 遍历方向 | 只能从头到尾 | 可以双向遍历 |
| 查找指定节点 | O ( n ) O(n) O(n) | O ( n ) O(n) O(n) |
| 插入操作 | 逻辑简单,O(1) (在已知前驱时) | 逻辑稍复杂,O(1) (在已知邻居时) |
| 删除操作 | 需要找到前驱节点,O(n) | 无需找前驱,O(1) (在已知节点时) |
选择建议:
- 如果你的应用场景内存敏感,且主要操作是顺序遍历和尾部添加,那么单向链表是更经济的选择。
- 如果你需要频繁地在任意位置插入/删除,或者需要双向导航功能,那么多付出的那一点内存空间来换取双向链表的灵活性和高效率是完全值得的。
四、总结
本文我们对双向链表进行了全面而深入的探讨。通过本次学习,我们应掌握以下核心要点:
- 核心结构: 双向链表的核心在于其节点设计,除了数据域
data和后继指针next,额外增加了一个前驱指针prev,这使得链表节点之间形成了“双向奔赴”的关系。 - 关键优势:
prev指针带来了两大核心优势:一是支持双向遍历;二是当持有目标节点引用时,可以实现 O ( 1 ) O(1) O(1) 时间复杂度的删除操作,因为它无需再从头查找前驱节点。 - 操作复杂性: 双向链表的插入和删除操作需要同时维护
prev和next两个指针,逻辑比单向链表复杂,编码时需要更加细心,防止指针断裂。 - 空间与时间的权衡: 双向链表是典型的**“以空间换时间”**思想的体现。它用额外的指针存储空间,换取了操作上的灵活性与更高的时间效率。
- 适用场景: 在需要双向导航(如浏览器历史)、高效随机删除/插入(如文本编辑器、LRU缓存)的场景中,双向链表是比单向链表和数组更优的数据结构。
&spm=1001.2101.3001.5002&articleId=149799206&d=1&t=3&u=a2b69069068b431ebf7ff1ce662f89b1)
1453

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



