Files

8.9 KiB
Raw Permalink Blame History

tags, create time
tags create time
LeetCode
链表
双指针
简单
2026-05-16 14:30

22-相交链表

题面

给你两个单链表的头节点 headA 和 headB,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 nil。

注意:

  • 函数返回结果后,链表必须保持其原始结构。
  • 整个链式结构中不存在环。
  • 如果相交,相交节点的地址(引用)相同——仅值相等不算相交。

示例 1:

输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:链表 A 为 [4,1,8,4,5],链表 B 为 [5,6,1,8,4,5]。在 A 中相交节点前有 2 个节点,在 B 中有 3 个节点。

示例 2:

输入:intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出:Intersected at '2'

示例 3(不相交):

输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:No intersection
解释:两个链表不相交,因此返回 nil 。

提示:

  • listA 中节点数目为 m,listB 中节点数目为 n
  • 1 <= m, n <= 3 * 10^4
  • 1 <= Node.val <= 10^5
  • 进阶: 你能否设计一个时间复杂度 O(m + n)、仅用 O(1) 内存的解决方案?

思路

[!question] 💡 关键区分 相交 ≠ 值相等。题目判断的是内存地址是否相同——即同一个节点对象被两个链表同时引用。值相同的不同节点不算相交。

[!info] 🧩 重要性质 由于每个节点最多只有一个 next 指针,且不存在环,两个链表一旦相交就永远不会分开——它们从相交点开始形成 Y 字形共享尾部。这意味着:要么完全不相交,要么共享一段相同的后缀。

方法一:暴力枚举(O(m×n))

对链表 A 的每个节点,遍历链表 B 的所有节点,检查是否存在地址相同的节点。

缺点: 时间复杂度太高,不满足进阶要求。直接跳过。

方法二:双指针交换法 ⭐(最优 O(m+n),O(1) 空间)

[!question] 💡 引导思考 假设 A 长 m 个节点,B 长 n 个节点,两个指针 pA、pB 分别从 headA、headB 出发。如何让它们同时到达相交点?

核心洞察:让两个指针走等长的路程。

如果指针 pA 遍历完 A 后转到 B 头部继续走,指针 pB 遍历完 B 后转到 A 头部继续走——各自恰好走了 m + n 步。那么它们会在哪里相遇?

[!info] 🧮 关键等式:m + len(B→交点) = n + len(A→交点)

定义变量如下:

符号 含义
pA, pB 两个移动中的指针
m, n 链表 A、B 的总长度
lenA 从 headA 到交点的距离(即 skipA)
lenB 从 headB 到交点的距离(即 skipB)
L 共享后缀长度 = m − lenA = n − lenB

指针 pA 的路径 = 「A 全长」+「B 中从开头走到交点」= m + lenB
指针 pB 的路径 = 「B 全长」+「A 中从开头走到交点」= n + lenA

两者相等吗?验证:

m + \text{lenB} = (\text{lenA} + L) + \text{lenB} = \text{lenA} + \text{lenB} + L n + \text{lenA} = (\text{lenB} + L) + \text{lenA} = \text{lenA} + \text{lenB} + L

两边相等 ✅ — 都等于 lenA + lenB + L。

以示例 1(m=5, n=6, lenA=2, lenB=3, L=3)验证:

指针 路径拆解 总步数
pA 5(A 全长)+ 3(B 的前缀到交点) 8
pB 6(B 全长)+ 2(A 的前缀到交点) 8

两指针同步前进,总路程相等,必然在同一个位置首次相遇——那就是交点。

flowchart LR
    subgraph A ["链表 A(4→1→[8→4→5])"]
        A1["4"] --> A2["1"]
        A2 --> JC["8 ★ 相交点"]
        JC --> JN1["4"]
        JN1 --> JN2["5"]
        JN2 --> NIL1["nil"]
    end

    subgraph B ["链表 B(5→6→1→[8→4→5])"]
        B1["5"] --> B2["6"]
        B2 --> B3["1"]
        B3 --> JC2["8 ★ 同一节点"]
        JC2 -.-> JN1
        JC2 -.-> JN2
        JN2 --> NIL2["nil"]
    end

    style JC fill:#f9d,stroke:#333
    style JC2 fill:#f9d,stroke:#333

图解两个指针的路径:

pA 的路径:headA → … → nil(A) → headB → … → JC ← 相遇!
pB 的路径:headB → … → nil(B) → headA → … → JC ← 同时到达!
时刻 pA 所在位置 pB 所在位置 说明
初始 headA headB 各自主张的起点
第 2 步 A 中第 2 个 B 中第 3 个 各自在自己的链表中
第 5 步 nil(A 末尾) nil(B 末尾) 各自走完自己的链表
切换 headB[1] headA[1] null 时切换到对方头部
第 8 步 JC JC 同时到达相交点!

[!warning] ⚠️ 如果不相交呢? 如果没有相交点,两个指针会继续走完 m + n 步后都停在 nil。此时 pA == nil && pB == nil,退出循环,返回 nil。逻辑仍然正确。

时间复杂度:O(m + n) — 每个指针最多遍历两条链表的总长度。 空间复杂度:O(1) — 只使用两个指针变量。

[!note] 🤔 为什么一定在相交点相遇而不是更早或更晚? 因为两指针同步前进(每步都移动),且走的总路程完全相等。第一个相遇的位置必然是第一次"踩到"同一个节点的时刻,也就是相交点。不可能更早相遇——否则那才是相交点;也不可能更晚——因为在相交点处路程已经对齐了。


代码提示

// 伪代码模板
pA := headA
pB := headB

for pA != pB {
    if pA == nil {
        pA = headB    // pA 走完 A,转去走 B
    } else {
        pA = pA.next
    }

    if pB == nil {
        pB = headA    // pB 走完 B,转去走 A
    } else {
        pB = pB.next
    }
}

return pA  // pA == pB,要么是相交节点,要么是 nil

Go 语言技巧:

  • Go 不支持多赋值同时推进两个指针到不同目标,需要用临时变量或逐行判断
  • 利用 Go 的 nil 语义:if node == nil 安全且直观
  • 也可以用 for 循环内联两种切换逻辑,但拆分会更清晰易读

[!tip] 🔑 一行 Go 写法(竞赛向) 利用 Go 的短变量声明,可以在循环体内依次推进 a 和 b,利用延迟求值的特性完成切换:

```go
for pA != pB {
    if pA == nil {
        pA = headB
    } else {
        pA = pA.Next
    }
    if pB == nil {
        pB = headA
    } else {
        pB = pB.Next
    }
}

技巧

[!tip] 🔑 核心模式:交叉遍历(Cross Walk / Two-pointer Switching) 当两个序列长度不同但需要"对齐"时,让它们互相接管对方的剩余路程。本质是构造等长路径来消除长度差异。类似的变体包括「环形链表入口」(Floyd 判圈算法也用了对称思想)。

[!note] 🐹 Go 中的链表定义 LeetCode 的 Go 环境内置如下结构体定义:

type ListNode struct {
    Val  int
    Next *ListNode
}

不需要手动定义,直接在解题中使用即可。

[!info] 📊 其他解法对比

方法 时间复杂度 空间复杂度 备注
暴力枚举 O(m × n) O(1) 双重嵌套遍历,太慢
哈希集合 O(m + n) O(m) 把 A 的所有节点存入 set,再遍历 B 查找
计算长度差 O(m + n) O(1) 先求各自长度,长的先走差值步数
交叉遍历 ⭐ O(m + n) O(1) 最优,无需预先遍历

[!success] ✅ 关于"计算长度差"法的补充 这也是 O(m + n) 和 O(1) 空间的合法方案,且更符合直觉:

  1. 分别遍历得到长度 lenA、lenB
  2. 让较长的链表先走 |lenA - lenB| 步
  3. 然后两个指针同步前进,第一个相同的节点就是相交点

缺点是需要两次遍历(一次算长度 + 一次找交点),而交叉遍历法虽然也遍历 m + n 步,但在实际运行中往往更快收敛到答案。


代码

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */

func getIntersectionNode(headA, headB *ListNode) *ListNode {
	pA, pB := headA, headB

	for pA != pB {
		// pA 走完 A 后转到 B 头部
		if pA == nil {
			pA = headB
		} else {
			pA = pA.Next
		}

		// pB 走完 B 后转到 A 头部
		if pB == nil {
			pB = headA
		} else {
			pB = pB.Next
		}
	}

	return pA // pA == pB,要么是非 nil 的相交节点,要么是 nil
}

[!success] ✅ 运行验证 这是 LeetCode 第 160 题,通过率约 50%。看似简单但双指针交换法非常优雅——它用"以空间换对称"的思想,将长度差异的问题转化为"一起绕一圈就能对齐"的自然过程。建议动手实现并理解其正确性证明(数学归纳:每一步 a 和 b 距离各自起点的步数之差恒等于 |m - n|)。