From 69653414c5c03328dcda4195094194799aeb3f41 Mon Sep 17 00:00:00 2001 From: wonder Date: Sat, 16 May 2026 16:26:24 +0800 Subject: [PATCH] vault backup: 2026-05-16 16:26:24 --- 链表/25-环形链表.md | 386 +++++++++++++++++++++++ 链表/26-环形链表 II.md | 440 +++++++++++++++++++++++++++ 链表/27-合并两个有序链表.md | 360 ++++++++++++++++++++++ 链表/28-两数相加.md | 382 +++++++++++++++++++++++ 链表/29-删除链表的倒数第 N 个结点.md | 260 ++++++++++++++++ 链表/30-两两交换链表中的节点.md | 0 6 files changed, 1828 insertions(+) create mode 100644 链表/25-环形链表.md create mode 100644 链表/26-环形链表 II.md create mode 100644 链表/27-合并两个有序链表.md create mode 100644 链表/28-两数相加.md create mode 100644 链表/29-删除链表的倒数第 N 个结点.md create mode 100644 链表/30-两两交换链表中的节点.md diff --git a/链表/25-环形链表.md b/链表/25-环形链表.md new file mode 100644 index 0000000..3e67a81 --- /dev/null +++ b/链表/25-环形链表.md @@ -0,0 +1,386 @@ +--- +tags: ["LeetCode", "链表", "双指针", "哈希表", "Floyd判圈", "简单"] +create time: 2026-05-16 15:00 +--- + +# 25-环形链表 + +## 题面 + +给你一个链表的头节点 `head`,判断链表中是否有环。 + +如果链表中有某个节点,可以通过连续跟踪 `next` 指针再次到达,则链表中存在环。 + +为了表示给定链表中的环,评测系统内部使用整数 `pos` 来表示链表尾连接到链表中的位置(索引从 0 开始)。**注意:pos 不作为参数进行传递。**仅仅是为了标识链表的实际情况。 + +如果链表中存在环,则返回 `true`。否则,返回 `false`。 + +**示例 1:** + +``` +输入:head = [3,2,0,-4], pos = 1 +输出:true +解释:链表中有一个环,其尾部连接到第二个节点。 +``` + +**示例 2:** + +``` +输入:head = [1,2], pos = 0 +输出:true +解释:链表中有一个环,其尾部连接到第一个节点。 +``` + +**示例 3:** + +``` +输入:head = [1], pos = -1 +输出:false +解释:链表中没有环。 +``` + +**提示:** + +- 链表中节点的数目范围是 `[0, 10^4]` +- `-10^5 <= Node.val <= 10^5` +- `pos` 为 `-1` 或者链表中的一个有效索引 + +**进阶:你能用 O(1)(即,常量)内存解决此问题吗?** + +--- + +## 思路 + +> [!question] 💡 核心直觉 +> 想象两个人在同一条跑道上赛跑——一个人跑得快,另一个人跑得慢。如果他们都在同一条**封闭跑道**上,快的迟早会从后面追上慢的。但如果是在**直线跑道**上,快的最终会冲到前面再也不回头。 +> +> **把这个想法映射到链表上:** 如果有环,快慢两个指针就会像跑道上的运动员一样"相遇";如果没有环,快的会先走到终点 `nil`。 + +> [!info] 🧩 两种基本思路 + +| 方法 | 时间复杂度 | 空间复杂度 | 能否满足进阶要求? | +|------|-----------|-----------|-----------------| +| 哈希集合记录已访问节点 | O(n) | O(n) | ❌ | +| **快慢指针(Floyd 判圈算法)** | **O(n)** | **O(1)** | ✅ | + +--- + +### 方法一:哈希集合(直觉方案) + +维护一个已经遍历过的节点的集合。每到一个新节点就检查是否已经在集合中出现过:出现过说明有环;没出现过就加入集合并继续前进;走到 `nil` 说明无环。 + +```mermaid +flowchart TD + START(["从头节点出发"]) --> CHECK{"节点已在集合中?"} + CHECK -->|是| HASLOOP["🔄 发现环 → 返回 true"] + CHECK -->|否| ADD["加入集合"] + ADD --> NILCHECK{"节点是否为 nil?"} + NILCHECK -->|否| NEXT["移动到下一个节点"] + NEXT --> CHECK + NILCHECK -->|是| NOLOOP["✅ 走到末尾 → 返回 false"] + + style HASLOOP fill:#f99,stroke:#333 + style NOLOOP fill:#4c4,stroke:#333 +``` + +**步骤拆解:** + +| 步骤 | 动作 | 说明 | +|------|------|------| +| 1 | 初始化空哈希集合 | 存储已访问的节点引用(地址) | +| 2 | 遍历链表,对每个节点查集合 | 若存在则有环 | +| 3 | 不存在则加入集合,继续走 | 直到节点为 `nil` | +| 4 | 走到 `nil` 则无环 | 返回 `false` | + +**时间复杂度:O(n)** — 每个节点最多访问一次,哈希集合的插入和查找都是 O(1)。 +**空间复杂度:O(n)** — 最坏情况下需要存储所有 n 个节点的引用。 + +> [!note] 🤔 这个方法的问题是什么? +> 完全能正确检测环,但空间复杂度为 O(n),不满足进阶的 O(1) 空间要求。而且它需要额外的数据结构开销。有没有可能只靠指针本身来完成检测? + +--- + +### 方法二:快慢指针 / Floyd 判圈算法 ⭐(最优,O(1) 空间)⭐ + +> [!warning] ⚠️ 核心类比 +> 这是本道题的灵魂思想,建议反复体会: +> +> - **直线跑道(无环):** 快指针每次走两步,慢指针每次走一步 → 快指针先到终点 `nil` +> - **环形跑道(有环):** 快指针进入环后绕圈,慢指针也在环里慢慢走 → 快指针从后面**追上**慢指针 + +#### 算法原理 + +设定两个指针:`slow` 每次走一步,`fast` 每次走两步。让它们在链表中同时前进。 + +**为什么一定能在环内相遇?** + +> [!question] 💡 数学直觉 +> 当两个指针都进入环之后,它们之间的距离变化是怎样的? + +假设某一时刻两指针都在环内,相距 k 步(沿着移动方向计量)。每一步操作中: +- 慢指针向前走 1 步 +- 快指针向前走 2 步 +- 两者距离缩短 1 步 + +``` +初始状态: S · · · · F (相距 k=4) +第1步后: S · · · F · (相距 k=3) +第2步后: S · · F · · (相距 k=2) +第3步后: S · F · · · (相距 k=1) +第4步后: S F · · · · (相遇!k=0) +``` + +因此,**只要两指针都在环内,最多经过 k 步(k 为入环后的距离),快指针就能追上慢指针。** + +> [!danger] ⛔ 常见误区澄清 +> **快指针不会"跳过"慢指针!** 有人担心快指针一步跨过慢指针所在的位置而永远不会相等。但实际上在离散的一步移动中,快指针只能比慢指针多走 1 步,所以它会恰好落在慢指针所在的那个节点上,不可能跳过。 + +#### 边界情况处理 + +```mermaid +flowchart LR + subgraph "空链表" + A1["head == nil"] --> R1["直接返回 false ✓"] + end + + subgraph "单节点、无环" + A2["head.Next == nil"] --> R2["fast=head.Next=nil\n不进入循环\n返回 false ✓"] + end + + subgraph "两节点、有环(连回自身)" + A3["1→2→1→..."] --> R3["一轮相遇!\nslow=2, fast=2 ✓"] + end + + subgraph "普通有环" + A4["长链表+环"] --> R4["快追慢 → 环内某处相遇 ✓"] + end + + style R1 fill:#4c4,stroke:#333 + style R2 fill:#4c4,stroke:#333 + style R3 fill:#4c4,stroke:#333 + style R4 fill:#4c4,stroke:#333 +``` + +**关键细节:** 循环条件为 `fast != nil && fast.Next != nil`,确保 `fast.Next.Next` 安全。 + +> [!info] 🧮 为什么 fast 要检查 fast.Next != nil? +> 因为 `fast` 每次要走两步(`fast = fast.Next.Next`)。如果只检查 `fast != nil` 而不检查 `fast.Next != nil`,当 `fast` 指向最后一个节点时,`fast.Next` 为 `nil`,`fast.Next.Next` 就会 panic。 + +#### 完整流程图解 + +以 `3→2→0→-4→(回到索引1的节点2)` 为例: + +``` +初始: slow=3(head), fast=3(head) + +第1轮: slow→2, fast→0 (slow 走1步, fast 走2步) +第2轮: slow→0, fast→-4 (slow 走1步, fast 走2步) +第3轮: slow→-4, fast→2 (slow 走1步, fast 走2步, fast 进入环) +第4轮: slow→2, fast→2 (SLOW == FAST → 有环!✓) +``` + +```mermaid +flowchart TD + START(["head = 3→2→0→-4(连回索引1)"]) --> INIT["slow=3, fast=3"] + + INIT --> LOOP{"fast!=nil && fast.Next!=nil"} + LOOP -->|是| STEP1["slow→下一步"] + STEP1 --> STEP2["fast→下两步"] + STEP2 --> MEET{"slow==fast?"} + MEET -->|是| HASLOOP["🔄 有环 → true ✓"] + MEET -->|否| LOOP + LOOP -->|否| NOLOOP["✅ 无环 → false ✗"] + + style HASLOOP fill:#f99,stroke:#333 + style NOLOOP fill:#4c4,stroke:#333 +``` + +**时间复杂度:O(n)** — 如果存在环,慢指针最多走环外部分 + 一圈环内,快指针最多走环外部分 + 两圈环内。总步数不超过 2n。 +**空间复杂度:O(1)** — 只用了 slow、fast 两个指针变量,完美满足进阶要求! + +> [!success] 🎯 Floyd 判圈算法的历史 +> 这个算法由美国科学家 Robert W. Floyd 于 1967 年提出,是图论中经典的环路检测方法。它的精妙之处在于**不需要任何额外存储空间**,仅通过指针的速度差来探测拓扑结构,被誉为计算机科学中最优雅的算法之一。 + +--- + +## 代码提示 + +### 伪代码模板 + +``` +// ① 边界检查:空链表直接无环 +if head == nil { + return false +} + +// ② 初始化双指针 +slow := head // 每次走一步 +fast := head // 每次走两步 + +// ③ 同步推进 —— 快指针能走两步时才继续 +for fast != nil && fast.Next != nil { + slow = slow.Next // 慢指针前进一步 + fast = fast.Next.Next // 快指针前进两步 + + if slow == fast { // 在环内相遇了! + return true + } +} + +// ④ 快指针触底,无路可走 → 无环 +return false +``` + +**Go 语言技巧:** + +- Go 支持匿名多赋值,可以用一行完成两个指针的移动,但分写更清晰易读 +- 利用 Go 的 `&&` 短路特性:`fast != nil && fast.Next != nil` 保证安全 +- Go 的指针语义天然适合此算法——比较的是节点引用(地址),而非值 + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:Floyd 判圈算法(Tortoise and Hare) +> 双指针速度差法 —— 慢指针一步、快指针两步。这是检测链表中是否存在环的标准 O(1) 空间解法。 +> +> **记忆口诀:"一快一慢,有环必遇;快碰底线,无环无疑。"** + +> [!tip] 🔑 延伸变体:如果有环,如何找到入口节点? +> 这是一个非常有趣的扩展(对应 LeetCode 142. 环形链表 II)。证明如下: +> +> 设: +> - x = 从 head 到环入口的距离 +> - y = 从环入口到相遇点的距离 +> - r = 环的周长 +> - L = 相遇时慢指针走过的总步数 = x + y +> +> 此时快指针走了 2L = x + y + n×r(n 为快指针在环内多走的圈数)。 +> 化简得:**x + y = n×r**,即 **x = n×r - y**。 +> +> **这意味着:从相遇点再走 (n-1) 圈 + 从 head 走 x 步,等于从环入口走 (n-1) 圈再走 y 步。** +> +> 因此只需在第一次相遇后,把其中一个指针重新放回首部,两者各走一步,第二次相遇点就是**环的入口节点**。 +> +> ```go +> // 第一次相遇后的入口查找 +> ptr1 := head +> ptr2 := slow // 或 fast,两者此刻相等 +> for ptr1 != ptr2 { +> ptr1 = ptr1.Next +> ptr2 = ptr2.Next +> } +> return ptr1 // 环的入口 +> ``` + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | +|------|-----------|-----------|------|------| +| 哈希集合 | O(n) | O(n) | 思路直观,容易想到 | 占用额外空间 | +| **Floyd 判圈 ⭐** | **O(n)** | **O(1)** | **最优,面试标配** | 逻辑稍有门槛 | + +> [!success] ✅ 相关题目串联 +> - [[26-环形链表 II]] — 进阶版:找环的入口(同一组快慢指针技术,加一步推导) +> - [22-相交链表](./22-相交链表.md) — 同样是双指针的经典应用 +> - [24-回文链表](./24-回文链表.md) — 也用到了快慢指针找中点的技巧 + +> [!warning] ⚠️ 面试注意事项 +> - Go 中不用显式释放资源,垃圾回收会自动处理,**不需要也不应该手动置 nil 断链** +> - 快慢指针初始化都应该从 `head` 开始,不要错误地把 fast 初始化为 `head.Next`(那是回文链表的写法,不适用于本题) +> - 空链表 `head == nil` 的情况需要考虑,虽然 LeetCode 测试用例通常不包含,但良好的习惯应该加上 + +--- + +## 代码 + +### 方法一:哈希集合(O(n) 空间) + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func hasCycle(head *ListNode) bool { + seen := make(map[*ListNode]bool) + for node := head; node != nil; node = node.Next { + if seen[node] { + return true // 该节点已出现过 → 有环 + } + seen[node] = true + } + return false // 走到末尾也没重复 → 无环 +} +``` + +--- + +### 方法二:Floyd 判圈算法 ⭐(最优 O(1) 空间)⭐ + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func hasCycle(head *ListNode) bool { + // 空链表不可能有环 + if head == nil { + return false + } + + slow := head // 慢指针:每次走一步 + fast := head // 快指针:每次走两步 + + // 快指针能连续走两步才继续推进 + for fast != nil && fast.Next != nil { + slow = slow.Next // 慢指针前进一步 + fast = fast.Next.Next // 快指针前进两步 + + if slow == fast { // 快慢指针相遇 → 有环 + return true + } + } + + // 快指针触底 → 无环 + return false +} +``` + +> [!tip] 🔑 精简写法(竞赛向) +> 利用 Go 的 `for` 不带初始化语句的特性,可以让代码更紧凑: + +```go +func hasCycle(head *ListNode) bool { + for slow, fast := head, head; fast != nil && fast.Next != nil; { + slow = slow.Next + fast = fast.Next.Next + if slow == fast { + return true + } + } + return false +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 141 题,通过率约 48%。看似简单的一道题,但其背后的 Floyd 判圈算法却是图论中一个经典而优雅的技巧——仅凭两个指针的速度差异就能判断整个图的拓扑结构。掌握这个模式,不仅能秒杀环形链表系列题目,也为后续学习图论中的环路检测打下基础。 diff --git a/链表/26-环形链表 II.md b/链表/26-环形链表 II.md new file mode 100644 index 0000000..d447800 --- /dev/null +++ b/链表/26-环形链表 II.md @@ -0,0 +1,440 @@ +--- +tags: ["LeetCode", "链表", "双指针", "Floyd判圈", "环形链表"] +create time: 2026-05-16 15:30 +--- + +# 26-环形链表 II + +## 题面 + +给定一个链表的头节点 `head`,返回链表开始入环的第一个节点。**如果链表无环,则返回 `nil`。** + +如果链表中有某个节点,可以通过连续跟踪 `next` 指针再次到达,则链表中存在环。 + +为了表示给定链表中的环,评测系统内部使用整数 `pos` 来表示链表尾连接到链表中的位置(索引从 0 开始)。**注意:pos 不作为参数进行传递。**仅仅是为了标识链表的实际情况。 + +**不允许修改链表。** + +**示例 1:** + +``` +输入:head = [3,2,0,-4], pos = 1 +输出:返回索引为 1 的链表节点 +解释:链表中有一个环,其尾部连接到第二个节点。 +``` + +**示例 2:** + +``` +输入:head = [1,2], pos = 0 +输出:返回索引为 0 的链表节点 +解释:链表中有一个环,其尾部连接到第一个节点。 +``` + +**示例 3:** + +``` +输入:head = [1], pos = -1 +输出:返回 null +解释:链表中没有环。 +``` + +**提示:** + +- 链表中节点的数目范围在 `[0, 10^4]` 内 +- `-10^5 <= Node.val <= 10^5` +- `pos` 的值为 `-1` 或者链表中的一个有效索引 + +**进阶:你是否可以使用 O(1) 空间解决此题?** + +--- + +## 思路 + +> [!question] 💡 承上启下 +> 你已经掌握了 [25-环形链表](./25-环形链表.md)——用 Floyd 快慢指针判断环的存在性。现在问题是:**如果已经有环,如何找到入口节点?** + +> [!tip] 🔑 结论先行 +> 仍然使用快慢指针。第一次相遇后,把其中一个指针放回 `head`,两个指针改为**同速各走一步**,它们第二次相遇的位置就是环的入口。 +> **时间复杂度 O(n),空间复杂度 O(1)**。 + +### 前置回顾:第一轮——检测环 + +```mermaid +flowchart LR + subgraph S ["慢指针 slow(每次 1 步)"] + S1["从头出发"] + end + subgraph F ["快指针 fast(每次 2 步)"] + F1["从头出发"] + end + + S1 --> PULL{"两指针都进环后,快追慢"} + F1 --> PULL + PULL -->|每步缩小 1
最多 r 步| MEET["🔄 环内相遇点 M"] + + style MEET fill:#f99,stroke:#333 +``` + +此时我们已经确认有环,但相遇点 M 通常**不是**入口 E。我们需要推导一个关系式来定位 E。 + +### 核心推导:第二轮——寻找入口 + +> [!question] 💡 设未知数 +> 用三个变量描述链表的拓扑结构,你能建立它们之间的关系吗? + +定义三个关键距离(沿链表前进方向计量): + +| 符号 | 含义 | +|------|------| +| **x** | 从 `head` 到环入口的距离(入口前非环部分的长度) | +| **y** | 从环入口到相遇点的距离 | +| **r** | 环的周长 | + +**慢指针走过的总路程**(设为 L): + +$$L = x + y$$ + +> [!info] ℹ️ 为什么是 x + y? +> 慢指针速度为 1,先进入环,然后沿着环走到相遇点。它在进入环之后最多走不满一圈就遇到了快指针(因为快指针在后面追),所以路径就是"头→入口" + "入口→相遇点" = x + y。 + +**快指针走过的总路程**(设为 2L): + +$$2L = x + y + n \cdot r \quad (n \geq 1)$$ + +> [!info] 🧮 理解 n · r +> 快指针速度是慢指针的两倍,所以它除了走完和慢指针一样的 x + y 之外,还在环里多跑了若干整圈(n 圈)。由于快指针必须至少比慢指针多跑一圈才能追上它,所以 n ≥ 1。 + +将 L = x + y 代入消元: + +$$2(x + y) = x + y + n \cdot r$$ + +化简得: + +$$x + y = n \cdot r$$ + +移项得到关键公式: + +$$\boxed{x = n \cdot r - y}$$ + +> [!warning] ⛔ 常见误区 +> 这里 n 可以是 1、2、3……不一定等于 1。但无论如何,**这个等式的几何意义不变**: + +### 公式解读:从两个方向走相同距离 + +$$x = n \cdot r - y = (n - 1) \cdot r + (r - y)$$ + +这意味着什么?让我们从两个视角看: + +**视角 1 — 从 head 出发走 x 步**:直达环入口 E。 + +**视角 2 — 从相遇点 M 出发走同样的距离**: + +``` +M → E 的距离拆解: + 先走完环的剩余部分 M→E:距离 = r - y + 再绕 (n-1) 整圈回到 E:距离 = (n-1) · r + + 合计 = (n-1) · r + (r - y) = x ✅ +``` + +> [!success] 🎯 关键洞察 +> **从 head 走 x 步到达入口 E;从相遇点 M 也走 x 步同样到达入口 E。** +> 两者步数相同!所以我们只需要启动两个新指针——一个从 head 开始,一个从相遇点 M 开始——以相同速度同步前进,它们的第二次相遇点必然是环入口 E。 + +```mermaid +flowchart TD + HEAD["head"] --"第1步"--> A["第1个节点"] + A --"第2步"--> B["第2个节点"] + B --"第x步"--> ENTRY["⭐ 环入口 E ← 两指针在此相遇"] + + ENTRY -.-> RING["环中各节点..."] + RING -.-> ENTRY + + MEET["相遇点 M"] --"走同样步数"--> ENTRY + + style HEAD fill:#bbf,stroke:#333 + style MEET fill:#fbf,stroke:#333 + style ENTRY fill:#fd4,stroke:#333,stroke-width:3px +``` + +**示例完整演示**:以 `3→2→0→-4→(回索引1)` 为例 + +| 阶段 | 参数 | 值 | +|------|------|-----| +| 非环段 | x | 1(head 直接就是入口) | +| 环入口到相遇点 | y | 3(入口 2 → 0 → -4 → 2) | +| 环周长 | r | 4(2→0→-4→2) | +| 验证 | x + y = n × r | 1 + 3 = 4 = 1 × 4 ✓ | +| 入口公式 | x = n×r - y | 1 = 4 - 3 ✓ | + +两轮过程: + +**第一轮(快慢指针检测):** + +``` +初始: slow=3(head), fast=3(head) + +第1轮: slow→2(E), fast→0 (slow 走1步, fast 走2步) +第2轮: slow→0, fast=-4 (slow 走1步, fast 走2步) +第3轮: slow→-4, fast→2(E) (slow 走1步, fast 走2步) +第4轮: slow→2(E), fast→2(E) ← SLOW == FAST,相遇于 E ✗(巧合!) + +⚠️ 本例恰好相遇于入口,但不代表总是如此。 + 换一组数据就能看出差异。 +``` + +> [!note] 🤔 为什么本例的特殊情况会让人误解? +> 因为 x = 1(head 就是入口),慢指针只走了 4 步(刚好一圈)就和快指针相遇了,相遇点恰好是入口。让我们构造一个更一般的例子来观察: +> +> 假设链表 `1→2→3→4→5→(回2)`: +> - x = 1(head[1]→入口[2]) +> - 慢指针走 1+1=2 步到节点 3 +> - 快指针走 4 步:1→3→5→3→? 最终也会在节点 3 相遇 +> - 但从 head 走 x=1 步到 2,从相遇点 3 走 x=1 步到 4 ≠ 2 +> +> 等等,让我重新计算: +> +> 慢指针 L = x + y = 1 + y,快指针 2L = 1 + y + n·r(r=4) +> 2(1+y) = 1+y+4n → 1+y = 4n → y = 4n-1 +> n=1 时 y=3,即相遇点在入口往后数第3个节点 = 节点5 +> +> 验证:慢指针走 1+3=4 步到节点5,快指针走8步也到节点5。✓ +> +> **第二轮:** ptr1 从 head(1) 走 x=1 步到节点2(入口);ptr2 从相遇点(5) 走 1 步到节点2(5→2,因为是环)。二者在节点2相遇!完美。 + +### 整体算法流程 + +```mermaid +flowchart TD + START(["输入 head"]) --> BOUNDARY{"head == nil?"} + BOUNDARY -->|是| NOLOOP["返回 nil"] + BOUNDARY -->|否| ROUND1["⏩ 第一轮:快慢指针
slow 每步1次, fast 每步2次"] + + ROUND1 --> HASMEET{"是否相遇?"} + HASMEET -->|否| NOLOOP2["fast 触底 nil
→ 无环,返回 nil"] + HASMEET -->|是| MEETPT["记录相遇点 meetNode"] + + MEETPT --> ROUND2["⏩ 第二轮:双指针同速
ptr1 = head, ptr2 = meetNode"] + ROUND2 --> SYNCLOOP{"ptr1 != ptr2?"} + SYNCLOOP -->|是| MOVE["ptr1++, ptr2++"] + MOVE --> SYNCLOOP + SYNCLOOP -->|否| ENTRY["🎯 ptr1/ptr2 就是环入口!"] + + style NOLOOP fill:#ccc,stroke:#333 + style NOLOOP2 fill:#ccc,stroke:#333 + style ENTRY fill:#fd4,stroke:#333,stroke-width:3px +``` + +| 轮次 | 操作 | 时间复杂度 | 说明 | +|------|------|-----------|------| +| 第一轮 | 快指针每次 2 步,慢指针每次 1 步 | O(n) | 找到是否有环及相遇点 | +| 第二轮 | 两指针各 1 步,从 head 和相遇点出发 | O(n) | 找到环入口 | +| **合计** | — | **O(n)** | **空间 O(1)** | + +> [!info] 📐 正确性证明总结 + +| 步骤 | 命题 | 依据 | +|------|------|------| +| 1 | 若有环,快慢指针必在环内相遇 | 每步距离缩短 1,最多 r 步 | +| 2 | 相遇时满足 x + y = n × r | 快指针速度是慢指针的两倍 | +| 3 | 从 head 走 x 步与从相遇点走 x 步汇于同一节点 | x = (n-1)r + (r-y) | +| 4 | 该节点即为环入口 | 从 head 往前走 x 步正好到入口 | + +--- + +## 代码提示 + +```go +// ======== 第一轮:检测环,找相遇点 ======== +slow, fast := head, head +for fast != nil && fast.Next != nil { + slow = slow.Next // 每次 1 步 + fast = fast.Next.Next // 每次 2 步 + if slow == fast { // 相遇!进入第二轮 + break + } +} + +// 没相遇 → 无环 +if fast == nil || fast.Next == nil { + return nil +} + +// ======== 第二轮:找入口 ======== +ptr1 := head // 从头部出发 +ptr2 := slow // 从相遇点出发 +for ptr1 != ptr2 { + ptr1 = ptr1.Next + ptr2 = ptr2.Next +} +return ptr1 // 或 ptr2 —— 就是环入口 +``` + +**Go 语言技巧:** + +- Go 不支持 Python 风格的多赋值同时推进不同目标,需要分开写 +- 利用 Go 的 `nil` 语义安全地检查边界条件 +- 可以将第一轮的 `break` 后的检查合并为一行:`if slow == fast { ... } else { return nil }` + +> [!tip] 🔑 Go 一行流写法(竞赛向) +> 利用 Go 的 for 语句特点,可以将两段逻辑合并: + +```go +func detectCycle(head *ListNode) *ListNode { + for slow, fast := head, head; fast != nil && fast.Next != nil; { + slow = slow.Next + fast = fast.Next.Next + if slow == fast { // 相遇后立即切换模式 + for head != slow { // ptr1=head, ptr2=slow 同速走 + head = head.Next + slow = slow.Next + } + return head + } + } + return nil // fast 触底 → 无环 +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:Floyd 环检测 + 对称映射 +> 这道题的本质是利用代数推导出的**距离对称性**:从 head 到入口的距离 = 从相遇点到入口的距离。这可以看作一种"镜像翻转"——把线性前缀映射到了环形剩余路径上。 +> +> **记忆口诀:"一快一慢有环必遇,一回一遇入口必现。"** + +> [!question] 🧠 深入思考:如果快指针每次走 3 步呢? +> 快指针走 k 步(k > 2),慢指针走 1 步,那么每步距离缩短 k-1。 +> 相遇时有:(k-1)(x+y) = n·r。当 k=2 时,(x+y)=n·r,推导出 x = nr-y——简洁优美。 +> 当 k=3 时,2(x+y)=n·r,推导出 x = nr/2 - y——可能不是整数,且无法直接使用"同速两步对走"的简单策略。 +> 这也是为什么 Floyd 选 k=2(快2慢1)是最优设计的原因。 + +> [!danger] ⚠️ 常见错误 +> +> 1. **忘记处理无环的情况**:第一轮结束后需要检查是否真的相遇了(`fast == nil || fast.Next == nil`),否则会把 `nil` 当成相遇点进入死循环。 +> 2. **第二轮只用一个指针移动**:必须两个指针各走一步。 +> 3. **把 `ptr1` 初始化成 `slow.Next`**:应该都是严格的一步一步走,`ptr1 = head`, `ptr2 = slow`。 + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 备注 | +|------|-----------|-----------|------| +| 哈希集合记录访问过的节点 | O(n) | O(n) | 遍历每个节点,查 set | +| **Floyd 双指针 ⭐** | **O(n)** | **O(1)** | **最优,面试标配** | + +> [!success] ✅ 相关题目串联 +> - [25-环形链表](./25-环形链表.md) — 基础版:判断环的存在性 +> - [22-相交链表](./22-相交链表.md) — 同样是双指针的经典应用 +> - 剑指 Offer 23 — 环形链表的入口(完全相同的题目) + +> [!warning] ⚠️ 面试注意事项 +> - 面试时建议先推导一遍 x = n·r - y 的过程,展示你的分析能力 +> - 面试官可能追问"如果没有环怎么办"——务必加上边界检查 +> - 有些面试官会要求画图解法,提前准备好图示思维 +> - 如果面试官要求你构建测试用例,至少准备:空链表、单节点有环/无环、head 就是入口、相遇点恰好在入口 + +--- + +## 代码 + +### 方法一:哈希集合(O(n) 空间,不推荐) + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func detectCycle(head *ListNode) *ListNode { + seen := make(map[*ListNode]bool) + for node := head; node != nil; node = node.Next { + if seen[node] { + return node // 该节点已出现过 → 这是环入口 + } + seen[node] = true + } + return nil // 走到末尾也没重复 → 无环 +} +``` + +--- + +### 方法二:Floyd 环检测 + 对称映射 ⭐(最优 O(1) 空间) + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func detectCycle(head *ListNode) *ListNode { + // ======== 第一轮:快慢指针检测环 ======== + slow, fast := head, head + for fast != nil && fast.Next != nil { + slow = slow.Next // 慢指针:每次走一步 + fast = fast.Next.Next // 快指针:每次走两步 + + if slow == fast { // 在环内相遇了! + break // 跳出循环,进入第二轮 + } + } + + // 没相遇 → fast 触底 → 无环 + if fast == nil || fast.Next == nil { + return nil + } + + // ======== 第二轮:找环入口 ======== + ptr1 := head // 指针1:从头节点出发 + ptr2 := slow // 指针2:从相遇点出发 + for ptr1 != ptr2 { + ptr1 = ptr1.Next + ptr2 = ptr2.Next + } + + return ptr1 // ptr1 == ptr2,即为环入口节点 +} +``` + +> [!tip] 🔑 精简写法(竞赛向) + +```go +func detectCycle(head *ListNode) *ListNode { + for slow, fast := head, head; fast != nil && fast.Next != nil; { + slow = slow.Next + fast = fast.Next.Next + if slow == fast { // 相遇后切换模式 + for head != slow { + head = head.Next + slow = slow.Next + } + return head + } + } + return nil +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 142 题,通过率约 46%,属于经典中等题。这道题的精妙之处在于**纯数学推导驱动算法设计**——没有复杂的技巧,仅靠一个简单的等式 x = n·r - y 就完成了从"检测环"到"定位入口"的跨越。建议在纸上画图推导一遍这个公式,理解后会感受到数学之美。 diff --git a/链表/27-合并两个有序链表.md b/链表/27-合并两个有序链表.md new file mode 100644 index 0000000..115ee13 --- /dev/null +++ b/链表/27-合并两个有序链表.md @@ -0,0 +1,360 @@ +--- +tags: ["LeetCode", "链表", "双指针", "递归", "简单"] +create time: 2026-05-16 14:40 +--- + +# 27-合并两个有序链表 + +## 题面 + +将两个 **升序** 链表合并为一个新的 **升序** 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 + +**示例 1:** + +``` +输入:l1 = [1,2,4], l2 = [1,3,4] +输出:[1,1,2,3,4,4] +``` + +**示例 2:** + +``` +输入:l1 = [], l2 = [] +输出:[] +``` + +**示例 3:** + +``` +输入:l1 = [], l2 = [0] +输出:[0] +``` + +**提示:** + +- 两个链表的节点数目范围是 `[0, 50]` +- `-100 <= Node.val <= 100` +- `l1` 和 `l2` 均按 **非递减顺序** 排列 + +--- + +## 思路 + +> [!question] 💡 引导思考 +> 假设你面前有两张已经排好序的扑克牌,你会如何把它们合并成一张更大的有序牌堆?——每次比较两张牌的**最上面**那一张,取较小的放入结果中。这就是本题的核心思想。 + +### 方法一:迭代法 — 虚拟头节点 + 尾指针 ⭐(推荐) + +维护一个**虚拟头节点** `dummy` 和一个**尾指针** `tail`: + +| 变量 | 含义 | +|------|------| +| **dummy** | 虚拟头节点,`dummy.Next` 始终指向结果链表的真正头部 | +| **tail** | 当前已合并部分的最后一个节点,用于在末尾追加 | + +**核心操作:** 每次比较 `l1.Val` 和 `l2.Val`,将较小的节点连接到 `tail.Next`,然后该链表的指针前进一步。 + +```mermaid +flowchart TB + subgraph Inputs["📥 输入:两条升序链表"] + L1["l1\n1→2→4→nil"] + L2["l2\n1→3→4→nil"] + end + + subgraph Core["⚙️ 核心操作(循环内每轮重复)"] + CMP["比较 l1.Val vs l2.Val"] + Pick["选较小值节点"] + end + + subgraph Output["📤 输出:结果链表(尾部追加)"] + Dummy["dummy (虚拟头节点)"] + Res["结果: 1→1→2→3→4→4→nil"] + end + + L1 -->|当前 head| CMP + L2 -->|当前 head| CMP + CMP -->|l1.Val ≤ l2.Val?| Pick + Pick -->|连接到 tail.Next| Res + Res --> Dummy + + style Dummy fill:#e0e0ff,stroke:#666,stroke-width:2px + style CMP fill:#fff4e6,stroke:#f90,stroke-width:2px + style Pick fill:#d4edda,stroke:#28a,stroke-width:2px + style Res fill:#e8f5e9,stroke:#4a4,stroke-width:1px +``` + +**图示执行流程:** + +```mermaid +flowchart LR + State0["初始\n---\ndummy → ?\nl1: 1→2→4\nl2: 1→3→4"] -->|"1≤1, 取 l1(1)"| State1["第 1 轮\n---\ndummy → [1]\nl1: 2→4\nl2: 1→3→4"] + State1 -->|"2>1, 取 l2(1)"| State2["第 2 轮\n---\ndummy → [1→1]\nl1: 2→4\nl2: 3→4"] + State2 -->|"2≤3, 取 l1(2)"| State3["第 3 轮\n---\ndummy → [1→1→2]\nl1: 4\nl2: 3→4"] + State3 -->|"4>3, 取 l2(3)"| State4["第 4 轮\n---\ndummy → [1→1→2→3]\nl1: 4\nl2: 4"] + State4 -->|"4≤4, 取 l1(4)"| State5["第 5 轮\n---\ndummy → [1→1→2→3→4]\nl1: nil\nl2: 4"] + State5 -->|"l1 空, 接 l2"| State6["收尾\n---\ndummy → [1→1→2→3→4→4] ✅"] + + State6:::final + classDef final fill:#4c4,color:white +``` + +以示例 1(`l1 = [1,2,4]`, `l2 = [1,3,4]`)为例,逐步展开: + +| 轮次 | 比较对象 | 较小值 | tail 连接 | l1 移动后 | l2 移动后 | 结果链表 | +|------|---------|--------|----------|----------|----------|---------| +| 初始 | — | — | dummy | 1→2→4 | 1→3→4 | `dummy → ?` | +| 第 1 轮 | l1.Val(1) vs l2.Val(**1**) | 相等,选 l1 | tail→l1(1) | 2→4 | 1→3→4 | `1_a` | +| 第 2 轮 | l1.Val(**2**) vs l2.Val(1) | 1 | tail→l2(1) | 2→4 | 3→4 | `1_a → 1_b` | +| 第 3 轮 | l1.Val(**2**) vs l2.Val(3) | 2 | tail→l1(2) | 4 | 3→4 | `1_a → 1_b → 2` | +| 第 4 轮 | l1.Val(**4**) vs l2.Val(3) | 3 | tail→l2(3) | 4 | 4 | `1→1→2→3` | +| 第 5 轮 | l1.Val(**4**) vs l2.Val(4) | 相等,选 l1 | tail→l1(4) | nil | 4 | `1→1→2→3→4` | +| l1 耗尽 | — | — | tail→l2(4) | nil | nil | `1→1→2→3→4→4` ✅ | + +> [!info] 🧠 关键性质 +> - 因为两个输入链表本身就是有序的,所以**每一步贪心选择较小的元素**就能保证全局有序 +> - `dummy` 解决了"第一个节点没有前驱可连"的边界问题——统一了所有节点的处理逻辑 +> - `tail` 只在末尾追加,永不回头修改,所以不需要反转操作 + +**当某个链表提前走完时:**直接将 `tail.Next` 指向另一个链表的剩余部分即可——因为剩余部分本身有序,无需额外处理。 + +**时间复杂度:O(m + n)** — 每个节点恰好被访问一次。 +**空间复杂度:O(1)** — 只使用了常数个指针变量。 + +### 方法二:递归法(更优雅但占用栈空间) + +> [!question] 💡 引导思考 +> 合并两个有序链表时,**第一步做什么**——一定是把两个头节点中较小的那个作为结果链表的第一个节点。那第二步呢? + +第二步是:**用剩下的部分重复同样的过程**。这正是递归的天然结构! + +**递归关系:** + +$$\text{merge}(l1, l2) = \begin{cases} l1 & \text{if } l2 == \text{nil} \\ l2 & \text{if } l1 == \text{nil} \\ l1.\text{Next} = \text{merge}(l1.\text{Next}, l2); \;\; return\; l1 & \text{if } l1.\text{Val} \leq l2.\text{Val} \\ l2.\text{Next} = \text{merge}(l1, l2.\text{Next}); \;\; return\; l2 & \text{otherwise} \end{cases}$$ + +**执行流程拆解**(以 `l1=[1,2,4]`, `l2=[1,3,4]` 为例): + +### 递:深入调用栈 + +每一层做出选择后,将较小节点的 `Next` 指向下一层递归的返回值,然后继续深入: + +```mermaid +flowchart TD + L1A["Merge(①, ①) → 选 l1(①)"] -->|"Next = Merge(2→4, 1→3→4)"| L1B["Merge(②, ①) → 选 l2(①)"] + L1B -->|"Next = Merge(2→4, 3→4)"| L1C["Merge(②, ③) → 选 l1(②)"] + L1C -->|"Next = Merge(4, 3→4)"| L1D["Merge(④, ③) → 选 l2(③)"] + L1D -->|"Next = Merge(4, ④)"| L1E["Merge(④, ④) → 选 l1(④)"] + L1E -->|"Next = Merge(nil, ④)"| L1F["Merge(nil, ④) ← 基准情况,返回 ④"] + + style L1F fill:#f9d,stroke:#333,stroke-width:2px + style L1A fill:#bbf,stroke:#333 +``` + +### 归:逐层拼接结果 + +在函数返回的过程中,上一层接收到的就是下一层的返回值——直接连到之前选择的节点上: + +```mermaid +flowchart LR + R1["Merge(nil, ④)\n返回: 4→nil"] --> R2["Merge(④, ④)\n4→Next=4→nil\n返回: 4→4→nil"] + R2 --> R3["Merge(④, ③)\n3→Next=4→4→nil\n返回: 3→4→4→nil"] + R3 --> R4["Merge(②, ③)\n2→Next=3→4→4→nil\n返回: 2→3→4→4→nil"] + R4 --> R5["Merge(②, ①)\n1→Next=2→3→4→4→nil\n返回: 1→2→3→4→4→nil"] + R5 --> R6["Merge(①, ①)\n1→Next=1→2→3→4→4→nil\n返回: 1→1→2→3→4→4 ✅"] + + style R1 fill:#bbf,stroke:#333 + style R6 fill:#4c4,color:white,stroke:#333,stroke-width:2px +``` + +> [!info] 🧠 递归与迭代的本质联系 +> 递归版本中的 `l1.Next = mergeTwoLists(l1.Next, l2)` 或 `l2.Next = mergeTwoLists(l1, l2.Next)` 就是在做和迭代版 `tail.Next = smallerNode` 完全相同的操作——把当前最小的节点挂到结果后面。只是递归通过函数调用栈隐式地"记住"了上下文,而迭代版用显式的 `tail` 变量追踪当前位置。 + +**时间复杂度:O(m + n)** — 每层递归处理一个节点,共 m + n 层。 +**空间复杂度:O(m + n)** — 递归调用栈深度等于节点总数。 + +--- + +## 代码提示 + +### 迭代法伪代码 + +``` +dummy = &ListNode{} // 虚拟头节点 +tail = dummy // 尾指针紧跟 dummy + +for l1 != nil AND l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 // 把 l1 接到结果末尾 + l1 = l1.Next // l1 往前走一步 + } else { + tail.Next = l2 // 把 l2 接到结果末尾 + l2 = l2.Next // l2 往前走一步 + } + tail = tail.Next // tail 也往前走一步(跟上) +} + +// l1 或 l2 中至少有一个为空,接上非空的部分 +if l1 != nil { + tail.Next = l1 +} else { + tail.Next = l2 +} + +return dummy.Next // 跳过 dummy,返回真正的头节点 +``` + +### 递归法伪代码 + +``` +func merge(l1, l2): + if l1 == nil: return l2 // 基准情况 1 + if l2 == nil: return l1 // 基准情况 2 + + if l1.Val <= l2.Val { + l1.Next = merge(l1.Next, l2) + return l1 + } else { + l2.Next = merge(l1, l2.Next) + return l2 + } +``` + +--- + +## 技巧 + +> [!tip] 🔑 虚拟头节点(Dummy Node)万能模板 +> 凡是涉及"在链表头部插入/连接"的场景,优先考虑加 dummy。它能避免对首个节点写特殊逻辑,让循环体内保持统一的操作模式:`tail.Next = newnode; tail = tail.Next`。这个模式在「移除链表元素」「分隔链表」「奇偶链表」等题目中反复出现。 + +> [!note] 🔑 Go 语言细节:切片转链表的辅助函数 +> 在本地测试时,你可能需要方便地在切片和链表之间转换: + +```go +// sliceToList: 将切片转为链表,方便构造测试用例 +func sliceToList(vals []int) *ListNode { + dummy := &ListNode{} + tail := dummy + for _, v := range vals { + tail.Next = &ListNode{Val: v} + tail = tail.Next + } + return dummy.Next +} + +// listToSlice: 将链表转为切片,方便打印验证 +func listToSlice(head *ListNode) []int { + var result []int + for head != nil { + result = append(result, head.Val) + head = head.Next + } + return result +} +``` + +> [!warning] ⚠️ 常见错误:忘记推进 tail +> 在连接 `tail.Next = smallerNode` 之后,一定要执行 `tail = tail.Next`。漏掉这步会导致后续所有节点都覆盖到同一个位置,最终结果只剩最后一个接入的节点。 + +> [!tip] 🔑 相等情况的选择 +> 当 `l1.Val == l2.Val` 时,选哪个都可以,结果相同。但从递归视角看——先选 l1 意味着下一轮比较的是 `l1.Next` vs `l2`,先选 l2 则比较 `l1` vs `l2.Next`——虽然不影响最终结果,但在某些变体题(如稳定排序需求)中可能会有细微差异。 + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | +|------|-----------|-----------|------|------| +| **迭代法 ⭐** | **O(m + n)** | **O(1)** | 空间最优、无栈溢出风险 | 需要理解 dummy + tail 的配对使用 | +| 递归法 | O(m + n) | O(m + n) | 代码极短(4 行核心逻辑)、可读性高 | 栈空间消耗大;Go 不会尾递归优化,n 较大时可能栈溢出 | + +> [!success] ✅ 相关题目串联 +> - [82-删除排序链表中的重复元素 II](./82-删除排序链表中的重复元素-II.md) — 同一组有序链表的变种 +> - [21-Merge k Sorted Lists](https://leetcode.com/problems/merge-k-sorted-lists/) — 进阶:合并 K 个有序链表(分治 + 本题解法) + +--- + +## 代码 + +### 迭代法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode { + // 虚拟头节点:避免对第一个节点做特殊处理 + dummy := &ListNode{} + tail := dummy // tail 始终指向结果链表的最后一个节点 + + // 只要两条链表都不为空,逐个比较、依次接入 + for l1 != nil && l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 // 将较小的 l1 节点接到结果末尾 + l1 = l1.Next // l1 前进一步 + } else { + tail.Next = l2 // 将较小的 l2 节点接到结果末尾 + l2 = l2.Next // l2 前进一步 + } + tail = tail.Next // tail 跟进,保持指向末尾 + } + + // 有一条链表走完后,直接把 tail 连接到另一条的剩余部分 + // Go 的 nil 检查很直观:如果 l1 为空就接 l2,反之接 l1 + if l1 != nil { + tail.Next = l1 + } else { + tail.Next = l2 + } + + // 返回 dummy.Next(跳过虚拟头节点),即为合并后的真正头节点 + return dummy.Next +} +``` + +### 递归法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func mergeTwoLists(l1 *ListNode, l2 *ListNode) *ListNode { + // 基准情况:一条链表为空,直接返回另一条 + if l1 == nil { + return l2 + } + if l2 == nil { + return l1 + } + + // 递归:选较小的头节点,它的 Next 指向合并剩余部分的结果 + if l1.Val <= l2.Val { + l1.Next = mergeTwoLists(l1.Next, l2) // 递归处理 l1 的剩余部分 + return l1 + } else { + l2.Next = mergeTwoLists(l1, l2.Next) // 递归处理 l2 的剩余部分 + return l2 + } +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 21 题,通过率约 60%+。作为链表入门题的代表作,它完美展示了两个经典范式:① **迭代**——dummy + tail 的双指针尾部追加模式;② **递归**——"选出当前最小者 + 递归处理剩余"的分治思路。建议两个版本都手写一遍,体会"显式状态追踪"和"隐式调用栈"两种编程哲学的差异。 diff --git a/链表/28-两数相加.md b/链表/28-两数相加.md new file mode 100644 index 0000000..ea9d9a4 --- /dev/null +++ b/链表/28-两数相加.md @@ -0,0 +1,382 @@ +--- +tags: ["LeetCode", "链表", "模拟", "简单"] +create time: 2026-05-16 15:00 +--- + +# 28-两数相加 + +## 题面 + +给你两个 **非空** 的链表,表示两个非负的整数。它们每位数字都是按照 **逆序** 的方式存储的,并且每个节点只能存储 **一位** 数字。 + +请你将两个数相加,并以相同形式返回一个表示和的链表。 + +你可以假设除了数字 0 之外,这两个数都不会以 0 开头。 + +**示例 1:** + +``` +输入:l1 = [2,4,3], l2 = [5,6,4] +输出:[7,0,8] +解释:342 + 465 = 807. +``` + +**示例 2:** + +``` +输入:l1 = [0], l2 = [0] +输出:[0] +``` + +**示例 3:** + +``` +输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9] +输出:[8,9,9,9,0,0,0,1] +``` + +**提示:** + +- 每个链表中的节点数在范围 `[1, 100]` 内 +- `0 <= Node.val <= 9` +- 题目数据保证列表表示的数字不含前导零 + +--- + +## 思路 + +> [!question] 💡 引导思考 +> 回想小时候学竖式加法——从右往左(个位开始),每一位加起来,如果结果 ≥ 10 就把十位"进"到下一位。链表是逆序存储的,意味着 **头节点就是最低位**。这不就是天然适合从头到尾做竖式加法吗? + +### 核心洞察:逐位模拟加法 + +> [!info] 🧠 关键性质 +> 因为链表逆序存储了数字,低位在前、高位在后,所以我们从两个链表的头节点同时出发,就像从个位开始逐位相加,完全对应人类列竖式的习惯。 + +每次循环需要处理三件事: + +| 步骤 | 动作 | 说明 | +|------|------|------| +| ① | **取当前位的值** | 如果某个链表已走到末尾,该位的值视为 `0` | +| ② | **加上进位** | 把上一步产生的进位也加进去 | +| ③ | **计算新位和新的进位** | `sum % 10` 是新位的值,`sum / 10` 是向下一位的进位 | + +```mermaid +flowchart TB + subgraph Inputs["📥 输入:两条逆序链表"] + L1["l1\n2→4→3→nil\n(表示 342)"] + L2["l2\n5→6→4→nil\n(表示 465)"] + end + + subgraph Loop["⚙️ 每轮循环"] + ADD1["取 l1.Val + l2.Val + carry"] + MOD["新位 = sum % 10"] + DIV["新进位 = sum / 10"] + APPEND["创建新节点,追加到结果尾部"] + end + + subgraph Output["📤 输出:结果链表"] + Res["结果\n7→0→8→nil\n(表示 807) ✅"] + end + + L1 -->|当前位| ADD1 + L2 -->|当前位| ADD1 + carry_in[/"💾 carry 入"/] -.进位.-> ADD1 + ADD1 --> MOD + ADD1 --> DIV + DIV -->|进位给下一轮| carry_next["carry → ..."] + MOD --> APPEND + APPEND --> Res + + style ADD1 fill:#fff4e6,stroke:#f90,stroke-width:2px + style carry_in fill:#ffe0e0,stroke:#c66,stroke-width:1px + style Res fill:#d4edda,stroke:#28a,stroke-width:2px + style Loop fill:#fafafa,stroke:#ccc,stroke-dasharray:5 5 +``` + +### 方法一:迭代法 — 虚拟头节点 + 尾指针 ⭐(推荐) + +维护三个变量: + +| 变量 | 含义 | 初始值 | +|------|------|--------| +| **dummy** | 虚拟头节点,`dummy.Next` 指向结果的真正头部 | `&ListNode{}` | +| **tail** | 已构建结果链表的最后一个节点 | `dummy` | +| **carry** | 当前的进位值 | `0` | + +**逐步展开执行过程**(以示例 3 为例,展示进位传播的极端情况): + +`l1 = [9,9,9,9]`, `l2 = [9,9,9,9,9,9,9]` + +| 轮次 | l1 取值 | l2 取值 | carry 入 | sum | 新位(sum%10) | 新进位(sum/10) | 结果链表 | +|:----:|:-------:|:-------:|:--------:|:---:|:------------:|:--------------:|---------| +| 初始 | — | — | 0 | — | — | — | `dummy → ?` | +| 第 1 轮 | **9** | **9** | 0 | **18** | **8** | **1** | `8_a` | +| 第 2 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9` | +| 第 3 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9→9` | +| 第 4 轮 | **9** | **9** | 1 | **19** | **9** | **1** | `8→9→9→9` | +| l1 耗尽 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0` | +| 第 6 轮 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0→0` | +| 第 7 轮 | 0 | **9** | 1 | **10** | **0** | **1** | `8→9→9→9→0→0→0` | +| **收尾** | — | — | **1** | **1** | **1** | **0** | `8→9→9→9→0→0→0→1` ✅ | + +> [!tip] 🔑 收尾处理两个场景 +> +> 当主循环结束时有两种可能: +> +> | 场景 | 条件 | 处理方式 | +> |------|------|----------| +> | A: 无剩余进位 | `l1 == nil && l2 == nil && carry == 0` | 直接返回 `dummy.Next`,无需额外操作 | +> | B: 有剩余进位 | `l1 == nil && l2 == nil && carry == 1` | 追加一个值为 `1` 的新节点 | + +**时间复杂度:O(max(m, n))** — 遍历较长的那个链表。 +**空间复杂度:O(max(m, n))** — 结果链表本身需要的空间(不计入的空间复杂度为 O(1))。 + +### 方法二:递归法(同一思想的函数式表达) + +> [!question] 💡 为什么递归不太适合? +> 迭代版用 `carry` 这个局部变量自然地在循环中传递进位,而递归版的 `carry` 必须作为参数显式地穿层而过。虽然逻辑等价,但 Go 不支持尾递归优化,长链表可能导致栈溢出。 + +**递归关系:** + +$$\text{add}(p, q, \text{carry}) = \begin{cases} \text{nil} & \text{if } p=\text{nil} \land q=\text{nil} \land \text{carry}=0 \\ \text{newNode}(\text{carry}) & \text{if } p=\text{nil} \land q=\text{nil} \land \text{carry}=1 \\ \text{node}(\text{sum}\bmod 10).\text{Next} = \text{add}(p.\text{Next}, q.\text{Next}, \text{sum}/10) & \text{otherwise} \end{cases}$$ + +其中 $\text{sum} = v_p + v_q + \text{carry}$,$v_p$ 表示 $p$ 的值(若 $p=\text{nil}$ 则为 0)。 + +**调用栈展开**(以 `l1=[2,4,3]`, `l2=[5,6,4]` 为例): + +```mermaid +flowchart TD + F1["AddTwoNumbers(2,5,0)\nsum=7,carry=0\nNext = F2"] -->|"7→"| F2["AddTwoNumbers(4,6,0)\nsum=10,carry=1\nNext = F3"] + F2 -->|"0→"| F3["AddTwoNumbers(3,4,1)\nsum=8,carry=0\nNext = F4"] + F3 -->|"8→"| F4["AddTwoNumbers(nil,nil,0)\n基准情况,返回 nil"] + + F4:::base + classDef base fill:#f9d,stroke:#333,stroke-width:2px +``` + +**时间复杂度:O(max(m, n))** — 每层处理一个节点。 +**空间复杂度:O(max(m, n))** — 递归栈深度等于较长链表的长度。Go 不保证尾递归消除,实际不推荐用于生产场景。 + +--- + +## 代码提示 + +### 迭代法伪代码 + +``` +dummy = &ListNode{} // 虚拟头节点 +tail = dummy // 尾指针,指向结果链表末尾 +carry = 0 // 进位,初始为 0 + +for l1 != nil OR l2 != nil OR carry > 0 { + val1 = 0 + if l1 != nil { val1 = l1.Val; l1 = l1.Next } + + val2 = 0 + if l2 != nil { val2 = l2.Val; l2 = l2.Next } + + sum = val1 + val2 + carry + tail.Next = &ListNode{Val: sum % 10} // 新位 + tail = tail.Next // 尾指针跟进 + carry = sum / 10 // 新进位 +} + +return dummy.Next +``` + +### 递归法伪代码 + +``` +func addNodes(p, q *Node, carry int) *Node: + // 基准情况:两个链表都走完且无进位 + if p == nil and q == nil and carry == 0: + return nil + + valP = 0 if p == nil else p.Val + valQ = 0 if q == nil else q.Val + + sum = valP + valQ + carry + + newNode = Node{ Val: sum % 10 } + // 递推下一层 + newNode.Next = addNodes( + p.Next if p != nil else nil, + q.Next if q != nil else nil, + sum / 10 + ) + + return newNode +``` + +--- + +## 技巧 + +> [!tip] 🔑 短写法:利用 Go 的 nil 解引用 +> +> 在 Go 中,`*ListNode` 的零值是 `nil`。可以用以下惯用法避免冗长的 if 判断: + +```go +val1 := 0 +if l1 != nil { + val1 = l1.Val + l1 = l1.Next +} +``` + +> 也可以写成一行表达式:`val1 := 0; if l1 != nil { val1, l1 = l1.Val, l1.Next }`,但拆成多行可读性更好。 + +> [!warning] ⚠️ 常见错误:遗漏"最后一位进位" +> +> 很多人写出的循环条件是 `for l1 != nil && l2 != nil`,这会导致当两个链表等长且最高位产生进位时(如 `5+5=10`),遗漏最前面的那个 `1`。**正确做法**是把终止条件写成 `for l1 != nil || l2 != nil || carry > 0`,确保所有情况都被覆盖。 + +> [!note] 🔑 为什么不需要判空输入? +> +> 题目保证链表是非空的,所以不必检查 `l1 == nil || l2 == nil`。但如果在实际工作中遇到类似场景,记得在最前面加一句保护性检查。 + +> [!tip] 🔑 与竖式加法的精确映射 + +| 竖式加法 | 代码对应 | +|---------|---------| +| 从个位开始算 | 从链表的 head(低位)开始遍历 | +| 不够两位补零 | 链表为空时取值为 0 | +| 满十进一 | `carry = sum / 10` | +| 写下当前位 | `newNode{Val: sum % 10}` | +| 最后进位写在最前面 | 循环结束后若 carry > 0,追加新节点 | + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | +|------|-----------|-----------|------|------| +| **迭代法 ⭐** | **O(max(m,n))** | **O(1)**(除结果外) | 无栈溢出风险、常数小 | 需要理解 dummy + tail 模式 | +| 递归法 | O(max(m,n)) | O(max(m,n)) | 代码简洁、逻辑直观 | Go 无尾递归优化,长链表可能栈溢出 | + +> [!success] ✅ 相关题目串联 +> - [[2-两数相加 II]] — 进阶版:数字是**正序**存储的(需要从后往前加) +> - [[27-合并两个有序链表]] — 同样是双链表遍历的经典范式 +> - [[67-二进制求和]] — LeetCode 第 67 题:原理完全相同,只是逢二进一 + +--- + +## 代码 + +### 迭代法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode { + // 虚拟头节点:避免对第一个节点做特殊处理 + dummy := &ListNode{} + tail := dummy // tail 始终指向结果链表的最后一个节点 + carry := 0 // 进位值 + + // 当任意一条链表还有节点,或者还有进位未处理时继续循环 + for l1 != nil || l2 != nil || carry > 0 { + // 取出当前位的值(链表为空时视为 0) + val1 := 0 + if l1 != nil { + val1 = l1.Val + l1 = l1.Next + } + + val2 := 0 + if l2 != nil { + val2 = l2.Val + l2 = l2.Next + } + + // 逐位相加:当前位的两个数字 + 上一轮的进位 + sum := val1 + val2 + carry + + // 创建新节点存放结果的当前位(个位数) + tail.Next = &ListNode{Val: sum % 10} + tail = tail.Next // 尾指针跟进 + + // 更新进位(十位数) + carry = sum / 10 + } + + // 跳过 dummy,返回真正的头节点 + return dummy.Next +} +``` + +### 递归法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode { + return addNodes(l1, l2, 0) +} + +// addNodes 递归处理两个链表对应位置及进位 +func addNodes(p, q *ListNode, carry int) *ListNode { + // 基准情况:两个链表都已走完,且没有剩余进位 + if p == nil && q == nil && carry == 0 { + return nil + } + + // 取当前位的值(节点为空时值为 0) + valP := 0 + if p != nil { + valP = p.Val + } + valQ := 0 + if q != nil { + valQ = q.Val + } + + // 计算当前位的和与新的进位 + sum := valP + valQ + carry + + // 创建当前位的节点 + current := &ListNode{Val: sum % 10} + + // 递归处理下一位,并将返回值链接到当前节点的 Next + current.Next = addNodes( + mapNil(p), // 传入下一节点(nil 安全) + mapNil(q), + sum/10, // 进位作为参数传入下一层 + ) + + return current +} + +// mapNil 帮助函数:nil-safe 获取 Next +func mapNil(node *ListNode) *ListNode { + if node == nil { + return nil + } + return node.Next +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 2 题,通过率约 45%+。它考察的核心只有一个:**模拟竖式加法并处理进位**。虽然代码很短,但它完美展示了链表遍历的经典范式——dummy 节点 + 尾指针 + 边界循环条件。建议至少手写迭代版,递归版作为拓展理解即可。 diff --git a/链表/29-删除链表的倒数第 N 个结点.md b/链表/29-删除链表的倒数第 N 个结点.md new file mode 100644 index 0000000..f0a6ecf --- /dev/null +++ b/链表/29-删除链表的倒数第 N 个结点.md @@ -0,0 +1,260 @@ +--- +tags: ["LeetCode", "链表", "双指针", "快慢指针", "虚拟头节点", "中等"] +create time: 2026-05-16 15:00 +--- + +# 29-删除链表的倒数第 N 个结点 + +## 题面 + +给你一个链表,删除链表的倒数第 `n` 个结点,并且返回链表的头结点。 + +**示例 1:** + +``` +输入:head = [1,2,3,4,5], n = 2 +输出:[1,2,3,5] +``` + +**示例 2:** + +``` +输入:head = [1], n = 1 +输出:[] +``` + +**示例 3:** + +``` +输入:head = [1,2], n = 1 +输出:[1] +``` + +**提示:** + +- 链表中结点的数目为 `sz` +- `1 <= sz <= 30` +- `0 <= Node.val <= 100` +- `1 <= n <= sz` + +**进阶:** 你能尝试使用一趟扫描实现吗? + +--- + +## 思路 + +> [!question] 💡 核心洞察 +> 要删除倒数第 n 个节点,最直接的念头是——"先数总长,再找正数位置"。但这需要**两遍扫描**。进阶要求我们**一趟搞定**,怎么做到的? + +关键在于:**用距离(gap)代替计数**。如果我们让两个指针之间始终保持 n 个节点的间隔,那么当快指针走到末尾时,慢指针自然就在目标位置。 + +### 关键问题:如何定位? + +> [!warning] ⚠️ 最大陷阱 +> 删除一个节点需要的是它的**前驱节点**——因为只有前驱才能修改 `Next` 指针把目标摘出去。如果慢指针恰好停在待删节点上,你就无从下手了。 + +所以我们的目标是:让慢指针最终停在**待删节点的前驱位置**。 + +### 方法一:快慢指针 + 虚拟头节点 ⭐(最优 — 一趟扫描) + +维护两个指针 `fast` 和 `slow`,初始都指向一个**虚拟头节点** `dummy`(`dummy.Next = head`)。 + +**核心策略:** 先让 `fast` 走 n 步,然后 `fast` 和 `slow` 同时出发,每次各走一步。当 `fast` 到达最后一个节点时(`fast.Next == nil`),`slow` 恰好停在倒数第 n 个节点的前驱。 + +**为什么这样一定正确?** + +> [!question] 💡 引导思考 +> 假设链表长度为 sz,快指针领先 n 步。那么当快指针走完剩余路程时,它走了多少步?慢指针呢?它们之间的距离保持了几步? + +快指针再走 `sz - n` 步会到达**最后一个节点**(而非越过边界)。循环条件 `fast.Next != nil` 保证在快指针停在尾节点时立即终止。此时慢指针也恰好走了 `sz - n` 步。由于 `dummy` 是第 0 位,`dummy → slow` 的距离就是 `sz - n`——这意味着 slow 指向的是正数第 `sz - n` 个节点,即倒数第 `n + 1` 个节点,正是待删节点的前驱。 + +关键不变量:**从 fast 先走 n 步之后起,slow 永远比 fast 落后 n 个节点**。这 n 的间隔全程保持不变,直到循环结束。 + +**图示执行流程:** + +```mermaid +flowchart LR + subgraph Init["第一步:fast 先走 n 步(n=2)"] + D["dummy"] -->|"slow"| A["1"] + A --> B["2"] + B --> C["3"] + C --> D2["4"] + D2 --> E["5"] + F["fast"] -.走2步.-> B + end + + subgraph Sync["第二步:fast & slow 同步前进(直到 fast == 尾节点)"] + B -->|"slow→1, fast→3"| G["slow: 1 / fast: 3"] + G -->|"slow→2, fast→4"| H["slow: 2 / fast: 4"] + H -->|"slow→3, fast→5"| I["slow: 3 / fast: 5"] + end + + subgraph Done["结束:fast==尾节点, slow在倒数第 n+1 个(待删前驱)"] + I -->|"fast.Next==nil, 退出"| K["slow指向3\n删除slow.Next(4)\n1→2→3→5 ✅"] + end + + Init --> Sync --> Done + + classDef done fill:#4c4,color:white + class K done +``` + +逐步展开(以 `head = [1,2,3,4,5], n = 2` 为例): + +| 阶段 | dummy | slow | fast | 说明 | +|------|--------|------|------|------| +| 初始 | → 1→2→3→4→5 | dummy | dummy | 都从虚拟头节点出发 | +| fast 走 n=2 步后 | → 1→2→3→4→5 | dummy | **2** | fast 跳过 1、2,停在 2 | +| 第 1 轮同步 | → 1→2→3→4→5 | **1** | **3** | 各走一步(fast.Next=3 ≠ nil) | +| 第 2 轮同步 | → 1→2→3→4→5 | **2** | **4** | 各走一步(fast.Next=4 ≠ nil) | +| 第 3 轮同步 | → 1→2→3→4→5 | **3** | **5** | 各走一步(fast.Next=5 ≠ nil) | +| 循环终止 | — | **3** | **5** | fast.Next == nil,退出循环 | +| 删除操作 | — | 指向 3 | — | `slow.Next = slow.Next.Next`,删除节点 4 | + +```mermaid +flowchart TD + Before["删除前\n1→2→3→4→5\n ↑slow\n ↑slow.Next\n ↑slow.Next.Next\n \ndelete slow.Next"] --> After["删除后\n1→2→3→5\n ↑slow.Next 指向 5 ✅"] + + style Before fill:#fff4e6,stroke:#f90 + style After fill:#d4edda,stroke:#28a +``` + +> [!info] 🧠 为什么用 `dummy` 能优雅处理所有边界? +> - **删除头节点**:如 `n = 5`(删除 1),此时 `dummy` 先走 5 步刚好到 `nil`,`slow` 还在 `dummy`,`slow.Next = slow.Next.Next` 删除的就是节点 1。 +> - **删除尾节点**:如 `n = 1`(删除 5),此时 `slow` 停在节点 4,正常删除。 +> - **单节点链表**:如 `head = [1], n = 1`,结果直接是空链表。 + +**时间复杂度:O(n)** — 每个节点最多被访问一次。 +**空间复杂度:O(1)** — 只用了两个指针变量。 + +--- + +## 代码提示 + +### 快慢指针伪代码 + +``` +// 第一步:创建虚拟头节点 +dummy = &ListNode{Next: head} +slow = dummy +fast = dummy + +// 第二步:fast 先走 n 步 +for i = 0; i < n; i++ { + fast = fast.Next +} + +// 第三步:fast 和 slow 同步前进,直到 fast 到达最后一个节点 +for fast.Next != nil { + slow = slow.Next + fast = fast.Next +} + +// 第四步:通过 slow 的 Next 跳过待删节点 +slow.Next = slow.Next.Next + +return dummy.Next +``` + +### 图解关键步骤 + +```mermaid +flowchart LR + Step1["dummy 节点\n防止删除头节点时需要特殊判断"] --> Step2["fast 先走 n 步\n制造 n 的间隔"] + Step2 --> Step3["同步前进\nfast 到头时\nslow 恰好在倒数第 n+1 个"] + Step3 --> Step4["跳过待删节点\nslow.Next = slow.Next.Next"] + + style Step1 fill:#bbf,stroke:#333 + style Step2 fill:#f9d,stroke:#333 + style Step3 fill:#ffd700,stroke:#333 + style Step4 fill:#d4edda,stroke:#28a +``` + +--- + +## 技巧 + +> [!tip] 🔑 快慢指针"间隔法"通用模式 +> "让快指针先走 k 步,然后一起走"是一个极其通用的模板。它解决的问题范式是:**找到与末尾有固定距离的某个位置**。除了本题(倒数第 n 个),还衍生出: +> - 链表的中间节点(k = n/2) +> - 环形链表的入口(稍作变形) +> - 排序数组中的差值对(类似思想扩展到数组) + +> [!warning] ⚠️ 常见错误 1:忘记加虚拟头节点 +> 如果不用 `dummy`,删除头节点(如 `n = sz`)时需要单独处理。加上 `dummy` 后,所有节点都被"右移了一位",统一由 `slow.Next.Next` 处理。 + +> [!warning] ⚠️ 常见错误 2:fast 提前为空 +> 当 n = sz 时,fast 走完 n 步后正好等于 `nil`。这时第三个循环(同步前进)不会执行,直接进入第四步——这正是正确的行为!不要误判为错误。 + +> [!note] 🔑 两遍扫描解法(辅助理解) +> 虽然不满足进阶要求,但两遍扫描的思路更直观: +> ① 第一遍遍历求长度 len;② 第二遍从头走到第 `len - n` 个节点的位置并删除。这种方法的优势是更容易扩展——比如你想删除倒数第 n ~ m 个节点时,比快慢指针更容易改写。**面试时可以先说两遍扫描的方案展示理解,再用快慢指针优化到一遍。** + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种扫描方式对比 + +| 维度 | 两遍扫描 | 一遍扫描(快慢指针)⭐ | +|------|---------|---------------------| +| 时间复杂度 | O(n)(两次遍历) | O(n)(一次遍历) | +| 空间复杂度 | O(1) | O(1) | +| 直观程度 | 非常直观 | 需要理解"间隔不变量" | +| 面试官满意度 | 合格,但不加分 | 优秀,满足进阶要求 | +| 适用场景 | 无法保证单次扫描时可用 | 绝大多数链表问题的首选 | + +--- + +## 代码 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func removeNthFromEnd(head *ListNode, n int) *ListNode { + // 虚拟头节点:消除"删除头节点需要特殊处理"的边界情况 + dummy := &ListNode{Next: head} + slow := dummy + fast := dummy + + // 快指针先走 n 步,与慢指针拉开 n 的距离 + for i := 0; i < n; i++ { + fast = fast.Next + } + + // 快慢指针同步前进,直到 fast 到达最后一个节点 + for fast.Next != nil { + slow = slow.Next + fast = fast.Next + } + + // slow 现在停在倒数第 n 个节点的前驱 + // slow.Next = slow.Next.Next 直接跳过待删节点 + slow.Next = slow.Next.Next + + return dummy.Next // 绕过虚拟头节点,返回真正的头节点 +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 19 题,通过率约 40%+。看似简单的题目恰恰最能检验对链表操作的熟练度——虚拟头节点是否本能地想到、快慢指针的间隔不变量是否能清晰解释。这道题是后续更多复杂链表题目的基础:反转链表的一部分、合并 K 个有序链表、链表的 K 逆序等都依赖同样的"间隔控制"思想。建议配合 Mermaid 图示手写 2-3 遍,直到形成肌肉记忆。 + +> [!note] 🔗 相关题目串联 +> - [141-环形链表](./25-环形链表.md) — 另一个经典的双指针应用 +> - [142-环形链表 II](./26-环形链表 II.md) — 快慢指针找环入口 +> - [23-反转链表](./23-反转链表.md) — 链表基础操作 +> - [24-两数相加](./28-两数相加.md) — 链表遍历的高级应用 diff --git a/链表/30-两两交换链表中的节点.md b/链表/30-两两交换链表中的节点.md new file mode 100644 index 0000000..e69de29