数据结构与算法 —— Java单链表从“0”到“1”

一、为什么要学习单链表

数组在开发中很常用,但有两个致命问题:

1、内存必须连续,扩容时需复制整个数组(如 ArrayListgrow() 方法)

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.nextnull,但仍会执行打印),适合「打印所有节点」;

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; 
}
  • 当链表为空时(HeadTail 均为 null),插入第一个节点后:
    • 该节点既是头节点(Head),也是尾节点(Tail),因此需要让 Tail 指向这个新节点;
    • 若不处理,后续尾插法可能因 Tailnull 导致空指针异常。
(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);
  • 避免了后续操作中因 HeadTailnull 导致的空指针异常。
(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--;
}
代码逻辑拆解
  1. 严格的索引合法性校验

    arduino 复制代码
    if (index < 0 || index >= size) {
        throw new IllegalArgumentException(String.format("index [%d] 不合法%n", index));
    }
    if (Head == null) { // 空链表无法删除
        throw new IllegalArgumentException(...);
    }
    • 索引范围必须在 [0, size-1]index >= size 直接越界);
    • 空链表删除操作直接抛异常,避免后续空指针。
  2. 删除头节点(特殊场景)

    ini 复制代码
    if (index == 0) {
        Head = Head.next; // 头节点后移
        if (Head == null) { // 若删除后链表为空
            Tail = null; // 尾节点同步置空
        }
    }
    • 头节点没有前驱,直接让 Head 指向原头节点的 next
    • 若删除后链表为空(Head == null),需将 Tail 也置为 null(保持头、尾指针逻辑一致)。
  3. 删除非头节点(通用场景)

    ini 复制代码
    else {
        Node<T> prev = findNode(index - 1); // 找到前驱节点
        Node<T> delete = prev.next; // 待删除节点
        prev.next = delete.next; // 前驱节点跳过待删除节点
        if (delete == Tail) { // 若删除的是尾节点
            Tail = prev; // 尾节点更新为前驱节点
        }
    }
    • 核心逻辑:通过前驱节点 prevnext 指针跳过待删除节点(prev.next = delete.next),使待删除节点脱离链表;
    • 特殊处理:若删除的是尾节点,需将 Tail 更新为前驱节点(确保 Tail 始终指向最后一个节点)。
  4. 更新长度

    arduino 复制代码
    size--;

    删除后链表长度减 1,保持 size 与实际长度一致。

到这里,单链表的核心操作就全部讲解完毕了。从节点定义到增删改查,我们一步步揭开了单链表的神秘面纱 ------ 它没有数组的连续内存限制,却通过指针的灵活跳转实现了高效的插入与删除,这种 "以空间换时间" 的设计思路,正是数据结构的魅力所在。

学习数据结构就像搭建积木,基础越扎实,越能组合出复杂而高效的算法。或许初期会对指针的跳转感到困惑,但只要多动手实现、多画图分析,就能慢慢掌握其中的规律。正如华罗庚所说:"聪明在于学习,天才在于积累。" 每一行代码的练习,每一次调试的思考,都是在为更复杂的算法世界铺路。

希望这篇文章能成为你数据结构学习路上的一块垫脚石,接下来不妨试试用单链表实现一个简单的队列或栈,让知识在实践中真正沉淀~

相关推荐
同元软控2 小时前
首批CCF教学案例大赛资源上线:涵盖控制仿真、算法与机器人等9大方向
算法·机器人·工业软件·mworks
yiqiqukanhaiba3 小时前
Linux编程笔记2-控制&数组&指针&函数&动态内存&构造类型&Makefile
数据结构·算法·排序算法
PKNLP3 小时前
逻辑回归(Logistic Regression)
算法·机器学习·逻辑回归
可触的未来,发芽的智生4 小时前
新奇特:神经网络的自洁之道,学会出淤泥而不染
人工智能·python·神经网络·算法·架构
放羊郎4 小时前
SLAM算法分类对比
人工智能·算法·分类·数据挖掘·slam·视觉·激光
Juan_20124 小时前
P1447题解
c++·数学·算法·题解
ai智能获客_狐狐4 小时前
智能外呼产品架构组成
人工智能·算法·自然语言处理·架构·语音识别
Algo-hx5 小时前
数据结构入门 (五):约束即是力量 —— 深入理解栈
数据结构·算法
芒果量化6 小时前
ML4T - 第7章第5节 用线性回归预测股票回报Prediction stock returns with linear regression
算法·回归·线性回归