刷这道题的那天下午,我差点被一个很蠢的错误劝退——链表相交我一看就会,一写就翻车。后来在几家公司的面试里反复遇到它,我才意识到这题真正的难点不在解法本身,而在“你怎么理解链表里相交这两个字”。LeetCode 面试题 02.07 这题,表面上是求两个单链表的交点,实际上考的是对节点地址、遍历路径和边界条件的理解。这篇文章我就把三种主流解法、踩过的坑、以及面试现场怎么回答都捋一遍,给正要刷链表题的朋友做个参考。
这题适合两种人:一是准备算法面试、刷题量还不到两百的入门选手,想找一道性价比高的链表题建立信心;二是已经在用双指针解链表题,但碰到“循环怎么退出”“为什么能相遇”这类追问时说不清楚的人。两种人看完都能有收获。
1. 先把题目看清楚:链表的“相交”根本不是一个等值问题
1.1 题面到底在问什么
给你两个单链表的头节点 headA 和 headB,请你找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null。题目保证整个链式结构中不存在环,而且要求时间复杂度尽量做到 O(n),空间上最好是 O(1)。
这里的“相交”和我们做数学题时说的两条线段交叉完全不是一回事。链表是节点通过指针串起来的,每个节点在内存里有一个地址,节点里存着 next 指针指向下一个节点。两个链表相交,意味着从某个节点开始,两个链表后续走的是同一批节点,也就是同一个内存地址。
注意看官方示例:
listA = [4,1,8,4,5]listB = [5,0,1,8,4,5]
相交节点的值是 8。为什么不是 1?因为在链表 A 中值是 1 的那个节点,和链表 B 中值是 1 的那个节点,是两个不同地址的节点,它们只是值恰好一样。用肉眼观察数组时很容易误判,但链表是用地址串起来的,比较节点是否相同,必须比较节点地址,也就是指针相等。
1.2 最容易踩的坑:把“值相等”当成“相交”
我见过不少初学者一上来就按值比较,代码长这样:
python复制def getIntersectionNode(self, headA, headB):
while headA and headB:
if headA.val == headB.val:
return headA
headA = headA.next
headB = headB.next
return None
这个写法的问题很明显:两个链表长度都不一定相同,交点的偏移位置也可能不同,同步移动两个指针时根本对不齐。更关键的是,即使有两个节点值相同,也不能说明它们是同一个节点。
举个例子:
- 链表 A:
1 -> 2 -> 3 - 链表 B:
9 -> 2 -> 8
这里两个链表都有值为 2 的节点,但它们各自指向不同的 next,后续节点完全不同,根本没有相交。这道题的判断标准必须是“指针相等”,而不是“值相等”。所以实现时比较的是 pa == pb,而不是 pa.val == pb.val。
1.3 为什么一旦相交,后续节点就一定全部相同
这是个值得想明白的点。单向链表的每个节点只有一个 next,这意味着从某个节点出发往后走的路径是唯一的。如果 A 和 B 在节点 X 处相交,X 的 next 是固定的,它后面跟着谁也是固定的,因此 A 和 B 从 X 之后的所有节点都必然相同。
这个性质非常关键,它保证了我们可以在 O(n) 时间内判断交点,也让双指针解法有了理论基础:我们不需要记录整条链表的历史,只需要想办法让两个指针在相同的位置相遇。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最不容易出错的做法:哈希集合记录地址
2.1 思路拆解
第一次做这题,我建议先别追求最优雅的双指针,先把哈希集合方案写对。思路特别简单:走一遍链表 A,把每个节点的地址放进一个集合;然后再走链表 B,边走边查当前节点在不在集合里,如果在了,这个节点就是交点;走到头还没有,就返回 null。
这个方案的优点是逻辑非常直白,几乎不需要证明,写出来很难出错。面试紧张的时候,它能帮你快速拿到底分。虽然空间复杂度是 O(n),没有达到题目暗示的 O(1) 最优,但快速给出一个正确解法,然后再优化,本来就是面试沟通的正常节奏。
2.2 C++ 实现
cpp复制ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
unordered_set<ListNode*> visited;
ListNode* cur = headA;
while (cur != nullptr) {
visited.insert(cur);
cur = cur->next;
}
cur = headB;
while (cur != nullptr) {
if (visited.count(cur)) {
return cur;
}
cur = cur->next;
}
return nullptr;
}
这里存的不是 int,而是 ListNode*,这是理解哈希解法的核心。visited.count(cur) 判断的是“这个地址之前有没有出现过”,而不是“这个值之前有没有出现过”。
2.3 Python 实现
python复制def getIntersectionNode(self, headA, headB):
seen = set()
cur = headA
while cur:
seen.add(cur)
cur = cur.next
cur = headB
while cur:
if cur in seen:
return cur
cur = cur.next
return None
Python 里直接用 set() 存节点对象就行。因为 Python 的节点对象默认按内存地址计算哈希,存进去的对象和查出来的对象如果地址相同,就会被判定为同一个。
2.4 时间空间复杂度与适用场景
时间复杂度是 O(lenA + lenB),不管有没有交点,最多把两个链表各遍历一遍。空间复杂度是 O(lenA),因为要把链表 A 的节点全部放进集合。
哈希解法适合用来“兜底”。如果面试里你先写了它,面试官追问“能不能把空间优化到 O(1)”,你再顺势引出后面两种解法,节奏会非常自然。它不适合的场合只有一个:题目明确要求空间 O(1),或者面试官摆明了想看双指针。
3. 面试官很爱追问的等长对齐法
3.1 为什么直接同步遍历会错过交点
你可能会想:两个指针从头开始一起走,走到某个位置它们指向的节点相同,不就找到交点了吗?问题在于,两个链表的起点到交点之间的距离往往不一样。如果 A 比 B 长,A 的指针已经走到交点后面了,B 的指针还在前面晃悠,两个指针根本没机会在交点处对齐。
还是用官方示例来看。listA 长度是 5,listB 长度是 6,交点在 8 那个节点。从 headA 出发走到交点需要 3 步,从 headB 出发走到交点需要 2 步,二者相差 1 步。如果同步移动,等到 B 的指针到了交点,A 的指针已经走到 4 或者 5 了,自然不可能相等。
3.2 先求长度再对齐:步骤拆解
思路很朴素:既然差在一个长度差上,那就先算出两个链表的长度,让长的那个先走差值的步数,把长度差消掉,然后两个指针同步走。此时如果有交点,它们一定会在同一个时刻到达交点。
具体步骤:
- 遍历链表 A,求出长度
lenA。 - 遍历链表 B,求出长度
lenB。 - 计算差值
diff = |lenA - lenB|。 - 长的链表上的指针先走
diff步。 - 两个指针一起走,每次比较
pa == pb。 - 相等则返回该节点,走到
nullptr都没遇见则返回nullptr。
C++ 实现:
cpp复制ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
int lenA = 0, lenB = 0;
ListNode* pa = headA;
ListNode* pb = headB;
while (pa) {
lenA++;
pa = pa->next;
}
while (pb) {
lenB++;
pb = pb->next;
}
pa = headA;
pb = headB;
int diff = lenA - lenB;
if (diff > 0) {
while (diff--) pa = pa->next;
} else {
diff = -diff;
while (diff--) pb = pb->next;
}
while (pa && pb) {
if (pa == pb) return pa;
pa = pa->next;
pb = pb->next;
}
return nullptr;
}
Python 实现:
python复制def getIntersectionNode(self, headA, headB):
def get_len(head):
n = 0
while head:
n += 1
head = head.next
return n
lenA = get_len(headA)
lenB = get_len(headB)
pa, pb = headA, headB
if lenA > lenB:
for _ in range(lenA - lenB):
pa = pa.next
else:
for _ in range(lenB - lenA):
pb = pb.next
while pa and pb:
if pa == pb:
return pa
pa = pa.next
pb = pb.next
return None
这个解法的时间复杂度是 O(lenA + lenB),空间复杂度是 O(1)。它比哈希解法更符合面试官的期待,因为它用“长度差”这个道理解决了问题,思路也很容易讲清楚。
3.3 边界情况:空链表、同一链表、无交点
写链表的题,边界情况一定要自己先过一遍。
- 如果
headA或headB是nullptr,两个链表不可能相交,直接返回nullptr。上面代码里,长度为 0 的链表不会走进循环,自然返回nullptr,所以不需要单独特判。 - 如果两个链表从头就是同一个链表,
lenA和lenB相等,差值 0,两个指针从头就开始同步走,第一步就会相遇。 - 如果两个链表没有交点,它们会在
pa和pb同时为nullptr时退出循环,返回nullptr。由于我们已经消除了长度差,它们一定同时走完,不会出现一个指针还没走完、另一个已经空了的情况。
这个“同时走完”的性质,是等长对齐法在无交点情况下的关键保证。我在第一次写代码时,曾经在无交点的情况下只判断了 pa 为空就跳出,结果 pb 还有剩余节点,逻辑就错了。后来意识到,长度差消掉之后,两个指针剩下的可走路程一定一样长,所以只要同步判断 pa && pb 即可。
4. 最优解:双指针交替遍历如何“无中生有”地消除长度差
4.1 核心代码只有几行
这道题最漂亮的解法是双指针,空间复杂度 O(1),而且不需要事先遍历求长度。核心思想是:两个指针分别从 headA 和 headB 出发,每次走一步。如果某个指针走到了链表尾部,就把它重置到另一个链表的头部,继续走。这样它们最终一定会在交点相遇;如果没有交点,它们会在同时走到 nullptr 时退出。
C++ 实现:
cpp复制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;
}
Python 实现:
python复制def getIntersectionNode(self, headA, headB):
pa, pb = headA, headB
while pa != pb:
pa = pa.next if pa else headB
pb = pb.next if pb else headA
return pa
这个写法我第一次看到时觉得像魔术,几个字符就搞定了,完全不需要求长度。但理解它为什么能工作,比背代码更重要。
4.2 数学原理:两个指针走过的路程长度相同
设链表 A 从 headA 到交点的距离是 a,链表 B 从 headB 到交点的距离是 b,交点之后到链表结尾的公共部分长度是 c。那么:
- 链表 A 的总长度是
a + c - 链表 B 的总长度是
b + c
双指针的做法,本质上是让 pa 走完 A 的全部路程后,再去走 B;让 pb 走完 B 的全部路程后,再去走 A。如果存在交点,当两个指针相遇时,pa 走过的总路程是 a + c + b,pb 走过的总路程是 b + c + a。
你会发现它们相等。更准确地说,pa 在到达交点之前,会先走完 A 的 a 段,再走完 B 的 b 段,此时它停在交点;pb 先走完 B 的 b 段,再走完 A 的 a 段,也停在交点。这两个路程都是 a + b,所以在同一步,两个指针会同时落在交点上。
这就是“用长度差对消”的另一个版本:把两个链表“拼接”起来,让长度差消失。
如果没有交点会怎样?pa 走完 A 加 B 的全部节点,pb 走完 B 加 A 的全部节点,它们走过的路程都是 lenA + lenB。走到终点时,两个指针同时变成 nullptr,nullptr == nullptr,循环退出,返回 nullptr。不会死循环,这是这个解法最精妙的地方。
4.3 两个最容易搞混的细节
细节一:为什么重置指针时用 headB 和 headA,而不是用另一个链表当前的节点?因为当 pa 走到 A 的尾部时,它需要重新开始走 B 链表的头部,才能保证路程是“A 全段 + B 全段”。如果你把它接到了 B 链表的某个中间位置,路程就变了,可能永远无法对齐。
细节二:pa = pa ? pa->next : headB 和 pa = pa->next ? pa->next : headB 是不同的。前者的判断条件是“当前指针是否为空”,后者是“下一步是否为空”。必须用前者。如果写成后者,当 pa 指向最后一个节点时,它会被错误地当作空指针处理,导致提前跳回另一个链表头,路径长度就不对了。
我见过不少同学在这个细节上栽跟头。建议你写完代码后,专门用一个无交点的测试用例过一遍:pa 和 pb 必须在同一轮循环里同时变成 nullptr,如果出现一个已经为空、另一个还在走的情况,多半就是重置条件写错了。
5. 从一道题延伸出去:只判断相交、有环链表和值相等的伪装
5.1 只判断“是否相交”:尾节点法
如果面试题只要求判断两个链表有没有交点,不要求返回交点,那有一个更省事的办法:分别遍历两个链表到尾部,如果尾节点是同一个,就说明它们相交;否则不相交。
原因还是那个性质:单向链表的节点只有一个 next,一旦两个链表相交,最后一截一定是公共的,尾节点必然相同。这个方法的时间复杂度是 O(lenA + lenB),空间 O(1),代码非常简单。
不过要注意,这个技巧只能回答“是否相交”,不能回答“交点是哪个”。如果要找交点,还是回到上面的三种完整解法。
5.2 变体:如果链表可能有环怎么办
面试官如果追问“假如链表可能有环呢”,这题瞬间会从简单变成复杂不少。核心难点在于,有环的链表可能永远不会走到 nullptr,双指针的“让两个指针走完两个链表”的思路就不成立了。你需要先分别判断两个链表是否成环,找到它们的入环节点,再分情况讨论:
- 两个都无环:回到原题。
- 一个有环一个无环:不可能相交。
- 两个都有环:如果相交,交点在环外某处,或者两者共享同一个环;这时需要判断它们的入环节点是否相同,不同入环节点还要继续判断是否在环上相遇。
这类问题实际面得很少,但作为深挖题,聊到“有环”是很常见的。你可以先把无环情况下的双指针解法讲熟,再提一句有环时需要用快慢指针找入环节点,这已经能让面试官看到你的深度了。
5.3 变体:如果有人在值上做了文章
还有一种常见变形,是把节点里的值改成用来判断的标准。比如让你在“值相同的节点列表”里找第一个值相同且后续值都相同的节点。这种情况下,哈希集合存值就不能直接等价于存地址了,因为它无法区分“值相同但地址不同”的巧合。正确答案仍然应该以地址为准,值只能作为辅助观察。
你可以用这个例子来测试自己到底有没有理解链表相交的本质:假设 A 是 1->2->3,B 是 4->2->3,两者值序列相同,但它们是两个独立构造的链表,并没有相交。如果代码里比较的是 val,就会错误地返回节点;如果比较的是地址,则能正确返回 nullptr。
为了便于对比,我把三种解法放在一起来看:
| 解法 | 思路 | 时间复杂度 | 空间复杂度 | 优点 |
|---|---|---|---|---|
| 哈希集合 | 存 A 的地址,遍历 B 查 | O(n) | O(n) | 思路简单,不容易错 |
| 等长对齐 | 求长度差,先走差值步 | O(n) | O(1) | 逻辑直观,容易讲明白 |
| 双指针交替 | 两指针走完后切换链表 | O(n) | O(1) | 代码最简,最优解 |
表里三种解法都值得写一遍。哈希集合是保底方案,等长对齐是面试常用方案,双指针是最优方案。能一口气把三种方案讲清楚,面试官对这块的考察基本就过了。
5.4 我用测试用例验证代码的经验
写链表题,只靠眼睛看代码是看不出问题的,一定要构造测试用例跑一遍。我在本地上跑代码时,常用的构造方式是这样的:先创建一个公共的链表段,再分别创建两个头节点,让它们指向公共段的不同位置。这样做能保证两个链表的相交是“真相交”,也就是同一个内存地址。
python复制class ListNode:
def __init__(self, x):
self.val = x
self.next = None
# 构造公共段 8 -> 4 -> 5
common = ListNode(8)
common.next = ListNode(4)
common.next.next = ListNode(5)
# A: 4 -> 1 -> 公共段
headA = ListNode(4)
headA.next = ListNode(1)
headA.next.next = common
# B: 5 -> 0 -> 1 -> 公共段
headB = ListNode(5)
headB.next = ListNode(0)
headB.next.next = ListNode(1)
headB.next.next.next = common
# 跑双指针解法,期望返回值是 8 那个节点
无交点的测试用例更简单,两个独立的链表直接构造即可。这种“构造公共段”的方法,能帮你把调试的重点放在算法正确性上,而不是被复杂的链表构造分散注意力。
6. 我自己刷这道题和陪别人面试总结的几点经验
6.1 代码风格上的一点建议
双指针解法虽然短,但面试时不要直接甩出五行业代码就结束。比较好的节奏是:先讲哈希集合方案的思路,再说“空间上可以优化到 O(1),用双指针,核心是让两个链表在逻辑上拼接,消除长度差”,然后边写边解释每一步在干什么。这样写出来的代码即使有瑕疵,面试官也能看到你的思考过程。
命名上我建议用 pa、pb 而不是 curA、curB,因为双指针的语义就是“两个指针在两条链表上交错移动”,pa 和 pb 更自然地对应这个动作。等长对齐法里也可以用 curA、curB,语义更清楚。
6.2 面试现场容易翻车的几个瞬间
翻车点一:把双指针循环条件写成 while (pa != pb && pa && pb)。这个写法在无交点时确实能退出,但一旦其中一个指针为 nullptr 而另一个不是,循环就提前退出了,交点还没找到就返回,结果就错了。正确写法是 while (pa != pb),让循环自己走到两个都为 nullptr 的时刻。
翻车点二:重新开始遍历时,把 pa = headB 写成了 pa = headA。这意味着 pa 永远在 A 和 A 之间循环,pb 永远在 B 和 B 之间循环,路程差无法消除,没有交点时会陷入死循环。
翻车点三:在求长度时改变了头节点指针。如果你在算 lenA 时用了 while (headA) headA = headA->next,后面又想把 headA 作为遍历起点,就会发现问题——原头节点已经被你弄丢了。正确做法是用临时指针遍历,或者先把头节点存下来。
6.3 这道题之后还该练什么
链表相交的核心是“节点地址比较”和“消除长度差”。顺着这条线,你可以继续练两个经典的链表题:一个是 LeetCode 141 环形链表,用快慢指针判断是否有环;另一个是 LeetCode 21 合并两个有序链表,它也会频繁用到“两个指针在不同链表中移动、同步比较值”的思路。
等你能把这三道题都独立写出来,链表类问题的基础就相当扎实了。我个人的体会是,链表题最怕的不是想不到思路,而是对“指针指向哪里、下一步怎么移动”缺乏肌肉记忆。建议每道题至少手写两遍,第一遍盯着代码理解,第二遍直接在白纸上默写,直到不卡壳为止。这样到了面试现场,手稳心不慌。
