ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

相交链表双指针法:O(1)空间最优解与数学推导

相交链表双指针法:O(1)空间最优解与数学推导

1. 从一道题看链表相交的本质

链表相交这个问题,在算法题里算是那种"看起来简单、写起来容易翻车"的典型。我刷 LeetCode 那会儿,第一次看到"相交链表"这个标题,脑子里第一反应是:两个链表相交,不就是找第一个公共节点吗?结果真动手写的时候,边界情况一个接一个地冒出来,指针走着走着就不知道跑哪去了。

这道题的核心场景很明确:给你两个单链表的头节点headA和headB,判断它们是否相交,如果相交就返回相交的起始节点,不相交就返回null。听起来像是一道纯粹的指针操作题,但它背后考察的东西其实挺多——空间复杂度的取舍、双指针的数学直觉、以及边界条件的处理能力。

为什么这道题值得单独拿出来讲?因为它是链表类题目里少有的"可以用多种思路解、但最优解非常优雅"的代表。暴力哈希表能做,但空间复杂度 O(n);双指针法能做到 O(1) 空间,而且代码短到只有几行。这种"用数学思维把空间省下来"的解法,在实际工程里处理内存敏感的场景时,思路是可以直接迁移的。

适合谁来参考这篇内容?如果你刚开始刷链表题,对指针操作还不够熟练,这篇会帮你把每一步走位讲清楚;如果你已经刷过一遍但总是记不住双指针的推导逻辑,这篇会从数学角度把"为什么两个指针走的总路程相等"这件事讲透。Java 和 Python 两个版本的代码我都会给出来,并且逐行注释关键点。

2. 相交链表的判定逻辑与方案选型

2.1 相交的本质:Y 字形而非 X 字形

很多人第一次理解"链表相交"时会有一个误区,以为两个链表可以像两条直线一样交叉成 X 形,中间交叉一下然后各自继续延伸。但在单链表的结构下,这是不可能的。

原因在于单链表的每个节点只有一个next指针。一旦两个链表在某个节点处相交,那个节点之后的所有节点都是共享的,因为从相交节点开始,next的指向是唯一的。所以两个相交链表的结构一定是 Y 字形——上面两个分叉,下面合并成一条。

这个认知非常关键,它直接决定了后面所有解法的正确性。如果你脑子里想的是 X 形,那你会觉得"相交节点之后可能又分开",从而写出错误的判断逻辑。

提示:判断链表相交,只需要看两个链表的尾节点是否相同。如果尾节点相同,一定相交;如果尾节点不同,一定不相交。这是所有解法的基础前提。

2.2 三种解法的取舍分析

面对这道题,通常有三条路可以走,我分别说一下它们的适用场景和取舍逻辑。

第一种:暴力双重循环。固定链表 A 的一个节点,遍历链表 B 看是否有相同节点,然后移动 A 到下一个,重复。时间复杂度 O(m*n),空间 O(1)。这个解法在面试里基本不会加分,但作为保底思路可以先说出来,展示你有解题方向。

第二种:哈希表记录法。先遍历链表 A,把所有节点存入一个哈希集合(注意存的是节点对象本身,不是节点的值)。然后遍历链表 B,第一个在集合中出现的节点就是相交起始节点。时间复杂度 O(m+n),空间复杂度 O(m)。这个解法思路直观,代码好写,面试里可以作为第一版答案。

第三种:双指针法。这是最优解,时间 O(m+n),空间 O(1)。核心思路是让两个指针分别从两个头节点出发,走到末尾就切换到另一个链表的头部继续走。当两个指针相遇时,就是相交节点。

为什么双指针法能成立?这里有一个很漂亮的数学推导,我在下一节详细展开。

2.3 为什么优先选双指针法

从工程角度看,哈希表法在大多数场景下已经够用了,毕竟现在的机器内存都不小。但双指针法的价值在于两点:一是它展示了你对链表结构的深刻理解,二是它把空间压到了常数级,在处理超长链表或者内存受限的嵌入式场景时,这个优势是实打实的。

