深蓝黑背景品牌封面:上方居中是大标题「两条链表找交点」,副标题「对齐长度,同步推进」;下方横贯画面是原创装饰——一个 Y 字形链表:左上链 A 两个节点、右上链 B 三个节点,两链在中部汇入同一节点后继续向下延伸出公共尾段,汇合点以紫色高亮标注「第一个公共节点」;左上标注「经典题解析 · 算法」,右上标注「Day 03 · 10-11」 图 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²) 在十万节点规模上的真实差距。

解法结构图:上部是问题模型——链 A 独有段长 a、链 B 独有段长 b,交点之后共享长 c 的公共尾段,因此两链总长之差就是独有段之差。中部是主解法三步——①分别遍历求两链长度;②长链指针先走长度差,把两条链的「剩余长度」对齐;③两指针同步逐节点比较,第一个相同的节点就是第一个公共节点,全程 O(n) 时间、O(1) 空间。下部是等价巧解——指针 A 走完链 A 接着走链 B,指针 B 走完链 B 接着走链 A,两人各走 a+b+c 步必在交点相遇,不相交则在末尾的空指针处碰头。底部结论:核心洞察是「对齐剩余路程」,与找环入口同属指针技巧家族 图 2:问题模型、对齐三步法与互补走法。

可能的追问与易错点。

「比较的是值还是引用?」必须是引用——两个节点值相同不代表是同一个节点,相交的定义是共享内存里的同一段节点。「能不能用哈希集合?」能:把链 A 所有节点存进集合再扫链 B,O(n) 时间但要 O(n) 空间,面试里通常作为先给出的过渡方案,再被追问「空间能不能省掉」时祭出对齐法。易错点主要有三:一是把链表想成 X 形交叉,没意识到单向链表相交后必重合;二是互补走法里把 pa = pa.next 写成不判空,不相交时死循环;三是只求解不谈复杂度,漏掉「为什么能 O(1) 空间」这一层。

回到面试现场,建议的答题节奏:先画 Y 字模型并论证「相交后必重合」,再给哈希集合的过渡解,主动指出空间代价,然后推出对齐法三步,最后补一句互补走法作为彩蛋。这个节奏展示的是推理过程,不是背诵结果。「对齐剩余路程」这套思路在工程里也常见:数据管道里两条事件流找汇合点、版本链找分叉点,都是同一个动作。

你在链表题上被追问到 O(1) 空间时,是当场推出来的还是提前背过的?欢迎聊聊。