1. 一道题看出链表基本功:相交链表到底在考什么
我最早在“代码随想录”里刷到“160. 相交链表”时,第一反应是这题挺简单的,结果第一版代码就翻车了。不是因为没读过题,而是把“相交”理解成了“值相等”。这个误区太常见了,后面我会专门说。单说这道题能带给你的东西,绝对超过一道普通算法题的量:链表遍历、指针移动、循环终止条件、时间和空间复杂度取舍,全都在这里了。比如你可以在A链表里先走一遍,同时把节点地址记下来,然后去B链表里看有没有重复的,这就是哈希表思路;也可以耍点小聪明,让两个指针在链路上绕一圈,最终神奇地“接上头”,这就是双指针思路。两种方式各有各的适用场景,但面试里最被认可的,往往是空间复杂度更低的那个。
题目本身不复杂:给两个单链表,链表的头节点分别是 headA 和 headB,如果两个链表在某个节点开始共享同一段内存,就返回这个共享节点的指针;如果没有任何公共节点,就返回空指针。这里的“共享同一段内存”是至关重要的前提,它意味着两个链表如果相交,那么从相交点开始,后面所有节点都是同一个地址,而不是恰好值相同。用生活里的例子说:两条马路从某个路口开始完全汇成一条路,之后的路况、路灯、摄像头全是同一套,这才是“相交”。如果只是路边电线杆等高所以看起来一样,不算。理解这个前提之后,代码会不会写对,基本就靠边界情况了。
这个题目在面试里出现的频率很高,而且经常作为后续题目的铺垫。你把它吃透了,再去碰环形链表、找链表中点、合并有序链表,会明显顺手很多。因为链表题的解题手感,主要就来自对“指针移动”的掌控力:什么时候该停、什么时候该换路、什么时候会死循环,这些判断力不是背题背出来的,是反复调试调出来的。接下来我就把三条主流思路逐一拆开,讲清楚它们的取舍和实现细节。
2. 为什么暴力法不行:从哈希表到双指针的思路演进
2.1 暴力枚举和哈希表:能解但不够优雅
最容易想到的做法,就是用嵌套循环。对 headA 中的每个节点,遍历整个 headB,看看有没有节点和它指向同一个地址。空间复杂度是 O(1),但时间复杂度是 O(m*n)。m 和 n 分别是两条链表的长度,一旦链表长度破万,基本就等着超时。这种写法在笔试里能不能过看数据范围,在面试里却几乎没法拿出来聊,因为面试官马上会追问“太慢,优化一下”。
优化方向很直觉:既然 A 链表的节点可能被反复遍历,那就先存起来。用一张哈希表记录 headA 经过的所有节点指针,然后遍历 headB,逐个检查当前节点是否在哈希表中。第一次命中的节点就是相交节点。时间复杂度降到 O(m+n),因为两条链表各遍历了一遍,哈希表增删查都是平均 O(1)。但代价是空间复杂度变成 O(m),因为要把较长的一条链表所有节点都存进去。哈希表解法是很多教科书里的标准答案,也是大家最容易写出来的版本,但我个人认为它不是最优解,原因不是它没法用,而是它有更轻量、更体现“算法直觉”的版本。面试如果只写哈希表,大概率会被继续追问一句:“能不能把空间复杂度也降到 O(1)?”
这道题真正让人眼前一亮的地方,就是那个空间 O(1) 的双指针解法。理解它需要一点逻辑铺垫:两个指针分别从 A、B 出发,当一条链走完时,让这个指针跑到另一条链的头部继续走,两个指针最终会同时走到相交点。看起来像变魔术,实际上数学上非常严谨。后面我会解释为什么能相遇,这里先给结论:如果两条链表相交,那两个指针会“殊途同归”;如果两条链表完全不相交,那两个指针会同时走向空指针。无论是哪种情况,循环都不会卡死,这个特性让双指针解法成为这道题的最佳解法。
2.2 双指针的相遇逻辑:用一段路程差弥合长度差
为什么双指针能对?设链表 A 在相交前的长度为 a,链表 B 在相交前的长度为 b,公共部分长度为 c。两个指针同时出发,速度相同,走过的节点数也相同。指针 pA 走完 A 的长度 a+c 后,会切到 headB 上接着走;指针 pB 走完 B 的长度 b+c 后,会切到 headA 上接着走。它们在各自完成“本链 + 对方链”的行走后,分别走过的总节点数都是 a+c+b 和 b+c+a,完全相等。而这条路线的总长度,等于 a+b+c,也就是两条链表相连成一个环之后,恰好把相交点包含在内。两个速度一样的人走同一条路,自然会在同一个时间、同一个点相遇。
用例子验证:A 链表为 1->2->3->4->5,B 链表为 9->8->3->4->5,相交点是 3。此时 a=2,b=2,c=3。pA 走完 1,2,3,4,5 后切到 B 的 9,继续走 9,8;pB 走完 9,8,3,4,5 后切到 A 的 1,继续走 1,2。最终它们都在第三个节点“3”相遇。有意思的是,即使 a 和 b 差距很大,这个“差值”也会在切换路径后被抵消,因为慢速的那个会提前进入对方的链表,走得更远一点。这一套逻辑让双指针解法既不需要知道长度,也不需要额外存储,只靠指针的移动就解决问题。
2.3 长度差法:直观但需要额外一次遍历
除了双指针,还有另一种容易理解的解法:长度差法。先分别遍历两条链表,数出长度 lenA 和 lenB。让更长的链表先走 lenA-lenB 的差值步(假设结果是正数),这样两个指针就处在同一起跑线,之后同步前进,第一个位置相同的节点就是相交点。这种思路的代码比双指针长一点,但逻辑更“按部就班”,特别适合口头解释给面试官听。空间复杂度同样是 O(1),时间复杂度是 O(m+n),因为要分别遍历一次取长度,然后可能再遍历一次找交点。
我在刷“代码随想录”的时候,明显感觉到它推荐双指针解法是有道理的:长度差法需要先知道两个链表的长度,这有赖于“链表有尾”的前提,而双指针连长度都不用算,代码也更简洁。面试时如果时间不够,双指针版本甚至可以三五行搞定。不过话说回来,长度差法的代码更不容易写错,因为它的步骤是线性的:先量长度,再对齐,最后找交点。我自己准备面试的时候,会把这两种方法都背熟,因为同一个题很难保证面试官就喜欢听某一种思路。他要是先问我:“你怎么找两条链表的长度差?”我就顺着给长度差法;他要是问:“能不能再优化?”我就顺势切换到双指针。这种一鱼两吃的准备方式,每次面试效果都不错。
3. 完整代码实现:把思路落到 C++ / Python / Java
3.1 C++ 双指针实现:贴合代码随想录的写法
如果你看过“代码随想录”里链表篇的代码风格,会习惯它定义 ListNode 结构体时用得很顺手,例如:
struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} };然后 160 题主函数可以这样写:
class Solution { public: ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { ListNode *curA = headA; ListNode *curB = headB; while (curA != curB) { curA = curA ? curA->next : headB; curB = curB ? curB->next : headA; } return curA; } };有些同学第一次看到这段代码会愣一下,因为curA = curA ? curA->next : headB这个写法有点绕。拆开看就是:如果 curA 不是空,就往前移动一步;如果 curA 已经走到空,说明 A 链表走完了,就跳去 headB 从头走。curB 同理。这个写法巧妙地利用了“空指针”作为“换路”的信号,不需要额外维护一个标志位。循环结束有两种情况:两个指针在相交点相遇,或者两个指针都变成空指针(代表没有交点)。无论哪种,返回的都是“第一对相等指针”,代码自然成立。
如果觉得三目运算符影响可读性,完全可以改成 if 语句,效果一样:
while (curA != curB) { curA = (curA != nullptr) ? curA->next : headB; curB = (curB != nullptr) ? curB->next : headA; }3.2 Python 和 Java 版本:换语言,思路不变
Python 的写法最简洁,也是我平时做算法题时最常用的版本:
class Solution: def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode: p, q = headA, headB while p is not q: p = p.next if p else headB q = q.next if q else headA return p注意这里判断用的是is not,不是!=。因为 Python 里两个节点的“值相等”不代表“是同一个对象”。ListNode 没有重写__eq__方法的情况下,==默认判断的是内存地址,但使用is能更加明确表达“我们要比较的是对象身份”。如果题目给的链表节点类来自 LeetCode,那!=其实也能凑合,但写is更稳妥,也更能体现你清楚“相交”的本质是同一个对象。
Java 版本的代码同样不难:
public class Solution { public ListNode getIntersectionNode(ListNode headA, ListNode headB) { ListNode p = headA; ListNode q = headB; while (p != q) { p = (p == null) ? headB : p.next; q = (q == null) ? headA : q.next; } return p; } }Java 里没有is这样的身份运算符,直接用!=和==比较对象引用,正好符合我们的需求。三段代码的核心思想一模一样:两个指针走完自己的路,再去走对方的路,最终殊途同归。刷题的时候不用刻意背三种语言,掌握一种,另外两种自然能默写出来。
3.3 边界条件:什么时候最容易翻车
链表题最大的坑永远是边界。这里列几个我当初踩过、也常见别人踩的坑。第一,两个链表都为空。这时循环条件curA != curB检查的是两个空指针,二者相等,根本不进入循环,直接返回空指针,结果正确。第二,两个链表从头就相交。比如两个链表完全一样,每个节点地址相同,那 curA 和 curB 一开始就相等,返回 headA,结果正确。第三,两个链表无交点。双指针会把两条链表彻底走完,最后同时为 null 退出循环,返回 null。关键是,这种无交点情况会不会死循环?答案是:不会,因为走完m+n步之后两个指针都会停在空上,循环条件不成立。这一点在 4.1 里会再算一遍。
还有一类隐蔽错误是拿“值”比较。假如链表节点值恰好有重复,而两个链表并没有共享节点,curA->val == curB->val可能会误报。我第一次写这个题,就是从头到尾比 val,然后看起来部分测试能过,但一旦两个链表在数值存在相同但地址不同时,直接得出错误答案。正确做法是比较指针本身:C++ 里比较的是 ListNode* 的地址,Java 和 Python 里比较的是对象引用,而不是 val 字段。这也是这类“引用类题目”的通用护身符。
4. 模拟运行与复杂度:不仅要知道“能过”,还得知道“为什么能过”
4.1 无交点情况的数学验证
很多文章只讲双指针在有交点时如何相遇,却没回答“万一没交点呢”。这里补上。设链表 A 长度为 L1,链表 B 长度为 L2。pA 在 A 上走完 L1 步后跳到 B,再走 L2 步后到空;pB 在 B 上走完 L2 步后跳到 A,再走 L1 步后到空。总的行走步数,pA 是 L1+L2,pB 是 L2+L1。完全相等。也就是说,两个指针会在同一时刻走到空指针,循环条件while (curA != curB)在看到两个 null 时自动不成立,于是返回空。这个过程和有没有交点无关,只是一个精确的“步数同步”机制。
有交点的时候,两个指针会在某个更早的节点相遇,总步数小于等于 L1+L2。无交点的时候,它们会在第 L1+L2 步相遇,而这个“相遇点”是空。这个设计非常精妙:不管结果如何,循环都能终止,而且不会把逻辑写复杂。面试里如果能主动说出这一层“环状行走”的数学依据,绝对是个加分点。
4.2 时间复杂度、空间复杂度与 LeetCode 上的实际体验
双指针解法的时间复杂度是 O(m+n)。这里的 m、n 是两个链表长度,每个指针最多走 m+n 步,两个指针合计最多走 2(m+n) 步,常数 2 不影响复杂度。空间复杂度是 O(1),因为只用了两个辅助指针。相比之下,哈希表法的空间复杂度是 O(max(m,n)),在数据量大的场景下容易吃到内存限制。在 LeetCode 的测试用例里,双指针运行时间通常能击败绝大多数提交,因为链表操作本身很快,而且空间占用小。
我实测过几次,当 m=n=100000 左右,双指针解法耗时基本稳定在几十毫秒内;哈希表解法可能耗时会稍高一些,主要花在哈希表的插入和查询上,即便理论复杂度一样,常数因子也偏大。所以从工程角度讲,双指针不只是面试的“漂亮解法”,实际处理嵌入式链表、C++ 内存受限场景时,空间 O(1) 的优势也很明显。后面第 6 节我会细说这种思路在代码里的延伸价值。
4.3 模拟一遍:用表格看指针移动
为了方便理解,我拿一组简单的链表模拟双指针移动。假设 A 链表节点分别是 A1、A2、C1、C2,B 链表节点分别是 B1、B2、B3、C1、C2,相交点是 C1。设 pA 从 A1 出发,pB 从 B1 出发,每一步同步前进。表格里每一步两个指针所在节点:
| 步数 | pA 指向 | pB 指向 | 说明 |
|---|---|---|---|
| 1 | A1 | B1 | 无交点,继续 |
| 2 | A2 | B2 | 无交点,继续 |
| 3 | C1 | B3 | pA 先进入公共段 |
| 4 | C2 | C1 | pB 进入公共段 |
| 5 | 空 | C2 | pA 走完 A,切到 headB |
| 6 | B1 | 空 | pB 走完 B,切到 headA |
| 7 | B2 | A1 | 两人都在对方链表上走 |
| 8 | B3 | A2 | 继续走 |
| 9 | C1 | C1 | 相遇,返回 |
可以看出,它们在步数 9 时于相交点 C1 相遇。如果纸上画一下,会发现 pA 和 pB 的路径正好形成一个类似“8”字的回路,而公共段是那个交叉点。很多人第一次看到这个表会问:为什么 pA 先走到 C1,pB 还没到,但它们不会在 C1 相遇?因为循环条件是“每一步都检查”,不是“等走到同一个节点再碰头”,后到的 pB 需要再走两步才能到 C1,而此时 pA 已经过了 C1 并走到空指针,切换到了 B 链。这种错位正是双指针能消除长度差的原因:它让先跑完的指针去对方链路上“补长度”,最终两个指针跑过的总步数相同,并且总能在同一时刻抵达同一个点。
5. 刷题过程中最常见的 5 个坑和排查技巧
5.1 空链表和单节点链表
很多人会想当然地把 head 为空的情况放在循环外处理,其实没必要。上面代码已经覆盖了。如果 headA 为空而 headB 非空,循环第一次检查的就是null和headB,显然不等,进入循环后 pA 会变成 headB,pB 也按照pB ? pB->next : headA移动,最终也能走完并返回 null。这个行为正确,只是不够直观。我的习惯是写链表题前先问自己一句:这个解法在“空链表”、“单节点链表”、“全等链表”、“无交点链表”这四种情况下分别会怎样?如果都能自洽,大概率没问题。
5.2 整条链表从头相交
当 headA 和 headB 指向同一个节点时,循环不会执行,直接返回 headA。这正确。但有些同学会错误地把这种情况排除在外,认为“相交必须发生在两个节点之后”,然后强行让指针先各走一步,导致结果变成第一个节点的 next。这个坏习惯多半是从“找第一个交点”的语义里带出来的,但实际上两个链表可以从头节点就开始共享,比如 A 和 B 都是同一个链表的别名。代码不需要特殊处理。
5.3 用值相等判断相交
这个坑我在开头就提过,值得再强调一次。链表相交是“地址相交”,不是“值相交”。假设 A 链表是 1->2->3,B 链表也是 1->2->3,但是两组节点是在内存中分别创建的,那么它们的地址完全不相同,不存在相交节点。用值相等去判断,会返回第一个节点,直接判错。在 C++ 中判断指针是否相等,比较的是指针变量本身,而不是curA->val。在 Python 中比较对象用is,在 Java 中比较引用用==,这些都是硬规矩。
5.4 指针走到空就立刻切换
有同学会写成curA = curA->next然后发现空指针崩溃,于是加个 if,但 if 的条件写成了while (curA->next),导致最后一个节点直接跳过,终点永远是倒数第二个节点。这里要理清楚:“当前节点为空”才需要换路,而不是“下一个节点为空”就换路。判断时必须用curA == nullptr,然后让 curA 指向另一条链表的 head。如果写成curA->next == nullptr才换,那等 curA 走到最后一个节点时,还没有换路,下一步你的 curA 会变成空指针,而空指针没有 next,再次访问就会崩溃。正确的语义是:等 curA 已经为空了,说明这条链表的路走完了,此时把它安放到另一条链表的起点。顺序不能反。
5.5 无交点链表跑不完
有些人担心两个不相交的链表会让循环无限跳转。根据 4.1 的数学验证,不会。但如果你把切换条件写错,比如让 pA 从 headB 走完后再次跳回 headA,而不是让 pA 在 A 走完后去 headB,且 pB 在 B 走完后去 headA,就可能出现两个指针各自在自己链表里打转,永远碰不到。最直接的排查方法:加一个计数器,如果步数超过 L1+L2,就说明写错了。或者干脆在本地跑几个极端用例,比如 A 长度 100,B 长度 1,确保结果和样例一致。我每次刷链表题都会打印最终返回节点的val和一个内存地址值,用肉眼确认它确实属于公共段。
6. 刷完这道题之后:双指针思想还能往哪走
我个人非常喜欢“相交链表”的原因,不只是它作为一道面试题很经典,而是它引出了一种思维模式:当两条路径长度不一致时,可以让执行者交换路径来“对齐距离”。这种思维在链表相关题目里反复出现。例如寻找链表的倒数第 k 个节点,让一个指针先走 k 步,另一个再跟上;寻找链表的中点,一个快指针走两步,一个慢指针走一步。它们本质上都是“用步数差来定位”。再比如环形链表 II,要求找环的入口节点,它的一条经典解法也是双指针,其中数学推导和“相交链表”的推导一脉相承:两个指针在环内相遇后,让一个指针回到头节点,然后两个指针再同步走,最终会在入口相遇。如果能把相交链表这一题的指针切换逻辑理解透彻,再去学环形链表会快很多。
具体到工程应用,嵌入式或者驱动代码里经常出现链表结构,比如 Linux 内核里的 list_head 设计,用偏移量计算宿主结构体,本身就依赖地址操作。在内存受限环境中,你要判断两个集合是否有重合区域,或者两条设备链表是否有公共节点,双指针的思路比哈希表更省资源,因为它不需要额外的分配和释放。即便语言层面没有内置链表结构,只要你能自己写出节点结构,这套算法就能原样迁移。所以这道题刷完后,我习惯了在遇到“两条路径”“两个序列”“双数组”类问题时多问一句:能否用双指针做到 O(1) 空间?这种条件反射就是靠刷这种经典题养出来的。
关于代码随想录,我自己的体会是它很适合用来建立知识框架。“相交链表”在链表章节里看似独立,但它和“两个链表的公共子序列”“链表的成环”等概念是互相关联的。最好的刷题方式不是照着代码默写,而是先把思路理解了,再自己动手实现,然后用三五个测试用例验证,最后再回头对比参考代码。我在实际写这道题时,一开始把双指针的换路条件写反了,结果在无交点链表上死循环,后来把两个指针的位置在纸上画清楚,才真正记住:谁走完谁换路,换到另一条链表的起点。这个“画图模拟”的习惯,才是刷链表题不困的核心秘诀。
如果以后再有人问我“相交链表怎么做”,我不会直接扔代码,而是先问一句:“你知道两个链表相交意味着它们的尾部一定相同吗?”从这个问题出发,自然就能引出长度差法和双指针法。代码只是手段,把链表结构在脑子里转起来才是本事。希望这篇总结也能帮你把这道题真正装进长期记忆里,下次在白板前面写这道题时,三分钟能说清思路,两分钟能写完代码,那就真的到位了。