链表基础:单链表与双链表
作为一名程序员,你一定遇到过这样的困境:用数组存储数据时,插入删除操作让你头疼不已。今天就带你彻底掌握解决这个问题的利器------链表!
1. 链表到底是什么?我们先从生活场景理解
1.1 现实世界的链表例子
想象一下火车车厢:
- 每节车厢都是独立的
- 车厢之间通过挂钩连接
- 可以轻松添加或移除车厢
- 不需要连续的铁轨空间
这就是链表的精髓!链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
1.2 为什么需要链表?数组的痛点
先来看个真实案例:
java
// 用数组实现排队系统 - 痛苦!
String[] queue = {"张三", "李四", "王五", null, null};
// 当李四离开时...
queue[1] = queue[2]; // 王五前移
queue[2] = queue[3]; // 后续元素都要前移
queue[3] = queue[4]; // 效率太低了!
数组的三大痛点:
- 插入删除需要移动大量元素
- 大小固定,扩容成本高
- 内存必须连续,可能分配失败
链表的优势:
- ✅ 插入删除只需修改指针
- ✅ 动态大小,按需分配
- ✅ 内存不需要连续
2. 单链表:最简单的链式结构
2.1 单链表节点:链表的"原子"
java
/**
* 单链表节点类:就像火车的一节车厢
*
* 结构分析:
* ┌────────────┬────────────┐
* │ 数据域 │ 指针域 │
* │ data │ next │
* └────────────┴────────────┘
*
* 功能说明:
* - data: 存储实际数据(乘客)
* - next: 指向下一个节点(挂钩)
*/
class SingleListNode {
int data; // 节点存储的数据
SingleListNode next; // 指向下一个节点的指针
// 构造函数
SingleListNode(int data) {
this.data = data;
this.next = null; // 初始时独立节点,不连接任何其他节点
}
}
节点关系图示:
css
节点A:┌─────┬─────┐ 节点B:┌─────┬─────┐ 节点C:┌─────┬─────┐
│ 10 │ ●──┼───→│ 20 │ ●──┼───→│ 30 │ null │
└─────┴─────┘ └─────┴─────┘ └─────┴─────┘
2.2 单链表的完整实现与操作详解
java
/**
* 单链表实现类:管理整个链表结构
*
* 核心组成:
* head → 节点1 → 节点2 → ... → 节点n → null
*
* 特点:
* - 只能从头开始顺序访问
* - 插入删除效率高
* - 随机访问效率低
*/
class SingleLinkedList {
private SingleListNode head; // 头指针:链表的起点
public SingleLinkedList() {
head = null; // 空链表:head指向null
}
/**
* 在链表头部插入节点 - O(1)时间复杂度
*
* 操作流程:
* 1. 创建新节点
* 2. 新节点指向原头节点
* 3. 更新头指针指向新节点
*
* 示例:
* 插入前:head → 2 → 3 → null
* 插入1:head → 1 → 2 → 3 → null
*/
public void addAtHead(int data) {
// 1. 创建新节点
SingleListNode newNode = new SingleListNode(data);
// 2. 新节点指向原头节点
newNode.next = head;
// 3. 更新头指针
head = newNode;
System.out.println("在头部插入节点: " + data);
}
/**
* 在链表尾部插入节点 - O(n)时间复杂度
*
* 操作流程:
* 1. 创建新节点
* 2. 找到最后一个节点(next为null)
* 3. 最后一个节点指向新节点
*
* 特殊情况:空链表时,新节点就是头节点
*/
public void addAtTail(int data) {
SingleListNode newNode = new SingleListNode(data);
// 空链表特殊情况处理
if (head == null) {
head = newNode;
return;
}
// 遍历找到最后一个节点
SingleListNode current = head;
while (current.next != null) {
current = current.next;
}
// 连接新节点
current.next = newNode;
System.out.println("在尾部插入节点: " + data);
}
/**
* 在指定位置插入节点 - O(n)时间复杂度
*
* 操作流程:
* 1. 处理特殊情况(头部插入、空链表)
* 2. 找到插入位置的前一个节点
* 3. 调整指针连接
*
* 指针调整四步法:
* 新节点.next = 前节点.next
* 前节点.next = 新节点
*/
public void addAtIndex(int index, int data) {
if (index < 0) {
System.out.println("索引不能为负数");
return;
}
// 头部插入特殊情况
if (index == 0) {
addAtHead(data);
return;
}
SingleListNode newNode = new SingleListNode(data);
SingleListNode current = head;
// 找到插入位置的前一个节点
for (int i = 0; i < index - 1 && current != null; i++) {
current = current.next;
}
// 检查索引是否有效
if (current == null) {
System.out.println("索引超出链表长度");
return;
}
// 调整指针连接
newNode.next = current.next; // 新节点指向原位置节点
current.next = newNode; // 前节点指向新节点
System.out.println("在位置 " + index + " 插入节点: " + data);
}
/**
* 删除指定位置节点 - O(n)时间复杂度
*
* 操作流程:
* 1. 处理特殊情况(删除头节点)
* 2. 找到要删除节点的前一个节点
* 3. 调整指针跳过要删除的节点
*
* 内存管理:被跳过的节点会被Java垃圾回收器自动回收
*/
public void deleteAtIndex(int index) {
if (head == null || index < 0) {
System.out.println("链表为空或索引无效");
return;
}
// 删除头节点特殊情况
if (index == 0) {
int deletedData = head.data;
head = head.next;
System.out.println("删除头节点: " + deletedData);
return;
}
SingleListNode current = head;
// 找到要删除节点的前一个节点
for (int i = 0; i < index - 1 && current != null; i++) {
current = current.next;
}
// 检查索引是否有效
if (current == null || current.next == null) {
System.out.println("索引超出链表长度");
return;
}
int deletedData = current.next.data;
current.next = current.next.next; // 跳过要删除的节点
System.out.println("删除位置 " + index + " 的节点: " + deletedData);
}
/**
* 查找节点 - O(n)时间复杂度
*
* 线性搜索:从头部开始逐个比较
* 适用于无序链表
*/
public boolean contains(int data) {
SingleListNode current = head;
int position = 0;
while (current != null) {
if (current.data == data) {
System.out.println("找到节点 " + data + " 在位置 " + position);
return true;
}
current = current.next;
position++;
}
System.out.println("未找到节点: " + data);
return false;
}
/**
* 获取链表长度 - O(n)时间复杂度
*/
public int getLength() {
int length = 0;
SingleListNode current = head;
while (current != null) {
length++;
current = current.next;
}
return length;
}
/**
* 打印链表:可视化链表结构
*
* 输出格式:1 → 2 → 3 → null
*/
public void printList() {
SingleListNode current = head;
if (current == null) {
System.out.println("链表为空");
return;
}
while (current != null) {
System.out.print(current.data);
if (current.next != null) {
System.out.print(" → ");
}
current = current.next;
}
System.out.println(" → null");
}
}
2.3 单链表操作流程详解
🔧 插入操作详细流程:
头部插入图示:
csharp
初始状态:
head → 2 → 3 → null
步骤1:创建新节点(1)
[1] → null
步骤2:新节点指向原头节点
[1] → 2 → 3 → null
步骤3:更新head指针
head → [1] → 2 → 3 → null
中间插入图示(在位置1插入):
ini
初始状态:
head → 1 → 3 → null
步骤1:找到位置0的节点(1)
current指向节点1
步骤2:创建新节点(2)
[2] → null
步骤3:调整指针
节点1.next = 节点2
节点2.next = 节点3
结果:
head → 1 → 2 → 3 → null
🗑️ 删除操作详细流程:
删除中间节点图示(删除位置1):
vbscript
初始状态:
head → 1 → 2 → 3 → null
步骤1:找到位置0的节点(1)
current指向节点1
步骤2:跳过要删除的节点
节点1.next = 节点1.next.next
即:节点1直接指向节点3
结果:
head → 1 → 3 → null
节点2失去引用,被垃圾回收
2.4 单链表实战应用
应用场景1:浏览器历史记录
java
// 使用单链表实现简单的浏览历史
SingleLinkedList history = new SingleLinkedList();
// 访问新页面
history.addAtHead("page1");
history.addAtHead("page2");
history.addAtHead("page3");
// 点击后退:删除头节点
history.deleteAtIndex(0); // 回到page2
// 打印历史:page2 → page1 → null
history.printList();
应用场景2:任务队列
java
// 简单的任务调度系统
SingleLinkedList taskQueue = new SingleLinkedList();
// 添加任务
taskQueue.addAtTail(101); // 低优先级
taskQueue.addAtTail(102); // 低优先级
taskQueue.addAtHead(201); // 高优先级插队
// 执行任务:从头开始处理
// 队列:201 → 101 → 102 → null
3. 双链表:双向通行的增强链表
3.1 双链表节点:前后都能访问
如果说单链表是单行道,那双链表就是双行道!
java
/**
* 双链表节点类:像地铁车厢,前后都能通行
*
* 结构分析:
* ┌────────────┬────────────┬────────────┐
* │ prev │ 数据域 │ next │
* │ 指针域 │ data │ 指针域 │
* └────────────┴────────────┴────────────┘
*
* 优势:
* - 可以向前遍历
* - 可以向后遍历
* - 删除操作更高效
*
* 代价:
* - 每个节点多一个指针的内存开销
*/
class DoubleListNode {
int data; // 节点数据
DoubleListNode prev; // 指向前驱节点的指针
DoubleListNode next; // 指向后继节点的指针
// 构造函数
DoubleListNode(int data) {
this.data = data;
this.prev = null;
this.next = null;
}
}
双链表节点关系图示:
csharp
null ⇄ 节点A ⇄ 节点B ⇄ 节点C ⇄ null
← ← ←
→ → →
3.2 双链表的完整实现
java
/**
* 双链表实现类:支持双向遍历的链表
*
* 结构特点:
* null ⇄ head ⇄ 节点1 ⇄ 节点2 ⇄ ... ⇄ tail ⇄ null
*
* 优势操作:
* - 头部插入/删除:O(1)
* - 尾部插入/删除:O(1)
* - 双向遍历
*/
class DoubleLinkedList {
private DoubleListNode head; // 头指针
private DoubleListNode tail; // 尾指针
private int size; // 链表长度
public DoubleLinkedList() {
head = null;
tail = null;
size = 0;
}
/**
* 头部插入 - O(1)时间复杂度
*
* 操作流程:
* 1. 创建新节点
* 2. 处理空链表情况
* 3. 调整头指针和相关指针
*
* 指针调整规则:
* - 新节点.next = 原head
* - 原head.prev = 新节点
* - head = 新节点
*/
public void addAtHead(int data) {
DoubleListNode newNode = new DoubleListNode(data);
if (head == null) {
// 空链表:新节点既是头也是尾
head = newNode;
tail = newNode;
} else {
// 非空链表:调整指针关系
newNode.next = head; // 新节点指向原头节点
head.prev = newNode; // 原头节点指回新节点
head = newNode; // 更新头指针
}
size++;
System.out.println("在头部插入节点: " + data);
}
/**
* 尾部插入 - O(1)时间复杂度
*
* 双链表的巨大优势:不需要遍历整个链表!
* 直接通过tail指针访问尾部
*/
public void addAtTail(int data) {
DoubleListNode newNode = new DoubleListNode(data);
if (tail == null) {
// 空链表
head = newNode;
tail = newNode;
} else {
// 非空链表
tail.next = newNode; // 原尾节点指向新节点
newNode.prev = tail; // 新节点指回原尾节点
tail = newNode; // 更新尾指针
}
size++;
System.out.println("在尾部插入节点: " + data);
}
/**
* 指定位置插入 - O(n)时间复杂度
*
* 双链表中间插入的指针调整比较复杂:
* 需要同时维护prev和next指针
*
* 指针调整四步法:
* 1. 新节点.prev = 前节点
* 2. 新节点.next = 后节点
* 3. 前节点.next = 新节点
* 4. 后节点.prev = 新节点
*/
public void addAtIndex(int index, int data) {
if (index < 0 || index > size) {
System.out.println("索引无效: " + index);
return;
}
// 头部插入特殊情况
if (index == 0) {
addAtHead(data);
return;
}
// 尾部插入特殊情况
if (index == size) {
addAtTail(data);
return;
}
// 找到要插入位置的节点
DoubleListNode current = getNode(index);
DoubleListNode newNode = new DoubleListNode(data);
// 调整四个指针!
newNode.prev = current.prev; // 1. 新节点前指针
newNode.next = current; // 2. 新节点后指针
current.prev.next = newNode; // 3. 前节点的后指针
current.prev = newNode; // 4. 当前节点的前指针
size++;
System.out.println("在位置 " + index + " 插入节点: " + data);
}
/**
* 删除指定位置节点 - O(1)或O(n)时间复杂度
*
* 双链表删除的优势:
* - 删除头尾节点:O(1)
* - 删除中间节点:O(1)指针调整,但查找需要O(n)
*/
public void deleteAtIndex(int index) {
if (index < 0 || index >= size) {
System.out.println("索引无效: " + index);
return;
}
DoubleListNode toDelete = getNode(index);
if (size == 1) {
// 只有一个节点
head = null;
tail = null;
} else if (toDelete == head) {
// 删除头节点
head = head.next;
head.prev = null;
} else if (toDelete == tail) {
// 删除尾节点
tail = tail.prev;
tail.next = null;
} else {
// 删除中间节点:调整前后指针
toDelete.prev.next = toDelete.next;
toDelete.next.prev = toDelete.prev;
}
size--;
System.out.println("删除位置 " + index + " 的节点: " + toDelete.data);
}
/**
* 获取指定位置节点 - 优化版本
*
* 根据位置选择从头或从尾遍历
* 平均时间复杂度:O(n/2)
*/
private DoubleListNode getNode(int index) {
if (index < 0 || index >= size) return null;
DoubleListNode current;
// 优化:根据位置选择遍历方向
if (index < size / 2) {
// 从前向后遍历
current = head;
for (int i = 0; i < index; i++) {
current = current.next;
}
} else {
// 从后向前遍历
current = tail;
for (int i = size - 1; i > index; i--) {
current = current.prev;
}
}
return current;
}
/**
* 前向遍历:从头到尾
*/
public void printForward() {
DoubleListNode current = head;
System.out.print("前向遍历: null ⇄ ");
while (current != null) {
System.out.print(current.data);
if (current.next != null) {
System.out.print(" ⇄ ");
}
current = current.next;
}
System.out.println(" ⇄ null");
}
/**
* 后向遍历:从尾到头 - 单链表做不到!
*/
public void printBackward() {
DoubleListNode current = tail;
System.out.print("后向遍历: null ⇄ ");
while (current != null) {
System.out.print(current.data);
if (current.prev != null) {
System.out.print(" ⇄ ");
}
current = current.prev;
}
System.out.println(" ⇄ null");
}
/**
* 获取链表长度
*/
public int getSize() {
return size;
}
}
3.3 双链表操作流程详解
🔧 双链表插入操作流程:
头部插入详细步骤:
ini
初始状态:
null ⇄ head[1] ⇄ [2] ⇄ [3] ⇄ null
步骤1:创建新节点[0]
[0] : prev=null, next=null
步骤2:新节点指向原head
[0].next = [1]
[1].prev = [0]
步骤3:更新head指针
head = [0]
结果:
null ⇄ head[0] ⇄ [1] ⇄ [2] ⇄ [3] ⇄ null
中间插入详细步骤(在位置1插入):
ini
初始状态:
null ⇄ [1] ⇄ [3] ⇄ null
步骤1:找到位置1的节点[3]
current = [3]
步骤2:创建新节点[2]
[2] : prev=null, next=null
步骤3:调整四个指针:
[2].prev = [1] // 新节点前指
[2].next = [3] // 新节点后指
[1].next = [2] // 前节点后指
[3].prev = [2] // 后节点前指
结果:
null ⇄ [1] ⇄ [2] ⇄ [3] ⇄ null
3.4 双链表实战应用
应用场景1:浏览器历史记录功能
java
// 双链表实现支持前进后退的浏览器历史
DoubleLinkedList browserHistory = new DoubleLinkedList();
DoubleListNode currentPage = null;
// 访问页面
browserHistory.addAtTail("google.com");
browserHistory.addAtTail("github.com");
browserHistory.addAtTail("stackoverflow.com");
// 前进后退功能
public void goBack() {
if (currentPage != null && currentPage.prev != null) {
currentPage = currentPage.prev;
System.out.println("后退到: " + currentPage.data);
}
}
public void goForward() {
if (currentPage != null && currentPage.next != null) {
currentPage = currentPage.next;
System.out.println("前进到: " + currentPage.data);
}
}
应用场景2:音乐播放列表
java
// 双链表实现音乐播放器
DoubleLinkedList playlist = new DoubleLinkedList();
DoubleListNode currentSong = null;
// 添加歌曲
playlist.addAtTail("歌曲1");
playlist.addAtTail("歌曲2");
playlist.addAtTail("歌曲3");
// 上一曲/下一曲功能
public void previousSong() {
if (currentSong != null && currentSong.prev != null) {
currentSong = currentSong.prev;
System.out.println("播放上一曲: " + currentSong.data);
}
}
public void nextSong() {
if (currentSong != null && currentSong.next != null) {
currentSong = currentSong.next;
System.out.println("播放下一曲: " + currentSong.data);
}
}
4. 链表 vs 数组:全方位对比
4.1 性能对比详细分析
操作 | 数组 | 单链表 | 双链表 | 详细说明 |
---|---|---|---|---|
随机访问 | 🟢 O(1) | 🔴 O(n) | 🔴 O(n) | 数组通过索引直接计算内存地址 |
头部插入 | 🔴 O(n) | 🟢 O(1) | 🟢 O(1) | 链表只需调整1-2个指针 |
尾部插入 | 🟢 O(1) | 🔴 O(n) | 🟢 O(1) | 双链表有tail指针优势 |
中间插入 | 🔴 O(n) | 🟡 O(n) | 🟡 O(n) | 都需要查找位置,但链表插入本身O(1) |
头部删除 | 🔴 O(n) | 🟢 O(1) | 🟢 O(1) | 链表指针调整优势明显 |
尾部删除 | 🟢 O(1) | 🔴 O(n) | 🟢 O(1) | 双链表直接通过tail操作 |
内存使用 | 固定 | 动态 | 动态 | 链表按需分配,更灵活 |
内存布局 | 连续 | 分散 | 分散 | 数组缓存友好,链表指针开销 |
4.2 内存布局对比图示
数组内存布局:
ini
内存地址: 1000 1004 1008 1012 1016
数据: [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ]
特点:连续存储,缓存友好
链表内存布局:
ini
内存地址: 2000 3048 1500 4096
数据: [1|3048]→[2|1500]→[3|4096]→[4|null]
特点:分散存储,有指针开销
4.3 选择方案:什么时候用什么?
🟢 选择数组的情况:
适用场景:
- 需要频繁随机访问元素
- 数据量固定或可预测
- 对性能要求极高,需要缓存友好性
- 内存使用要求严格
具体例子:
java
// 游戏中的地图网格
int[][] gameMap = new int[100][100];
// 学生成绩表(固定数量)
int[] scores = new int[50];
// 图像像素数据
int[] pixels = new int[1920 * 1080];
🟢 选择单链表的情况:
适用场景:
- 频繁在头部插入删除
- 数据量变化很大,无法预测
- 内存比较紧张
- 只需要单向遍历
具体例子:
java
// 撤销操作栈
SingleLinkedList undoStack = new SingleLinkedList();
// 消息队列(先进先出)
SingleLinkedList messageQueue = new SingleLinkedList();
// 多项式表示(稀疏矩阵)
// 只存储非零项:系数|指数|next
🟢 选择双链表的情况:
适用场景:
- 需要双向遍历
- 频繁在头部和尾部操作
- 需要实现撤销/重做功能
- 内存充足,追求操作效率
具体例子:
java
// 浏览器历史记录
DoubleLinkedList browserHistory = new DoubleLinkedList();
// 音乐播放列表
DoubleLinkedList musicPlaylist = new DoubleLinkedList();
// 文本编辑器中的行存储
// 支持向上向下遍历
5. 链表实战技巧与常见问题
5.1 链表操作的核心技巧
技巧1:虚拟头节点(Dummy Node)
java
/**
* 虚拟头节点技巧:简化边界条件处理
*
* 优势:
* - 避免空链表特殊情况
* - 统一插入删除逻辑
* - 简化代码复杂度
*/
public class LinkedListWithDummy {
private SingleListNode dummyHead; // 虚拟头节点
public LinkedListWithDummy() {
dummyHead = new SingleListNode(0); // 数据域无意义
}
// 所有操作都通过dummyHead.next访问真实头节点
public void addAtHead(int data) {
SingleListNode newNode = new SingleListNode(data);
newNode.next = dummyHead.next; // 总是安全的!
dummyHead.next = newNode;
}
}
技巧2:快慢指针法
java
/**
* 快慢指针法:解决链表经典问题
*
* 应用场景:
* - 判断链表是否有环
* - 找到链表中间节点
* - 找到倒数第K个节点
*/
public SingleListNode findMiddleNode(SingleListNode head) {
if (head == null) return null;
SingleListNode slow = head; // 慢指针:每次1步
SingleListNode fast = head; // 快指针:每次2步
while (fast != null && fast.next != null) {
slow = slow.next; // 乌龟走1步
fast = fast.next.next; // 兔子走2步
}
return slow; // 当兔子跑到终点,乌龟就在中间
}
5.2 常见问题与解决方案
问题1:空指针异常
java
// ❌ 错误写法:可能抛出NullPointerException
public void badDelete(SingleListNode node) {
node.next = node.next.next; // 如果node.next为null就崩溃!
}
// ✅ 正确写法:先检查再操作
public void safeDelete(SingleListNode node) {
if (node != null && node.next != null) {
node.next = node.next.next;
}
}
问题2:循环引用(内存泄漏)
java
// ❌ 错误:创建循环引用
nodeA.next = nodeB;
nodeB.next = nodeA; // 循环引用,无法被垃圾回收!
// ✅ 正确:确保链表的最终指向null
nodeA.next = nodeB;
nodeB.next = null; // 正确终止
5.3 链表调试技巧
java
/**
* 增强的链表打印方法:包含更多调试信息
*/
public void debugPrintList() {
SingleListNode current = head;
int position = 0;
System.out.println("=== 链表调试信息 ===");
System.out.println("头节点: " + (head != null ? head.data : "null"));
System.out.println("链表长度: " + getLength());
System.out.println("详细内容:");
while (current != null) {
System.out.printf("位置%d: 数据=%d, next=%s\n",
position,
current.data,
current.next != null ? String.valueOf(current.next.data) : "null"
);
current = current.next;
position++;
}
System.out.println("===================");
}
6. 总结与进阶
📚 核心知识点回顾
单链表要点:
- 结构特点:每个节点包含数据和指向下一个节点的指针
- 操作复杂度 :
- 插入删除:O(1)到O(n)
- 查找访问:O(n)
- 适用场景:栈、队列、只需要单向遍历的场景
双链表要点:
- 结构特点:每个节点包含指向前后节点的指针
- 操作复杂度 :
- 头尾操作:O(1)
- 中间操作:O(n)查找 + O(1)调整
- 适用场景:需要双向遍历、频繁头尾操作的场景
选择策略总结:
- 随机访问多 → 选择数组
- 插入删除多 → 选择链表
- 只需要单向遍历 → 单链表
- 需要双向遍历 → 双链表
- 内存紧张 → 单链表
- 追求操作效率 → 双链表
推荐练习题目:
- 简单:反转链表、合并两个有序链表
- 中等:链表排序、重排链表、复制带随机指针的链表
- 困难:LRU缓存机制、LFU缓存
结语
链表作为数据结构家族中的重要成员,是每个程序员必须掌握的硬核技能。记住:理解指针操作是掌握链表的关键,多动手实践,在纸上画出指针的变化过程,你会对链表有更深的理解。 有任何问题欢迎在评论区留言,我会认真解答每一个问题!🚀