
1. 为什么先搞懂链表再动手写代码链表几乎是每个Java开发者的第一道数据结构门槛。写业务代码时你可能一整年都碰不到手写链表的机会但一到了面试、源码阅读、或者需要自己设计缓存和队列的场景链表立刻变成绕不开的东西。LinkedList、ConcurrentLinkedQueue、HashMap里红黑树的前身、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 按值查找与按索引访问一个需要防越界链表查找有两种常见维度按值找节点、按索引找节点。两者的写法很接近但边界条件略有差异。按值查找就是遍历链表比对每个节点的valpublic ListNode find(int val) { ListNode cur head; while (cur ! null) { if (cur.val val) { return cur; } cur cur.next; } return null; }按索引访问要小心索引越界。比如get(int index)合法范围是0到size-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你要删除Bprev指向AA.next指向B那A.next.next就是C。把A.next改成CB就被摘下来了。我见过不少初学的小伙伴在这里绕不过来总是想“让B自己指向null”之类。其实根本不需要。你只要问一个问题还有没有引用来“访问”B没有了那它就会被垃圾回收。链表删除的核心就是改一条引用仅此而已。3.4 修改节点值与清空链表修改操作比较简单按索引找到对应节点覆盖valpublic 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.next为null如果链表节点数是偶数快指针最终会走到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题。思路是用两个指针保持n1的距离快指针先走n1步然后快慢一起走。快指针走到末尾时慢指针正好停在要删除的节点的前驱位置。先看图解。链表为1 - 2 - 3 - 4 - 5要删除倒数第2个节点也就是4。快指针先走3步n1走到3。慢指针从head出发快指针继续走两者同步移动。快指针走到null时慢指针走到33.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即可。这里“快指针先走n1步”是关键。为什么不是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从而避免空指针。如果把顺序反过来cur为null时直接调用cur.next啪异常就来了。还有一种隐藏的空指针场景出现在递归解法里。用递归反转链表时递归终止条件通常写成if (head null || head.next null) { return head; }这里两个条件也必须按顺序写。如果先判断head.next当链表为空时一样会空指针。看似微不足道的小顺序在实际运行中就是一道明确的分界线。5.3 循环引用调试时发现链表打印不完了如果你写了个方法打印链表结果程序疯狂输出多半是链表里被搞出了环。常见的来源有两个一是反转时漏了“抓住下一个节点”导致自循环二是在插入时把节点的next指向了它自身。举个例子。你想在头节点前插入一个新节点错误的写法是newNode.next newNode; // 指向自己 head newNode;这代码运行后newNode的next指向自己链表从newNode开始就进死循环了。打印链表时会无限输出同一个值内存占用蹭蹭涨。排查的方法很简单写一个“带步数上限”的打印函数比如最多打印size 5个节点超过就提示可能有环。或者用前面讲的快慢指针判断定位问题节点。5.4 用IDE调试器观察链表结构的高效姿势很多人写链表最爱用System.out.println打印。但打印只能看到值看不到节点之间的引用关系。我推荐直接在IntelliJ IDEA里打断点然后看调试面板。IntelliJ的Debugger对链表结构有特殊优化它会以类似图形的形式展示每个节点的val和next你可以一层层展开非常直观。如果不是IDEA用VS Code或者Eclipse也都有类似的变量查看面板。说一句实在话看懂别人画的图和自己动手在调试器里看着指针变化是完全不同的体验。后者对你的空间理解能力提升是巨大且不可替代的。学链表这几周里请务必把调试器用起来这比任何教程都靠谱。5.5 常用自测用例清单为了尽可能覆盖边界情况我整理了一份链表自测时可以照着走的用例。每次写完链表方法不要只测一个正常用例就收工至少要跑一遍下面这些场景用例期望结果空链表对空链表执行查找、删除、反转不抛异常返回null或空结果单节点链表对单个节点执行删除、反转操作后链表为空或返回该节点自身双节点链表反转、找中间节点不丢节点中间节点位置正确头部操作在头部插入、删除头指针正确更新尾部操作在尾部插入、删除尾节点正确更新越界索引index为负数、等于size、大于size操作被安全拒绝重复值链表查找重复值返回第一个匹配节点环形链表判断是否有环返回true不进入死循环这份清单并不复杂但价值很高。我在带新人做代码评审时看到链表相关的代码都会天然多一分谨慎就是因为链表出错非常隐蔽逻辑上看着对运行起来却很容易翻车。把这套清单练熟了是在低成本地给代码上保险。6. 写在最后的一点经验回头再看“链表”这个题目你会发现它本质上是一个“引用操作游戏”。Java没有显式指针但next字段的作用和指针一模一样。所有单链表的操作都可以归结为两条规则想清楚当前节点的前驱是谁修改指向之前先抓住接下来要访问的节点。我自己刚学链表时也走过弯路总想背代码、背模板结果隔几天就忘一到变种题就傻眼。后来换了方法每道题都手动画一遍节点的箭头变化画完再用代码复现理解速度反而快了很多。这个习惯后来一直保留到现在看源码遇到看不懂的链表结构第一反应也是掏出纸笔画一画。如果你正在准备面试我建议不要只满足于会写还要能说清楚“为什么这样写”。把每一段的指针变化用一句话讲明白比死记硬背二十道链表题管用得多。链表是后面栈、队列、图、哈希表的基础花上两三天彻底吃透收益可以覆盖整个数据结构的学习周期。最后送你一个小技巧写任何链表代码之前先定义清楚三个东西——当前遍历指针、前驱指针、临时保存指针。三者各司其职思路清晰了代码基本不会错。愿你早日建立起对指针变化的画面感链表这座山翻过去之后后面就是一马平川了。