图 1:本文封面,Y 字形就是这道题的完整模型。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。链表找交点是双指针题里的经典款,面试官真正考察的不是背答案,而是你能否把「对齐剩余路程」这个洞察自己推出来。
想象面试现场:「两条单向链表如果相交,怎么找到第一个公共节点?时间空间复杂度能不能再压一压?」
第一步,先把问题模型画清楚。
两条单向链表如果相交,形状一定是个 Y 字:链 A 有自己的独有段,长 a;链 B 有自己的独有段,长 b;从某个节点开始两链合并,共享一段长 c 的公共尾段。为什么一定是 Y 而不是 X?因为单向链表每个节点只有一个后继,两链一旦在某点汇合,之后的路径就完全重合,不可能再分开。这一步想清楚,「第一个公共节点」的定义就落地了:Y 字分叉收口的那个节点。
第二步,主解法:先对齐,再同步推进。
核心洞察一句话:两链总长之差,就是独有段之差。交点之后大家走的是同一段路,所以只要让长链的指针先走掉长度差,两条链的「剩余路程」就一样长了。之后两个指针齐步走,逐个节点比较引用,第一个相同的节点就是答案。如果一路走到末尾都没遇上,说明不相交。
三步拆开:分别遍历两条链求长度(各一遍 O(n));长链指针先走差值;两指针同步推进,比较节点是否相等。全程 O(n) 时间、O(1) 空间,不开任何辅助容器。
第三步,等价巧解:交换赛道走两遍。
还有一个更漂亮的写法:指针 A 走完链 A 就接着走链 B,指针 B 走完链 B 就接着走链 A。两人各走 a+b+c 步,路程被强制拉平,必在交点相遇;不相交的两条链,两人会同时走到 null,在「空」处碰头,循环自然结束。代码只有四行,但它和对齐法是同一个洞察的两种表达:把剩余路程对齐。
第四步,写出可运行的代码并实测。
下面这份实现同时给出对齐法、互补走法和朴素两层循环对照,并构造相交与不相交两组用例自验。用 Node.js 22.15.0 直接运行(node list-intersection.js)即可:
'use strict';
function node(val, next = null) {
return { val, next };
}
// 主解法:先量长度,长链先走差值对齐,再同步推进
function firstCommonByAlign(headA, headB) {
const len = (h) => { let n = 0; for (let p = h; p; p = p.next) n++; return n; };
let [pa, pb] = [headA, headB];
const [la, lb] = [len(headA), len(headB)];
if (la > lb) for (let i = 0; i < la - lb; i++) pa = pa.next;
else for (let i = 0; i < lb - la; i++) pb = pb.next;
while (pa && pb) {
if (pa === pb) return pa; // 比较的是节点引用,不是值
pa = pa.next; pb = pb.next;
}
return null;
}
// 等价巧解:A 走完接 B,B 走完接 A,各走 a+b+c 步必在交点相遇
function firstCommonBySwap(headA, headB) {
let [pa, pb] = [headA, headB];
while (pa !== pb) {
pa = pa ? pa.next : headB;
pb = pb ? pb.next : headA;
}
return pa; // 相交时是第一个公共节点,不相交时两人都走到 null
}
// 朴素对照:两层循环逐个比,O(m*n)
function firstCommonBrute(headA, headB) {
let cmp = 0;
for (let pa = headA; pa; pa = pa.next)
for (let pb = headB; pb; pb = pb.next) {
cmp++;
if (pa === pb) return { found: pa, cmp };
}
return { found: null, cmp };
}
// 相交用例:A 独有 2 个节点,B 独有 3 个,共享尾段 3 个
const tail = node('c1', node('c2', node('c3')));
const headA = node('a1', node('a2', tail));
const headB = node('b1', node('b2', node('b3', tail)));
console.log('相交用例 对齐法:', firstCommonByAlign(headA, headB)?.val); // 期望 c1
console.log('相交用例 互补走法:', firstCommonBySwap(headA, headB)?.val); // 期望 c1
// 不相交用例
const loneA = node('a1', node('a2'));
const loneB = node('b1', node('b2', node('b3')));
console.log('不相交 对齐法返回:', firstCommonByAlign(loneA, loneB)); // 期望 null
console.log('不相交 互补走法返回:', firstCommonBySwap(loneA, loneB)); // 期望 null
// 规模实测:独有段各 5 万节点,公共尾段 1 万节点
let bigTail = null;
for (let i = 0; i < 10000; i++) bigTail = node('c', bigTail);
let bigA = bigTail, bigB = bigTail;
for (let i = 0; i < 50000; i++) { bigA = node('a', bigA); bigB = node('b', bigB); }
console.time('对齐法(11 万节点规模)');
firstCommonByAlign(bigA, bigB);
console.timeEnd('对齐法(11 万节点规模)');
console.log('朴素两层循环的比较次数:', firstCommonBrute(bigA, bigB).cmp);
实测输出如下(耗时数字随机器波动,量级稳定):
相交用例 对齐法: c1
相交用例 互补走法: c1
不相交 对齐法返回: null
不相交 互补走法返回: null
对齐法(11 万节点规模): 2.581ms
朴素两层循环的比较次数: 3000050001
最后两行把复杂度差异摆上了台面:对齐法扫描两遍就交卷,毫秒级;朴素两层循环要做三十亿次比较——O(n) 与 O(n²) 在十万节点规模上的真实差距。
图 2:问题模型、对齐三步法与互补走法。
可能的追问与易错点。
「比较的是值还是引用?」必须是引用——两个节点值相同不代表是同一个节点,相交的定义是共享内存里的同一段节点。「能不能用哈希集合?」能:把链 A 所有节点存进集合再扫链 B,O(n) 时间但要 O(n) 空间,面试里通常作为先给出的过渡方案,再被追问「空间能不能省掉」时祭出对齐法。易错点主要有三:一是把链表想成 X 形交叉,没意识到单向链表相交后必重合;二是互补走法里把 pa = pa.next 写成不判空,不相交时死循环;三是只求解不谈复杂度,漏掉「为什么能 O(1) 空间」这一层。
回到面试现场,建议的答题节奏:先画 Y 字模型并论证「相交后必重合」,再给哈希集合的过渡解,主动指出空间代价,然后推出对齐法三步,最后补一句互补走法作为彩蛋。这个节奏展示的是推理过程,不是背诵结果。「对齐剩余路程」这套思路在工程里也常见:数据管道里两条事件流找汇合点、版本链找分叉点,都是同一个动作。
你在链表题上被追问到 O(1) 空间时,是当场推出来的还是提前背过的?欢迎聊聊。