1. 题目拆解:先搞清楚“相交”到底在比什么
1.1 题目描述与核心考点
相交链表,LeetCode Hot 100 里的第二十二题,题号 160,在链表题型中属于那种“看起来简单、写起来容易翻车”的经典题。题目给你两个单链表的头节点 headA 和 headB,要求判断这两个链表是否相交,如果相交,返回相交的起始节点;如果完全不相交,返回 null。
这里有个特别容易混淆的点:题目里的“相交”指的是两个链表从某个节点开始,后面的节点完全共享,也就是同一个节点对象同时出现在两个链表中,而不是两个链表里某个节点的值恰好相等。判断依据是节点的引用(地址),不是节点的值。很多人在初学阶段都会在这个地方栽跟头,写了个双层循环比较 val,结果值一重复就返回错误节点。
在 LeetCode 原题里还有一个隐含条件:整个链表结构中不能有环。这对解题很关键,因为如果有环,双指针的终止条件就需要重新设计。题目另外要求时间复杂度尽量控制在 O(m+n),空间复杂度能压到 O(1) 最好。这其实就是逼着你放弃哈希表那类“空间换时间”的懒人解法,去找更本质的规律。
从面试角度来说,这道题考察的不只是链表的基本操作,更重要的是你有没有“把两条路径补成一样长”的抽象思维。链表遍历、数据结构链表的基本功、双指针技巧这些关键词全部扣在这道题上。你把这道题吃透,后面碰到的环形链表、寻找公共后缀、甚至一些树相关的双指针题目,思路都是通的。
1.2 这道题为什么值得认真刷
LeetCode Hot 100 的题目都是经过大量面试反馈筛选出来的高频题,相交链表能在里面占一个位置,说明它触及了面试官喜欢的几个考察点:代码简洁性、边界条件处理、还有对时间空间复杂度的自觉。
我第一次刷这道题的时候,第一反应是“这有什么难的,两层循环遍历一下不就完了”。但当我把暴力解的代码写出来,自己都觉得丢人——时间复杂度 O(m*n),题目稍微给长一点的链表就直接超时。后来去看题解,才发现双指针的解法简洁到离谱,总共五六行代码就能把问题解决。这种“原来还能这样”的冲击感,是我觉得这道题最值得认真研究的地方。
另外,这道题非常考验你对“链表末尾是 null”这个事实的利用。很多人写链表代码的时候,只把 null 当作循环终止条件,但相交链表的双指针解法恰恰是把 null 也当作一个合法的“跳板”,让两个指针走完自己的链表后绕到对方的链表上继续走,从而在数学上强制对齐两条路径的长度差。这个思想在很多更复杂的链表题目里都会反复出现。
所以这道题值得你花一个下午的时间,把每种解法都亲手写一遍,把为什么这样做能成立的道理彻底弄清楚,而不是背个代码模板就完事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针解法的核心原理与数学推导
2.1 双指针法的思路来源
相交链表最常见的优秀解法是双指针,也叫“浪漫相遇”解法。思路非常朴素:两个指针 pA 和 pB 分别从 headA 和 headB 出发,同步向后走;当 pA 走到链表末尾时,让它跳到 headB 继续走;当 pB 走到末尾时,让它跳到 headA 继续走。如果两个链表相交,两个指针最终会在相交节点相遇;如果不相交,它们会同时走到 null。
这个思路我第一次看到的时候觉得“这不是碰运气吗”,但其实它背后有一个非常优美的观察:两个指针走过的路径长度在数学上被强制拉成了相同值。如果你把链表 A 看成“A 的非公共部分 + 公共部分”,链表 B 看成“B 的非公共部分 + 公共部分”,两个指针最终都会各自走完“A 的全部长度 + B 的全部长度”,或者说“B 的全部长度 + A 的全部长度”,区别只是顺序交换了一下。
这里用到的核心技巧就是“链表遍历”中常见的一种处理方式:利用 null 作为跳板来切换链表。很多链表题解里都有这种套路,比如合并两个有序链表时,一个链表走到头就直接接上另一个链表的剩余部分。但相交链表的双指针解法更有意思,它不只是切换剩余部分,而是把整个访问路径拼接成一个逻辑上的闭环。
提示:理解双指针法之前,建议先在纸上画出两个相交链表的示意图,把每个节点标上编号,然后手动模拟一遍 pA 和 pB 的行走轨迹。手动模拟过一遍,比看十遍题解都管用。
2.2 数学推导:为什么两个指针一定会相遇
设链表 A 不相交部分的长度为 a,链表 B 不相交部分的长度为 b,两个链表的公共部分长度为 c。注意,公共部分是两个链表从相交点开始共享的那段链表,长度是同一个 c。
现在来看 pA 的轨迹:它从头开始走完链表 A,也就是走完 a + c 的长度,到达链表末尾的 null;然后跳转到 headB,继续走链表 B 的不相交部分,也就是再走 b 的长度。所以 pA 在第一次可能的相遇点之前,累计走过的节点数是 a + c + b。
再看 pB 的轨迹:它走完链表 B 也就是 b + c 的长度,到达 null;然后跳转到 headA,再走 a 的长度。pB 在第一次可能的相遇点之前,累计走过的节点数是 b + c + a。
看一眼就明白了:a + c + b 和 b + c + a 在数值上是完全相等的。也就是说,当 pA 走完“A 全量 + B 非公共部分”时,pB 也恰好走完“B 全量 + A 非公共部分”。如果 c > 0,它们相遇的位置正是公共部分的第一个节点;如果 c = 0,也就是两个链表完全不相交,那么两个指针会在同时走到 null 时相遇,循环条件 pA != pB 不成立,循环结束,返回 null。
这个推导本身不难,难的是“为什么可以假设它们一定同时到达”。关键在于两个指针的移动速度是相同的,每次循环都各自走一步,所以累计步数始终相同。路径总长度一致 + 步速一致,结果必然是同时到达同一点。
我在实际推导时喜欢把它类比成两个人跑步:一个从 A 跑到 B 再跑回 A 的入口,另一个从 B 跑到 A 再跑回 B 的入口。不管两条跑道相差多少,只要跑道是同一个终点(相交节点)连接起来的,两个人的总路程就是一样的,最终会在那个连接点碰上。这个类比在面试里跟面试官沟通时特别好用,既直观又说明你真正理解了原理。
2.3 其他解法横向对比
双指针法虽然优雅,但不是唯一解法。我刷题时习惯把每种解法都写一遍,这样才能理解为什么双指针是最优解。
第一种是哈希集合法。遍历链表 A,把每个节点的引用存进一个 Set;然后遍历链表 B,每到一个节点就检查它是否已经在 Set 里,第一个命中的节点就是相交节点。这个解法非常直观,时间复杂度 O(m+n),空间复杂度 O(m),因为要存下 A 的所有节点。
第二种是暴力法。两层循环,外层遍历 A,内层遍历 B,逐个比较节点引用是否相同。时间复杂度 O(m*n),空间复杂度 O(1)。这个写法只适合链表很短的情况,一旦链表长度过千,基本就超时了。
第三种是栈解法。既然相交节点之后的部分是公共的,那么从链表的尾部往前看,公共部分一定是从某个位置开始的连续后缀。把两个链表分别压入两个栈,然后同时弹出,最后一组相等的节点就是相交节点的前一个节点,下一组弹出的就是相交节点本身。时间复杂度 O(m+n),空间复杂度 O(m+n)。
我把这几种解法放在一起对比过:
| 解法 | 时间复杂度 | 空间复杂度 | 是否修改链表 | 实现难度 |
|---|---|---|---|---|
| 暴力法 | O(m*n) | O(1) | 否 | 低 |
| 哈希集合法 | O(m+n) | O(m) | 否 | 低 |
| 栈解法 | O(m+n) | O(m+n) | 否 | 中 |
| 双指针法 | O(m+n) | O(1) | 否 | 中 |
从表格可以清楚看到,双指针是唯一同时满足线性时间和常数空间的解法。这也是 LeetCode 官方题解把双指针作为最优解的原因。遇到“要求空间 O(1)”的面试追问时,哈希法和栈法都会直接出局,双指针法才是唯一的答案。
3. 代码实现与实操细节
3.1 Python 实现与逐行解读
Python 的链表节点定义一般长这样:
python复制class ListNode:
def __init__(self, x):
self.val = x
self.next = None
双指针解法的代码非常短:
python复制def getIntersectionNode(headA: ListNode, headB: ListNode) -> ListNode:
pA, pB = headA, headB
while pA != pB:
pA = pA.next if pA else headB
pB = pB.next if pB else headA
return pA
这段代码的精髓在循环体里那两行赋值。pA 每轮先判断自己是不是 None,如果不是 None 就继续往后走;如果是 None,说明已经走完了链表 A,于是跳到 headB 从头开始。pB 的逻辑完全对称。这里最需要注意的是“先判断当前指针是不是 None,再决定下一步走向”,而不是“先走一步再判断”。如果你把顺序搞反了,就会在链表末尾多走一步,导致空指针解引用。
我在本地跑这段代码时还专门验证过一个细节:两个指针在不相交的情况下,最终会不会真的同时变成 None。答案是会的。假设 A 长 3、B 长 4 且不相交,pA 走完自己的 3 步后跳到 B,再走 4 步;pB 走完自己的 4 步后跳到 A,再走 3 步。两者在各自走完第 7 步时,pA 和 pB 都指向 None,循环条件 pA != pB 此时不成立,while 结束,返回 pA 也就是 None。这个行为非常干净。
对比:还有一种常见的写法是“先分别求两个链表的长度,让长的链表先走差值步,然后同步走”。这个思路也正确,代码量会多一点,但不需要处理指针跳转。它的缺点是必须额外遍历两次链表求长度,总体也是 O(m+n) 时间。双指针法的好处是不需要预先知道链表长度,天然“边走边对齐”。
3.2 C++ 实现与指针操作的注意事项
C++ 版的链表节点定义和 LeetCode 一致:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
双指针的 C++ 写法:
cpp复制class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode *pA = headA;
ListNode *pB = headB;
while (pA != pB) {
pA = pA ? pA->next : headB;
pB = pB ? pB->next : headA;
}
return pA;
}
};
注意 C++ 和 Python 在指针判断上的一个小差别:Python 里 pA.next if pA else headB 先判断 pA 是否为空对象,而 C++ 里 pA ? pA->next : headB 先判断指针是否为 nullptr。两者逻辑一样,但 C++ 必须小心“空指针解引用”的坑。
C++ 里推荐在使用 pA->next 之前先检查 pA 是否为 nullptr,这个习惯不只在本题有用,在链表的增删改查中都是保命的基本功。还有一点,C++ 面试时经常会有面试官问“这个函数会不会修改原链表结构”,答案是“不会”,因为整个过程只改变了指针的指向位置,没有修改任何节点的 next 字段。如果你在回答时说错了,面试官可能会追问内存泄漏的问题,那又是另一个话题了。
我在 LeetCode 上还试过把循环条件改成 while (pA && pB) 的写法,那是不对的,因为相交节点有可能正好是某个链表的头节点,此时 pA 和 pB 中有一个为 nullptr 但另一个不是,循环会提前退出。所以循环条件必须是比较两个指针是否相同,而不是笼统地判断是否都非空。
3.3 边界条件与特殊用例
链表题最怕的就是边界条件,相交链表也不例外。我整理了一下实际提交中反复踩到的边界情况:
第一个,两个链表都为空。headA 和 headB 都是 nullptr,pA 和 pB 从一开始就相等,while 一次都不执行,直接返回 nullptr。这个用例在代码里天然兼容,但如果你在写题时把“空链表”当成特殊情况单独处理,反而容易写错。
第二个,两个链表从头节点开始就相交。也就是 headA == headB,两个指针一开始就相等,函数直接返回 headA。这个行为是对的,因为相交的起始节点就是两个链表的公共头节点。
第三个,两个链表长度相差很大。比如 A 长 10000,B 长 1,两者在很近的位置相交。双指针的跳转逻辑能保证 pA 在走完 A 后跳到 B 上继续走,而 pB 早已走完 B 跳到了 A 上。整个过程最多走 m+n 步,不存在“某个指针已经走了两圈”的隐患,因为没有环。
第四个,两个链表完全不相交。前面推导过,两个指针最终同时指向 nullptr 并退出循环。这也是判断不相交的标准特征:不是某个指针走到了 nullptr,而是两个指针同时到达 nullptr。
我还建议你专门构造一个“值相同但引用不同”的测试用例。比如两个链表各有几个节点的值都是 1,但它们是不同的节点对象。如果你在代码里不小心用 val 去比较,这组用例就会直接暴露错误。
4. 常见错误与调试排查实录
4.1 最容易踩的几个坑
这个题目代码量虽少,但错误率一点也不低。我在各个刷题平台上看过不少人的提问,也帮朋友排查过代码,总结下来有几个频发问题。
第一个坑是用“值相等”代替“节点相等”。链表中节点值重复是很正常的,两个链表可能有多个值为 1 的节点,但它们并不相交。判断相交的唯一标准是“节点对象是否相同”,也就是引用相等。在 Python 里是 is 而不是 ==,在 C++ 里是 pA == pB 而不是 pA->val == pB->val。这一点一定要刻进 DNA 里。
第二个坑是跳转时用错了目标。有人会写成 pA = pA.next if pA else headA,也就是走完 A 之后又跳回 A。这会导致 pA 在 A 和 B 之间反复横跳,永远等不到和 pB 相遇的时刻,最终形成死循环。正确写法是走完 A 之后跳到 headB,走完 B 之后跳到 headA。
第三个坑是改动链表结构。有人为了简化判断,试图把 A 的尾节点接到 B 的头节点上,形成一个“人为环”,然后用环形链表的快慢指针去解。这样做虽然理论上可行,但违反了题目“不得修改链表结构”的隐含要求,而且很容易在测试用例里造成副作用。面试时千万不要选择这种“花活”。
第四个坑是遗漏“两个链表都不相交”的情况。很多人的代码在相交场景下没有问题,但不相交时返回的不是 None 而是报错。原因多半是循环终止条件写得不对,比如把 while 写成了 while pA.next and pB.next,那样一旦 pA 走到最后一个节点,循环就提前退出,根本等不到 pB 绕到 headA 上。
4.2 调试技巧与判定方法
我在调试链表题目时,习惯在循环体里加打印语句,把每一轮 pA 和 pB 指向的值打印出来,然后手动核对中间结果。比如在 Python 里写:
python复制while pA != pB:
print(pA.val if pA else None, pB.val if pB else None)
pA = pA.next if pA else headB
pB = pB.next if pB else headA
注意这行打印语句里也做了空指针判断,否则 pA 为 None 时访问 pA.val 会直接抛异常。通过打印可以清楚地看到两个指针是否按预期完成了“跳转”,以及它们在第几步相遇。
如果你手边没有断点调试器,用纸笔画图也是一种高效的排查方法。画出两个链表的拓扑结构,标出每个节点的地址和值,然后模拟指针行走。当你发现代码跑出来的顺序和你手推的不一致,问题往往就出在某一行跳转逻辑上。
另一个有用的自测方法是写一个辅助函数,把链表转成列表用来预览。比如:
python复制def to_list(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
这样你可以快速确认自己构造的测试用例是不是真的“相交”了。很多时候你构造的用例本身就不满足相交条件,代码没问题但结果总是 None,这时候不是代码的锅,是测试数据的锅。
4.3 测试用例设计清单
刷 LeetCode 时,很多人只跑题目给的示例就提交,结果被隐藏用例打回。我给相交链表题目总结了一套测试用例清单,写代码前先想清楚这几类:
第一类:标准相交。A = [4,1,8,4,5],B = [5,0,1,8,4,5],相交节点值为 8。这是题目自带示例,用来验证基本逻辑。
第二类:不相交。A = [1,2,3],B = [4,5,6],预期返回 null。重点验证循环能否正常退出。
第三类:一个链表为空。A = [],B = [1,2,3],预期返回 null。验证空指针处理。
第四类:相交节点在中间偏后位置。比如 A 很长,B 很短,但相交点在中段,验证双指针能通过跳转对齐路径。
第五类:相交节点是头节点。也就是两个链表的第一个节点就是同一个对象,预期直接返回第一个节点。
第六类:值相同但不相交。A = [1,1,1],B = [1,1,1],但所有节点引用都不同,预期返回 null。这一组专门针对“用 val 比较”的错误写法,非常能暴露问题。
如果你自己构造测试用例不方便,写个简单的数组转链表的函数也不难:
python复制def list_to_linkedlist(arr):
dummy = ListNode(0)
cur = dummy
for val in arr:
cur.next = ListNode(val)
cur = cur.next
return dummy.next
构造相交链表稍微复杂一点,需要先建公共部分,再分别接上两个各自的前缀链表。这个步骤建议自己也写一遍,能加深对“共享节点”概念的理解。
5. 延伸思考与工程应用
5.1 从相交链表到环形链表
如果你把相交链表的双指针解法理解透了,再看环形链表的检测,会发现思路是相通的。环形链表 II(LeetCode 142)同样是快慢指针,一个走一步一个走两步,如果快慢指针最终相遇,就说明链表里有环。相遇后,再用一个指针从头节点出发,和慢指针同步走,两者再次相遇的位置就是环的入口。
为什么环形链表检测用“快慢指针”而不是“两个同速指针”?因为环形链表的核心特征是存在一个无限循环,两个同速指针在环上会陷入“追逐但永远追不上”的尴尬。而相交链表没有环,所以两个同速指针能在有限步内完成路径对齐。
这两种题放在一起对比,可以帮助你建立“指针 + 路径”的思维模型。面试官经常会把这两道题连着问,先让你做相交链表,再让你做环形链表,最后问“如果相交链表里有环怎么办”。这个问题我至今没在面试里遇到过,但思路应该是在双指针的基础上加入环检测的先验判断,判断结果为有环就直接套环形链表那套解法。
5.2 工程中的链表处理场景
很多人觉得链表题只是面试用的,实际工作里用不到。这个想法不太对。链表作为一种基础数据结构,在系统底层和资源管理的场景中非常常见。比如内存分配器维护空闲块链表时,需要判断不同的链表是否有重叠区域;芯片设计里处理网表或时序约束时,也会用到链表来组织节点,两个模块的约束链是否相交直接影响能否合并优化。
在比较大型的软件工程中,两个链表是否相交还可以用来检测“共享资源”问题。比如两个对象分别维护了一份操作日志链表,如果它们的后半段是同一个内存区域,说明这两份日志共享了一部分底层缓冲区,这在排查内存重复释放或者资源冲突时是一个重要线索。
当然,日常工作里不太可能让你手写双指针去处理这些问题,但这些思维模式会潜移默化地影响你设计代码的方式。你遇到两个数据结构要合并、去重、找公共部分时,能下意识地去判断“这是一个链表问题”,并且知道有 O(1) 空间的解法存在,这就是刷题带来的工程价值。
实际建议:链表题不要只在刷题平台上学,我建议私下用自己熟悉的语言把链表节点结构、遍历、插入、删除、反转这些基本操作都手写一遍。基本功越牢,做题时越能把注意力放在算法思想上,而不是被语法细节绊住。
5.3 面试中的表达与加分角度
相交链表的面试表达可以很有层次感。我建议按这三个层次来答:
第一层,先说理解。直接点明“相交指的是节点引用相同,而不是值相等”,这个表述立刻就能让面试官知道你抓住了关键。然后简单描述题目要求,不要急着写代码。
第二层,说思路。先提出最朴素的哈希法,主动分析它的时间和空间复杂度,说“这个解法能通过,但空间可以优化”。然后引出双指针法,用“人人跑步,总路程相同”的类比解释为什么两个指针会相遇。这个过程既体现你能先给出可行解,又体现你愿意进一步追求最优解。
第三层,代码细节。写代码时注意边界处理,写完以后主动把前面提到的第六类测试用例跑一遍,比如“两个链表值相同但节点引用不同,最后返回 null”。这种主动测试意识在面试里非常加分。
再有一个小技巧:写代码之前可以先问面试官“这里可以假设链表中没有环吗”,因为题目默认无环,但你主动问一句,既显得严谨,又暗示你考虑过更复杂的场景。如果面试官反问“那如果有环呢”,你就可以顺势把自己对环形链表的理解也展示出来。
我个人在实际操作中的体会是,这道题真正难的点不在于写代码,而在于你能不能在一分钟之内想到“用跳转对齐长度”这个视角。如果第一次接触就卡住了,不用灰心,这种思维需要积累。多刷几道链表题之后,你会发现双指针技巧就是一个工具包,见多了自然就会用。最后分享一个小技巧:刷题时每道题都写下自己的思路笔记,哪怕只有两三句话,三个月后再回头看,你会明显感觉到自己的进步。
