2026-05-16 18:12:04 +08:00
|
|
|
|
---
|
|
|
|
|
|
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 个节点」这道同级别的题目,"两两交换"的核心难度在于**多指针同时操作的顺序控制**——任何一个赋值的先后颠倒都可能导致断链或死循环。掌握这个四步重组模板后,面对任何涉及局部结构调整的链表问题都会更加从容。建议先用纸笔画出指针变化图,再动手编码。
|