图解Java单链表:从节点定义到高频操作与实战避坑全解析
2026/9/16 21:21:35 网站建设 项目流程

1. 为什么先搞懂链表再动手写代码

链表几乎是每个Java开发者的第一道数据结构门槛。写业务代码时你可能一整年都碰不到手写链表的机会,但一到了面试、源码阅读、或者需要自己设计缓存和队列的场景,链表立刻变成绕不开的东西。LinkedListConcurrentLinkedQueueHashMap里红黑树的前身、AQS同步队列,底层全是节点加指针这套逻辑。说白了,链表不只是一道面试题,它是理解后续复杂数据结构的底座。

这篇内容适合正在学Java基础、准备校招或社招面试、或者单纯想把数据结构底子打牢的人。我会从单链表的节点定义开始,把插入、删除、查找、反转、快慢指针这些高频操作一个个拆开讲,配合“画图讲故事”的方式,把每一步指针变化的过程说清楚。你不用提前掌握什么高深知识,只要有最基础的Java语法,能看懂类和方法,就能跟下来。

标题里写了“图解”,我先把话说明白:真正的图得自己动手画,或者配合IDE里的调试器去观察。但我会用文字把这个“图”的演变过程一点点描述出来,告诉你每个时刻节点长什么样、箭头指向哪里。看完再动手敲一遍,比你闷头刷三十道题都管用。

写这篇文章之前,我把网上关于“链表”“单链表”“Java面试”的高频问题也过了一遍,发现大家问得最多的其实不是链表怎么写,而是这几个点:插入和删除时到底谁会丢、反转链表为什么老写错、如何判断有没有环。这些问题归根结底是同一个病因:对指针变化的中间过程没有形成画面感。所以这篇文章的全部重心,就是帮你建立这个画面感。

2. 单链表的结构设计与节点定义

2.1 节点是什么:一块存储加一根指针线

单链表的核心是“节点”,Java里就是一个普通的类。每个节点存两样东西:一个数据域,用来存放实际的值;一个指针域,用来指向下一个节点。用生活里的例子来理解,链表就像一列火车,每节车厢里装货物,车厢和车厢之间用挂钩连接。你从车头开始走,顺着挂钩一个一个摸过去,就能检查完所有车厢。

在Java里定义节点,最常见的写法是这样:

public class ListNode { public int val; public ListNode next; public ListNode() {} public ListNode(int val) { this.val = val; } public ListNode(int val, ListNode next) { this.val = val; this.next = next; } }

这种设计在力扣和《剑指Offer》里几乎成了默认标准。val存值,next存“下一个节点是谁”的引用。这里有个关键点:Java里的next本质上存的是对象引用,不是C语言那类指针,但你可以完全按照“指针”的思维去理解它——它指向的是堆内存里另一个ListNode对象的地址。面试时说“指针”也没问题,大家都能听懂。

注意:val的类型用什么取决于你的使用场景。面试和练习场景直接用int最省事;如果写通用工具类,可以改成泛型T,但链表操作的原理完全不变。我建议初学阶段先别上泛型,把逻辑理顺了再扩展。

2.2 头节点和哨兵节点:到底需不需要

接下来要讨论一个很容易让新手纠结的问题:链表要不要一个“头节点”?

这里的“头节点”有两种理解。第一种叫“头指针”,它只是一个引用变量,指向链表的第一个真实节点。链表为空时,头指针为null。大部分算法题和面试手写场景都采用这种模式。

第二种叫“哨兵节点”或“虚拟头节点”,英文里叫dummy node。它不是真实数据,而是一个人为造出来的占位节点。它的next指向真正的第一个数据节点。为什么要多造一个节点出来?因为当你在头部插入或删除节点时,真实链表的“头指针”会发生变化,代码里需要单独处理“头指针为空”“插到头节点前面”这类边界情况。有了哨兵节点,所有插入和删除都统一成“在某个节点后面操作”,边界逻辑大幅简化。

给你看一个具体例子。假如没有哨兵节点,在链表头部插入一个新节点,代码得这样写:

public void addFirst(int val) { ListNode newNode = new ListNode(val); newNode.next = head; head = newNode; }