我个人的习惯是:面试时先说哈希表法(快速给出可行解),然后主动提出"我可以优化空间复杂度到 O(1)",再写双指针法。这样既展示了思路的完整性,又展示了优化能力。

3. 双指针法的数学推导与核心细节

3.1 走位逻辑的数学证明

假设链表 A 的独立部分长度为 a(从 headA 到相交节点的距离),链表 B 的独立部分长度为 b,两个链表的公共部分长度为 c。

那么链表 A 的总长度是 a + c,链表 B 的总长度是 b + c。

现在让指针 pA 从 headA 出发,指针 pB 从 headB 出发,速度相同,每次各走一步。当 pA 走到链表 A 末尾时,它走了 a + c 步,然后切换到 headB 继续走。当 pB 走到链表 B 末尾时,它走了 b + c 步,然后切换到 headA 继续走。

关键来了:pA 走的总路程是 (a + c) + b,pB 走的总路程是 (b + c) + a。这两个值都等于 a + b + c。

也就是说,当两个指针都走完 a + b + c 步时,它们必然同时到达相交节点。如果两个链表不相交(c = 0),那么它们会同时到达 null,此时返回 null 即可。

这个推导的美妙之处在于:它不需要你知道 a、b、c 的具体值,只需要让两个指针"走对方走过的路",就能自然对齐。

3.2 边界条件处理清单

双指针法代码很短,但边界条件如果不注意,很容易出问题。我整理了一份检查清单:

  • 两个链表都为空:直接返回 null,代码里循环条件会自然处理。
  • 其中一个为空:同上,指针切换后会在 null 处相遇。
  • 两个链表不相交:两个指针最终会同时变成 null,循环结束返回 null。
  • 相交节点就是头节点:比如两个链表从第一个节点就共享,此时 a = 0 或 b = 0,双指针第一轮就能相遇。
  • 链表只有一个节点:需要确保指针切换逻辑不会导致死循环。

注意:循环的终止条件是pA != pB,而不是pA != null && pB != null。这一点非常关键,因为如果写成后者,当两个链表不相交时,指针会在 null 处无法正确相遇。

3.3 指针切换的写法细节

指针切换那一步,很多人会写成这样:

if (pA == null) { pA = headB; } else { pA = pA.next; }

这种写法是对的,但更简洁的写法是利用三元表达式:

pA = (pA == null) ? headB : pA.next;

两种写法逻辑完全一样,但后者更紧凑。不过我要提醒一点:在面试手写代码时,建议用第一种展开的写法,因为面试官更容易看清你的逻辑,不容易因为一行太长而看漏细节。

Python 版本因为语法特性,可以写得更简洁:

pA = headB if not pA else pA.next

但同样建议在面试时展开写,清晰优先。

4. Java 与 Python 完整实现及逐行解析

4.1 Java 版本实现

先看 Java 的完整代码,我加了详细注释:

public class Solution { public ListNode getIntersectionNode(ListNode headA, ListNode headB) { // 如果任一链表为空,直接返回 null if (headA == null || headB == null) { return null; } // 定义两个指针,分别指向两个链表头部 ListNode pA = headA; ListNode pB = headB; // 当两个指针不相等时继续循环 while (pA != pB) { // pA 走到末尾就切换到 headB,否则继续前进 pA = (pA == null) ? headB : pA.next; // pB 走到末尾就切换到 headA,否则继续前进 pB = (pB == null) ? headA : pB.next; } // 循环结束时,pA 和 pB 要么都指向相交节点,要么都为 null return pA; } }

这段代码有几个点值得展开说。

第一,开头的空判断其实可以省略,因为如果 headA 为 null,pA 初始就是 null,循环条件pA != pB在 pB 也为 null 时不成立,直接返回 null。但如果只有一个为 null,另一个不为 null,循环会进入,然后 pA 切换到 headB,pB 继续走,最终两个都会变成 null。所以空判断是"防御性编程",不是必须的,但写上更稳妥。

第二,pA = (pA == null) ? headB : pA.next;这一行的执行顺序很重要。先判断 pA 是否为 null,如果是就切换到 headB,否则前进到下一个节点。注意这里切换后不会立即再判断,而是等下一轮循环。

第三,循环结束后的返回值。如果两个链表相交,pA 和 pB 会指向同一个相交节点;如果不相交,两个都会是 null。所以直接返回 pA 即可,不需要额外判断。

4.2 Python 版本实现

Python 版本的逻辑完全一样,但语法更简洁:

class Solution: def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode: # 任一链表为空则直接返回 None if not headA or not headB: return None # 初始化两个指针 pA, pB = headA, headB # 当两个指针不相等时循环 while pA != pB: # pA 走到末尾切换到 headB,否则前进 pA = headB if not pA else pA.next # pB 走到末尾切换到 headA,否则前进 pB = headA if not pB else pB.next # 返回相遇节点(可能为 None) return pA

Python 里not pA等价于pA is None,因为链表节点对象在布尔上下文中默认为 True,只有 None 才是 False。这个写法比pA is None更 Pythonic,但在团队协作中,如果队友对 Python 特性不熟,建议还是写pA is None更明确。

4.3 哈希表法作为对照实现

为了完整性,我把哈希表法的 Java 实现也放出来,方便你对比两种解法的差异:

public class Solution { public ListNode getIntersectionNode(ListNode headA, ListNode headB) { Set<ListNode> visited = new HashSet<>(); // 先把链表 A 的所有节点存入集合 ListNode cur = headA; while (cur != null) { visited.add(cur); cur = cur.next; } // 遍历链表 B,找到第一个在集合中的节点 cur = headB; while (cur != null) { if (visited.contains(cur)) { return cur; } cur = cur.next; } return null; } }

哈希表法的优势是逻辑直观,不需要数学推导,适合在面试紧张时快速写出可行解。劣势是空间复杂度 O(m),如果链表 A 特别长,内存占用会比较明显。

4.4 两种解法的性能对比

对比维度双指针法哈希表法
时间复杂度O(m+n)O(m+n)
空间复杂度O(1)O(m)
代码行数约 10 行约 15 行
理解难度需要数学推导直观易懂
面试加分高中
适用场景内存敏感、超长链表快速实现、逻辑清晰优先

从表里可以看出来,双指针法在空间上有明显优势,但理解成本略高。我的建议是两种都要会,面试时根据情况选择。

5. 实操踩坑记录与常见问题排查

5.1 我踩过的三个典型坑

第一个坑:把节点值相等当成节点相等。刚开始刷题时,我写过if (pA.val == pB.val)这样的判断,结果在链表有重复值的时候直接翻车。相交链表判断的是节点对象是否相同(内存地址相同),不是节点的值相同。这个错误在 Java 里表现为==和equals的混淆,在 Python 里表现为==和is的混淆。

注意:Java 里判断节点相同用pA == pB(引用比较),Python 里用pA is pB。不要用值比较。

第二个坑:循环条件写错导致死循环。我见过有人把循环条件写成while (pA != null && pB != null),这样当两个链表不相交时,指针走到 null 就退出循环了,但此时无法区分"不相交"和"还没走完"。正确的写法是while (pA != pB),让指针在 null 处也能相遇。

第三个坑:指针切换后忘记继续走。有人写成这样:

while (pA != pB) { if (pA == null) pA = headB; if (pB == null) pB = headA; pA = pA.next; pB = pB.next; }

这段代码的问题在于,当 pA 切换到 headB 后,紧接着又执行了pA = pA.next,相当于跳过了 headB 这个节点。正确的逻辑应该是"切换或前进,二选一",而不是"先切换再前进"。

5.2 常见问题速查表

问题现象可能原因解决方法
返回了错误的相交节点用了值比较而非引用比较改用==(Java)或is(Python)
程序死循环循环条件写成了pA != null && pB != null改为pA != pB
不相交时返回了非 null指针切换逻辑有误检查切换和前进是否二选一
相交节点是头节点时出错空判断或初始化有问题检查 headA/headB 为 null 的处理
链表很长时超时用了暴力双重循环改用双指针法或哈希表法

5.3 调试技巧:打印指针走位

如果代码逻辑怎么看都对但结果不对,我常用的方法是加打印,把每一步指针的位置和值打出来:

while (pA != pB) { System.out.println("pA: " + (pA == null ? "null" : pA.val) + ", pB: " + (pB == null ? "null" : pB.val)); pA = (pA == null) ? headB : pA.next; pB = (pB == null) ? headA : pB.next; }

跑一遍小规模的测试用例,看指针走位是否符合预期。这个方法虽然笨,但在排查指针类问题时特别有效。我试过好几次,打印出来一看就发现是切换逻辑写反了。

5.4 测试用例设计建议

写完代码后,至少用以下几组用例验证:

  • 两个链表完全不相交,长度分别为 3 和 4。
  • 两个链表在中间节点相交,独立部分长度不同。
  • 两个链表从头节点就相交(完全重合)。
  • 其中一个链表为空。
  • 两个链表都只有一个节点且不相交。
  • 两个链表都只有一个节点且是同一个节点。

这六组用例覆盖了绝大多数边界情况,跑通之后基本可以放心提交。

6. 从这道题延伸出的链表思维

6.1 快慢指针与双指针的家族关系

相交链表用的双指针,和环形链表用的快慢指针,其实是同一个家族的不同变体。快慢指针利用的是"速度差"来检测环,相交链表利用的是"路程和相等"来对齐位置。两者都是通过指针的巧妙走位,把空间复杂度压到 O(1)。

我建议把这两道题放在一起刷,体会一下"指针走位"这个技巧的通用性。刷完这两道,再看"链表的中间节点""删除倒数第 N 个节点"这类题,你会发现思路是相通的。

6.2 空间换时间与时间换空间的取舍

这道题是"用数学思维省空间"的典型案例。哈希表法是典型的空间换时间,双指针法是典型的用逻辑换空间。在实际工程里,这两种取舍无处不在。

比如在数据库查询优化里,加索引就是空间换时间,而全表扫描就是时间换空间。在缓存设计里,缓存就是空间换时间。理解这道题背后的取舍逻辑,比记住代码本身更有价值。

6.3 链表题的通用心法

刷了这么多链表题,我总结出一个心法:画图、标长度、定指针。

画图是把链表结构画出来,标长度是把各段距离标清楚,定指针是确定用几个指针、初始位置在哪、移动规则是什么。这三步做完,代码基本就是翻译图上的逻辑。

相交链表这道题,画一个 Y 字形,标上 a、b、c 三段长度,定两个指针,剩下的就是数学推导了。这个心法适用于绝大多数链表题,包括反转链表、合并链表、环形检测等等。

6.4 面试中的表达技巧

如果面试遇到这道题,我建议这样组织回答:

先说出暴力解法的思路(展示你有解题方向),然后提出哈希表法(展示你能快速给出可行解),最后主动说"我可以优化空间到 O(1)",引出双指针法(展示你的优化能力)。在写双指针法时,边写边解释数学推导,让面试官看到你不仅会写,还理解为什么这样写。

这种"由浅入深"的表达方式,比一上来就写最优解更容易让面试官跟上你的思路,也更能展示你的思维过程。

6.5 代码之外的收获

这道题让我意识到一个事情:很多算法题的最优解,背后都有一个简洁的数学原理。双指针法的 a+b+c 推导,快慢指针的相遇定理,二分查找的区间收敛,都是这样。刷题刷到一定程度,你会发现记住的不是代码,而是这些数学直觉。

这种直觉一旦建立起来,遇到新题时你会有一种"感觉应该这样解"的判断力。这种判断力,才是刷题真正的收获。

最后分享一个我个人的小习惯:每刷完一道有代表性的题,我会用一句话把核心思路记在笔记里。相交链表这道题,我记的是"两指针走对方的路,总路程相等则相遇"。这句话我到现在都记得,遇到类似的指针走位题,脑子里会自动跳出这个模式。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进