Files
leetcode-go/链表/22-相交链表.md
T

277 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: ["LeetCode", "链表", "双指针", "简单"]
create time: 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** |
两指针同步前进,总路程相等,必然在同一个位置首次相遇——那就是交点。
```mermaid
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
```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 环境内置如下结构体定义:
```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 步,但在实际运行中往往更快收敛到答案。
---
## 代码
```go
/**
* 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|)。