逻辑其实也不麻烦,无非是新节点的next指向旧头,然后更新head。但如果你要在指定位置插入,尤其是插入位置恰好是0(头部),情况就开始变复杂了——你得先判断position == 0,然后走addFirst那一套,否则就是普通中间插入。这种“分支”会让代码显得啰嗦,出错概率也高。

用哨兵节点重构一下:

public void addAtIndex(int index, int val) { ListNode dummy = new ListNode(-1); dummy.next = head; ListNode prev = dummy; for (int i = 0; i < index; i++) { prev = prev.next; } ListNode newNode = new ListNode(val); newNode.next = prev.next; prev.next = newNode; head = dummy.next; }

可以看到,插入位置从index == 0到任意位置,走的是同一条逻辑路径:先找到目标位置的前驱节点,再完成两行指针操作。这就是哨兵节点的价值——用空间换逻辑一致性。

我的建议是:刷题阶段两种方式都要能写。力扣上很多链表题默认给你的就是“无哨兵头指针”,你直接操作它就行;但自己设计链表类时,我强烈建议内部维护一个哨兵节点,对外屏蔽掉边界复杂度。

2.3 一次性搭出双向可用的链表骨架

既然要彻底吃透单链表,我建议你别只写一个孤零零的ListNode类,而是把它装进一个MyLinkedList类里,把该有的方法都预留好。这样做的好处是后续加功能不需要改结构,调试也方便。

基本的类结构可以长这样:

public class MyLinkedList { private ListNode head; private int size; public MyLinkedList() { head = null; size = 0; } public boolean isEmpty() { return size == 0; } public int size() { return size; } public ListNode getHead() { return head; } }

size这个字段很多人会忽略。它的意义在于:一、查长度时不用遍历;二、判断索引是否越界;三、定位节点时可以少走几步。链表是“物理不连续、逻辑连续”的结构,没有size你永远得从head开始数,这在需要频繁获取长度的场景下是浪费。

到这里,底子就打好了。接下来进入最核心的部分——增删查改的具体操作。

3. 增删查改:单链表核心操作拆解

3.1 头部插入与尾部插入的快速实现

头部插入是最基础的操作。新节点进来,先让它的next指向当前head,再更新head指向新节点。顺序不能反。如果先更新head,你就把原来的链表首节点“弄丢”了,再也找不回起点。

public void addFirst(int val) { ListNode newNode = new ListNode(val); newNode.next = head; head = newNode; size++; }

尾插稍微麻烦一点。因为单链表没有反向索引,你得先从头走到尾,找到最后一个next == null的节点,再让它指向新节点。如果链表是空的,那新节点就直接成为head

public void addLast(int val) { ListNode newNode = new ListNode(val); if (head == null) { head = newNode; return; } ListNode cur = head; while (cur.next != null) { cur = cur.next; } cur.next = newNode; size++; }

这两段代码的逻辑都很直白。我的建议是:把“找到最后一个节点”这个操作单独抽成一个方法,比如getLastNode(),这样后面其他逻辑也能复用。写顺手之后你会发现,链表的很多操作本质都是在“找前驱节点”和“改引用”之间来回切换。

3.2 按值查找与按索引访问:一个需要防越界

链表查找有两种常见维度:按值找节点、按索引找节点。两者的写法很接近,但边界条件略有差异。

按值查找,就是遍历链表,比对每个节点的val

public ListNode find(int val) { ListNode cur = head; while (cur != null) { if (cur.val == val) { return cur; } cur = cur.next; } return null; }

按索引访问,要小心索引越界。比如get(int index),合法范围是0size-1,越界了直接返回-1(或者抛异常,看你的设计):

public int get(int index) { if (index < 0 || index >= size) { return -1; } ListNode cur = head; for (int i = 0; i < index; i++) { cur = cur.next; } return cur.val; }

这里有一个经常被忽视的性能细节:链表的索引访问是O(n)的。你在for循环里用linkedList.get(i)去遍历,总复杂度就是O(n²)。数组随机访问是O(1),链表做不到。所以能用迭代器或者for-each循环的情况下,尽量别用索引遍历。

