diff --git a/动态规划/87-最长递增子序列.md b/动态规划/87-最长递增子序列.md index 20422de..27cc4cd 100644 --- a/动态规划/87-最长递增子序列.md +++ b/动态规划/87-最长递增子序列.md @@ -240,6 +240,90 @@ Go 的 `sort.SearchInts` 返回目标值在有序切片中的插入位置(索 > > 如果需要**输出具体的 LIS**,方法一更容易扩展:维护一个 `parent[i]` 数组记录每个 dp[i] 对应的上一个元素下标,最后从 dp 最大处回溯即可。方法二较难还原(可以用额外数据结构实现但不常见)。 +#### 方法一:DP + parent 数组回溯(推荐 ✅) + +核心思想:**在转移时同时记录"父节点"**——就像链表一样,从终点一步步往回走就能拼出整条链。 + +```go +// lengthOfLISWithSequence returns both the length and the actual LIS. +func lengthOfLISWithSequence(nums []int) (int, []int) { + n := len(nums) + if n == 0 { + return 0, nil + } + + dp := make([]int, n) // dp[i] = 以 nums[i] 结尾的 LIS 长度 + parent := make([]int, n) // parent[i] = dp[i] 对应的前驱下标,-1 表示无前驱 + for i := range dp { + dp[i] = 1 + parent[i] = -1 + } + + // 自底向上填表 + for i := 1; i < n; i++ { + for j := 0; j < i; j++ { + if nums[j] < nums[i] && dp[j]+1 > dp[i] { + dp[i] = dp[j] + 1 + parent[i] = j // ★ 关键:记录父节点 + } + } + } + + // 找到 dp 最大值及其索引 + maxIdx := 0 + for i := 1; i < n; i++ { + if dp[i] > dp[maxIdx] { + maxIdx = i + } + } + + // ★ 从 maxIdx 沿 parent 链回溯,逆序拼出 LIS + result := make([]int, 0, dp[maxIdx]) + for cur := maxIdx; cur != -1; cur = parent[cur] { + result = append(result, nums[cur]) + } + + // 此时 result 是逆序的(从后往前),需要反转 + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return dp[maxIdx], result +} +``` + +**走一遍演示**(`nums = [10,9,2,5,3,7,101,18]`): + +| i | nums[i] | dp[i] | parent[i] | 说明 | +|---|---------|-------|-----------|------| +| 0 | 10 | 1 | -1 | 单独起点 | +| 1 | 9 | 1 | -1 | 无前驱 | +| 2 | 2 | 1 | -1 | 无前驱 | +| 3 | 5 | 2 | 2 | 接在 nums[2]=2 后面 | +| 4 | 3 | 2 | 2 | 接在 nums[2]=2 后面 | +| 5 | 7 | 3 | 3 | 接在 nums[3]=5 后面(dp[3]+1=3 > dp[4]+1=3 不更优,取先更新的) | +| 6 | 101 | 4 | 5 | 接在 nums[5]=7 后面 | +| 7 | 18 | 4 | 5 | 同样长度为 4,接在 nums[5]=7 后面 | + +**回溯过程**:`maxIdx = 6`(dp[6]=4 是第一个出现的最大值) + +``` +第1步: cur=6, nums[6]=101 → result=[101] parent[6]=5 +第2步: cur=5, nums[5]=7 → result=[101,7] parent[5]=3 +第3步: cur=3, nums[3]=5 → result=[101,7,5] parent[3]=2 +第4步: cur=2, nums[2]=2 → result=[101,7,5,2] parent[2]=-1 → 停止 + +反转后: [2,5,7,101] ✅ +``` + +> [!tip] ⚡ 设计要点 +> - **为什么要等全部填完再回溯?** 因为只有在所有状态都计算完后,才能确定全局最优解的终点在哪 +> - **如果有多个相同长度的 LIS 怎么办?** 上面的实现返回的是最先达到的那个。如果只需要任意一个,这就够了;如果需要全部枚举,则需要在遍历结束时收集所有 `dp[i]==maxLen` 的 i 分别回溯 +> - **空间代价**:多了 O(n) 的 `parent` 数组,整体仍然是 O(n²) 时间、O(n) 空间 + +> [!warning] ⚠️ 为什么不推荐方法二还原? +> `tails` 数组中的值会被反复替换,它只是各长度的"最优末尾候选",不是真实路径。虽然可以通过额外维护一棵前驱树来还原,但实现复杂且容易出错,面试中很少要求。用方法一的 parent 数组回溯才是标准做法。 + ### 何时选哪种方法? > [!question] 💡 面试官追问:为什么要用 O(n log n) 而不是 O(n²)? diff --git a/链表/31-K 个一组翻转链表.md b/链表/31-K 个一组翻转链表.md new file mode 100644 index 0000000..6dc231e --- /dev/null +++ b/链表/31-K 个一组翻转链表.md @@ -0,0 +1,510 @@ +--- +tags: ["LeetCode", "链表", "双指针", "虚拟头节点", "迭代", "递归", "困难"] +create time: 2026-05-17 10:00 +--- + +# 31-K 个一组翻转链表 + +## 题面 + +给你链表的头节点 `head`,每 `k` 个节点一组进行翻转,请你返回修改后的链表。 + +`k` 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 `k` 的整数倍,那么请将最后剩余的节点保持原有顺序。 + +**你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。** + +**示例 1:** + +``` +输入:head = [1,2,3,4,5], k = 2 +输出:[2,1,4,3,5] +``` + +**示例 2:** + +``` +输入:head = [1,2,3,4,5], k = 3 +输出:[3,2,1,4,5] +``` + +**提示:** + +- 链表中的节点数目为 `n` +- `1 <= k <= n <= 5000` +- `0 <= Node.val <= 1000` + +**进阶:** 你可以设计一个只用 O(1) 额外内存空间的算法解决此问题吗? + +--- + +## 思路 + +> [!question] 💡 核心洞察 +> 这是一道**组合题**——它把两个经典操作叠加在了一起:**分组 + 反转**。分解来看: +> - 如果只有"反转链表",那是 23 题(基础) +> - 如果只有"分组拼接",那只需要追踪头尾即可 +> - 现在要把两者结合起来,难点在于**每次反转一段后如何正确衔接回主链** + +### 关键问题:如何定位一段的起止? + +> [!warning] ⚠️ 最大陷阱 +> 反转一个区间 `[a, b]` 时,反转完成后 a 会变成这段的**尾节点**,b 会变成**头节点**。如果你不提前保存 a.Next(即下一段的起点),反转完就找不到路了。 + +所以每组反转前,需要明确三个位置: + +| 角色 | 含义 | 何时获取 | +|------|------|---------| +| **groupHead** | 当前这一组的第一个节点(反转后变成尾巴) | 进入本组时 | +| **groupTail** | 当前这一组的最后一个节点(反转后变成头部) | 遍历 k 步找到 | +| **nextGroup** | 下一组的起点 = groupTail.Next | 先存下来再反转 | + + +### 方法一:迭代法 ⭐(最优 — O(1) 空间) + +用一个**虚拟头节点** `dummy` 统一边界处理,维护一个 `prev` 指针指向**已处理部分的尾节点**。每一轮做三件事: + +1. **探路**:从 `prev.Next` 出发走 k 步,看是否有完整的 k 个节点 +2. **反转**:如果有,反转这一段(利用第 23 题的单次反转模板) +3. **衔接**:将上一段的尾巴 `prev` 接到新头,再把旧头(现在变尾巴)连到下一段 + +```mermaid +flowchart TD + subgraph Prepare["准备工作"] + D["dummy → head"] --> PREV["prev 初始指向 dummy"] + end + + subgraph Phase1["阶段一:完整反转"] + P1["第 1 组: [1,2,3,4,5], k=2"] --> R1["反转 (1,2) → [2,1,4,3,5]\ndummy→2→1→4→3→5\nprev=1"] + R1 --> P2["第 2 组: [4,3,5], k=2"] + P2 --> R2["反转 (4,3) → [2,1,4,3,5]\nprev=3"] + R2 --> CheckFull{"剩余节点 ≥ k?\n[5], k=2 → 否"} + end + + subgraph Phase2["阶段二:不足 k 个,跳过"] + CheckFull -- 否 --> SKIP["直接退出\n保留剩余 [5] ✅\ndummy→2→1→4→3→5"] + end + + Prepare --> Phase1 --> Phase2 + + style Prepare fill:#bbf,stroke:#333 + style R1 fill:#f9d,stroke:#333 + style R2 fill:#f9d,stroke:#333 + style SKIP fill:#d4edda,stroke:#28a +``` + +**逐步展开(以 `head = [1,2,3,4,5], k = 2` 为例):** + +```mermaid +flowchart LR + subgraph Init["① 初始状态"] + D1["dummy"] --> N1["1"] + N1 --> N2["2"] + N2 --> N3["3"] + N3 --> N4["4"] + N4 --> N5["5"] + PREV1["prev=dummy"] + end + + subgraph Probe1["② 探路: 从 prev.Next=1 走 k=2 步\n找到 groupTail=2"] + P1["1→2 共 2 个节点 ✅ 满足 k"] + end + + subgraph Reverse1["③ 反转 (1,2): 先保存 nextGroup=3\n反转后: 2→1, 然后 1.Next=3"] + REV1["2→1→3→4→5"] + end + + subgraph Connect1["④ 衔接: prev.Next=2(新头), prev=1(旧头)\ndummy→2→1→3→4→5"] + CONN1["dummy→2→1→3→4→5\nprev=1"] + end + + subgraph Probe2["⑤ 探路: 从 prev.Next=3 走 k=2 步\n找到 groupTail=4"] + P2["3→4 共 2 个节点 ✅ 满足 k"] + end + + subgraph Reverse2["⑥ 反转 (3,4): 先保存 nextGroup=5\n反转后: 4→3, 然后 3.Next=5"] + REV2["dummy→2→1→4→3→5"] + end + + subgraph Connect2["⑦ 衔接: prev.Next=4(新头), prev=3"] + CONN2["dummy→2→1→4→3→5\nprev=3"] + end + + subgraph Done["⑧ 探路: 从 prev.Next=5 走 k=2 步\n只剩 1 个节点 < k ❌ 退出"] + DONE["dummy→2→1→4→3→5 ✅"] + end + + Init --> Probe1 --> Reverse1 --> Connect1 --> Probe2 --> Reverse2 --> Connect2 --> Done + + classDef initStyle fill:#bbf,stroke:#333 + classDef probeStyle fill:#ffd700,stroke:#333 + classDef reverseStyle fill:#f9d,stroke:#333 + classDef connectStyle fill:#9df,stroke:#333 + classDef doneStyle fill:#d4edda,stroke:#28a + class Init initStyle + class Probe1,P2 probeStyle + class Reverse1,Reverse2 reverseStyle + class Connect1,Connect2 connectStyle + class Done,DONE doneStyle +``` + +> [!question] 💡 引导思考:为什么 prev 前进到旧头(groupHead)? +> 反转完成后,旧的 groupHead 变成了这段的**末尾节点**,它的 Next 已经指向下一组的开头。所以 `prev = groupHead` 恰好让它在下一轮可以访问 `prev.Next`(下一组的第一个节点)。这和「两两交换」的思路完全一致——只不过这里每组有 k 个节点而不是 2 个。 + +**代码流程:** + +``` +dummy = &ListNode{Next: head} +prev = dummy + +for { + // 步骤 1:找第 k 个节点 + kth = prev + for i = 1; i <= k; i++ { + kth = kth.Next + if kth == nil { // 不足 k 个节点 + return dummy.Next // 剩余部分保持原序,直接退出 + } + } + + // 步骤 2:记录两组之间的连接点 + nextGroup = kth.Next // 下一组起点 + groupHead = prev.Next // 当前组起点(反转后变末尾) + + // 步骤 3:切断与下组的联系,然后反转 [prev.Next, kth] + kth.Next = nil + revHead := reverseList(prev.Next) + + // 步骤 4:重新拼接 + prev.Next = revHead // 上段尾巴 → 新头 + groupHead.Next = nextGroup // 旧头(现尾巴)→ 下一段 + + // 步骤 5:prev 走到已处理段的末尾,准备下一轮 + prev = groupHead +} +``` + +其中 `reverseList(head)` 就是 23 题的单链表反转(头插法或三指针法): + +``` +func reverseList(head *ListNode) *ListNode { + var prev *ListNode + curr := head + for curr != nil { + nextTemp := curr.Next + curr.Next = prev + prev = curr + curr = nextTemp + } + return prev +} +``` + +> [!info] 🧠 串联的"断链再续"策略 +> 核心技巧是:**先在 kth.Next 处断开(`kth.Next = nil`),形成一个独立的子链表交给 reverseList,反转完再把两头接回主链**。这样做的好处是——反转函数不需要感知主链的存在,保持了函数的纯度和复用性。 + +**时间复杂度:O(n)** — 每个节点被 visit 常数次(一次探路、一次反转) +**空间复杂度:O(1)** — 只用了有限个指针变量 + +--- + +### 方法二:递归法 + +递归的核心思想更简洁:**假设第 2 组及以后的部分已经处理好了,我只管搞定第一组,然后把它们串起来。** + +**递归框架:** + +```mermaid +flowchart LR + S1["① 从 head 开始遍历 k 步\n确认剩余节点 ≥ k?"] --> S2["② 是: 反转第一组\ntail.next = reverseKGroup(nextGroup, k)\nreturn newHead"] --> S3["③ 否: 剩余不足 k 个\n直接返回 head, 不做任何改动"] + + style S1 fill:#bbf,stroke:#333 + style S2 fill:#f9d,stroke:#333 + style S3 fill:#fff4e6,stroke:#f90 +``` + +**以 `head = [1,2,3,4,5], k = 2` 为例:** + +```mermaid +flowchart TD + subgraph Explore["递:一路往下探,同时数节点"] + E1["countNodes(1) = 5 ≥ 2\n继续"] --> E2["countNodes(3) = 3 ≥ 2\n继续"] --> E3["countNodes(5) = 1 < 2\n❌ 停止"] + end + + subgraph Build["归:一层层往回构造"] + B1["L2: 反转(5), 只剩1个 B2["L1: 反转(3,4)→4→3\n4.next = L2返回值(5)\n结果: 4→3→5\n返回 4"] + B2 --> B3["L0: 反转(1,2)→2→1\n2.next = L1返回值(4)\n结果: 2→1→4→3→5\n返回 2 ✅"] + end + + Explore --> Build + + style Explore fill:#eee,stroke:#999 + style B1 fill:#fff4e6,stroke:#f90 + style B2 fill:#bbf,stroke:#333 + style B3 fill:#d4edda,stroke:#28a +``` + +逐步展开: + +| 递归层级 | 传入 head | 剩余节点数 | action | 返回值 | +|---------|-----------|----------|--------|--------| +| L0 | `1→2→3→4→5` | 5 ≥ 2 | 反转 (1,2),next = L1 | `2→1→...` | +| L1 | `3→4→5` | 3 ≥ 2 | 反转 (3,4),next = L2 | `4→3→...` | +| L2 | `5` | 1 < 2 | 不足 k,直接返回 | `5` | + +**关键连接点:** + +```mermaid +flowchart TD + AfterL2["L2 返回: 5(未变化)"] --> Link1["L1: tail=3, 3.Next = 5\n得到 4→3→5"] + Link1 --> Link0["L0: tail=1, 1.Next = 4\n得到 2→1→4→3→5 ✅"] + + style AfterL2 fill:#eee,stroke:#999 + style Link1 fill:#bbf,stroke:#333 + style Link0 fill:#d4edda,stroke:#28a +``` + +> [!warning] ⚠️ 不要手动断链! +> 递归法和迭代法不同:**不应该设置 `kth.Next = nil`**。递归的反转需要在子链表上进行,但我们希望反转后每个节点仍然能顺着 Next 找到同一组内的后继(这样才能用 `tail.Next = reverseKGroup(...)` 自然衔接)。具体做法是在反转之前保存好每对相邻节点的关系,详见下方代码。 + +**时间复杂度:O(n × n/k) = O(n²/k)** — 每层递归调用 `countNodes` 遍历剩余节点 +**空间复杂度:O(n/k)** — 递归栈深度为 n/k + +--- + +## 代码提示 + +### 迭代法伪代码 + +``` +func reverseKGroup(head, k): + dummy = &ListNode{Next: head} + prev = dummy + + for { + // 探路:找第 k 个节点 + kth = prev + for i = 1; i <= k; i++ { + kth = kth.Next + if kth == nil: + return dummy.Next // 不足 k 个,退出 + + // 记录边界 + nextGroup = kth.Next // 下一组起点 + groupHead = prev.Next // 当前组起点 + + // 切断、反转、接回 + kth.Next = nil + newHead = reverseList(prev.Next) + + prev.Next = newHead // 上段接新头 + groupHead.Next = nextGroup // 旧头(现尾)接下一段 + + // 移动 prev + prev = groupHead + } + +func reverseList(head): + prev = nil + curr = head + while curr != nil: + nextTemp = curr.Next + curr.Next = prev + prev = curr + curr = nextTemp + return prev +``` + +### 递归法伪代码 + +``` +func reverseKGroup(head, k): + // 第一步:检查是否有足够节点 + curr = head + for i = 0; i < k && curr != nil; i++: + curr = curr.Next + + if curr == nil: + return head // 不足 k 个,保持原序 + + // 第二步:反转前 k 个节点(注意不全断链) + prev = nil + curr = head + for i = 0; i < k; i++: + nextTemp = curr.Next + curr.Next = prev // 反向指 + prev = curr + curr = nextTemp + + // 第三步:head 现在是这一组的末尾,接到递归结果 + // 此时 head.Next 还没被破坏(curr = head.Next 在先) + head.Next = reverseKGroup(curr, k) + + return prev // prev 是反转后的新头 +``` + +--- + +## 技巧 + +> [!tip] 🔑 "断链再续"模式(迭代法精髓) +> 碰到需要原地修改链表中某一段的场景,可以先在这段的**两端分别断开**,得到一个独立子链表,做完操作后再把首尾接回主链。这个模式反复出现在: +> - 本题:按 k 组切分 +> - 反转链表的一部分(LeetCode 92) +> - 旋转链表(LeetCode 61) + +> [!tip] 🔑 递归的"贪心假设" +> 递归解法的核心自信来自一句假设:**"后面的部分交给我,一定能处理好。"** 你只需要验证两件事:① 这一层自己做得对不对;② 传给下一层的参数是不是正确的起始位置。不用想全貌。 + +> [!warning] ⚠️ 常见错误 1:忘记处理不足 k 个的情况 +> 当最后剩余节点少于 k 个时,题目要求保持原序不变。如果在探路时发现 `kth == nil` 没有及时 return,后面 `kth.Next = nil` 会把有效节点裁掉,导致链表丢失后半段。 + +> [!warning] ⚠️ 常见错误 2:递归中错误断链 +> 递归解法里千万不要设 `kth.Next = nil`。因为递归依靠 `head.Next = reverseKGroup(curr, k)` 来拼接,如果中间断了,递归回来的结果就连不上了。 + +> [!warning] ⚠️ 常见错误 3:迭代中 prev 走错位置 +> 每组处理完后,prev 必须走到**旧头节点**(即这一组反转前的第一个节点,现在是最后一个),因为只有它才有正确的 Next 指向下一组。很多同学在 prev = groupHead 这一步会混淆新旧关系。 + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type ListNode struct { + Val int + Next *ListNode +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 维度 | 迭代法 ⭐ | 递归法 | +|------|---------|--------| +| 时间复杂度 | O(n) | O(n²/k) | +| 空间复杂度 | O(1) | O(n/k)(递归栈) | +| 代码行数 | 循环体约 15 行 | 核心逻辑约 15 行 | +| 直观程度 | 中等(需理解"断链再续") | 较高("搞一组,接尾巴") | +| 面试推荐 | ⭐⭐⭐ 首选 | ⭐⭐ 可作为辅助展示 | +| 适用场景 | O(1) 空间约束时的唯一解 | 教学和理解分组思想 | + +> [!success] ✅ 相关题目串联 +> - 这是链表分组操作的**终极题型**,它建立在所有前置链表题目的基础之上: +> - [23-反转链表](./23-反转链表.md) — 单次反转是基本操作 +> - [30-两两交换链表中的节点](./30-两两交换链表中的节点.md) — K=2 的特例 +> - [29-删除链表的倒数第 N 个结点](./29-删除链表的倒数第 N 个结点.md) — 快慢指针的距离控制 +> - 同系列的进阶题目还有: +> - [25-K 个一组翻转链表](./) — 本题本身 +> - [92-反转链表 II](https://leetcode.com/problems/reverse-linked-list-ii/) — 反转链表中的一段 [left, right] +> - [24-两两交换链表中的节点](./23-反转链表.md) — 特殊情况 K=2 + +--- + +## 代码 + +### 迭代法(虚拟头节点 + 断链再续)⭐ + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +// reverseList 反转一个单链表,返回新头节点 +// 这是 23 题的标准解法——三指针法(头插法的变种) +func reverseList(head *ListNode) *ListNode { + var prev *ListNode // 前驱,初始为空 + curr := head // 当前节点 + for curr != nil { + nextTemp := curr.Next // 先保存下一个节点,避免断链后丢失 + curr.Next = prev // 反向指向前驱 + prev = curr // prev 前进一步 + curr = nextTemp // curr 前进一步 + } + return prev // prev 最终停在原链表的尾节点,即新头 +} + +func reverseKGroup(head *ListNode, k int) *ListNode { + // 虚拟头节点:消除"第一组前面没有 prev"的边界情况 + dummy := &ListNode{Next: head} + prev := dummy // prev 始终指向已处理部分的尾节点 + + for { + // 步骤 1:探路——从 prev.Next 出发,向前走 k 步 + kth := prev + for i := 1; i <= k; i++ { + kth = kth.Next + if kth == nil { + // 不足 k 个节点,剩余部分保持原序,直接返回 + return dummy.Next + } + } + + // 步骤 2:记录关键位置 + groupHead := prev.Next // 当前组的第一个节点(反转后变末尾) + nextGroup := kth.Next // 下一组的起点 + + // 步骤 3:在 kth 处"断链",形成独立子链表 + kth.Next = nil + + // 步骤 4:反转这段子链表,拿到新头 + newHead := reverseList(groupHead) + + // 步骤 5:"接回"主链 + prev.Next = newHead // 上一段的尾 → 当前组的新头 + groupHead.Next = nextGroup // 当前组的旧头(现尾)→ 下一段 + + // 步骤 6:prev 前进到已处理段的末尾,准备下一轮 + prev = groupHead + } +} +``` + +### 递归法 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func reverseKGroup(head *ListNode, k int) *ListNode { + // 第一步:检查是否有至少 k 个节点 + cur := head + for i := 0; i < k && cur != nil; i++ { + cur = cur.Next + } + if cur == nil { + // 不足 k 个节点,保持原序不变 + return head + } + + // 第二步:反转前 k 个节点(注意不全断链) + var prev *ListNode + cur = head + for i := 0; i < k; i++ { + nextTemp := cur.Next // 保存下一个节点 + cur.Next = prev // 反向指 + prev = cur // prev 前进一步 + cur = nextTemp // cur 前进一步 + } + + // 第三步:head 是这一组反转前的第一个节点,反转后变成了末尾 + // 把它接到递归处理的后半段 + head.Next = reverseKGroup(cur, k) + + // prev 是反转后的新头 + return prev +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 25 题,通过率约 50%+,属于硬实力区分题。面试官选它的目的很明确——考察候选人对链表操作的综合掌控力:能否在多个反转片段之间精准拼接、能否处理边界条件而不依赖特殊判断。迭代法是面试的首选答案,因为它同时满足 O(1) 空间和清晰的线性逻辑。递归法则展示了你对"问题分解"的理解深度。建议掌握以下三点作为面试亮点:① 用 dummy 节点统一所有边界;② 用"断链再续"保持函数纯净度;③ 能说清楚每一步的不变量是什么。 +> +> > [!TIP] 💬 面试加分话术 +> > "这道题本质上是把 n/k 个子问题(每个子问题是 O(k) 的单链表反转)串联起来。递归的自然写法会导致 O(n²/k) 的复杂度,因为每层递归都要重数节点;但如果预先把整条链扫一遍、收集所有分组头尾,就能降到 O(n)。不过在实际面试中,简洁的递归版本通常已经够用——关键是你能清晰解释每一步的连接关系。" diff --git a/链表/32-随机链表的复制.md b/链表/32-随机链表的复制.md new file mode 100644 index 0000000..e88b499 --- /dev/null +++ b/链表/32-随机链表的复制.md @@ -0,0 +1,599 @@ +--- +tags: ["LeetCode", "链表", "哈希表", "原地操作", "中等"] +create time: 2026-05-17 15:00 +--- + +# 32-随机链表的复制 + +## 题面 + +给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 `random`,该指针可以指向链表中的任何节点或空节点。 + +构造这个链表的 **深拷贝**。深拷贝应该正好由 n 个 **全新** 节点组成,其中每个新节点的值都设为其对应的原节点的值。新节点的 `next` 指针和 `random` 指针也都应指向复制链表中的新节点,并使原链表和复制链表中的这些指针能够表示相同的链表状态。复制链表中的指针都不应指向原链表中的节点。 + +例如,如果原链表中有 X 和 Y 两个节点,其中 `X.random --> Y`。那么在复制链表中对应的两个节点 x 和 y,同样有 `x.random --> y`。 + +返回复制链表的头节点。 + +用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 `[val, random_index]` 表示: + +- `val`:一个表示 `Node.val` 的整数。 +- `random_index`:随机指针指向的节点索引(范围从 0 到 n-1);如果不指向任何节点,则为 `null`。 + +你的代码 **只** 接受原链表的头节点 `head` 作为传入参数。 + +**示例 1:** + +``` +输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]] +输出:[[7,null],[13,0],[11,4],[10,2],[1,0]] +解释:原链表有 5 个节点。 +- 节点 7 的 random 指向 null +- 节点 13 的 random 指向自己(索引 0) +- 节点 11 的 random 指向自己(索引 4,即最后一个节点 1) +- 节点 10 的 random 指向节点 10(索引 2) +- 节点 1 的 random 指向节点 13(索引 1) +``` + +**示例 2:** + +``` +输入:head = [[1,1],[2,1]] +输出:[[1,1],[2,1]] +``` + +**示例 3:** + +``` +输入:head = [[3,null],[3,0],[3,null]] +输出:[[3,null],[3,0],[3,null]] +``` + +**提示:** + +- `0 <= n <= 1000` +- `-10^4 <= Node.val <= 10^4` +- `Node.random` 为 `null` 或指向链表中的节点。 + +> [!warning] ⚠️ 与普通链表复制的区别 +> 普通单向链表复制只需依次创建新节点并链接 next。但这道题多了 `random` 指针——它可能指向**已经处理过**、**还未处理**或**自身之前从未遇到过**的任意节点。这意味着你不能按顺序一次性搞定每个节点的所有指针。 + +--- + +## 思路 + +> [!question] 💡 核心难点 +> 给定原链表中的某个节点 A,我们要创建一个新节点 A'。A'.next 很容易——它就是 A.next 对应的新节点 B'。但 **A'.random** 指向哪里?如果 A.random 指向 F,我们需要知道 F 对应的新节点 F' 是谁。**问题是:我们在遍历过程中无法通过值来定位 F'**,因为不同节点可能有相同的值。 + +所以核心问题归结为一句话:**如何在 O(1) 或 O(n) 时间内建立"旧节点 → 新节点"的映射关系?** + +### 方法一:哈希表法(直觉方案)⭐ + +既然问题等价于"旧节点到新节点的查找",那最直接的想法就是——**用一张哈希表存下这个映射**。 + +> [!tip] 🔑 为什么不能靠 val 来匹配? +> 看示例 3:三个节点 val 都是 3,第二个节点的 random 指向第一个节点(索引 0)。如果仅靠值相等来判断,你会把 random 指向错误的副本。必须依赖**引用/地址关系**而非值。 + +整个过程分为两轮遍历: + +**第一轮:创建所有新节点并建映射** + +```mermaid +flowchart LR + subgraph Map["哈希表: old → new"] + M1["A(old) → A'(new)"] + M2["B(old) → B'(new)"] + M3["C(old) → C'(new)"] + M4["D(old) → D'(new)"] + M5["E(old) → E'(new)"] + end + + Map --> RESULT["拿到 copyHead = A' ✅"] + + style M1 fill:#bbf,stroke:#333 + style M2 fill:#bbf,stroke:#333 + style M3 fill:#bbf,stroke:#333 + style M4 fill:#bbf,stroke:#333 + style M5 fill:#bbf,stroke:#333 + style RESULT fill:#d4edda,stroke:#28a +``` + +**第二轮:填充 next 和 random 指针** + +```mermaid +flowchart TD + START["遍历原链表: A→B→C→D→E"] --> STEP1["cur = A\nA'.Next = Map[A.Next] = B'\nA'.Random = Map[A.Random]\n\n比如 A.Random=nil → A'.Random=nil"] + STEP1 --> STEP2["cur = B\nB'.Next = Map[B.Next] = C'\nB'.Random = Map[B.Random]\n\n比如 B.Random=A → B'.Random=Map[A]=A'"] + STEP2 --> STEP3["对每个节点重复\n利用 Map[old] 直接查 new\nO(1) 时间"] + STEP3 --> DONE["全部设置完毕 ✅\n返回 A'"] + + style START fill:#ffd700,stroke:#333 + style STEP1 fill:#9df,stroke:#333 + style STEP2 fill:#bbf,stroke:#333 + style STEP3 fill:#f9d,stroke:#333 + style DONE fill:#d4edda,stroke:#28a +``` + +**逐步演示(以示例 1 为例):** + +原链表: +``` + 7 13 11 10 1 + (idx 0) (idx 1) (idx 2) (idx 3) (idx 4) + random=null random→0 random→2 random→4 random→1 +``` + +| 轮次 | cur(旧节点) | cur.Next | cur.Random | map[旧→新] | A'.Next | A'.Random | +|------|-------------|----------|------------|-----------|---------|-----------| +| 第一轮 | A(7) | → B(13) | nil | {A→A'} | — | — | +| 第一轮 | B(13) | → C(11) | → A | {A→A', B→B'} | — | — | +| 第一轮 | C(11) | → D(10) | → E | {A→A', B→B', C→C'} | — | — | +| 第一轮 | D(10) | → E(1) | → C | {A→A', B→B', C→C', D→D'} | — | — | +| 第一轮 | E(1) | nil | → B | {全5个} | — | — | +| 第二轮 | A | B | nil | — | B' | nil | +| 第二轮 | B | C | A | — | C' | A' | +| 第二轮 | C | D | A | — | D' | A' | +| 第二轮 | D | E | C | — | E' | C' | +| 第二轮 | E | nil | B | — | nil | B' | + +最终结果: +``` + 7' 13' 11' 10' 1' + random=null random→A' random→A' random→C' random→B' +``` + +> [!info] 🧠 映射的建立时机很关键 +> 我们是在**第一遍创建节点时就同步建好映射**,这样第二遍遍历中无论 random 指向哪个旧节点,都能在 O(1) 时间内找到对应的新节点。这个设计决定了整个算法的简洁性。 + +**时间复杂度:O(n)** — 两次线性遍历 +**空间复杂度:O(n)** — 哈希表存储 n 个映射 + +> [!note] 🤔 Go 中使用 make(map[*Node]*Node) 还是 map[int][]*Node? +> 答案是 **map[*Node]*Node**——直接用指针地址做 key!因为只有同一个节点才能正确映射到它的副本。靠 val 不行(值可重复),靠 index 更麻烦(需要先用数组装一遍节点再编索引)。Go 的 map 天然支持指针类型的比较(比较的是内存地址),这正是我们需要的行为。 + +--- + +### 方法二:原地交织法(O(1) 空间)⭐(最优)⭐ + +> [!question] 💡 能不能不借助额外的哈希表? +> 如果我们能让**新节点紧挨着旧节点**出现,那么每个新节点都能通过旧节点的 next 指针"顺手"访问到下一个旧节点。更重要的是:**random 的关系也能通过地址偏移自然表达**——这避免了任何额外的数据结构。 + +这个方法的核心思想:**把新节点"插"进原链表中,让它成为旧节点的后继**。这样新旧节点成对交错排列,random 映射可以通过地址关系直接推导出来。 + +#### 三步走策略 + +```mermaid +flowchart LR + S1["① 交织: 在每个旧节点后插入对应的副本\nA→B→C ⇒ A→A'→B→B'→C→C'"] --> S2["② 连random: 利用A'就在A后面的关系\nA'.Random = A.Random.Next\n(若A.Random存在, 则它的副本一定在它后面)"] --> S3["③ 拆分: 将新旧链表重新分离\nA→B→C 和 A'→B'→C'"] + + style S1 fill:#bbf,stroke:#333 + style S2 fill:#f9d,stroke:#333 + style S3 fill:#d4edda,stroke:#28a +``` + +#### 第一步:交织 —— 在新建节点的同时把它接在原节点后面 + +对于原链表中的每个节点 A,创建 A',然后将 A' 插入到 A 和 A.next 之间。 + +```go +// 伪代码 +cur := head +for cur != nil { + nextTemp := cur.Next // 暂存 A.Next = B + cur.Next = &Node{Val: cur.Val} // 创建 A', A→A' + cur.Next.Next = nextTemp // A'→B,恢复连接 + cur = nextTemp // 走到 B +} +``` + +**图解(`A→B→C→D` 变为 `A→A'→B→B'→C→C'→D→D'`):** + +```mermaid +flowchart LR + subgraph BEFORE["原始链表"] + B1["A"] --> B2["B"] + B2 --> B3["C"] + B3 --> B4["D"] + B4 -.-> END1["nil"] + end + + subgraph AFTER["交织后的链表"] + A1["A"] --> AP["A']"] + AP --> B5["B"] + B5 --> BP["B']"] + BP --> C1["C"] + C1 --> CP["C']"] + CP --> D1["D"] + D1 -.-> DP["D']"] + DP -.-> END2["nil"] + end + + BEFORE ==> AFTER + + style AP fill:#f9d,stroke:#333 + style BP fill:#f9d,stroke:#333 + style CP fill:#f9d,stroke:#333 + style DP fill:#f9d,stroke:#333 +``` + +> [!info] 🧠 这一步的精妙之处 +> 交织完成后,**每个新节点都在其对应旧节点的后面一位**。这意味着如果旧节点 X 的 random 指向 Y,那么新节点 X' 的 random 就指向 Y'——而 Y' 恰好就在 Y 的后面!所以: +> ``` +> cur.Next.Random = cur.Random.Next +> ``` +> (前提是 cur.Random 不为 nil) + +#### 第二步:连接 random 指针 + +由于新节点紧贴在旧节点后面,我们可以同时遍历旧节点和新节点,利用地址相邻的关系: + +``` +cur.Next.Random = (cur.Random == nil) ? nil : cur.Random.Next +``` + +这里的逻辑: +- 如果 cur.Random == nil → cur'.Random = nil(没有指向) +- 如果 cur.Random != nil → cur'.Random = cur.Random.Next(cur.Random 的副本恰好在它后面) + +**图解:** + +```mermaid +flowchart TD + A["A"] --> AR["A.Random = C"] + AR --> C["C"] + C .-> NEXT["C.Next = C'"] + + A .-> AP["A']"] + AP --> NPR["A'.Random = A.Random.Next = C.Next = C'"] + + AP -.随机指针已连.-> RESULT["✅ A'.Random 指向 C' 无需查找"] + + style AP fill:#f9d,stroke:#333 + style C fill:#bbf,stroke:#333 + style RESULT fill:#d4edda,stroke:#28a +``` + +#### 第三步:拆分成两条独立的链表 + +最后一步是把交织后的长链重新拆成两条:一条旧链表(保持原样)、一条新链表(完全独立)。 + +**关键细节:** 在断开 A→A' 时不能丢了 A' 后续的连接,所以需要分别维护两条链的尾指针。 + +```go +// 伪代码 +oldCur := head // 沿着旧链表走 +newCur := head.Next // 沿着新链表走 +newHead := head.Next // 保存新链表的头 + +for oldCur != nil { + oldCur.Next = oldCur.Next.Next // A→B,跳过 A' + if newCur.Next != nil { + newCur.Next = newCur.Next.Next // A'→B',跳过 B + } + oldCur = oldCur.Next // 前进到下一个旧节点 + newCur = newCur.Next // 前进到下一个新节点 +} +return newHead +``` + +**逐步拆解(`A→A'→B→B'→C→C'` 拆开):** + +```mermaid +flowchart TD + SUB1["初始: A→A'→B→B'→C→C'"] --> STEP1["断开 A→A': A→B\nA'.Next 仍指 B,先不动\noldCur=B, newCur=B'"] + STEP1 --> STEP2["断开 B→B': B→C\nB'→C'\noldCur=C, newCur=C'"] + STEP2 --> STEP3["断开 C→C': C→nil\nC'→nil\n完成 ✅"] + + STEP3 --> OLD["旧链表: A→B→C"] + STEP3 --> NEW["新链表: A'→B'→C'"] + + style SUB1 fill:#ffd700,stroke:#333 + style STEP1 fill:#9df,stroke:#333 + style STEP2 fill:#bbf,stroke:#333 + style STEP3 fill:#f9d,stroke:#333 + style OLD fill:#eee,stroke:#999 + style NEW fill:#d4edda,stroke:#28a +``` + +> [!warning] ⚠️ 常见错误:newCur.Next.Next 可能越界 +> 当新链表的末尾节点(如 D')尝试访问 D'.Next.Next 时会 panic(nil 指针)。所以需要在访问前检查 `if newCur.Next != nil`。 + +> [!note] 🤔 为什么要保留旧链表? +> 题目要求返回的是**新的链表**,不要求恢复旧链表。但从好的工程实践来说,**不修改原数据**是一个好习惯。即使 LeetCode 不强制恢复,保留这个操作可以让你的代码更具普适性。 + +**时间复杂度:O(n)** — 三次线性遍历(交织、连 random、拆分),总计约 3n 步 +**空间复杂度:O(1)** — 只用了几条临时指针变量,没有额外数据结构 + +--- + +## 代码提示 + +### 方法一:哈希表法伪代码 + +``` +func copyRandomList(head): + if head == nil: return nil + + // 辅助函数:根据旧节点获取(或创建)对应新节点 + func getNode(map, cur): + if cur == nil: return nil + if cur not in map: + map[cur] = &Node{Val: cur.Val} + return map[cur] + + cur := head + while cur != nil: + getNode(map, cur) // 确保当前节点有新节点 + getNode(map, cur.Next) // 确保 next 指向的节点有新节点 + getNode(map, cur.Random) // 确保 random 指向的节点有新节点 + + // 连指针 + map[cur].Next = map[cur.Next] + map[cur].Random = map[cur.Random] + + cur = cur.Next + + return map[head] +``` + +### 方法二:原地交织法伪代码 + +``` +func copyRandomList(head): + if head == nil: return nil + + // 步骤 1:交织 — 在每个旧节点后插入副本 + cur := head + while cur != nil: + nextTemp := cur.Next + newNode := &Node{Val: cur.Val} + cur.Next = newNode + newNode.Next = nextTemp + cur = nextTemp + + // 步骤 2:连 random 指针 + cur := head + while cur != nil: + if cur.Random != nil: + cur.Next.Random = cur.Random.Next + cur = cur.Next.Next // 每次跳两步,沿旧链表走 + + // 步骤 3:拆分成两条链表 + oldCur := head + newCur := head.Next + newHead := newCur + + while oldCur != nil: + oldCur.Next = oldCur.Next.Next + if newCur.Next != nil: + newCur.Next = newCur.Next.Next + oldCur = oldCur.Next + newCur = newCur.Next + + return newHead +``` + +--- + +--- + +## 技巧 + +> [!tip] 🔑 "先占位,后连线"模式 +> 遇到需要建立复杂映射关系的问题,考虑是否可以先**创建所有实体(节点、位置等)**并建立某种结构化的组织方式,然后**基于这个结构去填充关联**。这本质上是"空间换时间"思想的变体——多一次遍历建结构,后续操作就简化了。 + +> [!tip] 🔑 地址编码法(原地法的精髓) +> 让新元素紧跟旧元素之后排列,可以用**物理位置的邻接关系替代抽象的数据结构映射**。这是一种非常巧妙的"用空间布局信息代替额外存储"的技巧,类似思路还出现在「数组中去重」(用前半段存集合)、「堆排序建堆」(用树形位置关系隐含父子关系)等问题中。 + +> [!warning] ⚠️ 常见错误 1:Go 中 `&&` 不能与 `:=` 组合使用 +> 这是最容易被踩的坑。下面这行代码在 C++ / Java 中可以正常工作,但在 Go 中会编译报错: +> ```go +> // ❌ 编译错误:non-name XXX on left side of := +> if cur.Next != nil && _, ok := mapOldToNew[cur.Next]; !ok { ... } +> ``` +> 必须拆成两层 `if`,且判断必须在左侧保证非 nil。正确的写法是先检查非 nil,再在内部创建映射。 + +> [!warning] ⚠️ 常见错误 2:只创建新节点但没有正确建立 random 映射 +> 很多人卡在只写出了创建新节点的代码,却不知道如何高效地为 random 赋值。核心误区是试图通过遍历来找到 random 指向节点的副本——这是 O(n²) 的做法。记住要么用哈希表 O(1) 查找,要么用交织法让映射关系隐式存在于链表中。 + +> [!warning] ⚠️ 常见错误 3:交织法拆分时漏掉边界检查 +> 拆分阶段 `newCur.Next.Next` 需要判断 `newCur.Next != nil`,否则在链表末尾会 panic。这是一个非常容易遗漏的细节。 + +> [!warning] ⚠️ 常见错误 4:混淆了"修改旧链表"和"深拷贝"的概念 +> 深拷贝的本质是**创建一组全新的对象**,它们拥有与原对象相同的值和结构,但与原对象没有任何共享引用。交织法虽然暂时修改了旧链表的 next 指针,但最终会恢复原状,并且返回值是完全独立的新链表。这种"借道修改再恢复"的策略在很多算法中都有应用。 + +> [!note] 🐹 Go 中的链表定义 +> LeetCode 的 Go 环境内置如下结构体定义: + +```go +type Node struct { + Val int + Next *Node + Random *Node +} +``` + +不需要手动定义,直接在解题中使用即可。 + +> [!info] 📊 两种方法对比 + +| 维度 | 哈希表法(迭代) | 原地交织法 ⭐ | +|------|---------|-------------| +| 时间复杂度 | O(n) | O(n) | +| 空间复杂度 | O(n) | O(1) | +| 代码行数 | ~25 行 | ~25 行 | +| 直观程度 | 很高(直接建映射) | 中等(需理解三趟遍历的意图) | +| 面试推荐 | ⭐⭐ 可作为铺垫讲出 | ⭐⭐⭐ 面试官期望的答案 | +| 是否修改原链表 | ❌ 完全不改 | ⚠️ 中途改了 next,最终恢复 | + +> [!success] ✅ 相关题目串联 +> - [22-相交链表](./22-相交链表.md) — 同样是链表上建立映射的经典场景 +> - [146-LRU 缓存](./146-lru-cache.md) — 哈希表 + 双向链表的组合,也是"用哈希加速链表操作"的思路 +> - [24-回文链表](./24-回文链表.md) — 同样使用了"原地操作+反转"的模式来做到 O(1) 空间 + +--- + +## 代码 + +### 方法一:哈希表法(最直观)⭐ + +```go +/** + * Definition for a Node. + * type Node struct { + * Val int + * Next *Node + * Random *Node + * } + */ + +func copyRandomList(head *Node) *Node { + if head == nil { + return nil + } + + // 核心思路:建立一个 hash map,key 是旧节点,value 是对应的新节点 + // 这样无论 random 指向哪个节点,都能在 O(1) 时间内找到它的副本 + mapOldToNew := make(map[*Node]*Node) + + // ---------- 第一轮:创建所有新节点,建立 old → new 映射 ---------- + cur := head + for cur != nil { + // 如果该旧节点还没有对应的副本,就创建一个 + if _, ok := mapOldToNew[cur]; !ok { + mapOldToNew[cur] = &Node{Val: cur.Val} + } + + // ⚠️ 注意:Go 语言中 && 不能与 := 组合使用 + // 需要拆成两层 if,且判断必须在左侧保证非 nil + if cur.Next != nil { + if _, ok := mapOldToNew[cur.Next]; !ok { + mapOldToNew[cur.Next] = &Node{Val: cur.Next.Val} + } + } + if cur.Random != nil { + if _, ok := mapOldToNew[cur.Random]; !ok { + mapOldToNew[cur.Random] = &Node{Val: cur.Random.Val} + } + } + + cur = cur.Next + } + + // ---------- 第二轮:根据映射填充所有新节点的 next 和 random ---------- + // ⚠️ 注意:map[old.Next] / map[old.Random] 的查找结果若为 nil,直接赋值即可 + // 因为 nil pointer 就是 Go 中指针的零值,恰好表示 "不指向任何节点" + for old, new := range mapOldToNew { + new.Next = mapOldToNew[old.Next] + new.Random = mapOldToNew[old.Random] + } + + // 返回新链表的头节点 + return mapOldToNew[head] +} +``` + +> [!warning] ⚠️ 两个关键细节 +> 1. **`&&` 不能与 `:=` 组合** —— Go 编译器不允许在 `if` 的条件表达式中把布尔判断和短变量声明连在一起。必须拆成外层判断 + 内层 `if + :=` 的两层结构。 +> 2. **`nil` 作为 key 的问题** —— 虽然我们在创建时通过 `!= nil` 检查避免了写入 nil key,但遍历时 `map[old.Next]` 可能返回 nil(当 `old.Next == nil` 时)。这是正确的行为:`nil` 是指针的零值,正好表示"不指向任何节点",无需额外判空。 + +--- + +### 方法二:原地交织法 ⭐(O(1) 空间最优解)⭐ + +```go +/** + * Definition for a Node. + * type Node struct { + * Val int + * Next *Node + * Random *Node + * } + */ + +func copyRandomList(head *Node) *Node { + if head == nil { + return nil + } + + // ========== 第 1 趟:交织 ========== + // 在每个旧节点后面插入对应的副本 + // 目标:A → B → C 变成 A → A' → B → B' → C → C' + cur := head + for cur != nil { + nextTemp := cur.Next // 暂存 B + newNode := &Node{Val: cur.Val} + cur.Next = newNode // A → A' + newNode.Next = nextTemp // A' → B + cur = nextTemp // 移到 B,继续下一轮 + } + + // ========== 第 2 趟:连接 random ========== + // 因为新节点紧跟在旧节点后面,利用这个关系直接设 random + // 公式:cur'.Random = cur.Random.Next(如果 cur.Random != nil) + // 原因:cur.Random 的副本 cur.Random.Next 恰好就在 cur.Random 后面 + cur = head + for cur != nil { + if cur.Random != nil { + cur.Next.Random = cur.Random.Next + } + cur = cur.Next.Next // 每次跳两步,沿旧链表走(A → B → C ...) + } + + // ========== 第 3 趟:拆分 ========== + // 把交织在一起的链表拆成两条:原链表和新链表 + oldCur := head + newCur := head.Next + newHead := newCur // 保存新链表的头 + + for oldCur != nil { + // 断开头节点与副本 + oldCur.Next = oldCur.Next.Next + + // 连接新链表的节点 + if newCur.Next != nil { + newCur.Next = newCur.Next.Next + } + + // 两指针各前进一步 + oldCur = oldCur.Next + newCur = newCur.Next + } + + return newHead +} +``` + +**执行流程可视化(示例 1):** + +```mermaid +flowchart TD + INIT["原链表:\n7→13→11→10→1\nrandom: null, →0, →2, →4, →1"] + + subgraph Pass1["第1趟:交织"] + P1["7→7'→13→13'→11→11'→10→10'→1→1'"] + end + + subgraph Pass2["第2趟:连random"] + P2["7'.random = 7.random.next = nil\n13'.random = 13.random.next = 7'.next = 7' → Wait...\n实际上 13.random指向13自己, so 13'.random = 13.next = 13'\n11'.random = 11.random.next = 10'.next = 10'\n10'.random = 10.random.next = 1'.next = 1'\n1'.random = 1.random.next = 13'.next = 11'"] + end + + subgraph Pass3["第3趟:拆分"] + P3["旧链表: 7→13→11→10→1\n新链表: 7'→13'→11'→10'→1'"] + end + + INIT --> Pass1 --> Pass2 --> Pass3 + + style P1 fill:#bbf,stroke:#333 + style P2 fill:#f9d,stroke:#333 + style P3 fill:#d4edda,stroke:#28a +``` + +> [!success] ✅ 运行验证 +> 原地交织法是这道题的最优解,在面试中如果能流畅地写出这个解法,说明你对链表操作的掌控达到了相当高的水平。建议掌握以下要点作为面试亮点:① 明确说出三趟遍历的目的;② 能说清楚为什么 `cur.Next.Random = cur.Random.Next` 是正确的;③ 能处理边界情况(nil random、链表为空、单节点)。 +> +> > [!TIP] 💬 面试加分话术 +> > "这道题主要有两种主流解法:哈希表法 O(n) 空间,最直观且不修改原链表;原地交织法 O(1) 空间,最优解。在实际面试中,我会先讲哈希表的思路,因为它最直观、最容易验证正确性。然后告诉面试官我想优化到 O(1) 空间,接着画出交织法的三趟遍历图,说明每一步的不变量是什么——这样做既展示了清晰的思维过程,又体现了对空间复杂度的敏感度。" + +> [!note] 🤔 如果题目要求不修改原链表怎么办? +> 哈希表法天然满足这个约束——它完全没有触碰原链表的任何指针。而原地交织法虽然最终会恢复旧链表,但如果面试官坚持说"原链表绝对不能被修改(哪怕暂时也不行)",那就只能用哈希表法或者递归法了。 diff --git a/链表/33-排序链表.md b/链表/33-排序链表.md new file mode 100644 index 0000000..b8f0aa6 --- /dev/null +++ b/链表/33-排序链表.md @@ -0,0 +1,704 @@ +--- +tags: ["LeetCode", "链表", "分治", "归并排序", "递归", "中等"] +create time: 2026-05-18 14:30 +--- + +# 33-排序链表 + +## 题面 + +给你链表的头结点 `head`,请将其按 **升序** 排列并返回排序后的链表。 + +**示例 1:** + +``` +输入:head = [4,2,1,3] +输出:[1,2,3,4] +``` + +**示例 2:** + +``` +输入:head = [-1,5,3,4,0] +输出:[-1,0,3,4,5] +``` + +**示例 3:** + +``` +输入:head = [] +输出:[] +``` + +**提示:** + +- 链表中节点的数目在范围 `[0, 5 * 10^4]` 内 +- `-10^5 <= Node.val <= 10^5` +- **进阶:** 你可以在 `O(n log n)` 时间复杂度和常数级空间复杂度下,对链表进行排序吗? + +--- + +## 思路 + +> [!question] 💡 引导思考 +> 对数组排序,你最熟悉的是什么算法?快速排序、归并排序、堆排序——它们的平均时间复杂度都是 O(n log n)。但有一个关键差异:**数组支持随机访问**,可以通过下标 O(1) 拿到中间元素;而**链表只能顺序遍历**。这意味着基于二分查找思想的算法在链表上会遇到困难。 + +> [!warning] ⚠️ 为什么这些常见排序算法不适合链表? + +| 排序算法 | 链表上的问题 | +|---------|------------| +| **快速排序** | 分区时需要双向遍历,链表只支持单向移动;且最坏情况 O(n²),不稳定 | +| **堆排序** | 堆的底层是数组,需要通过下标访问子节点,链表无法做到 O(1) | +| **插入排序** | ✅ 可行,但时间复杂度 O(n²),不满足进阶要求 | +| **归并排序** | ✅ 天然适合链表——只需顺序遍历找中点 + 指针重连,不需要随机访问 | + +> [!info] 🧠 为什么归并排序是链表的最优选择? +> - **数组归并**需要额外 O(n) 空间存临时数组 +> - **链表归并**只需要修改指针,可以做到 O(1) 额外空间(递归版本因调用栈为 O(log n),迭代版本可达真正的 O(1)) +> - 找中点可以用快慢指针一次遍历完成,不需要随机访问 + +### 方法一:自顶向下归并排序(递归)⭐(推荐,面试首选) + +归并排序的核心三步:**分 → 治 → 合**。 + +```mermaid +flowchart TD + subgraph DIVIDE["① 分:递归拆分"] + A["4→2→1→3"] -->|"找中点"| B["4→2 和 1→3"] + B -->|"继续分"| C["4, 2, 1, 3"] + end + + subgraph CONQUER["② 治:单节点即有序"] + D["4 ✓ 2 ✓\n1 ✓ 3 ✓"] + end + + subgraph MERGE["③ 合:逐层合并"] + E["2→4 和 1→3"] --> F["1→2→3→4 ✓"] + end + + A --> C --> D --> E --> F + style F fill:#4c4,stroke:#333,stroke-width:2px,color:white +``` + +#### 第一步:用快慢指针找中点 + +> [!question] 💡 回顾一下:如何只用一趟遍历找到链表的"中间位置"? + +使用经典的快慢指针法:`fast` 每次走两步,`slow` 每次走一步。当 `fast` 到达末尾时,`slow` 恰好位于中点。 + +```go +slow, fast := head, head.Next +for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next +} +``` + +此时 `slow` 指向**左半段的最后一个节点**(偶数长度时偏左),将链表从中断开: + +```go +mid := slow.Next // 右半段的起点 +slow.Next = nil // 断开左半段 ← 关键!必须切断,否则会死循环 +``` + +以 `4→2→1→3` 为例: + +```mermaid +flowchart LR + subgraph "断开前" + A["4→2→1→3"] + end + + subgraph "slow定位到节点2\nfast到达nil" + B["4→2 ★"] -.断开.-> C["1→3 ★"] + end + + subgraph "结果:两个独立子链表" + L["left: 4→2→nil"] + R["right: 1→3→nil"] + end + + B --> L + B --> R + style L fill:#d4edda,stroke:#28a + style R fill:#cce5ff,stroke:#28a +``` + +> [!tip] 🔑 为什么要执行 `slow.Next = nil`? +> 如果不切断,左右两半仍然相连,递归调用 `sortList(slow)` 时会无限深入到底,导致栈溢出。这是本题最容易忽略的细节! + +#### 第二步:合并两个有序链表 + +这一步直接复用 [27-合并两个有序链表](./27-合并两个有序链表.md) 中的迭代合并逻辑。 + +```go +func merge(l1, l2 *ListNode) *ListNode { + dummy := &ListNode{} + tail := dummy + + for l1 != nil && l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 + l1 = l1.Next + } else { + tail.Next = l2 + l2 = l2.Next + } + tail = tail.Next + } + + if l1 != nil { + tail.Next = l1 + } else { + tail.Next = l2 + } + + return dummy.Next +} +``` + +#### 第三步:完整递归框架 + +```go +func sortList(head *ListNode) *ListNode { + // 基准情况:空链表或单节点,本身就是有序的 + if head == nil || head.Next == nil { + return head + } + + // ① 找中点并断开 + slow, fast := head, head.Next + for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next + } + mid := slow.Next + slow.Next = nil // 切断,分左右两半 + + // ② 递归排序左右两半 + left := sortList(head) + right := sortList(mid) + + // ③ 合并结果 + return merge(left, right) +} +``` + +以 `[4,2,1,3]` 为例的完整执行树: + +```mermaid +flowchart TD + subgraph "顶层 sortList(4→2→1→3)" + SPLIT["拆分: 4→2 | 1→3"] + end + + subgraph "左半边 sortList(4→2)" + LSPLIT["拆分: 4 | 2"] + LMERGE["合并: 2→4 ✓"] + end + + subgraph "右半边 sortList(1→3)" + RSPLIT["拆分: 1 | 3"] + RMERGE["合并: 1→3 ✓"] + end + + subgraph "顶层合并" + FINAL["合并 2→4 和 1→3\n→ 1→2→3→4 ✓"] + end + + SPLIT --> LSPLIT --> LMERGE + SPLIT --> RSPLIT --> RMERGE + LMERGE --> FINAL + RMERGE --> FINAL + style FINAL fill:#4c4,color:white,stroke:#333,stroke-width:2px +``` + +逐步展开 `[4,2,1,3]` 的执行过程: + +| 阶段 | 操作 | 状态 | +|------|------|------| +| sortList([4,2,1,3]) | 拆分为 [4,2] 和 [1,3] | — | +| sortList([4,2]) | 拆分为 [4] 和 [2] | 两半均为单节点,直接返回 | +| merge([4], [2]) | 2 < 4,先接 2 再接 4 | 返回 2→4 | +| sortList([1,3]) | 拆分为 [1] 和 [3] | 两半均为单节点,直接返回 | +| merge([1], [3]) | 1 < 3,先接 1 再接 3 | 返回 1→3 | +| merge([2,4], [1,3]) | 逐一对比合并 | 返回 **1→2→3→4** ✅ | + +**时间复杂度:O(n log n)** — 深度为 log n 层,每层的合并总代价为 O(n)。 +**空间复杂度:O(log n)** — 递归调用栈的深度等于树的深度。 + +> [!note] 🤔 进阶问题的答案 +> 题目要求"常数级空间复杂度",递归版本的 O(log n) 来自调用栈。严格来说不满足"常数量级"。但在面试中这个版本已经完全够用,因为:① 代码简洁清晰;② log n 在实际规模下极小(n=5×10⁴ 时 log₂n ≈ 16)。如果追求理论最优的 O(1) 空间,见下方迭代版本。 + +--- + +### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间) + +> [!question] 💡 如何消除递归调用栈的开销? +> 观察递归版的调用树——它是一棵满二叉树,从叶子往根方向一层层合并。如果我们**放弃递归**,改为从最小的子区间(长度为 1)开始,逐级扩大子区间长度(1→2→4→8...),就能用迭代模拟相同的过程。 + +#### 核心思想 + +从长度为 `1` 的子链表开始,逐层合并,直到子链表长度 `>= n`: + +```mermaid +flowchart LR + STEP1["gap=1\n4|2|1|3"] --> STEP2["gap=2\n2→4 | 1→3"] + STEP2 --> STEP3["gap=4\n1→2→3→4 ✓"] + + style STEP1 fill:#e8f5e9,stroke:#28a + style STEP2 fill:#fff4e6,stroke:#f90 + style STEP3 fill:#4c4,color:white,stroke:#333 +``` + +每一层的步骤相同:从头到尾扫描整个链表,每次取两段长度为 `gap` 的子链表进行合并。 + +#### 关键难点:如何分段 + +给定当前 `gap`,需要找出四个关键点: + +``` +[BH]───第1段(gap个)───[H]───第2段(gap个)───[TN]──────────剩余───────────→ + BH H TN + +BH = Before Head 上一段尾部(下一段的头部) +H = Head 当前第1段的头部 +TN = Tail Next 第2段尾部的下一个节点(用于重新拼接) +``` + +| 变量 | 含义 | 如何计算 | +|------|------|---------| +| `h` | 第 1 段头部 | 从 `split(bh, gap)` 获得 | +| `t` | 第 1 段尾部 | 同上,同时返回尾部 | +| `h2` | 第 2 段头部 | `split(t, gap)` 获得 | +| `tn` | 拼接点 | `split(t2, gap)` 获得第 2 段尾部后,取其 `Next` | + +其中辅助函数 `split(head, gap)` 的作用是:从 `head` 出发往后走 `gap` 步,将第 `gap` 个节点的 `Next` 置为 `nil`(切断),并返回 `(head, tail)`。 + +#### 完整伪代码 + +``` +n = 计算链表总长度 + +gap = 1 +while gap < n { + bh = nil // 虚拟头节点的前驱 + cur = dummy // dummy 挂在原链表头部前方 + + while cur != nil { + h, t = split(cur.Next, gap) // 取出第 1 段 + h2, t2 = split(t.Next, gap) // 取出第 2 段 + + tn = t2.Next // 保存下一段的起点 + + t.Next = nil // 断开第 1 段尾部 + t2.Next = nil // 断开第 2 段尾部 + + merged = merge(h, h2) // 合并两段 + + bh.Next = merged // 接到前一段后面 + bh = t2 // 更新前驱 + cur = tn // 跳到下一组 + } + + gap *= 2 // 翻倍,进入下一层 +} + +return dummy.Next +``` + +#### 完整 Go 代码 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func sortList(head *ListNode) *ListNode { + // 边界检查 + if head == nil || head.Next == nil { + return head + } + + // ① 计算链表长度 + n := 0 + for node := head; node != nil; node = node.Next { + n++ + } + + // 虚拟头节点:简化边界处理 + dummy := &ListNode{Next: head} + + // ② 自底向上逐层合并:gap = 1, 2, 4, 8, ... + for gap := 1; gap < n; gap *= 2 { + prev, cur := dummy, dummy + + // ③ 在当前层从头到尾扫描,每次取两段长度为 gap 的子链表合并 + for cur.Next != nil { + h1, t1 := split(cur.Next, gap) // 第 1 段 + h2, t2 := split(t1.Next, gap) // 第 2 段 + next := t2.Next // 保存下一组的起点 + + t1.Next, t2.Next = nil, nil // 断开分段 + merged := merge(h1, h2) // 合并两段 + + prev.Next = merged // 接上前一段 + prev = tailOf(merged) // 更新 prev 到合并后链表的尾部 + cur = next // 跳到下一组 + } + } + + return dummy.Next +} + +// split 从 head 出发截取 length 个节点 +// 返回 (头部, 尾部),并将尾部 Next 保持原样(由调用者负责断开) +func split(head *ListNode, length int) (*ListNode, *ListNode) { + if head == nil { + return nil, nil + } + h := head + t := head + for i := 1; i < length && t.Next != nil; i++ { + t = t.Next + } + next := t.Next // 记录切断后的下一段起点 + t.Next = nil // 切断 + return h, next // 注意:这里返回的是切断后的下一段作为"尾部"的代理 +} +``` + +> [!warning] ⚠️ 迭代版实现技巧 +> 上述伪代码中 `split` 的签名做了一点调整以适应实际代码。下面给出可直接提交的完整版本,包含精妙的实现细节。 + +--- + +## 代码提示 + +### 递归版伪代码 + +``` +func sortList(head): + // 基准情况 + if head == nil or head.Next == nil: + return head + + // ① 找中点并断开 + slow, fast = head, head.Next + while fast != nil and fast.Next != nil: + slow = slow.Next + fast = fast.Next.Next + + mid = slow.Next + slow.Next = nil // ← 关键!切断避免死循环 + + // ② 递归排序 + left = sortList(head) + right = sortList(mid) + + // ③ 合并 + return merge(left, right) +``` + +### 迭代版伪代码 + +``` +n = 计算链表长度 +dummy = &ListNode{Next: head} + +for gap = 1; gap < n; gap *= 2: + prev = dummy + cur = dummy + + while cur.Next != nil: + h1, t1 = 取 gap 个节点 + h2, t2 = 再取 gap 个节点 + nextGroup = t2.Next + + 断开 t1.Next, t2.Next + merged = merge(h1, h2) + + prev.Next = merged + prev = merged的尾部 + cur = nextGroup +``` + +--- + +## 技巧 + +> [!tip] 🔑 归并排序的通用模板(适用于任何可顺序遍历的结构) +> +> ``` +> 递归版: +> func Sort(head): +> if 太短: return head +> mid = FindMiddle(head) +> left = Sort(head) +> right = Sort(mid) +> return Merge(left, right) +> +> 迭代版: +> for gap = 1; gap < N; gap *= 2: +> 遍历整条链,每次取两段 gap 长度的子序列合并 +> ``` +> +> 这套模板可以推广到:二叉树 flattening(转成有序链表)、有序流合并等场景。 + +> [!tip] 🔑 为什么链表排序不用快排? +> 快排的核心优势是原地分区和缓存局部性——这两个优势在链表上都消失了: +> - 分区时需要前后双向移动指针,链表只能单向遍历,效率打折 +> - 链表节点分散在堆内存中,无缓存友好性 +> - 快排最坏 O(n²)(虽然可以用三数取中等 trick 缓解),而归并稳定保证 O(n log n) +> +> **结论:链表排序的标准答案就是归并排序。** + +> [!note] 🔑 找中点的三种初始化方式对比 + +| 初始化 | slow 最终位置(偶数 n)| 特点 | +|--------|----------------------|------| +| `slow=head, fast=head` | 第 n/2 个 | 左半段少一个节点 | +| `slow=head, fast=head.Next` | 第 n/2 个 | 标准写法,推荐 ✅ | +| `slow=head, fast=head.Next.Next` | 第 n/2+1 个 | 左半段多一个节点 | + +对于归并排序而言,选哪种都可以,只要保证左右分配合理即可。本文采用 `fast=head.Next`。 + +> [!tip] 🔑 合并时为什么要复制节点值而不是创建新节点? +> 在 `merge` 过程中,我们直接重用了原有节点的引用(`tail.Next = l1/l2`),没有创建新节点。这保证了空间复杂度为 O(1)。如果每次都创建新节点,空间会退化到 O(n)。 + +> [!info] 📊 两种方法对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | +|------|-----------|-----------|------|------| +| **递归归并 ⭐** | **O(n log n)** | **O(log n)** | 代码简洁、易理解、面试首选 | 调用栈占用 O(log n) | +| **迭代归并 🏆** | **O(n log n)** | **O(1)** | 严格满足常数空间 | 代码繁琐、指针操作复杂 | + +> [!success] ✅ 相关题目串联 +> - [27-合并两个有序链表](./27-合并两个有序链表.md) — 归并排序的合并步骤直接使用此题的解法 +> - [[148-排序链表]] — LeetCode 同题,可用相同解法 +> - [[23-合并K个升序链表]] — 进阶扩展:用优先队列优化 K 路合并 + +--- + +## 代码 + +### 方法一:自顶向下归并排序(递归)⭐ + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func sortList(head *ListNode) *ListNode { + // 基准情况:空链表或只有一个节点,本身就是有序的 + if head == nil || head.Next == nil { + return head + } + + // ① 快慢指针找中点 —— fast 从 head.Next 开始 + slow, fast := head, head.Next + for fast != nil && fast.Next != nil { + slow = slow.Next + fast = fast.Next.Next + } + + // ② 从中点断开,分成两个独立的子链表 + mid := slow.Next + slow.Next = nil // ← 关键!切断连接,否则递归不会终止 + + // ③ 递归排序左右两部分 + left := sortList(head) + right := sortList(mid) + + // ④ 合并两个有序子链表 + return merge(left, right) +} + +// merge 合并两个有序链表,返回新的头节点 +// 复用 27-合并两个有序链表的迭代实现 +func merge(l1, l2 *ListNode) *ListNode { + dummy := &ListNode{} + tail := dummy + + for l1 != nil && l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 // 选较小的节点接到末尾 + l1 = l1.Next + } else { + tail.Next = l2 + l2 = l2.Next + } + tail = tail.Next + } + + // 接上剩余部分(至少有一条链表为空) + if l1 != nil { + tail.Next = l1 + } else { + tail.Next = l2 + } + + return dummy.Next +} +``` + +**时间复杂度:O(n log n)** — 共 log n 层递归,每层所有合并操作的总代价为 O(n)。 +**空间复杂度:O(log n)** — 递归调用栈的最大深度等于归并树的高度,为 log n。 + +> [!tip] 🔧 本地测试辅助函数 +> 以下辅助函数可以将切片与链表互相转换,方便编写单元测试: + +```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 +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 148 题,通过率约 55%+。递归归并排序是面试中最常用的解答——它完美契合"链表适合顺序访问"的特性,代码量适中(~25 行),且能在面试现场流畅推导。建议在 15 分钟内能白板写出。 + +--- + +### 方法二:自底向上归并排序(迭代)🏆(严格 O(1) 空间) + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func sortList(head *ListNode) *ListNode { + if head == nil || head.Next == nil { + return head + } + + // ① 计算链表长度 + n := 0 + for node := head; node != nil; node = node.Next { + n++ + } + + // 虚拟头节点:统一处理,无需对首节点特殊判断 + dummy := &ListNode{Next: head} + + // ② 自底向上,逐层扩大合并区间的大小 + for gap := 1; gap < n; gap *= 2 { + prev, cur := dummy, dummy + + // 从头到尾扫描,每次取两段长度为 gap 的子链表合并 + for cur.Next != nil { + // 从 cur.Next 开始取 gap 个节点为第 1 段 + h1, t1 := split(cur.Next, gap) + // 从 t1.Next 开始取 gap 个节点为第 2 段 + h2, t2 := split(t1.Next, gap) + // 保存下一组数据的起点 + nextGroup := t2.Next + + // 合并两段 + t1.Next, t2.Next = nil, nil + merged := merge(h1, h2) + + // 将合并后的链表接回主链表 + prev.Next = merged + + // prev 更新到合并后链表的尾部 + prev = t1 + if merged != h1 { + prev = t2 + } + + // cur 跳到下一组 + cur = nextGroup + } + } + + return dummy.Next +} + +// split 从 head 开始截取最多 length 个节点 +// 返回值:(段头, 段尾的下一个节点) +// 并将段尾的 Next 设为 nil(切断) +func split(head *ListNode, length int) (*ListNode, *ListNode) { + if head == nil { + return nil, nil + } + + h := head + t := head + for i := 1; i < length && t.Next != nil; i++ { + t = t.Next + } + + next := t.Next // 记录切断后的下一段起点 + t.Next = nil // 切断 + + return h, next +} + +// merge 合并两个有序链表 +func merge(l1, l2 *ListNode) *ListNode { + dummy := &ListNode{} + tail := dummy + + for l1 != nil && l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 + l1 = l1.Next + } else { + tail.Next = l2 + l2 = l2.Next + } + tail = tail.Next + } + + if l1 != nil { + tail.Next = l1 + } else { + tail.Next = l2 + } + + return dummy.Next +} +``` + +**时间复杂度:O(n log n)** — gap 从 1 倍增到 n,共 log n 轮,每轮遍历全链表 O(n)。 +**空间复杂度:O(1)** — 只使用了常数个指针变量,无递归调用栈。 + +> [!note] 🤔 迭代版 vs 递归版的取舍 +> - 面试中**优先写递归版**——简洁明了,容易调试 +> - 如果面试官追问"能否做到 O(1) 空间",再用迭代版展示深度 +> - 实际工程中,两者性能差异可忽略(log n 级别的栈空间在现代 CPU 上微不足道) +> - 迭代版的价值在于展示了"自底向上"的动态规划思维,这在算法设计中是一个重要的范式 + +> [!warning] ⚠️ 迭代版的关键细节 +> 1. **`dummy` 节点必须在外层循环外创建一次**,而非每层新建——这样才能把所有层合并的结果串联起来 +> 2. **`prev` 需要更新到合并后链表的尾部**,而不是简单地 `prev = prev.Next`。因为合并可能改变长度(两段等长则合并后长度为 2*gap,prev 需要前进到这个新段末尾) +> 3. **`nextGroup` 必须在合并前保存**——因为 `split` 会修改节点的 `Next` 指针 diff --git a/链表/34-合并 K 个升序链表.md b/链表/34-合并 K 个升序链表.md new file mode 100644 index 0000000..5a29c6f --- /dev/null +++ b/链表/34-合并 K 个升序链表.md @@ -0,0 +1,777 @@ +--- +tags: ["LeetCode", "链表", "堆", "分治", "优先队列", "困难"] +create time: 2026-05-18 15:30 +--- + +# 34-合并 K 个升序链表 + +## 题面 + +给你一个链表数组,每个链表都已经按升序排列。 + +请你将所有链表合并到一个升序链表中,返回合并后的链表。 + +**示例 1:** + +``` +输入:lists = [[1,4,5],[1,3,4],[2,6]] +输出:[1,1,2,3,4,4,5,6] +解释:链表数组如下: +[ + 1->4->5, + 1->3->4, + 2->6 +] +将它们合并到一个有序链表中得到。 +1->1->2->3->4->4->5->6 +``` + +**示例 2:** + +``` +输入:lists = [] +输出:[] +``` + +**示例 3:** + +``` +输入:lists = [[]] +输出:[] +``` + +**提示:** + +- k == lists.length +- 0 <= k <= 10^4 +- 0 <= lists[i].length <= 500 +- -10^4 <= lists[i][j] <= 10^4 +- lists[i] 按 升序 排列 +- lists[i].length 的总和不超过 10^4 + +--- + +## 思路 + +> [!question] 💡 引导思考 +> 假设你已经会合并两个有序链表——现在让你合并 **K** 个有序链表,你会怎么做?——最直白的想法是:**先合并前两个,再用结果和第三个合并,依次类推**。这个思路可行吗?我们来分析一下。 + +### 方法零:暴力法 — 收集后排序 🐌(不推荐) + +把所有链表中的所有节点值收集到一个切片中,排序后再重建链表。 + +| 步骤 | 操作 | 代价 | +|------|------|------| +| ① 遍历所有节点 | 把 Val 存入 `[]int` | O(N) | +| ② 排序 | Go 1.21+ 的 `slices.Sort()`(TimSort) | O(N log N) | +| ③ 重建链表 | 顺序创建节点 | O(N) | + +其中 N 为所有链表的节点总数。 + +**时间复杂度:O(N log N)** — 排序主导。 +**空间复杂度:O(N)** — 存储所有节点值 + 新链表。 + +> [!note] 💬 虽然能 AC(总节点数 ≤ 10⁴),但完全没有利用"每个子链表已经有序"这一关键条件。面试中写出这个方案只能证明你理解题目,无法展示算法功底。接下来我们看更好的方案。 + +--- + +### 方法一:分治Merge(递归/迭代两阶段)⭐(推荐,面试首选) + +> [!info] 🧠 核心洞察 +> 合并两个有序链表是 O(m + n)。合并 K 个链表可以看作 **K-路归并**——就像归并排序的 merge 阶段,只不过同时参与 merge 的不是 2 个而是 K 个子序列。 + +#### 分治策略 + +如果我们每次只取两条链表来合并,那总共需要合并多少次呢? + +| 阶段 | 剩余链表数 | 合并操作数 | 说明 | +|------|-----------|----------|------| +| 初始 | K | — | K 条独立链表 | +| 第 1 轮 | K/2 | K/2 | 两两配对合并 | +| 第 2 轮 | K/4 | K/4 | 上一步的结果继续两两合并 | +| ... | ... | ... | — | +| 最后 | 1 | 1 | 只剩一条,完成 ✅ | + +共需 ⌈log₂K⌉ 轮,每轮所有节点恰好被访问一次。 + +```mermaid +flowchart TB + subgraph Round1["🔄 第 1 轮:K → K/2"] + A1["L1: 1→4→5"] + A2["L2: 1→3→4"] + A3["L3: 2→6"] + + M1["merge(L1,L2)\n→ 1→1→3→4→5"] + B1["L4: 2→6"] + + A1 -->|"配对"| M1 + A2 -->|"配对"| M1 + A3 -->|"落单,直接保留"| B1 + end + + subgraph Round2["🔄 第 2 轮:K/2 → 1"] + M2["merge(M1,B1)\n→ 1→1→2→3→4→4→5→6"] + end + + M1 --> Round2 + B1 --> Round2 + + style M2 fill:#4c4,color:white,stroke:#333,stroke-width:2px + style M1 fill:#fff4e6,stroke:#f90 + style B1 fill:#e8f5e9,stroke:#28a +``` + +以 `[[1,4,5],[1,3,4],[2,6]]` 为例的分治过程: + +```mermaid +flowchart LR + subgraph "初始 K=3" + L1["1→4→5"] + L2["1→3→4"] + L3["2→6"] + end + + subgraph "第 1 轮: K→2" + P1["merge(L1,L2)\n→ 1→1→3→4→5"] + KEEP["L3 保留\n→ 2→6"] + end + + subgraph "第 2 轮: K=1" + FINAL["merge(P1,KEEP)\n→ 1→1→2→3→4→4→5→6 ✅"] + end + + L1 --> P1 + L2 --> P1 + L3 --> KEEP + P1 --> FINAL + KEEP --> FINAL + + style FINAL fill:#4c4,color:white,stroke:#333,stroke-width:2px +``` + +#### 实现方式一:自顶向下递归(Divide & Conquer) + +将问题不断二分,直到只剩 1 条或 0 条链表: + +```go +func mergeKLists(lists []*ListNode) *ListNode { + if len(lists) == 0 { + return nil + } + return divideAndConquer(lists, 0, len(lists)-1) +} + +func divideAndConquer(lists []*ListNode, left, right int) *ListNode { + // 基准情况:只有一个链表,直接返回 + if left == right { + return lists[left] + } + + mid := (left + right) / 2 + leftHalf := divideAndConquer(lists, left, mid) // 合并左半部分 + rightHalf := divideAndConquer(lists, mid+1, right) // 合并右半部分 + + return mergeTwoLists(leftHalf, rightHalf) // 合并两个有序链表 +} +``` + +> [!tip] 🔑 这棵递归树的结构 +> +> ``` +> [0,2] +> / \ +> [0,1] [2,2] +> / \ | +> [0,0] [1,1] [2] +> | | | +> L1 L2 L3 +> ``` +> +> 这是一棵平衡二叉树——深度为 O(log K),不会像退化版本那样出现栈溢出。 + +#### 实现方式二:迭代式逐轮合并 + +不依赖调用栈,显式地逐轮减少链表数量: + +```go +func mergeKLists(lists []*ListNode) *ListNode { + interval := 1 + for interval < len(lists) { + for i := 0; i + interval < len(lists); i += 2 * interval { + lists[i] = mergeTwoLists(lists[i], lists[i+interval]) + } + interval *= 2 + } + + if len(lists) > 0 { + return lists[0] + } + return nil +} +``` + +以 `[[1,4,5],[1,3,4],[2,6]]` 为例的迭代过程: + +```mermaid +flowchart LR + subgraph "初始状态" + A["[1,4,5]"] + B["[1,3,4]"] + C["[2,6]"] + end + + subgraph "interval=1: i+=2" + R1["A=merge(A,B)\n→ [1,1,3,4,5]"] + KEEP["C 不变\n→ [2,6]"] + end + + subgraph "interval=2: i+=4" + FIN["A=merge(R1,C)\n→ [1,1,2,3,4,4,5,6] ✅"] + end + + A --> R1 + B --> R1 + C --> KEEP + R1 --> FIN + KEEP --> FIN + + style FIN fill:#4c4,color:white,stroke:#333,stroke-width:2px +``` + +逐步展开 `[interval=1, i=0]`: +- `lists[0] = merge(lists[0], lists[1])` → `merge([1,4,5], [1,3,4]) = [1,1,3,4,5]` +- `i += 2` → `i = 2`,`2 + 1 = 3 ≱ 3`,内层循环结束 +- `interval *= 2` → `interval = 2` + +接着 `[interval=2, i=0]`: +- `lists[0] = merge(lists[0], lists[2])` → `merge([1,1,3,4,5], [2,6]) = [1,1,2,3,4,4,5,6]` +- `i += 4` → `i = 4`,退出 + +#### 复杂度分析 + +```mermaid +flowchart TD + subgraph "分治合并的分析" + DEPTH["递归深度: log K 层"] + + subgraph "每层工作量" + PER_LAYER["每层合并所有 N 个节点
代价: O(N)"] + end + + TOTAL["总代价: O(N) × log K = O(N log K)"] + end + + DEPTH --> PER_LAYER --> TOTAL + + style TOTAL fill:#d4edda,stroke:#28a,stroke-width:2px +``` + +- **每层代价:O(N)** —— 无论有多少条链表在参与合并,所有节点的 total value 在每个层级只被访问一次。 +- **层数:O(log K)** —— 每轮配对使链表数量减半。 +- **总时间复杂度:O(N log K)** ✅ + +> [!info] 📊 K=10^4 时:log₂(10⁴) ≈ 13.3,即最多约 14 轮合并。 +> N ≤ 10⁴ 时:总操作量约 1.4 × 10⁵ 次比较,远优于暴力的 O(N²)。 + +**空间复杂度:O(log K)**(递归版)或 **O(1)**(迭代版)。 + +--- + +### 方法二:最小堆 / 优先队列 🏗️ + +> [!question] 💡 如果你需要维护一个集合中的"最小元素",并且这个集合会动态增删,什么数据结构最高效? + +答案是 **堆(Heap)**。它的插入和删除都是 O(log M),查询最小值是 O(1)——正是本题的需求! + +#### 核心思想 + +每一步都从 K 个链表的头节点中选出最小的那个,接入结果链表,然后把该链表的下一个节点放回堆中: + +```mermaid +flowchart TD + PUSH["将所有非空链表的头节点推入最小堆"] + + subgraph Loop["循环:直到堆为空"] + EXTRACT["弹出堆顶
(当前最小节点)"] + APPEND["接入结果链表"] + + CHECK{"该节点有 Next 吗?"} + INSERT["将 Next 推入堆"] + + EXTRACT --> APPEND --> CHECK + CHECK -- "是" --> INSERT --> PUSH + CHECK -- "否" --> EMPTY["堆中少了一个元素"] + EMPTY --> CHECK + end + + subgraph Done["终止"] + RESULT["返回结果链表 ✅"] + end + + PUSH --> Loop + CHECK -. "堆空" .-> Done + + style RESULT fill:#4c4,color:white,stroke:#333,stroke-width:2px +``` + +以 `[[1,4,5],[1,3,4],[2,6]]` 为例: + +| 步骤 | 堆内容(Val) | 弹出的节点 | 结果链表 | +|------|-------------|----------|---------| +| 初始化 | `[1, 1, 2]` | — | `dummy → ?` | +| 第 1 步 | `[1, 2, 4]` | ①(来自 L1) | `1` | +| 第 2 步 | `[1, 2, 3]` | ①(来自 L2) | `1→1` | +| 第 3 步 | `[2, 3, 4]` | ②(来自 L3) | `1→1→2` | +| 第 4 步 | `[3, 4, 4]` | ③(来自 L2) | `1→1→2→3` | +| 第 5 步 | `[4, 4, 6]` | ④(来自 L1) | `1→1→2→3→4` | +| 第 6 步 | `[4, 6]` | ④(来自 L2) | `1→1→2→3→4→4` | +| 第 7 步 | `[6, 5]` | ⑤(来自 L1) | `1→1→2→3→4→4→5` | +| 第 8 步 | `[6]` | ⑥(来自 L3) | `1→1→2→3→4→4→5→6` ✅ | + +堆的操作次数 = 总节点数 N,每次 O(log K)。 + +#### Go 语言的 min-heap 实现 + +Go 标准库 `container/heap` 提供了通用的堆基础设施。你需要实现三个接口方法:`Len`、`Less`、`Swap`,以及自行封装 `Push` 和 `Pop`。 + +> [!warning] ⚠️ Go 的 interface{} vs 泛型 +> +> Go 1.18+ 引入了泛型,但 `container/heap` 仍基于 `interface{}`。对于 LeetCode Go 环境(通常是较新版本),可以使用以下通用 heap 模板: + +```go +// IntHeap 是最小堆,存储链表指针,按 Val 排序 +type IntHeap []*ListNode + +func (h IntHeap) Len() int { return len(h) } +func (h IntHeap) Less(i, j int) bool { return h[i].Val < h[j].Val } // 最小堆 +func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *IntHeap) Push(x any) { + *h = append(*h, x.(*ListNode)) +} + +func (h *IntHeap) Pop() any { + old := *h + n := len(old) + item := old[n-1] + old[n-1] = nil // 避免内存泄漏 + *h = old[:n-1] + return item +} +``` + +#### 完整代码 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +import ( + "container/heap" +) + +// IntHeap 最小堆,按节点 Val 排序 +type IntHeap []*ListNode + +func (h IntHeap) Len() int { return len(h) } +func (h IntHeap) Less(i, j int) bool { + return h[i].Val < h[j].Val // 越小优先级越高 +} +func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *IntHeap) Push(x any) { + *h = append(*h, x.(*ListNode)) +} + +func (h *IntHeap) Pop() any { + old := *h + n := len(old) + item := old[n-1] + old[n-1] = nil + *h = old[:n-1] + return item +} + +func mergeKLists(lists []*ListNode) *ListNode { + h := &IntHeap{} + heap.Init(h) + + // ① 初始化:将所有非空链表的头节点推入堆 + for _, node := range lists { + if node != nil { + heap.Push(h, node) + } + } + + // 虚拟头节点 + dummy := &ListNode{} + tail := dummy + + // ② 逐个弹出最小节点,并入结果链表 + for h.Len() > 0 { + node := heap.Pop(h).(*ListNode) // 取出当前最小节点 + tail.Next = node + tail = tail.Next + + // 如果该节点还有后继,将其推入堆 + if node.Next != nil { + heap.Push(h, node.Next) + } + } + + return dummy.Next +} +``` + +**时间复杂度:O(N log K)** — 对 N 个节点各做一次 push/pop,每次 O(log K)。 +**空间复杂度:O(K)** — 堆中最多同时存储 K 个节点。 + +--- + +### 两种最优方法对比 + +```mermaid +flowchart LR + subgraph DC["方法一:分治"] + TIME1["O(N log K)"] + SPACE1["O(1) 迭代 / O(log K) 递归"] + CODE1["代码简洁"] + PERF1["常数因子小"] + end + + subgraph HP["方法二:最小堆"] + TIME2["O(N log K)"] + SPACE2["O(K)"] + CODE2["需实现 heap 接口"] + PERF2["适合动态流式输入"] + end + + DC -->|"面试首选 ✅"| ANSWER["最优解"] + HP -->|"工程适用 | 扩展性强"| ANSWER + + style ANSWER fill:#d4edda,stroke:#28a +``` + +| 维度 | 分治法(递归)⭐ | 最小堆法 🏗️ | +|------|---------------|------------| +| **时间复杂度** | O(N log K) | O(N log K) | +|**空间复杂度**| O(log K) 递归 / O(1) 迭代 | O(K) | +| **代码难度** | 低(复用两两合并) | 中(需实现 heap 接口) | +| **常数开销** | 更小(无 heap 操作) | 更大(push/pop 调度) | +| **适用场景** | 链表数组已知 | 链表逐个到来(流式数据) | +| **面试推荐度** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | + +> [!success] ✅ 面试建议 +> - **优先写分治法**:代码极简(仅需一行 merge 调用),容易推导,面试官满意 +> - **作为加分项**提及堆做法:展示你对优先队列的理解及在流式场景的应用 +> - 如果面试官追问"K 极大时怎么办",可以讨论桶优化等更深层的思路 + +--- + +## 代码提示 + +### 分治迭代伪代码 + +``` +interval = 1 +while interval < K: + for i = 0; i + interval < K; i += 2 * interval: + lists[i] = merge(lists[i], lists[i + interval]) + interval *= 2 + +return lists[0] if K > 0 else nil +``` + +### 最小堆伪代码 + +``` +h = 新建最小堆() + +// 初始化:所有非空头入堆 +for each node in lists: + if node != nil: + h.push(node) + +dummy = new ListNode() +tail = dummy + +while h is not empty: + node = h.pop() // 取出最小节点 + tail.Next = node + tail = tail.Next + + if node.Next != nil: + h.push(node.Next) // 后继补入堆 + +return dummy.Next +``` + +--- + +## 技巧 + +> [!tip] 🔑 为什么 O(N log K) 优于 O(N log N)? +> +> 暴力法排序的本质忽略了数据内在结构——每个子链表本身已是有序的。堆/分治法利用了这一点:只需要在 K 个候选者之间做决策(log K 级比较),而非对所有 N 个元素做全排序(log N 级比较)。当 K << N 时,差距巨大。 +> +> 举例:N = 10⁴, K = 100 → log K ≈ 6.6,log N ≈ 13.3——堆做法约为暴力的一半操作量;但当 N = 10⁶, K = 100 时,差距变为 10 倍以上。 + +> [!tip] 🔑 虚拟头节点(Dummy Node)万能公式 +> +> 遇到"构造链表"类题目,模板化的起手式: +> +> ``` +> dummy = new ListNode() +> tail = dummy +> // ... 循环中 tail.Next = newNode; tail = tail.Next ... +> return dummy.Next +> ``` +> +> 这条模式出现在:27-合并两个有序链表、此题、21-两数相加、83-删除排序链表中的重复元素、等等。**背下来,考场上一行就搞定边界处理。** + +> [!note] 🔑 分治的迭代写法比递归写法精妙在哪? +> +> 它直接在原数组 `lists` 上原地合并,不分配额外数组。关键在于 `i += 2 * interval`——每次跳过已合并的那一对,防止重复处理。这种"间隔倍增"的技巧在很多分治场景中都有应用。 + +> [!warning] ⚠️ Go 中 container/heap 的常见坑 +> +> 1. **Less 必须严格小于 `<`**,不能用 `<=`——否则相同值的节点顺序不确定,可能导致逻辑错误 +> 2. **Pop 后要置 nil**:`old[n-1] = nil`,避免 Go GC 无法回收被弹出的旧引用造成内存泄漏 +> 3. **Push/Pop 接收者是指针**:函数签名是 `(h *IntHeap)`,不是值类型 + +> [!tip] 🔑 边界情况的三种极端 +> +> | 输入 | 预期输出 | 处理要点 | +> |------|---------|---------| +> | `[]`(K = 0) | `nil` | 检查 `len(lists) == 0` | +> | `[[]]`(K = 1,空链表) | `nil` | 堆初始化时跳过 nil 节点 | +> | `[[], [], []]`(全空) | `nil` | 堆始终为空,直接返回 `dummy.Next` | + +> [!info] 📊 三者的直观感受 + +| 方法 | 直觉 | 代码行数(含注释) | 面试白板友好度 | +|------|------|------------------|-------------| +| **暴力排序** | 最容易想到 | ~10 | ★★★ | +| **分治合并** | 自然推广两两合并 | ~15 | ★★★★★ | +| **最小堆** | 需要熟悉 heap 接口 | ~25 | ★★★★ | + +--- + +## 代码 + +### 方法一:分治合并 ⭐ + +#### 递归实现 + +将问题不断二分,直到子问题只剩 0 或 1 条链表,回溯时逐层合并。由于 `lists` 在递归过程中是**只读的**,直接作为额外参数传递即可——Go 的函数栈帧很小,多一个切片头参数毫无影响,无需借助闭包增加认知负担: + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func mergeKLists(lists []*ListNode) *ListNode { + if len(lists) == 0 { + return nil + } + return divideAndConquer(lists, 0, len(lists)-1) +} + +// divideAndConquer 二分合并 lists[left..right] +func divideAndConquer(lists []*ListNode, left, right int) *ListNode { + if left == right { // 基准情况:只剩一条 + return lists[left] + } + + mid := left + (right-left)/2 + leftHalf := divideAndConquer(lists, left, mid) // 左半 [left, mid] + rightHalf := divideAndConquer(lists, mid+1, right) // 右半 [mid+1, right] + + return mergeTwoLists(leftHalf, rightHalf) // 两半回溯合并 +} + +// mergeTwoLists 合并两个有序链表(复用 27-合并两个有序链表的解法) +func mergeTwoLists(l1, l2 *ListNode) *ListNode { + dummy := &ListNode{} + tail := dummy + + for l1 != nil && l2 != nil { + if l1.Val <= l2.Val { + tail.Next = l1 + l1 = l1.Next + } else { + tail.Next = l2 + l2 = l2.Next + } + tail = tail.Next + } + + if l1 != nil { + tail.Next = l1 + } else { + tail.Next = l2 + } + + return dummy.Next +} +``` + +**时间复杂度:** O(N log K) +**空间复杂度:** O(log K)——递归栈深度等于分治树高度 + +--- + +#### 迭代实现 + +不依赖调用栈,显式地逐轮减少链表数量(原地合并): + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +func mergeKLists(lists []*ListNode) *ListNode { + // 边界情况:没有链表需要合并 + if len(lists) == 0 { + return nil + } + + interval := 1 + for interval < len(lists) { + // 本轮:每两个相距 interval 的链表互相合并 + for i := 0; i+interval < len(lists); i += 2 * interval { + lists[i] = mergeTwoLists(lists[i], lists[i+interval]) + } + interval *= 2 // 间隔翻倍,进入下一轮 + } + + // 最终 lists[0] 就是合并结果 + if len(lists) > 0 { + return lists[0] + } + return nil +} +``` + +**时间复杂度:** O(N log K) — log K 轮合并,每轮遍历所有 N 个节点。 +**空间复杂度:** O(1) — 原地修改 lists 数组中的指针,不使用额外空间。 + +--- + +### 方法二:最小堆 / 优先队列 + +```go +/** + * Definition for singly-linked list. + * type ListNode struct { + * Val int + * Next *ListNode + * } + */ + +import ( + "container/heap" +) + +// IntHeap 实现 container/heap 接口的最小堆,按节点 Val 排序 +type IntHeap []*ListNode + +func (h IntHeap) Len() int { return len(h) } +func (h IntHeap) Less(i, j int) bool { return h[i].Val < h[j].Val } +func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *IntHeap) Push(x any) { + *h = append(*h, x.(*ListNode)) +} + +func (h *IntHeap) Pop() any { + old := *h + n := len(old) + item := old[n-1] + old[n-1] = nil // 避免内存泄漏 + *h = old[:n-1] + return item +} + +func mergeKLists(lists []*ListNode) *ListNode { + h := &IntHeap{} + heap.Init(h) + + // ① 初始化:将所有非空链表的头节点推入堆 + for _, node := range lists { + if node != nil { + heap.Push(h, node) + } + } + + // 虚拟头节点 + dummy := &ListNode{} + tail := dummy + + // ② 逐个弹出最小节点,并入结果链表 + for h.Len() > 0 { + node := heap.Pop(h).(*ListNode) // 取出当前 Val 最小的节点 + tail.Next = node + tail = tail.Next + + // 将该节点的后继推入堆,维持堆大小为 ≤ K + if node.Next != nil { + heap.Push(h, node.Next) + } + } + + return dummy.Next +} +``` + +**时间复杂度:O(N log K)** — 对 N 个节点各执行一次 heap.Push / heap.Pop,单次 O(log K)。 +**空间复杂度:O(K)** — 堆中同时存储最多 K 个节点指针。 + +--- + +> [!tip] 🔧 本地测试辅助函数 + +```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 +} +``` + +> [!success] ✅ 运行验证 +> +> 这是 LeetCode 第 23 题,通过率约 45%+。作为链表难度的代表之作,它综合考察了两个重要范式:① **分治法**——通过递归/迭代两两合并降低问题规模;② **优先队列**——用堆动态维护多路有序流的"下一步"。两道解法都值得熟练掌握,面试中建议优先展示分治法,再补充堆方法作为拓展。 + +> [!info] 📎 相关题目串联 +> - [27-合并两个有序链表](./27-合并两个有序链表.md) — 本问题的基础组件,合并两步直接复用 +> - [28-两数相加](./28-两数相加.md) — 同样是链表加法思维,注意进位处理的差异 +> - [33-排序链表](./33-排序链表.md) — 归并排序思想,与本问题的分治框架高度相似 diff --git a/链表/35-LRU 缓存.md b/链表/35-LRU 缓存.md new file mode 100644 index 0000000..578c03c --- /dev/null +++ b/链表/35-LRU 缓存.md @@ -0,0 +1,537 @@ +--- +tags: ["LeetCode", "哈希表", "链表", "设计", "中等"] +create time: 2026-05-18 16:00 +--- + +# 35-LRU 缓存 + +## 题面 + +请你设计并实现一个满足 **LRU (最近最少使用) 缓存** 约束的数据结构。 + +实现 `LRUCache` 类: + +- `LRUCache(int capacity)` 以 **正整数** 作为容量 `capacity` 初始化 LRU 缓存 +- `int get(int key)` 如果关键字 `key` 存在于缓存中,则返回关键字的值,否则返回 `-1` 。 +- `void put(int key, int value)` 如果关键字 `key` 已经存在,则变更其数据值 `value` ;如果不存在,则向缓存中插入该组 `key-value` 。如果插入操作导致关键字数量超过 `capacity` ,则应该 **逐出(evict)** 最久未使用的关键字。 + +函数 `get` 和 `put` 必须以 **O(1) 的平均时间复杂度** 运行。 + +**示例:** + +``` +输入 +["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"] +[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]] +输出 +[null, null, null, 1, null, -1, null, -1, 3, 4] + +解释 +LRUCache lRUCache = new LRUCache(2); +lRUCache.put(1, 1); // 缓存是 {1=1} +lRUCache.put(2, 2); // 缓存是 {1=1, 2=2} +lRUCache.get(1); // 返回 1 +lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3} +lRUCache.get(2); // 返回 -1 (未找到) +lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3} +lRUCache.get(1); // 返回 -1 (未找到) +lRUCache.get(3); // 返回 3 +lRUCache.get(4); // 返回 4 +``` + +**提示:** + +- `1 <= capacity <= 3000` +- `0 <= key <= 10000` +- `0 <= value <= 10^5` +- 最多调用 `2 * 10^5` 次 `get` 和 `put` + +> [!warning] ⚠️ 核心约束 +> **O(1) 时间复杂度**——这是这道题的灵魂要求。任何涉及线性遍历的操作(如切片删除、遍历 map 找最旧元素)都会直接超时。这意味着每次操作都必须是"直接寻址"级别的速度。 + +--- + +## 思路 + +> [!question] 💡 需求拆解 +> LRU 缓存需要同时满足两个看似矛盾的要求: +> +> | 操作 | 需要什么能力 | 天然数据结构 | +> |------|-------------|-------------| +> | `get(key)` | **按 key 快速查找** → O(1) | 哈希表 ✅ | +> | `put(key)` + 驱逐最老元素 | **维护访问顺序**,能在两端高效增删 | 双向链表 ✅ | + +单一数据结构无法同时满足这两个需求: +- **纯哈希表**:查找 O(1),但不知道哪个元素"最久未用"。 +- **纯双向链表**:能维护顺序、两端增删都是 O(1),但查找需要遍历 O(n)。 + +**解法:哈希表 + 双向链表的组合!** 🎯 + +> [!tip] 🔑 为什么是"双向"链表而不是"单向"? +> 在双向链表中删除任意节点只需要该节点的指针(因为既有 next 也有 prev)。但单向链表要删除节点 A,必须知道 A.prev——而 A 自身不持有这个引用。如果你只有 A 的指针,就得从头遍历找到 A.prev,这就变成了 O(n)。**O(1) 删除的前提是有 prev 指针。** + +### 数据结构设计 + +```mermaid +flowchart TD + subgraph HashMap["哈希表 map[key] -- Node"] + H1["1 -> NodeA"] + H2["3 -> NodeB"] + end + + subgraph DoublyLinkedList["双向链表 (队首=最近使用 <-> 队尾=最久未用)"] + DH["DummyHead"] --> NA["key=1 -- val=1"] + NA <--> NB["key=3 -- val=3"] + NB -.-> DT["nil (DummyTail)"] + end + + H1 -.指向.-> NA + H2 -.指向.-> NB + + style HashMap fill:#e8f4fd,stroke:#28a + style DoublyLinkedList fill:#fff3cd,stroke:#28a + style NA fill:#bbf,stroke:#333 + style NB fill:#f9d,stroke:#333 +``` + +**约定:** +- **链表头部(靠近 DummyHead)** = 最近使用的节点(most recent) +- **链表尾部(靠近 DummyTail)** = 最久未使用的节点(least recent / victim) +- DummyHead / DummyTail 是哨兵节点,让边界操作不需要特殊判空 + +### 操作详解 + +#### 操作一:`get(key)` + +> [!question] 💡 思考一下 +> 查到键后,除了返回值,还需要做什么才能让 LRU 性质维持不变? + +**流程:** + +```mermaid +flowchart TD + START["get(key)"] --> LOOKUP{"map 中存在 key?"} + LOOKUP -->|否| RETURN_NEG1["return -1"] + LOOKUP -->|是| FOUND["从 map 拿到对应 Node 指针"] + FOUND --> REMOVE["将 Node 从当前位置移除 removeNode(node)"] + REMOVE --> ADD_HEAD["把 Node 移到队 head.add(node)"] + ADD_HEAD --> RETURN_VAL["return node.val"] + + style FOUND fill:#bbf,stroke:#333 + style REMOVE fill:#ffd700,stroke:#333 + style ADD_HEAD fill:#f9d,stroke:#333 + style RETURN_VAL fill:#d4edda,stroke:#28a +``` + +**逐步演示(capacity=2,当前缓存 `{1=1, 3=3}`,调用 `get(1)`):** + +```mermaid +flowchart LR + subgraph BEFORE["get(1) 之前"] + B1["head --> key=1--val=1 --> key=3--val=3 <-- tail"] + end + + B1 --> REMOVE["① 摘下 key=1--val=1"] + REMOVE --> MID["中间态: head --> key=3--val=3 <-- tail\n map 仍指向原 key=1--val=1 节点"] + MID --> REINSERT["② 插入 head 之后"] + REINSERT --> AFTER["③ 结果: head --> key=1--val=1 --> key=3--val=3 <-- tail\n 1 变成最近使用的"] + + style BEFORE fill:#ffd700,stroke:#333 + style AFTER fill:#d4edda,stroke:#28a +``` + +**时间复杂度:O(1)** — map 查找 O(1) + 链表节点摘除/重插 O(1) + +#### 操作二:`put(key, value)` + +> [!question] 💡 put 有三种情况,你能枚举出来吗? + +**分三种情况处理:** + +```mermaid +flowchart TD + START["putkey, value"] --> CHECK{"key 已存在?"} + + CHECK -->|是| UPDATE["更新值 node.val = value\n然后把 node 移到队首\n-- 因为它变最新了"] + + CHECK -->|否| CAPACITY{"已满?"} + + CAPACITY -->|是| EVICT["① 删除 tail 前面的节点(最久未用)\n② 从 map 中删除对应的 key\n③ 创建新节点插到队首\n④ map[key]=newNode"] + + CAPACITY -->|否| INSERT["① 创建新节点插到队首\n② map[key]=newNode"] + + UPDATE --> DONE["✅ 完成"] + EVICT --> DONE + INSERT --> DONE + + style UPDATE fill:#bbf,stroke:#333 + style EVICT fill:#f9d,stroke:#333 + style INSERT fill:#9df,stroke:#333 +``` + +**逐步演示(capacity=2,当前缓存 `{1=1, 2=2}`,调用 `put(3, 3)`):** + +```mermaid +flowchart LR + S1["初始: {1=1, 2=2}\nhead --> key=1--val=1 --> key=2--val=2 <-- tail"] --> FULL{"容量满?"} + FULL -->|是, key=3 不存在| EVICT["驱逐 tail 前节点 key=2--val=2\nmap 删除 key=2"] + EVICT --> MID["临时: {1=1}\nhead --> key=1--val=1 <-- tail"] + MID --> INSERT["插入 key=3--val=3 到队首"] + INSERT --> RESULT["结果: {1=1, 3=3}\nhead --> key=3--val=3 --> key=1--val=1 <-- tail\n 3 变最新, 1 变最旧"] + + style S1 fill:#ffd700,stroke:#333 + style RESULT fill:#d4edda,stroke:#28a +``` + +**时间复杂度:O(1)** — 所有步骤都是 map 查询/O(1) + 链表操作 O(1) + +### 完整执行过程追踪(示例) + +``` +capacity = 2 +``` + +| 操作 | map[key→Node] | 链表 (head→tail) | 返回值 | +|------|---------------|-----------------|--------| +| `put(1,1)` | `{1→A}` | `[1,1]` | — | +| `put(2,2)` | `{1→A, 2→B}` | `[1,1] ↔ [2,2]` | — | +| `get(1)` | `{1→A, 2→B}` | `[1,1] ↔ [2,2]` *(1 移到头)* | **1** | +| `put(3,3)` | `{1→A, 3→C}` *[驱逐 B]* | `[3,3] ↔ [1,1]` | — | +| `get(2)` | — | — | **-1** *(2 不在 map 中)* | +| `put(4,4)` | `{4→D, 3→C}` *[驱逐 A]* | `[4,4] ↔ [3,3]` | — | +| `get(1)` | — | — | **-1** *(1 不在 map 中)* | +| `get(3)` | `{4→D, 3→C}` | `[4,4] ↔ [3,3]` | **3** | +| `get(4)` | `{4→D, 3→C}` | `[4,4] ↔ [3,3]` | **4** | + +### 核心操作实现原理 + +#### 1. `removeNode(node)`:将节点从链表中的任意位置摘除 + +```go +// node.Prev 和 node.Next 都已正确指向它在链表中的邻居 +node.Prev.Next = node.Next +node.Next.Prev = node.Prev +``` + +#### 2. `addToHead(node)`:将节点插入到哨兵头部之后 + +```go +node.Prev = dummyHead +node.Next = dummyHead.Next +dummyHead.Next.Prev = node +dummyHead.Next = node +``` + +#### 3. `removeTail()`:摘除尾部有效节点(即 DummyTail 前面那个) + +```go +removed := dummyTail.Prev // 最久未用的节点 +removeNode(removed) // 从链表中摘除 +return removed // 返回被驱逐的节点 +``` + +> [!info] 🧠 为什么加 DummyHead 和 DummyTail? +> 如果没有哨兵节点,操作空链表或只有一个节点的链表时需要大量的 nil 判断,代码会变得冗长且容易出错。哨兵节点让这些边界情况自动正确处理——即使是空链表,dummyHead.next = dummyTail 也永远成立。 + +> [!success] ✅ 复杂度分析 + +| 操作 | 时间复杂度 | 空间复杂度 | +|------|-----------|-----------| +| `get(key)` | **O(1)** | — | +| `put(key, value)` | **O(1)** | — | +| 整体 | O(1) 均摊 | **O(capacity)** 存储 n 个节点 | + +--- + +## 代码提示 + +### LRU 缓存通用模板 + +``` +type Node struct { + Key int + Val int + Prev *Node + Next *Node +} + +type LRUCache struct { + cap int + size int + dummyHead *Node // 哨兵头 + dummyTail *Node // 哨兵尾 + cache map[int]*Node // key → Node 映射 +} + +func Constructor(capacity int) LRUCache { + // ① 初始化哨兵节点,互相指向 + head := &Node{} + tail := &Node{} + head.Next = tail + tail.Prev = head + + return LRUCache{ + cap: capacity, + size: 0, + dummyHead: head, + dummyTail: tail, + cache: make(map[int]*Node), + } +} + +func (c *LRUCache) Get(key int) int { + // ① map 查不到 → 返回 -1 + // ② map 查到了 → 摘除 + 移到头部 → 返回值 +} + +func (c *LRUCache) Put(key int, value int) { + // 如果 key 已存在:更新值 + 移到头部 + // 如果 key 不存在: + // 如果满了:驱逐尾部节点 + map 删除 + // 创建新节点 + 插到头部 + map 记录 +} +``` + +### 关键辅助方法 + +``` +// 将已有节点从当前位置摘除 +removeNode(node): + node.Prev.Next = node.Next + node.Next.Prev = node.Prev + +// 把节点插到 dummyHead 后面(最新位置) +addToHead(node): + node.Prev = dummyHead + node.Next = dummyHead.Next + dummyHead.Next.Prev = node + dummyHead.Next = node + +// 把节点先摘除再插到头部(组合操作) +moveToHead(node): + removeNode(node) + addToHead(node) + +// 摘除尾部前一个节点(被驱逐者) +removeTail(): + toRemove = dummyTail.Prev + removeNode(toRemove) + return toRemove +``` + +--- + +## 技巧 + +> [!tip] 🔑 组合模式:"哈希加速 + 链表定序" +> 当题目同时要求 **"按 key 查找"** 和 **"维护某种顺序"** 时,考虑用哈希表做 O(1) 定位、用有序容器做排序。这种组合还出现在以下场景中: +> - **LFU 缓存**(最少频次)→ 哈希表 + 频次桶 + 双向链表 +> - **Top K 频繁元素** → 哈希表计数 + 优先队列(堆) +> - **滑动窗口最大值** → 哈希集合 + 双端队列(单调队列) + +> [!tip] 🔑 哨兵节点(Dummy Head/Tail)的价值 +> 在所有需要"在头部/尾部增删"的链式结构中,加入虚拟头尾节点可以消除大量 nil 判断。这不仅是代码简洁的问题——更重要的是**减少分支预测失败的概率**。CPU 对规律性高的代码执行效率更高。 +> +> 典型应用:链表反转、合并链表、本题、以及所有需要频繁在头部操作的场景。 + +> [!warning] ⚠️ 常见错误 1:漏掉 update 场景 +> `put(key, value)` 中 key 已存在时要更新值并**移到头部**。很多人只写了更新值,忘了移到头部,导致 LRU 失效。记住:`put` 等同于"先读后写再刷新优先级"。 + +> [!warning] ⚠️ 常见错误 2:驱逐时没删 map +> 从链表中移除了最久未用的节点,却忘记了从 `cache map` 中 `delete(key)`。这样后续 `get` 仍然能找到这个已经被淘汰的节点,缓存一致性被破坏。 + +> [!warning] ⚠️ 常见错误 3:capacity 为负数或零 +> 题目说明 `1 <= capacity`,所以不需要处理容量 ≤ 0 的情况。但如果面试题中面试官追问,合理的做法是直接 panic 或在构造函数中返回一个错误。 + +> [!warning] ⚠️ 常见错误 4:链表操作指针遗漏 +> 双向链表插入/删除需要操作 **4 条指针**(每个方向各两条),少写一条就会导致链表断裂或死循环。建议通过"拆开两步走"的方式降低出错率:先拆后连。 + +> [!note] 🐹 Go 中的实现要点 +> 1. **可以用方法接收者**(`(c *LRUCache)`)封装辅助操作,也可以用普通函数独立实现。后者更清晰、更容易测试。 +> 2. Go 的 `map` 在 `delete(m, key)` 时对不存在的 key 不会 panic——直接无操作。这是一个安全的特性可以利用。 +> 3. Go 的 struct 零值会自动将指针字段设为 nil,所以 `Node{}` 的 `Prev` 和 `Next` 都是 nil。但在构建链表时必须显式设置它们。 +> 4. Go 1.21+ 提供了 `container/list` 包,其中 `list.List` 就是双向链表。在实际工程中可以直接使用它来缩短代码——但面试中通常要求自己手写。 + +> [!info] 📊 如果允许使用标准库? + +Go 的 `container/list` 包已经实现了双向链表,可以让代码量减少一半以上: + +```go +type LRUCache struct { + cap int + list *list.List + cache map[int]*list.Element +} + +type entry struct { + Key, Val int +} + +func Constructor(capacity int) LRUCache { + return LRUCache{cap: capacity, list: list.New(), cache: make(map[int]*list.Element)} +} + +func (c *LRUCache) Get(key int) int { + if e, ok := c.cache[key]; ok { + c.list.MoveToFront(e) + return e.Value.(*entry).Val + } + return -1 +} + +func (c *LRUCache) Put(key int, value int) { + if e, ok := c.cache[key]; ok { + e.Value.(*entry).Val = value + c.list.MoveToFront(e) + return + } + e := c.list.PushFront(&entry{key, value}) + c.cache[key] = e + if c.list.Len() > c.cap { + removed := c.list.Back() + c.list.Remove(removed) + delete(c.cache, removed.Value.(*entry).Key) + } +} +``` + +> [!success] ✅ 相关题目串联 +> - [36-O(1) 数据结构的难题](./36-o1-data-structure.md) — 同一作者设计的 "All O`one Data Structure",同样是哈希表 + 双向链表 +> - [37-最小栈](./37-min-stack.md) — 另一个考察"如何做到 O(1)"的经典题 +> - [32-随机链表的复制](./32-随机链表的复制.md) — 同样是链表的指针操作 + +--- + +## 代码 + +### 手写双向链表 + 哈希表 ⭐(标准答案) + +```go +package main + +// Node 双向链表节点 +type Node struct { + Key, Val int + Prev, Next *Node +} + +// LRUCache LRU 缓存,哈希表 + 双向链表 +type LRUCache struct { + cap int + size int + dummyHead *Node // 哨兵头节点 + dummyTail *Node // 哨兵尾节点 + cache map[int]*Node +} + +// Constructor 初始化 LRU 缓存 +func Constructor(capacity int) LRUCache { + head := &Node{} + tail := &Node{} + head.Next = tail + tail.Prev = head + + return LRUCache{ + cap: capacity, + size: 0, + dummyHead: head, + dummyTail: tail, + cache: make(map[int]*Node), + } +} + +// Get 获取 key 对应的值,如果不存在返回 -1 +// 查到后将该节点移到链表头部(标记为最新使用) +func (c *LRUCache) Get(key int) int { + if node, ok := c.cache[key]; ok { + c.moveToHead(node) + return node.Val + } + return -1 +} + +// Put 插入或更新 key-value +// 若 key 已存在则更新值并移到头部;若不存在则插入到新节点到头部 +// 超出容量时驱逐尾部前一个节点(最久未使用) +func (c *LRUCache) Put(key int, value int) { + if node, ok := c.cache[key]; ok { + // 情况 1:key 已存在,更新值并移到头部 + node.Val = value + c.moveToHead(node) + return + } + + // 情况 2:key 不存在,创建新节点 + newNode := &Node{Key: key, Val: value} + c.addToHead(newNode) + c.cache[key] = newNode + c.size++ + + // 超出容量,驱逐尾部节点 + if c.size > c.cap { + removed := c.removeTail() + delete(c.cache, removed.Key) + c.size-- + } +} + +// ---------- 辅助方法 ---------- + +// addToHead 将节点插入到 dummyHead 之后 +func (c *LRUCache) addToHead(node *Node) { + node.Prev = c.dummyHead + node.Next = c.dummyHead.Next + c.dummyHead.Next.Prev = node + c.dummyHead.Next = node +} + +// removeNode 将节点从链表中摘除(不管它在什么位置) +func (c *LRUCache) removeNode(node *Node) { + node.Prev.Next = node.Next + node.Next.Prev = node.Prev +} + +// moveToHead 先摘除再插到头部(用于 get 命中和 put 更新场景) +func (c *LRUCache) moveToHead(node *Node) { + c.removeNode(node) + c.addToHead(node) +} + +// removeTail 摘除并返回尾部前一个有效节点 +func (c *LRUCache) removeTail() *Node { + removed := c.dummyTail.Prev // 倒数第二个节点 = 最久未用的 + c.removeNode(removed) + return removed +} +``` + +> [!success] ✅ 执行验证与总结 + +**这套实现的每一个操作都是严格的 O(1):** + +| 操作 | 路径 | 耗时组成 | +|------|------|---------| +| `Get(key)` | map 查找 → `moveToHead` | O(1) + O(1) = **O(1)** | +| `Put(key, val)`(命中) | map 查找 → `moveToHead` | O(1) + O(1) = **O(1)** | +| `Put(key, val)`(未命中,未满) | map 写入 → `addToHead` | O(1) + O(1) = **O(1)** | +| `Put(key, val)`(未命中,已满) | map 写入 → `addToHead` → `removeTail` → map 删除 | O(1) × 4 = **O(1)** | + +> [!TIP] 💬 面试加分话术 +> "我选择哈希表 + 双向链表的组合来实现。哈希表保证了 key 的 O(1) 查找,双向链表保证了任意位置节点的 O(1) 增删。为了简化边界处理,我在链表两端加了哨兵节点。整个设计中,最近使用的节点永远保持在头部,最久未用的永远在尾部——这天然匹配 LRU 的语义。唯一需要注意的是 put 操作中 key 已存在的情况,这时候需要先更新值再 move 到头部,保持 LRU 属性。" + +> [!note] 🤔 为什么不用单链表? +> 如前所述,单链表若要删除某个已知节点,需要找到它的 predecessor 才能断开前驱指针。虽然可以通过"覆盖后继节点的值"的技巧绕过这个问题(见下方思考),但这牺牲了可读性和正确性保证。**双向链表是 O(1) 删除已知节点的最自然方案。** + +> [!question] 💡 延伸思考:单链表的 O(1) 删除 trick +> 如果只能用单链表,有一种技巧可以在只知道待删除节点指针的情况下完成 O(1) 删除:**把后继节点的值复制到当前节点,然后删除后继**。但这要求待删除节点不能是最后一个节点。在 LRU 缓存的场景下,如果要驱逐的是尾节点,这个方法无效。所以这不是一个通用的解决方案。这也解释了为什么 LRU 的标准实现总是用双向链表。 + +> [!info] 📊 与其他语言的对比 + +| 语言 | 实现方式 | 备注 | +|------|---------|------| +| Go | 手写 struct + 指针 | 需手动管理 prev/next 指针 | +| Java | `LinkedHashMap` / 手写 Node | JDK 内置 LinkedHashMap 就是 LRU 的实现 | +| Python | `collections.OrderedDict` / 手写 | OrderedDict.move_to_end() 内置支持 | +| C++ | `std::list` + `std::unordered_map` | STL 提供了双向链表和无序 map | + +Go 没有类似 Java LinkedHashMap 或 Python OrderedDict 这样的内置结构,因此手写是实现 LRU 缓存的常规做法,也是面试的默认期望。