Files

277 lines
8.9 KiB
Markdown
Raw Permalink Normal View History

2026-05-16 14:10:21 +08:00
---
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|)。