3.3 中间插入与删除:关键在前驱节点

插入和删除是链表操作的灵魂,也最容易写错。先说插入。

要在第index个位置插入一个新节点,本质是“找到原链表中位于该位置的那个节点(目标节点),把新节点插到它前面”。但单链表只能向后走,你无法通过目标节点找到它的前驱。所以正确做法是:定位到目标节点前面的那个节点,让新节点串进去。

画一下这个过程。假设链表是A -> B -> C,我们要在B后面插入X,操作是两句:

  • 第一句:X.next = B.next,也就是让X的指针先指向C
  • 第二句:B.next = X,把B的指针改到X

两句话的顺序不能反。如果先执行B.next = X,那C的引用就丢了——你再也没有办法让X指向C。

用代码写出来是这样:

public void addAtIndex(int index, int val) { if (index < 0 || index > size) return; ListNode dummy = new ListNode(0); dummy.next = head; ListNode prev = dummy; for (int i = 0; i < index; i++) { prev = prev.next; } ListNode newNode = new ListNode(val); newNode.next = prev.next; prev.next = newNode; head = dummy.next; size++; }

你会发现,用了哨兵节点dummy之后,插入位置是0还是非0,处理逻辑完全一致。这就是前面说“哨兵节点简化边界”的现场演示。

删除操作也是同样的思路:找到目标节点的前驱,让前驱的next直接跨过目标节点,指向目标节点的下一个节点。那被“跳过”的节点呢?在Java里没有主动释放一说,因为没有引用指向它之后,GC会帮我们回收。

public void deleteAtIndex(int index) { if (index < 0 || index >= size) return; ListNode dummy = new ListNode(0); dummy.next = head; ListNode prev = dummy; for (int i = 0; i < index; i++) { prev = prev.next; } prev.next = prev.next.next; head = dummy.next; size--; }

这里需要注意:prev.next.next这段代码看起来有点吓人,但它就是“前驱的下一个节点的下一个节点”。比如链表是A -> B -> C,你要删除B,prev指向A,A.next指向B,那A.next.next就是C。把A.next改成C,B就被摘下来了。

我见过不少初学的小伙伴在这里绕不过来,总是想“让B自己指向null”之类。其实根本不需要。你只要问一个问题:还有没有引用来“访问”B?没有了,那它就会被垃圾回收。链表删除的核心就是改一条引用,仅此而已。

3.4 修改节点值与清空链表

修改操作比较简单,按索引找到对应节点,覆盖val

public void set(int index, int val) { if (index < 0 || index >= size) return; ListNode cur = head; for (int i = 0; i < index; i++) { cur = cur.next; } cur.val = val; }

清空链表有两种理解。第一种是清空所有节点,只需要把head设为null即可,后面整条链没有被引用,GC会统一回收:

public void clear() { head = null; size = 0; }

第二种是只把指针关系解除,逐个把每个节点的next置为null,这种操作在特定场景(比如你想立刻释放大对象引用)才有意义。日常用第一种就够了。

到这里,链表最常规的增删查改就讲完了。你现在应该有底气说“单链表的基本操作我能写了”。但题目既然叫“图解链表”,只聊常规操作明显不够。面试和实战里更常考的是几个“稍微绕一下”的操作,它们才是真正检验你有没有理解指针的试金石。

4. 五个高频进阶操作图解与实现

4.1 反转链表:几乎每次面试都会出现

链表面试里出镜率最高的题目,没有之一。力扣206题,反转一个单链表。输入1 -> 2 -> 3 -> 4 -> 5,期望输出5 -> 4 -> 3 -> 2 -> 1

最经典的解法是迭代法,三行核心代码:

public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode nextTemp = cur.next; cur.next = prev; prev = cur; cur = nextTemp; } return prev; }

我一步步画给你看。

