Files
leetcode-go/链表/30-两两交换链表中的节点.md

369 lines
13 KiB
Markdown
Raw Permalink 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 15:30
---
# 30-两两交换链表中的节点
## 题面
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。**你必须在不修改节点内部的值的情况下完成本题**(即,只能进行节点交换)。
**示例 1:**
```
输入:head = [1,2,3,4]
输出:[2,1,4,3]
```
**示例 2:**
```
输入:head = []
输出:[]
```
**示例 3:**
```
输入:head = [1]
输出:[1]
```
**提示:**
- 链表中节点的数目在范围 `[0, 100]` 内
- `0 <= Node.val <= 100`
---
## 思路
> [!question] 💡 核心洞察
> "两两交换"意味着我们要把 `A → B → C → D` 变成 `B → A → D → C`。每一次操作只涉及**两个一组**的节点对,组内互换、组间拼接。
### 关键问题:三指针协作
> [!warning] ⚠️ 最大陷阱
> 交换两个相邻节点时,如果先改了其中一个的 `Next`,就会**丢失另一个节点的引用**。必须像处理断链风险一样,用额外变量保存需要保留的地址。
### 一次交换的本质:四步重组
以交换节点 `A` 和 `B` 为例(`A` 在前,`B` 在后):
```
交换前:... → prev → A → B → next → ...
↑ ↑ ↑
prev A.Next=B B.Next=next
```
要得到:
```
交换后:... → prev → B → A → next → ...
```
| 步骤 | 动作 | 对应代码 | 为什么必须先做这步? |
|------|------|---------|------------------|
| ① | **记录后继**:`tmp = B.Next` | 防止 B.Next 被覆盖后丢失后续链表 | 如果不保存,后面一步会把 B.Next 改掉,再也找不到 C 了 |
| ② | **B 指向前驱**:`B.Next = A` | 建立 B→A 的反向连接 | 这是成对交换的核心——让后面的节点跑到前面去 |
| ③ | **A 指向后继**:`A.Next = tmp` | 让 A 接到下一组的首节点 | A 原来是前面的节点,现在需要连到未处理的部分 |
| ④ | **前驱接入 B**:`prev.Next = B` | 把整段接回主链 | 如果没有这一步,交换后的段落就从主链上脱落了 |
```mermaid
flowchart LR
subgraph Before["交换前"]
P["prev"] --> A["A"]
A --> B["B"]
B --> N["next / C"]
end
subgraph Step1["① 记录后继: tmp = B.Next"]
S1["tmp ← C"]
end
subgraph Step2["② B.next = A"]
B2["B → A"]
end
subgraph Step3["③ A.next = tmp"]
A2["A → C"]
end
subgraph Step4["④ prev.next = B"]
P2["prev → B → A → C"]
end
Before --> S1 -.引导.-> Step2 -.引导.-> Step3 -.引导.-> Step4
style Before fill:#fff4e6,stroke:#f90
style Step4 fill:#d4edda,stroke:#28a
```
### 方法一:迭代法 + 虚拟头节点 ⭐(最优)
用一个**虚拟头节点** `dummy` 简化边界处理(特别是第一个节点对的交换),然后用一个指针 `prev` 滑动推进。
**不变量:** 每一轮循环开始时,`prev.Next` 始终是一个待交换节点对的第一个节点。
**执行流程演示(`head = [1,2,3,4]`):**
```mermaid
flowchart TD
Init["初始\ndummy→1→2→3→4\nprev=dummy"] --> Round1["第1轮: 交换 (1,2)\ndummy→2→1→3→4\nprev=1"]
Round1 --> Check1{"prev.Next != nil &&\nprev.Next.Next != nil?"}
Check1 -- 是 --> Round2["第2轮: 交换 (3,4)\ndummy→2→1→4→3\nprev=3"]
Round2 --> Check2{"prev.Next != nil &&\nprev.Next.Next != nil?"}
Check2 -- 否 --> Done["退出\nreturn dummy.Next ✅\n输出: [2,1,4,3]"]
style Init fill:#bbf,stroke:#333
style Round1 fill:#f9d,stroke:#333
style Round2 fill:#f9d,stroke:#333
style Done fill:#d4edda,stroke:#28a
```
逐步展开:
| 轮次 | 链表状态 | prev | 说明 |
|------|---------|------|------|
| 初始 | `dummy→1→2→3→4` | dummy | prev 从虚拟头出发 |
| 第 1 轮后 | `dummy→2→1→3→4` | 1 | 交换 (1,2),prev 前进到 A(=1) |
| 第 2 轮后 | `dummy→2→1→4→3` | 3 | 交换 (3,4),prev 前进到 A(=3) |
| 退出条件触发 | — | — | prev.Next == nil,退出循环 |
> [!question] 💡 引导思考:为什么 prev 要前进到 A(被交换的后那个节点)而不是 B?
> 因为交换完成后,B 跑到了前面,A 到了后面。下一组节点紧跟在 A 之后(`A.Next` 就是下一组的开头)。所以 `prev = A` 恰好让它在下一轮可以正确访问 `prev.Next`(下一组的第一个节点)。
**时间复杂度:O(n)** — 每个节点被访问常数次。
**空间复杂度:O(1)** — 只用了有限个指针变量。
### 方法二:递归法
递归的思路更抽象但极其简洁:**假设后半段已经处理好,我只管搞定第一对,然后把剩下的链接上去。**
```mermaid
flowchart LR
subgraph Recursion["整体递归框架"]
R1["① newHead = head.Next\n(拿到第二节点)"] --> R2["② nextPair = newHead.Next\n(保存剩余部分起点)"]
R2 --> R3["③ newHead.Next = head\n(第二节点指回第一节点)"]
R3 --> R4["④ head.Next = swapPairs(nextPair)\n(第一节点接到递归结果)"]
R4 --> R5["返回 newHead ✅"]
end
style R1 fill:#bbf,stroke:#333
style R5 fill:#d4edda,stroke:#28a
```
**递的过程:** 一直深入到没有足够的节点继续配对时停止(`head == nil` 或 `head.Next == nil`),返回当前 head。
**归的过程:** 每层负责三件事:
1. 确定这一组的第二个节点为新的局部头
2. 把自己的两个节点交换好
3. 把第一对的尾部接到递归返回的结果上
以 `1→2→3→4` 为例的完整递归栈:
| 阶段 | head / newHead | nextPair | 执行的操作 | 返回值 |
|------|---------------|----------|-----------|--------|
| 递 | `head=3→4` | nil | 4.Next = 3;3.Next = swapPairs(nil) = nil | `4→3` |
| 基准 | `head=nil` | — | 直接返回 `nil` | `nil` |
| 归 | `head=1→2` | 3 | 2.Next = 1;1.Next = swapPairs(3→4) = 4→3 | `2→1→4→3` |
```mermaid
flowchart TD
Base["基准: swapPairs(nil) → nil\n(不足一对,直接返回)"] --> L1["L1: head=3→4, newHead=4\n① 4.Next = 3\n② 3.Next = swapPairs(nil) = nil\n结果: 4→3→nil, 返回 4"]
L1 --> L2["L2: head=1→2, newHead=2\n① 2.Next = 1\n② 1.Next = swapPairs(3→4) = 4→3\n结果: 2→1→4→3, 返回 2 ✅"]
style Base fill:#eee,stroke:#999
style L1 fill:#bbf,stroke:#333
style L2 fill:#d4edda,stroke:#28a
```
> [!info] 🧠 递归的两个参数直觉
> - **函数签名**:`func swapPairs(head *ListNode) *ListNode` —— 传入一段链表,返回这段链表两两交换后的头
> - **假设成立**:调用 `swapPairs(rest)` 时,我们**假设它已经正确完成了任务**,只管拿到它的返回值来衔接
> - 这就是递归最强大的地方——不需要想全貌,每次只处理眼前的一对
**时间复杂度:O(n)** — 共 n/2 层递归,每层 O(1)。
**空间复杂度:O(n)** — 递归栈深度为 n/2。
---
## 代码提示
### 迭代法伪代码
```
// 创建虚拟头节点
dummy = &ListNode{Next: head}
prev = dummy
// 只要还有至少两个节点可以交换,就继续
while prev.Next != nil and prev.Next.Next != nil {
first = prev.Next // 待交换的第一个节点 (A)
second = prev.Next.Next // 待交换的第二个节点 (B)
third = second.Next // 第三组的起始节点
// 四步重组
second.Next = first // B → A
first.Next = third // A → C
prev.Next = second // prev → B → A → C
// prev 前进到已交换的第一对中的第二个(即 A)
prev = first
}
return dummy.Next
```
### 递归法伪代码
```
func swapPairs(head):
// 基准情况:不足两个节点,直接返回
if head == nil or head.Next == nil:
return head
// newHead 是第二节点,它就是这一对交换后的新头
newHead = head.Next
// 递归处理剩余部分
rest = swapPairs(newHead.Next)
// ① 第一对的第二个节点指向第一个节点
newHead.Next = head
// ② 第一个节点接到递归结果
head.Next = rest
return newHead
```
---
## 技巧
> [!tip] 🔑 "三步旋转"记忆法(迭代法核心)
> 两两交换本质上是在固定位置做一个**环形旋转**:`prev → A → B → next`。只需记住三条边的新方向:
> - `B.Next = A`(向后翻)
> - `A.Next = next`(跨过去)
> - `prev.Next = B`(接回来)
> 想象成一个三角形转了一圈。
> [!tip] 🔑 递归模板:"搞一对,接尾巴"
> 适用于所有**分组处理**的链表问题:K 个一组翻转、奇偶重排等。模式为:
> 1. 检查是否还有足够节点继续分组
> 2. 用局部变量拿到每组的头和尾
> 3. 组内反转/调整
> 4. 组的头部接到递归结果
> 5. 返回新的头部
> [!warning] ⚠️ 常见错误 1:先改 A.Next 再取 B.Next
> 如果写成 `first.Next = second.Next` 再去取 `second.Next`,看起来没错。但如果不小心写成 `first.Next = nil` 或其他操作,后面再用 `second.Next` 就会出错。**建议:第一步总是先全部读取所需节点到局部变量中。**
> [!warning] ⚠️ 常见错误 2:prev 走错位置
> 交换完成后 prev 必须走到 A(原第一个节点,现在在第二位),而不是走到 B。因为 A 的 Next 指向下一组的开头,这样才能在下一轮正确定位。
> [!note] 🐹 Go 中的链表定义
> LeetCode 的 Go 环境内置如下结构体定义:
```go
type ListNode struct {
Val int
Next *ListNode
}
```
不需要手动定义,直接在解题中使用即可。
> [!info] 📊 两种方法对比
| 维度 | 迭代法 ⭐ | 递归法 |
|------|---------|--------|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(1) | O(n)(递归栈) |
| 代码行数 | 循环体约 7 行 | 核心逻辑约 6 行 |
| 直观程度 | **高**(一步步模拟交换过程) | 中等(需理解假设正确性) |
| 面试推荐 | ⭐⭐⭐ 首选 | ⭐⭐ 可作为进阶展示 |
| 适用场景 | 通用,无栈溢出风险 | 适合教学和理解分组思想 |
> [!success] ✅ 相关题目串联
> - 这题是后续更多**分组类链表操作**的基础模板:
> - [25-K 个一组翻转链表](./) — K 个一组的泛化版本
> - [奇偶链表](https://leetcode.com/problems/odd-even-linked-list/) — 按奇偶位置分组
> - 同一思想的变体还出现在 [数组的两两配对问题](./) 中
---
## 代码
### 迭代法(虚拟头节点)⭐
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func swapPairs(head *ListNode) *ListNode {
dummy := &ListNode{Next: head} // 虚拟头节点,统一第一个节点对的边界处理
prev := dummy // prev 指向每一组待交换节点的前驱
for prev.Next != nil && prev.Next.Next != nil {
first := prev.Next // A:当前对的第一个节点
second := prev.Next.Next // B:当前对的第二个节点
third := second.Next // C:下一组的起始节点
// 四步重组:prev → B → A → C
second.Next = first // ① B 指向前驱对中的 A
first.Next = third // ② A 指向下一组的起始 C
prev.Next = second // ③ prev 接入 B,完成拼接
// prev 前进到已处理的这对中的 A 位置
// (A 现在是这对的末尾,Next 正好指向下一组的开头)
prev = first
}
return dummy.Next // 绕过虚拟头节点,返回真正的头节点
}
```
### 递归法
```go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func swapPairs(head *ListNode) *ListNode {
// 基准情况:空链表或只剩一个节点,无需交换
if head == nil || head.Next == nil {
return head
}
// newHead 是当前对的第二个节点,交换后将成为这一段的头
newHead := head.Next
// 保存剩余部分的起始点(即下一对的第一节点)
nextPair := newHead.Next
// ① 把第二节点接到第一节点前面
newHead.Next = head
// ② 递归处理剩余部分,并链接到当前第一节点的后面
head.Next = swapPairs(nextPair)
// newHead 成为整个链表的头
return newHead
}
```
> [!success] ✅ 运行验证
> 这是 LeetCode 第 24 题,通过率约 60%+。相比「删除倒数第 N 个节点」这道同级别的题目,"两两交换"的核心难度在于**多指针同时操作的顺序控制**——任何一个赋值的先后颠倒都可能导致断链或死循环。掌握这个四步重组模板后,面对任何涉及局部结构调整的链表问题都会更加从容。建议先用纸笔画出指针变化图,再动手编码。