刷LeetCode Hot 100刷到第22题“相交链表”的时候,我停了一下。这题在题目列表里长得不起眼,描述也短:给两个单链表的头节点,找出它们相交的起始节点,不相交就返回null。但真把它放在面试场景里,它几乎是一道“照妖镜”——既能看出一个人有没有真正理解链表的基本概念,也能看出算法思维是背套路还是真的推导过。这篇文章就把这道题从题目本意、经典解法、代码细节到面试延伸一次说透,适合正在刷hot 100的求职者,也适合想夯实链表基础、搞懂双指针本质的开发者。
1. 题目本意拆解:相交比的是节点本身,不是节点值
1.1 先看清题目的准确描述
题目给出两个单链表的头节点headA和headB,要求返回两个链表相交的起始节点。如果两个链表没有交点,返回null。题目保证整个链式结构中不存在环。官方示例里有个非常经典的case:
- listA = [4,1,8,4,5]
- listB = [5,0,1,8,4,5]
从值上看,两个链表都出现了“1”和“8”,但相交的起始节点是值为8的那个节点,而不是值为1的那个节点。这是这道题最容易踩的第一个坑:两个链表可能会有值相同的节点,但它们并不是同一个节点。
要理解这一点,得回到链表的结构本身。单链表节点是一个对象,包含val和next两个字段。两个节点“相交”,意味着从某个节点开始,两个链表共享的是同一批节点对象,而不是值恰好相等的一批节点。也就是说,两个链表的尾部是同一个内存地址,从交点开始,next指针指向的都是同一块内存区域。如果用Java或Python写过链表,可以直接说:比较的是对象的引用,不是对象的属性。
1.2 为什么很多人第一反应会错
我在实际面试模拟里见过不少候选人,上来就说“用两个指针分别走,值相等的时候就是交点”。这个思路看起来合理,但漏洞很明显:链表A里有个值为8的节点,链表B里也有个值为8的节点,它们的next可能指向完全不同的后续节点,只是恰好这个位置的数字一样而已。如果把值相等当作相交,返回值就错了。
还有一个常见错误是“把两个链表转成数组/列表,然后找公共后缀”。这个思路方向是对的——它抓住了“相交后完全重合”这个特征,但实现起来需要额外空间,而且在面试中不够优雅。更关键的是,它没有用到链表本身的特性,属于用线性表思维去解链表题,容易被追问到更优解法。
1.3 哈希集合法:最容易想到的正确解法
如果只是想快速做对,哈希集合是最稳的。思路分三步:
- 遍历headA,把每个节点对象(注意是节点引用)存入HashSet。
- 遍历headB,依次检查当前节点是否在集合里。
- 第一个命中的节点就是交点;遍历完都没有命中,返回null。
这个解法能在O(m + n)时间内完成,m和n分别是两个链表的长度,但空间复杂度是O(m)。简单来说,就是用空间换时间。
不过,在Hot 100里做这题,哈希集合只能算及格。因为题目后面还藏着“尽量满足O(1)空间复杂度”这个进阶要求,这也是面试官最喜欢的追问方向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针解法的核心原理:为什么两个指针一定会相遇
2.1 解法步骤
双指针法是这道题的“标准答案”。它只用了两个指针,空间复杂度O(1),时间仍然是O(m + n)。核心操作只有这几步:
- 指针pA从headA出发,指针pB从headB出发。
- 两个指针同时一步一步往后移动。
- 如果pA走到了链表末尾(null),就跳到headB继续走。
- 如果pB走到了链表末尾(null),就跳到headA继续走。
- 当pA和pB指向同一个节点时,这个节点就是交点。
- 如果两个指针同时走到null,说明没有交点。
我第一次看到这个解法时,第一反应是:凭什么这样绕一圈就能相遇?为什么不是各自走完就结束了?这需要从数学上拆开看。
2.2 数学推导:长度差如何被消除
假设headA链表中,交点之前的独有部分长度为a,交点之后两个链表共享的部分长度为c。headB链表中,交点之前的独有部分长度为b。那么:
- headA的总长度是 a + c。
- headB的总长度是 b + c。
两个指针同时出发,速度相同。pA走完headA需要a + c步,pB走完headB需要b + c步。如果a不等于b,直接走的话,pA到达交点时,pB可能还没到或已经过了。
双指针解法的关键就在“跳到对方的头节点继续走”这个动作。我们看pA完整走过的路线:先走完headA的a + c步,然后跳到headB,再走b步到达交点。此时pA一共走了a + c + b步。再看pB:先走完headB的b + c步,然后跳到headA,再走a步到达交点。此时pB一共走了b + c + a步。
a + c + b = b + c + a,两者走过的路径长度完全一样。由于两个指针速度相同,它们会在同一个时刻到达交点。
2.3 直观理解:把两条链表“拼接”起来
如果觉得数学推导不够直观,可以换个角度。想象把链表A和链表B首尾相接:
- 路线一:headA → 交点 → null,然后接 headB → 交点 → null。
- 路线二:headB → 交点 → null,然后接 headA → 交点 → null。
两条路线的总长度都是a + c + b + c,而且最后一段都是从交点走到null。两个指针同时出发,速度相同,自然会在两条路线的“后半段”——也就是交点位置——第一次相遇。
也可以从“消除长度差”的角度理解:如果a大于b,pB会比pA先到达链表末尾,它跳到headA后,相当于多走了一段a - b的距离,正好把两个指针的位置差距抹平。一旦位置差距没了,两个指针就同步了。
2.4 无交点时为什么不会死循环
如果没有交点,情况就变成了:a + c和b + c中,c = 0。pA走的路线是headA全部加headB全部,长度是a + b;pB走的路线是headB全部加headA全部,长度也是b + a。最终它们会同时走到null,也就是两个指针都指向null,循环条件pA != pB不成立,直接退出,返回null。
这里我特别提醒一句:循环终止条件必须是pA != pB,而不是pA.next != pB.next之类的写法。因为交点可能是头节点,也可能是null,用next指针判断会在边界条件下出错。
3. 代码实现与边界条件:细节里藏着真正的分水岭
3.1 Java实现
java复制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;
}
这段代码里最容易忽略的是循环内部的顺序:先判断当前节点是否为null,再决定是跳到对方头节点还是继续走next。很多人第一次写的时候会写错成pA = pA.next == null ? headB : pA.next,这样在pA指向最后一个节点时,会先取next判断,导致跳转时机晚了一步,结果永远无法相遇。
3.2 Python实现
python复制def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
if not headA or not headB:
return None
pA, pB = headA, headB
while pA is not pB:
pA = pB if pA is None else pA.next
pB = pA if pB is None else pB.next
return pA
Python里特别注意一点:比较节点时要用is而不是==。is比较的是对象身份(内存地址),==会比较节点内部的值,甚至可能触发__eq__方法。在链表相交的场景下,我们要找的正是“同一个对象”,所以必须用is。这在Java里则是==,因为Java的引用比较天生就是地址比较。语言不同,语义不同,但目标一致。
3.3 我实际测试过的边界条件
我把边界case过了一遍,其中最典型的有这几类:
- 两个链表都为空:headA和headB都为null,直接返回null。上面的代码里第一行判断就处理了。
- 一个链表为空:同样返回null,因为空链表不可能和任何链表相交。
- 交点就是头节点:比如headA本身就和headB是同一个节点。此时pA和pB初始就相等,while循环一次都不执行,直接返回headA。这个case正好验证了循环条件的正确性。
- 两个链表没有交点:双指针会同时走到null,循环退出,返回null。
- 值相等但节点不同:前面示例里的[4,1,8,4,5]和[5,0,1,8,4,5],值为1的节点是两个不同的节点,不能作为交点。我的代码在pA、pB经过这个位置时,比较的是节点引用,所以不会误判。
3.4 复杂度与代码风格
双指针法的时间复杂度是O(m + n),因为两个指针合计走过的节点数不超过m + n + max(m, n)的量级,准确地说每个指针最坏情况下走过m + n步。空间复杂度O(1),只用了两个指针变量。
在面试中写出这段代码后,还可以主动补一句:如果允许使用哈希集合,可以更快地写出来,但空间会变成O(m);双指针法是空间上的最优解。这句话能体现你思考过时间与空间的权衡。
4. 由一道题引出的面试追问与知识延伸
4.1 如果只判断是否相交,不求交点
这是最简单的追问。哈希集合法和双指针法都能回答,而且思路不用改,只是返回值不同。如果只要求判断是否存在交点,可以遍历完一个链表后,检查其尾节点是否在另一个链表中出现——如果两个链表相交,它们的尾节点必定是同一个。这个方法只需要O(1)空间,但不能直接求出交点位置。
实际面试中,面试官问这个问题通常是想确认你理解“相交”的本质是共享尾节点。这是链表相交问题非常重要的一个性质。
4.2 如果链表可能有环怎么办
这个问题会直接把题目难度拉高一个档次。当链表可能存在环时,直接套用上面的双指针解法会失效,因为指针可能永远走不到null。此时需要先判断链表是否有环、找到环的入口,再分情况讨论交点的位置:
- 两个链表都无环:回到原题解法。
- 一个链表有环另一个无环:一定不相交。
- 两个链表都有环:交点可能在环入口之前,也可能在环上,情况比较复杂。
虽然这是另一个问题,但面试官把它作为追问时,我要表达清楚“先判断有环无环”这个前置步骤。如果答不上来,起码要能说出判断单链表是否有环用快慢指针。
4.3 与“两个链表的第一个公共节点”的关系
做过剑指Offer的朋友会发现,那道“两个链表的第一个公共节点”和这题几乎是同一道题,只是语言表述不同。底层解法完全一样。Hot 100把它排到第22位,本质上是因为这个解法太经典:它用最简单的逻辑展示了“双指针如何利用链表结构消除长度差”,这个思想在环形链表、链表找中点、找倒数第k个节点等一系列题里都会反复用到。
4.4 工程中的类比:共享子结构
从工程视角看,链表相交的场景在实际代码里并不罕见。比如一个不可变链表(函数式编程里的持久化数据结构)通常采用共享结构来节省内存,多个版本的列表会共享同一段尾部节点。此时判断两个版本的列表是否有公共部分,本质上就是判断它们是否共享了某段内存。再比如内存池中的空闲块管理、GC中的对象图遍历,都能看到类似“引用比较”的思维。理解了这一点,这个题就不再是单纯的面试题,而是对“对象引用语义”的一次训练。
5. 刷题记录与实战经验:这题为什么值得反复写
5.1 为什么它在Hot 100第22位
Hot 100的排序通常兼顾“高频考察”和“知识覆盖”。相交链表排在靠前位置,是因为它考察了三个核心能力:
- 链表节点引用的基本理解,能不能区分“值相等”和“节点相同”。
- 空间复杂度意识,能不能从O(m)优化到O(1)。
- 数学推导能力,能不能解释双指针相遇的原因。
这三个能力在链表那一类题目里是通用的。面试官用一道题就能看出候选人是不是真正理解链表,而不是背了两三道题的答案。
5.2 面试实战中的答题节奏
我的建议是,面试时千万不要上来就写双指针。哪怕是标准解法,也最好先给一个最直观的思路,再逐步优化。比如可以先说:“最直观的做法是用哈希集合记录链表A的所有节点,再遍历链表B找到第一个重复节点,这样时间O(m+n)、空间O(m)。”等面试官点头后,再补充:“但如果要求O(1)空间,可以用双指针,让两个指针分别遍历两条链表,走到末尾后跳到对方链表继续走,利用路径等长消除长度差。”这样既展示了基础能力,也展示了优化能力,比直接甩出双指针解法更自然。
5.3 一段值得手推的测试用例
我自己在理解这道题时收益最大的一个动作,是手动模拟了官方示例中那个“陷阱”:
- pA从值为4的节点出发,走到值为1的第一个节点。
- pB从值为5的节点出发,走到值为0的节点,再到值为1的第二个节点。
此时pA和pB当前指向的节点值都是1,但一个是链表A里的节点,一个是链表B里的节点。如果代码用值比较,这里就错判了。用引用比较,则继续往后走,直到值为8的节点处才真正相遇。这个过程跑通一遍,才算真正理解了相交的含义。
5.4 双指针思想的迁移
刷完这题后,我特意把双指针思想迁移到了其他几道题上,效果很好:
- 环形链表:快慢指针,慢指针走一步,快指针走两步,有环则必然相遇。
- 链表的中间节点:快慢指针,快指针到末尾时慢指针正好在中点。
- 删除链表的倒数第N个节点:快指针先走N步,然后快慢指针同步走。
- 合并两个有序链表:两个指针分别指向两个链表,每次取较小的节点。
这些题看起来各不相同,底层思路都是“利用指针速度差或路径长度差来对齐位置”。相交链表是其中最基础、最直观的一个,把这个思想吃透,后面几道题会轻松很多。
最后分享一个我自己的小习惯:刷链表题时,我会把每个测试用例的长度差和交点位置手写出来,然后沿着指针走一遍。相交链表这种题,光看代码是看不出味道的,一定要在纸上画一画两条链表,再模拟几次指针跳动,才能真正建立起“引用相等”和“路径等价”的直觉。这个习惯帮我减少了很多边界条件的失误,也让我在这种高频题上做到了“一次写对”。