初始状态:prev = nullcur = 11 -> 2 -> 3 -> null

  • 第一轮循环:先用nextTemp保存cur.next,也就是2。然后把cur.next指向prev,这时1 -> null。更新prev = 1cur = 2
  • 第二轮循环:nextTemp = 3cur.next = prev,也就是2 -> 1 -> null。更新prev = 2cur = 3
  • 第三轮循环:nextTemp = nullcur.next = prev,也就是3 -> 2 -> 1 -> null。更新prev = 3cur = null
  • 退出循环,返回prev,此时它就是新链表的头。

这里最容易写错的地方是把“先用临时变量保存下一个节点”漏了。如果没有nextTemp,你在执行cur.next = prev之后,就再也找不到原始的下一个节点了,循环根本走不下去。这个临时变量不是可有可无,而是整个算法的生命线。

记忆技巧:反转链表就是在原地把每个节点的箭头调个头。调头之前先抓住下一个节点,别让它跑了。

4.2 找中间节点:快慢指针的入门案例

如果不允许提前遍历获取长度,如何找到单链表的中点?用快慢指针。

慢指针每次走一步,快指针每次走两步。当快指针走到链表末尾时,慢指针恰好走到中间位置。这个原理用物理类比特别容易理解:两个人同向跑步,甲速度是乙的两倍,同时出发,当甲到达终点时,乙正好在路程的一半处。

public ListNode middleNode(ListNode head) { ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; } return slow; }

这个写法里有个细节值得单独拿出来讲:while循环的条件为什么是fast != null && fast.next != null,而不是只判断fast != null

因为快指针一次走两步,如果链表节点数是奇数,快指针最终会落在最后一个节点上,此时fast.nextnull;如果链表节点数是偶数,快指针最终会走到null。这两个条件分别对应这两种情况,缺一个就会在循环体内访问fast.next.next时抛出空指针异常。

设想链表只有1 -> 2两个节点。慢指针在1,快指针在1,循环条件检查fast != null成立,fast.next != null成立(1的next是2),于是慢走到2,快走到null。下一轮判断fast != null不成立,循环退出,慢指针停在2。此时2是后半部分的起点,也符合力扣对“中间节点”的界定(偶数长度返回第二个中间节点)。

如果你希望偶数长度时返回前一个中间节点,只需要让快指针从head.next开始走就行:

ListNode slow = head; ListNode fast = head.next;

这个微调在找链表回文和二分查找场景中经常会用到,建议顺手记一下。

4.3 判断链表是否有环:快慢指针的进阶用法

环形链表的检测,也是面试常客。题目描述很简单:给定一个链表的头节点,判断链表中是否有环。所谓环,就是链表的某个节点的next指回了它前面的某个节点,导致你永远走不到尽头。

最直观的解法是借助HashSet,遍历链表,每次都把当前节点加入集合。如果某个节点重复出现,说明有环。这个解法正确,但空间复杂度是O(n)。

快慢指针做这件事更优雅。两个指针从同一个起点出发,快指针每次走两步,慢指针每次走一步。如果链表没有环,快指针会先走到null;如果有环,两个指针一定会相遇。

为什么一定会相遇?画个环形操场跑步的例子:假设操场的跑道是环形的,甲的速度是乙的两倍。乙跑到半圈时,甲跑完了一圈。再过一段时间,从甲“追上”乙的那一刻开始,甲每次都比乙多跑两倍的距离,两者的差距在不断缩小,直到完全重叠。数学上可以证明,无论环的起点在哪里,快的那个最终都会“套圈”追上慢的。

public boolean hasCycle(ListNode head) { if (head == null || head.next == null) return false; ListNode slow = head; ListNode fast = head.next; while (slow != fast) { if (fast == null || fast.next == null) { return false; } slow = slow.next; fast = fast.next.next; } return true; }

注意这里我把快慢指针的起点错开了(fast = head.next),这不是必须的,但这样能让两指针在环内更快相遇。两种写法都能通过测试,起点相同时,两个指针都从链表头部出发,在环内兜圈的相对关系依然成立,只是第一次相遇的时机略有不同。

面试时被问“为什么快慢指针相遇就一定有环”,你只要抓住一个核心点回答即可:进入环之后,快指针相对于慢指针的速度是每次一步,所以两者的距离会单调减少到零。

4.4 合并两个有序链表:常规操作的综合运用

