一、为什么要学习单链表
数组在开发中很常用,但有两个致命问题:
1、内存必须连续,扩容时需复制整个数组(如 ArrayList
的 grow()
方法)
2、中间插入 / 删除元素时,需移动后续所有元素(时间复杂度 O (n))
而单链表「指针串联零散节点」,完美解决这两个问题:
1、内存无需连续,新增节点直接申请内存即可
2、插入/删除只需修改指针指向 (在找到目标节点的前驱节点的情况下,时间复杂度为O(1))
二、单链表核心原理
1、节点(Node)的定义:节点类(当前单链表的值设定为int类型)
kotlin
public static class Node<T>{
T data;
Node<T> next;
public Node(T data) {
this.data = data;
this.next = null;
}
public Node(T data,Node<T> next){
this.data = data;
this.next = next;
}
}
2、单链表的逻辑结构(简单图解说明)
kotlin
head(头节点) → 节点1(val=1, next=节点2) → 节点2(val=2, next=节点3) → 节点3(val=3, next=null)
头节点 head 是链表的"入口",若 head = null,则链表为空;
遍历链表必须从 head 开始,通过 next 依次访问下一个节点。
三、单链表核心操作
1、单链表的初始化
csharp
public static class List<T>{
Node<T> Head;//表示头节点
Node<T> Tail;//表示尾节点
int size;//表示链表的长度
public List() {
this.Tail = null;
this.Head = null;
this.size = 0;
}
public void addFirst(T data){
Head=new Node<>(data,Head);
if(Tail==null){
Tail=Head;
}
size++;
}
2、单链表的遍历
csharp
public void loop(){
Node<T> p=Head;
while(p!=null){
System.out.print(p.data+" ");
p=p.next;
}
}
要点:
-
为什么要用临时指针?
若直接用
Head = Head.next
遍历,会导致头节点被覆盖,后续无法再访问链表(链表「丢失」)。临时指针p
是遍历的标准做法。 -
循环条件 p != null vs p.next != null ?
用
p != null
:会遍历到最后一个节点(p
指向尾节点时,p.next
是null
,但仍会执行打印),适合「打印所有节点」;
用 p.next != null
:会提前终止(最后一个节点不打印),适合「找尾节点的前驱」等场景(如尾插法)。
-
性能优化:StringBuilder的作用
若直接用
System.out.print(p.data + " ")
,每次字符串拼接都会创建新对象(Java 中字符串不可变),遍历长链表时性能较差。StringBuilder
可减少内存开销。
3、单链表插入节点(3种场景)
1、头插法
顾名思义,就是从头部插入节点,代码实现如下:
ini
public void addFirst(T data){
Head=new Node<>(data,Head);//新节点的next指向原头节点
if(Tail==null){
Tail=Head;
}
size++;
}
(1)为什么要判断 Tail == null
?
ini
if (Tail == null) {
Tail = Head;
}
- 当链表为空时(
Head
和Tail
均为null
),插入第一个节点后:- 该节点既是头节点(
Head
),也是尾节点(Tail
),因此需要让Tail
指向这个新节点; - 若不处理,后续尾插法可能因
Tail
为null
导致空指针异常。
- 该节点既是头节点(
(2)size++
的必要性
size
记录链表长度,避免每次获取长度都需要遍历链表(O (n) → O (1));- 适合需要频繁判断链表长度的场景(如判断是否为空、限制最大长度等)。
2、尾插法
顾名思义,就是从尾部插入节点,代码实现如下:
ini
public void addLast(T data){
Node<T> newNode=new Node<>(data);
if(Tail==null){
Head=newNode;
Tail=newNode;
}
else{
Tail.next=newNode;
Tail=newNode;
}
size++;
}
(1)当链表为空时(Tail == null
)
- 新节点既是头节点(
Head = newNode
),也是尾节点(Tail = newNode
); - 避免了后续操作中因
Head
或Tail
为null
导致的空指针异常。
(2)当链表非空时(Tail != null
)
- 先让当前尾节点的
next
指向新节点(Tail.next = newNode
),将新节点链接到链表尾部; - 再将尾指针更新为新节点(
Tail = newNode
),确保Tail
始终指向最后一个节点。
头插法和尾插法的比较
学到这里,想必大家都学会两种方法的使用,但具体在什么场景下使用,我们用下面的表格给大家解释
操作 | 头插法 | 尾插法 |
---|---|---|
时间复杂度 | O (1)(无需遍历) | O (1)(若有 Tail 指针) |
适用场景 | 需高频在头部插入(如栈) | 需高频在尾部插入(如队列) |
链表顺序 | 插入顺序与链表顺序相反 | 插入顺序与链表顺序一致 |
3、单链表任意位置插入
前面我们掌握了头部插入 和尾部插入 这两种特殊位置的插入方式,但实际开发中,更多场景需要在链表的任意位置 插入节点 ------ 比如在第 3 个元素前插入新值,或在值为10
的元素后插入补充数据。这就需要更通用的插入逻辑,我们分两种情况来实现:
(1)按索引插入
如果我们知道要插入的索引,可以通过找到目标位置的前驱节点来完成插入。不过在此之前,我们需要一个辅助方法:根据索引查找节点。
这个方法的作用是:给定索引index
,返回链表中对应位置的节点(索引从 0 开始,若索引越界则返回null
)。实现逻辑如下:
ini
public Node<T> findNode(int index){
Node<T> p=Head;
int i=0;
while(p!=null){
if(index==i){
return p;
}
i++;
p=p.next;
}
return null;
}
有了findNode
方法,我们就可以实现按索引插入了,代码实现如下:
ini
public void insert(int index,T data){
if(index==0){//往下标为0的位置添加元素
addFirst(data);
return;
}
Node<T> prev = findNode(index - 1);
if(prev==null){//表示当前只有一个头节点,没有找到该节点的上一个节点
throw new IllegalArgumentException( String.format("index [%d] 不合法%n",index));
}
if(index==size){
Tail=prev.next;
}
prev.next=new Node<>(data,prev.next);
size++;
}
核心思路:
- 若插入位置是头部(
index=0
),直接复用addFirst
; - 否则找到
index-1
位置的前驱节点,通过前驱节点完成插入; - 若插入位置是尾部(
index=size
),需同步更新尾指针;
(2)按目标值插入
另一种常见场景是:我们不知道具体索引,但知道要在某个值为target
的节点后插入新节点,代码实现如下:
ini
public void insert2(T target,T data){
Node<T> p=Head;
while(p!=null){
if(p.data.equals(target)){
Node<T> newNode = new Node<>(data);
newNode.next=p.next;
p.next=newNode;
if(p==Tail){
Tail=newNode;
}
size++;
return;
}
p=p.next;
}
System.out.println("没有找到该元素对应的节点");
}
核心思路:
- 先找到值为
target
的节点,再以该节点为前驱完成插入; - 若目标节点是尾节点,插入后新节点会成为新的尾节点,需同步更新
Tail
;
按索引插入和按目标值插入的比较
按索引插入和按目标值插入虽然逻辑不同,但本质上都遵循单链表插入的核心原则:通过前驱节点的指针调整完成插入。接下来我们通过表格对比一下这两种方式的适用场景
操作 | insert(按索引插入) | insert(按目标值插入) |
---|---|---|
核心 | 索引(需知道插入位置) | 目标值(需知道插入位置的前驱节点值) |
时间复杂度 | O (n)(查找前驱节点 findNode 遍历) |
O (n)(遍历查找目标节点) |
适用场景 | 已知位置的插入(如在第 3 个元素前插入) | 已知前驱值的插入(如在值为 5 的元素后插入) |
4、单链表删除节点
按索引删除节点
ini
public void delete1(int index){
if(index<0||index>=size){
throw new IllegalArgumentException( String.format("index [%d] 不合法%n",index));
}
if(Head==null){
throw new IllegalArgumentException( String.format("index [%d] 不合法%n",index));
}
if(index==0){
Head=Head.next;
if(Head==null){
Tail=null;
}
}
else{
Node<T> prev = findNode(index - 1);
Node<T> delete = prev.next;
prev.next=delete.next;
if(delete==Tail){
Tail=prev;
}
}
size--;
}
代码逻辑拆解
-
严格的索引合法性校验
arduinoif (index < 0 || index >= size) { throw new IllegalArgumentException(String.format("index [%d] 不合法%n", index)); } if (Head == null) { // 空链表无法删除 throw new IllegalArgumentException(...); }
- 索引范围必须在
[0, size-1]
(index >= size
直接越界); - 空链表删除操作直接抛异常,避免后续空指针。
- 索引范围必须在
-
删除头节点(特殊场景)
iniif (index == 0) { Head = Head.next; // 头节点后移 if (Head == null) { // 若删除后链表为空 Tail = null; // 尾节点同步置空 } }
- 头节点没有前驱,直接让
Head
指向原头节点的next
; - 若删除后链表为空(
Head == null
),需将Tail
也置为null
(保持头、尾指针逻辑一致)。
- 头节点没有前驱,直接让
-
删除非头节点(通用场景)
inielse { Node<T> prev = findNode(index - 1); // 找到前驱节点 Node<T> delete = prev.next; // 待删除节点 prev.next = delete.next; // 前驱节点跳过待删除节点 if (delete == Tail) { // 若删除的是尾节点 Tail = prev; // 尾节点更新为前驱节点 } }
- 核心逻辑:通过前驱节点
prev
的next
指针跳过待删除节点(prev.next = delete.next
),使待删除节点脱离链表; - 特殊处理:若删除的是尾节点,需将
Tail
更新为前驱节点(确保Tail
始终指向最后一个节点)。
- 核心逻辑:通过前驱节点
-
更新长度
arduinosize--;
删除后链表长度减 1,保持
size
与实际长度一致。
到这里,单链表的核心操作就全部讲解完毕了。从节点定义到增删改查,我们一步步揭开了单链表的神秘面纱 ------ 它没有数组的连续内存限制,却通过指针的灵活跳转实现了高效的插入与删除,这种 "以空间换时间" 的设计思路,正是数据结构的魅力所在。
学习数据结构就像搭建积木,基础越扎实,越能组合出复杂而高效的算法。或许初期会对指针的跳转感到困惑,但只要多动手实现、多画图分析,就能慢慢掌握其中的规律。正如华罗庚所说:"聪明在于学习,天才在于积累。" 每一行代码的练习,每一次调试的思考,都是在为更复杂的算法世界铺路。
希望这篇文章能成为你数据结构学习路上的一块垫脚石,接下来不妨试试用单链表实现一个简单的队列或栈,让知识在实践中真正沉淀~