链表重要节点全解:倒数K、相交与入环口的双指针套路 📅 发布时间:2026/8/31 3:01:47 👁 浏览次数: 作为一个经常刷题、也经常在线上排查链表类问题的开发者我对“节点”这个词一直比较敏感。普通节点增删改查也就罢了但有些节点非常特殊比如链表的倒数第 K 个节点、两个链表的相交节点、环形链表的入环口节点。这些节点往往不按常理出现如果没有掌握固定的解法套路面试时会卡壳线上排查时也容易绕晕。本文就用一套完整、可复现的思路把这些“重要节点”一次性讲透。全文包含链表基础概念回顾、环境准备、核心算法原理解析、完整 Java 代码示例、常见错误与调试技巧以及工程落地的建议。无论你是正在准备算法面试的开发者还是在日常开发中需要手写链表操作的工程师都可以直接参考本文的代码和思路。1. 背景与核心概念1.1 什么是链表中的“重要节点”先来明确一个基础概念。链表Linked List是一系列节点Node通过指针串联起来的线性数据结构。每一个节点通常包含两部分数据域存储实际值例如int val。指针域存储下一个节点的引用例如ListNode next。在单链表中最后一个节点的next指向null表示链表结束。// 文件路径ListNode.java public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val val; } ListNode(int val, ListNode next) { this.val val; this.next next; } }在日常业务代码中链表虽然没有数组那么常用但在操作系统内核、内存管理、LRU 缓存、浏览器历史记录、音乐播放列表等场景中链表都扮演着不可替代的角色。而在算法面试中链表更是高频考点。本文所说的“重要节点”特指以下几类节点类型典型问题实际场景倒数第 K 个节点删除链表倒数第 N 个节点日志系统回放最近 N 条记录相交节点找到两个链表的第一个公共节点服务调用链合并分析环形入口节点判断链表是否有环并返回入环点内存池循环检测、循环任务队列中间节点找到链表中间节点快排链表时寻找分割点1.2 为什么这些节点容易出问题这些“重要节点”之所以容易出现理解偏差原因主要有三个。第一链表无法像数组那样通过下标随机访问。数组想取倒数第 K 个元素直接通过arr.length - K就能拿到链表不行必须从头遍历。第二链表指针操作容易产生空指针。很多同学在处理next.next时没有判空导致NullPointerException。第三边界条件多。空链表、只有一个节点、只有两个节点、K 等于链表长度、K 大于链表长度等场景都需要单独考虑。下面我们通过一套系统的解法把这几个关键节点全部拿下。2. 环境准备与版本说明本文的代码使用 Java 编写借助 JDK 自带的语法特性不需要额外安装第三方库。为了验证算法结果我们还需要一个 main 方法或者单元测试环境。推荐环境如下工具说明JDK8 或更高版本本文代码使用 JDK 8 语法高版本同样兼容构建工具Maven 或 Gradle 均可不引入额外依赖时也可以直接用javacIDEIntelliJ IDEA、Eclipse 或 VS Code 均可操作系统Windows / macOS / Linux 均可如果你不想创建 Maven 工程可以直接把ListNode.java和Main.java放在同一个目录下使用命令行编译运行javac ListNode.java Main.java java Main下面创建项目结构linkedlist-demo/ ├── ListNode.java ├── Solution.java └── Main.javaListNode.java是节点定义Solution.java里存放各个算法方法Main.java用于运行测试用例。3. 核心算法原理解析在开始写代码之前先把几个核心技巧讲清楚。后面的所有例题都会用到这些思想。3.1 双指针法双指针是链表问题中使用频率最高的技巧。它通常包含两种形式快慢指针一个指针每次走一步另一个指针每次走两步。常用于环形检测、找中间节点。前后指针两个指针起点相同但一个先走 K 步另一个再出发。常用于找倒数第 K 个节点。以“找倒数第 K 个节点”为例思路是这样的定义两个指针fast和slow初始都指向头节点head。fast先走 K 步。此时fast和slow之间相差 K 个节点。fast和slow同时向后移动每次一步。当fast走到链表末尾的null时slow恰好指向倒数第 K 个节点。这种做法的空间复杂度是 O(1)时间复杂度是 O(n)只需要一次完整遍历。为什么不让slow先走因为链表长度未知无法直接确定倒数第 K 个节点的位置。双指针通过让fast充当“测量尺”巧妙地避免了计算链表长度。3.2 哈希表法用哈希表记录访问过的节点也是处理链表问题的常用手段。其核心思路是遍历链表把每个节点的引用存入HashSet。如果某个节点已经存在于集合中说明该节点被重复访问通常意味着链表有环或存在公共节点。哈希表法的优势是思路直观适合在不要求最优空间复杂度的情况下快速解决问题劣势是空间复杂度为 O(n)在面试中通常会要求进一步优化到 O(1)。3.3 数学推理法在“环形链表入环口”问题中仅靠双指针还不能直接得到答案。我们需要结合数学推理。假设链表头到入环口的距离为a入环口到快慢指针相遇点的距离为b相遇点继续走到入环口的距离为c环的长度为L b c。快指针速度是慢指针的两倍。当两者相遇时慢指针走的距离为a b。快指针走的距离为a b n * L其中n是快指针在环内多走的圈数。因为快指针走的距离是慢指针的两倍所以a b n * L 2 * (a b)化简得到a n * L - b又因为L b c所以a n * (b c) - b (n - 1) * L c当n 1时a c。这意味着当快慢指针在环内相遇后把快指针重新指向头节点然后快慢指针以相同速度前进它们再次相遇的位置就是入环口。这就是“相遇后从头再来”的数学原理。4. 完整实战案例链表重要节点问题全解下面进入实战环节。我们会依次实现以下四个方法找到链表倒数第 K 个节点。删除链表倒数第 N 个节点。找到两个链表的第一个相交节点。找到环形链表的入环口节点。所有代码都放在Solution.java中最后在Main.java中编写测试用例验证。4.1 创建项目结构先创建三个 Java 文件linkedlist-demo/ ├── ListNode.java ├── Solution.java └── Main.javaListNode.java的代码在前面已经给出。这里我们再补充一个getIntersectionNode不修改原始链表的要求因此所有方法都不会改变入参链表的原有结构。4.2 找到链表倒数第 K 个节点题目描述给定一个单链表的头节点head找到链表中倒数第 K 个节点。例如链表1 - 2 - 3 - 4 - 5倒数第 2 个节点是4。// 文件路径Solution.java public class Solution { /** * 找到链表倒数第 K 个节点 * 思路双指针fast 先走 K 步然后 fast 和 slow 一起走 * 当 fast 走到 null 时slow 就是倒数第 K 个节点 */ public ListNode getKthFromEnd(ListNode head, int k) { if (head null || k 0) { return null; } ListNode fast head; ListNode slow head; // fast 先走 k 步 for (int i 0; i k; i) { if (fast null) { // k 大于链表长度不存在倒数第 k 个节点 return null; } fast fast.next; } // fast 和 slow 同时走 while (fast ! null) { fast fast.next; slow slow.next; } return slow; } }关键点说明k 0属于非法参数直接返回null。fast先走k步时如果中途遇到null说明k大于链表长度不存在倒数第 K 个节点。当fast为空时slow刚好指向目标节点。4.3 删除链表倒数第 N 个节点题目描述给定一个链表删除链表的倒数第 N 个节点并返回链表的头节点。例如链表1 - 2 - 3 - 4 - 5删除倒数第 2 个节点后链表变为1 - 2 - 3 - 5。这个问题比“找到倒数第 K 个节点”多了一步需要找到待删除节点的前驱节点。因此我们引入虚拟头节点dummy node来简化边界处理。// 文件路径Solution.java追加到 Solution 类中 /** * 删除链表倒数第 N 个节点 * 思路虚拟头节点 双指针找到倒数第 N 个节点的前驱 */ public ListNode removeNthFromEnd(ListNode head, int n) { if (head null || n 0) { return head; } // 虚拟头节点避免处理“删除头节点”的特殊情况 ListNode dummy new ListNode(0); dummy.next head; ListNode fast dummy; ListNode slow dummy; // fast 先走 n 步 for (int i 0; i n; i) { if (fast null) { return head; } fast fast.next; } // fast 和 slow 同时走直到 fast 到达末尾 while (fast.next ! null) { fast fast.next; slow slow.next; } // 此时 slow 是倒数第 n 个节点的前驱 slow.next slow.next.next; return dummy.next; }为什么需要虚拟头节点如果要删除的是正数第一个节点也就是头节点直接修改head会非常麻烦。引入dummy后所有删除操作都统一通过slow.next slow.next.next完成代码更整洁也避免了空指针。注意一点这个实现中fast先走n步但fast和slow都从dummy出发。之所以最后判断fast.next ! null是因为我们要让slow停在待删节点的前驱位置上而不是待删节点自身。4.4 找到两个链表的第一个相交节点题目描述输入两个链表找出它们的第一个公共节点。如果两个链表没有交点返回null。下图展示两个链表在节点c1相交a1 - a2 - c1 - c2 - c3 ^ b1 - b2 - b3 ---这道题有两个经典解法哈希表法遍历链表 A把所有节点放入HashSet再遍历链表 B第一个在集合中出现的节点即为交点。双指针法两个指针分别从 headA 和 headB 出发遍历完当前链表后转而遍历另一个链表。如果两个链表相交两个指针最终会在交点相遇如果不相交两个指针最终都会走到null。双指针法的代码实现如下// 文件路径Solution.java追加到 Solution 类中 /** * 找到两个链表的第一个相交节点 * 思路双指针pA 走完 A 走 BpB 走完 B 走 A最终在交点相遇 */ public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA null || headB null) { return null; } ListNode pA headA; ListNode pB headB; while (pA ! pB) { pA (pA null) ? headB : pA.next; pB (pB null) ? headA : pB.next; } return pA; }这段代码非常精简但理解起来可能有些绕。我们拆解一下假设链表 A 的长度为a c链表 B 的长度为b c其中c是公共部分的长度。指针pA走完链表 A 后换到链表 B 的头部继续走。指针pB走完链表 B 后换到链表 A 的头部继续走。两个指针最终走过的路程分别为a c b和b c a路程相等所以它们必然同时到达交点。如果两个链表没有交点pA和pB最后都指向null循环结束返回null。这种解法的时间复杂度为 O(n)空间复杂度为 O(1)是面试中最推荐的实现。4.5 找到环形链表的入环口节点题目描述给定一个链表如果链表中有环返回入环口的第一个节点如果无环返回null。先判断是否有环使用快慢指针// 文件路径Solution.java追加到 Solution 类中 /** * 找到环形链表的入环口节点 * 思路快慢指针先判断是否有环相遇后一个指针从头出发再相遇即为入环口 */ public ListNode detectCycle(ListNode head) { if (head null || head.next null) { return null; } ListNode slow head; ListNode fast head; // 判断是否有环 boolean hasCycle false; while (fast ! null fast.next ! null) { slow slow.next; fast fast.next.next; if (slow fast) { hasCycle true; break; } } // 无环直接返回 null if (!hasCycle) { return null; } // 有环fast 重新指向头节点然后和 slow 以相同速度前进 fast head; while (fast ! slow) { fast fast.next; slow slow.next; } return fast; }代码的执行过程可以拆成两个阶段第一阶段快慢指针从头节点出发。slow每次走一步fast每次走两步。如果链表有环fast一定会在某个时刻追上slow此时两者相遇。第二阶段把fast重新指向head然后slow和fast都每次走一步。根据前面 3.3 节的数学推导它们再次相遇的位置就是入环口。这种做法的空间复杂度为 O(1)时间复杂度为 O(n)。相比哈希表法它不需要额外存储节点引用更适合在面试中展示算法功底。4.6 编写测试用例并运行现在我们把所有方法放到一起编写Main.java来验证结果。// 文件路径Main.java public class Main { public static void main(String[] args) { Solution solution new Solution(); // 测试 1找到倒数第 K 个节点 // 链表1 - 2 - 3 - 4 - 5 ListNode head1 buildList(new int[]{1, 2, 3, 4, 5}); ListNode kth solution.getKthFromEnd(head1, 2); System.out.println(倒数第 2 个节点值 (kth ! null ? kth.val : null)); // 测试 2删除倒数第 N 个节点 ListNode head2 buildList(new int[]{1, 2, 3, 4, 5}); ListNode removed solution.removeNthFromEnd(head2, 2); printList(删除倒数第 2 个节点后, removed); // 测试 3两个相交链表 // A: 1 - 2 - 3 - 4 - 5 // B: 9 - 3 - 4 - 53,4,5 为公共部分 ListNode common buildList(new int[]{3, 4, 5}); ListNode headA new ListNode(1); headA.next new ListNode(2); headA.next.next common; ListNode headB new ListNode(9); headB.next common; ListNode intersection solution.getIntersectionNode(headA, headB); System.out.println(相交节点值 (intersection ! null ? intersection.val : null)); // 测试 4环形链表入环口 // 构造链表1 - 2 - 3 - 4 - 5其中 5 指向 3入环口为 3 ListNode head4 buildList(new int[]{1, 2, 3, 4, 5}); ListNode node3 findNode(head4, 3); ListNode node5 findNode(head4, 5); node5.next node3; ListNode cycleNode solution.detectCycle(head4); System.out.println(环形链表入环口节点值 (cycleNode ! null ? cycleNode.val : null)); } // 根据数组构造链表 private static ListNode buildList(int[] values) { ListNode dummy new ListNode(0); ListNode cur dummy; for (int val : values) { cur.next new ListNode(val); cur cur.next; } return dummy.next; } // 查找链表中第一个值为 target 的节点 private static ListNode findNode(ListNode head, int target) { ListNode cur head; while (cur ! null) { if (cur.val target) { return cur; } cur cur.next; } return null; } // 打印链表 private static void printList(String prefix, ListNode head) { System.out.print(prefix); ListNode cur head; while (cur ! null) { System.out.print(cur.val); if (cur.next ! null) { System.out.print( - ); } cur cur.next; } System.out.println(); } }运行Main.java预期输出如下倒数第 2 个节点值4 删除倒数第 2 个节点后1 - 2 - 3 - 5 相交节点值3 环形链表入环口节点值3到这里四个常见“重要节点”问题都已经被解决并在本地验证通过。5. 常见问题与排查思路在实际编码和面试过程中以下几个问题出现频率非常高。5.1 空指针异常问题现象运行时报NullPointerException。常见原因在访问node.next.next时没有先判断node.next是否为空或者使用快慢指针时fast.next可能为null但循环条件没有覆盖。排查步骤根据异常堆栈定位到出错的代码行。检查当前节点是否为null。检查循环条件是否正确例如while (fast ! null fast.next ! null)。解决方案操作前增加判空。使用虚拟头节点减少对头节点的特殊处理。在while循环中把可能为空的条件都写在前面。5.2 K 值或 N 值超过链表长度问题现象程序返回结果不符合预期或者没有返回null。常见原因fast先走k步时已经到达null但代码没有处理。解决方案在fast走步的循环中每次移动前判断fast null如果为空直接返回null。5.3 删除头节点时出错问题现象删除头节点后链表输出结果少了一个节点或者返回了错误的头节点。常见原因没有使用虚拟头节点直接对head做了删除操作。解决方案统一使用dummy虚拟头节点最后返回dummy.next。5.4 环形链表检测死循环问题现象程序一直运行无法退出。常见原因快慢指针的移动步长写错例如fast fast.next而不是fast fast.next.next或者循环终止条件没有正确覆盖无环的情况。解决方案检查循环条件是否为while (fast ! null fast.next ! null)确保无环链表能正常退出。5.5 常见问题速查表问题现象常见原因解决思路NullPointerException未对 next 指针判空使用虚拟头节点完善判空逻辑返回结果少一个节点没有保留前驱节点使用 slow 定位前驱而非目标节点K 值过大返回异常未处理 K 大于链表长度fast 走步过程中判空并返回 null程序死循环快慢指针步长错误检查 fast 是否每次走两步相交节点找不到没有处理无交点情况使用双指针法允许 pA 和 pB 为空后互换链表入环口计算错误没有把 fast 重置到 head相遇后重置 fast 并同步前进6. 最佳实践与工程建议6.1 优先画图再写代码链表问题非常依赖空间想象力。拿到题目后先在纸上画一个简单的链表结构标注出节点的移动顺序。以“删除倒数第 N 个节点”为例画出fast、slow两个指针在不同阶段的位置比直接翻代码效率高得多。6.2 牢记虚拟头节点的作用在涉及“删除节点”的题目中虚拟头节点可以大幅简化代码逻辑。它避免了“删除头节点”这个特殊分支让代码更统一、更不容易出错。建议在不允许修改原始链表结构的前提下优先引入dummy。6.3 注意参数边界编写链表工具方法时建议在方法开头统一校验参数head null空链表直接返回。k 0、n 0非法参数直接返回null或原链表。索引值大于链表长度提前返回空结果。这些边界校验虽然看起来冗余但在生产环境中能避免大量空指针和越界问题。6.4 区分“找到节点”和“删除节点”“找到倒数第 K 个节点”和“删除倒数第 N 个节点”的代码非常相似但有一个关键区别删除需要定位到目标节点的前驱。很多同学把两段代码混用导致删除时指针指向错误。建议先掌握“找节点”再推导出“删节点”的写法。6.5 测试用例要覆盖多种场景在本地验证时不要只测标准情况。以下测试用例都建议跑一遍空链表。只有一个节点。只有两个节点。K 等于 1倒数第一个节点。K 等于链表长度。K 大于链表长度。两个链表不相交。环形链表的入环口在头节点。可以通过构造辅助方法buildList来快速生成不同链表降低手写节点连接的出错概率。6.6 关于空间复杂度的选择哈希表法实现简单但空间复杂度为 O(n)。在面试中如果时间紧迫可以先说出哈希表思路再补充双指针优化版。但在生产代码中如果对性能有较高要求优先选择 O(1) 空间复杂度的双指针方案。7. 总结与学习路线本文围绕链表中的“重要节点”展开系统讲解了四个典型问题倒数第 K 个节点、删除倒数第 N 个节点、两个链表的相交节点、环形链表的入环口节点。我们不仅给出了完整可运行的 Java 代码还分析了双指针、哈希表、数学推导等核心原理并针对空指针、边界条件、死循环等常见问题整理了排查思路。如果你想继续深入学习链表相关内容可以从以下几个方向入手反转链表系列包括全部反转、区间反转、K 个一组反转。链表排序归并排序链表、插入排序链表。复杂链表带 random 指针的链表复制。双向链表与 LRU 缓存结合哈希表实现 O(1) 的 get 和 put。链表与业务结合例如浏览器的前进后退、消息队列的持久化结构。在实际项目中如果遇到链表操作逻辑建议先画图、再写伪代码、最后实现并补充边界测试。链表问题的代码往往不长但细节极多。只要把这几道经典题吃透大部分“重要节点”问题都能在几分钟内写出正确解法。