力扣21题,合并两个升序链表,要求合并后依然有序。这个题在业务开发里也很有现实意义,比如合并两个有序日志队列、合并两个有序ID集合。

最常见的解法是使用哨兵节点加双指针遍历:

public ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode cur = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { cur.next = l1; l1 = l1.next; } else { cur.next = l2; l2 = l2.next; } cur = cur.next; } cur.next = l1 != null ? l1 : l2; return dummy.next; }

这个题之所以说是“综合运用”,是因为它同时涉及了哨兵节点、指针移动、链表的拼接这些基础能力。你还记得上面说的哨兵节点吗?在这里有了更直观的价值:不直接用cur = null起步,而是让dummy.next最终指向合并结果的头,无论l1还是l2谁先被选中,头都不会丢。

最后那行cur.next = l1 != null ? l1 : l2是个小优化。当一个链表被遍历完后,剩下的另一半链表整体拼上去即可,不需要再逐个节点接入。

如果面试官要求“不能使用额外节点”的解法,那本质上就变成了在原链表上改动指向,思路一样,只是省掉了dummy。但说实话,用哨兵节点写出来的代码可读性和健壮性都更好,面试时先用这个版本写对,再讨论优化空间,比一上来就炫技稳得多。

4.5 删除倒数第N个节点:双指针的经典场景

“删除链表的倒数第N个节点”,力扣19题。思路是用两个指针保持n+1的距离,快指针先走n+1步,然后快慢一起走。快指针走到末尾时,慢指针正好停在要删除的节点的前驱位置。

先看图解。链表为1 -> 2 -> 3 -> 4 -> 5,要删除倒数第2个节点(也就是4)。

  • 快指针先走3步(n+1),走到3。
  • 慢指针从head出发,快指针继续走,两者同步移动。
  • 快指针走到null时,慢指针走到3,3.next就是4,执行删除。

代码:

public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode fast = dummy; ListNode slow = dummy; for (int i = 0; i <= n; i++) { fast = fast.next; } while (fast != null) { fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return dummy.next; }

这里为什么需要哨兵节点?因为如果要删除的节点恰好是头节点,没有哨兵的话,返回新的head会非常别扭。用了dummy之后,不管删除哪个节点,统一返回dummy.next即可。

这里“快指针先走n+1步”是关键。为什么不是n步?因为我们要让慢指针最终落在被删节点的前驱,而不是被删节点本身。前驱和被删节点的距离相差1,那么快指针和慢指针的初始距离也应该相差1,这样同步移动后才能落实到位。数字敏感性就在这里体现:写错一步,整个逻辑全偏。

5. 避坑指南:链表操作中最常见的失败模式

5.1 断链:修改引用前必须先“抓住”依赖节点

链表操作翻车,十次有九次是“断链”。什么叫断链?就是你原本依赖某个节点来定位后续路径,结果你把这个节点指向别的地方了,旧路径就彻底找不回来。

举一个非常经典的错误写法。反转链表时,如果把临时变量省略掉:

public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { cur.next = prev; // 危险!下一步就没法拿到原始的下一个节点了 prev = cur; cur = cur.next; // cur.next已经变成prev了,这里会进入死循环 } return prev; }

这段代码执行后,cur = cur.next拿到的根本不是原始链表的下一个节点,而是已经被改成prev的旧引用,于是链表会在当前位置打转,永远走不到终点。

应对断链的方法是记住一个原则:任何“修改指向”的操作之前,先确认你还能从某个变量找到原始的后续路径。如果找不到了,先在修改前用一个临时变量保存它。

5.2 空指针:while循环条件的判断顺序很关键

链表为空的情况处处存在。不管你是遍历、查找、删除还是反转,第一步都得确认当前节点不为空。写完node.next前,先问自己:这个node会不会是null

标准的那句while (cur != null && cur.next != null),两个条件的顺序是有讲究的。&&有短路特性,先判断cur != null,如果为假就不会执行后面的cur.next != null,从而避免空指针。如果把顺序反过来,curnull时直接调用cur.next,啪,异常就来了。

还有一种隐藏的空指针场景出现在递归解法里。用递归反转链表时,递归终止条件通常写成:

if (head == null || head.next == null) { return head; }

这里两个条件也必须按顺序写。如果先判断head.next,当链表为空时一样会空指针。看似微不足道的小顺序,在实际运行中就是一道明确的分界线。

5.3 循环引用:调试时发现链表打印不完了

如果你写了个方法打印链表,结果程序疯狂输出,多半是链表里被搞出了环。常见的来源有两个:一是反转时漏了“抓住下一个节点”导致自循环,二是在插入时把节点的next指向了它自身。

举个例子。你想在头节点前插入一个新节点,错误的写法是:

newNode.next = newNode; // 指向自己 head = newNode;

这代码运行后,newNodenext指向自己,链表从newNode开始就进死循环了。打印链表时会无限输出同一个值,内存占用蹭蹭涨。

排查的方法很简单:写一个“带步数上限”的打印函数,比如最多打印size + 5个节点,超过就提示可能有环。或者用前面讲的快慢指针判断,定位问题节点。

5.4 用IDE调试器观察链表结构的高效姿势

很多人写链表,最爱用System.out.println打印。但打印只能看到值,看不到节点之间的引用关系。我推荐直接在IntelliJ IDEA里打断点,然后看调试面板。

IntelliJ的Debugger对链表结构有特殊优化,它会以类似图形的形式展示每个节点的valnext,你可以一层层展开,非常直观。如果不是IDEA,用VS Code或者Eclipse也都有类似的变量查看面板。

说一句实在话:看懂别人画的图和自己动手在调试器里看着指针变化,是完全不同的体验。后者对你的空间理解能力提升是巨大且不可替代的。学链表这几周里,请务必把调试器用起来,这比任何教程都靠谱。

5.5 常用自测用例清单

为了尽可能覆盖边界情况,我整理了一份链表自测时可以照着走的用例。每次写完链表方法,不要只测一个正常用例就收工,至少要跑一遍下面这些:

场景用例期望结果
空链表对空链表执行查找、删除、反转不抛异常,返回null或空结果
单节点链表对单个节点执行删除、反转操作后链表为空或返回该节点自身
双节点链表反转、找中间节点不丢节点,中间节点位置正确
头部操作在头部插入、删除头指针正确更新
尾部操作在尾部插入、删除尾节点正确更新
越界索引index为负数、等于size、大于size操作被安全拒绝
重复值链表查找重复值返回第一个匹配节点
环形链表判断是否有环返回true,不进入死循环

这份清单并不复杂,但价值很高。我在带新人做代码评审时,看到链表相关的代码都会天然多一分谨慎,就是因为链表出错非常隐蔽,逻辑上看着对,运行起来却很容易翻车。把这套清单练熟了,是在低成本地给代码上保险。

6. 写在最后的一点经验

回头再看“链表”这个题目,你会发现它本质上是一个“引用操作游戏”。Java没有显式指针,但next字段的作用和指针一模一样。所有单链表的操作都可以归结为两条规则:想清楚当前节点的前驱是谁;修改指向之前先抓住接下来要访问的节点。

我自己刚学链表时也走过弯路,总想背代码、背模板,结果隔几天就忘,一到变种题就傻眼。后来换了方法,每道题都手动画一遍节点的箭头变化,画完再用代码复现,理解速度反而快了很多。这个习惯后来一直保留到现在,看源码遇到看不懂的链表结构,第一反应也是掏出纸笔画一画。

如果你正在准备面试,我建议不要只满足于会写,还要能说清楚“为什么这样写”。把每一段的指针变化用一句话讲明白,比死记硬背二十道链表题管用得多。链表是后面栈、队列、图、哈希表的基础,花上两三天彻底吃透,收益可以覆盖整个数据结构的学习周期。

最后送你一个小技巧:写任何链表代码之前,先定义清楚三个东西——当前遍历指针、前驱指针、临时保存指针。三者各司其职,思路清晰了,代码基本不会错。愿你早日建立起对指针变化的画面感,链表这座山翻过去之后,后面就是一马平川了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询