diff --git a/动态规划/83-打家劫舍.md b/动态规划/83-打家劫舍.md new file mode 100644 index 0000000..777d752 --- /dev/null +++ b/动态规划/83-打家劫舍.md @@ -0,0 +1,290 @@ +--- +tags: [算法/动态规划, 基础DP, 选与不选] +create time: 2026-05-16 19:00 +--- + +# 83. 打家劫舍(House Robber) + +> [!quote] LeetCode 原题 · 中等 +> 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 +> +> 给定一个代表每个房屋存放金额的**非负整数数组**,计算你不触动警报装置的情况下,一夜之内能够偷窃到的**最高金额**。 +> +> - 输入范围:`1 <= nums.length <= 100`,`0 <= nums[i] <= 400` + +## 题面 + +**示例 1:** + +``` +输入:[1,2,3,1] +输出:4 +解释:偷窃 1 号房屋 (金额 = 1),然后偷窃 3 号房屋 (金额 = 3)。 + 偷窃到的最高金额 = 1 + 3 = 4 。 +``` + +**示例 2:** + +``` +输入:[2,7,9,3,1] +输出:12 +解释:偷窃 1 号房屋 (金额 = 2),偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 + 偷窃到的最高金额 = 2 + 9 + 1 = 12 。 +``` + +## 思路 + +### 第一步:明确"决策"是什么 + +想象你站在街边,一排房屋在你面前。对每一间房,你只有两个选择—— + +> **偷** 或者 **不偷**。 + +但有一个硬性约束:**不能偷相邻的两间房**。目标是在这个约束下最大化总金额。 + +> [!question] 💡 思考 1 +> 假设你来到了第 i 间房子,前面已经做出了最优决策。此时你最需要记住的信息是什么? + +答案是:**到第 i-1 间房为止的最高金额**和**到第 i-2 间房为止的最高金额**。因为对当前房子的决策完全依赖于这两个历史最优值。 + +### 第二步:定义状态和转移方程 + +设 `dp[i]` 表示**偷到第 i 间房子(可选第 i 间)时能拿到的最高金额**。注意:`dp[i]` 并不强制偷第 i 间,它只是"考虑前 i+1 间房子"的最优结果。 + +对第 i 间房子,你有两个选择: + +| 选择 | 说明 | 金额贡献 | +|------|------|----------| +| **偷第 i 间** | 那么第 i-1 间不能偷,金额 = `dp[i-2] + nums[i]` | `dp[i-2] + nums[i]` | +| **不偷第 i 间** | 最优就等于盗到第 i-1 间的结果,金额 = `dp[i-1]` | `dp[i-1]` | + +取较大者: + +> **`dp[i] = max(dp[i-1], dp[i-2] + nums[i])`** + +这就是经典的状态转移方程。核心思想是——**「选或不选,取较优」**。 + +```mermaid +flowchart TD + subgraph "第 i 间房的两种选择" + A["面对第 i 间房\nnums[i]"] --> B["偷!"] + A --> C["不偷"] + B --> D["dp[i-2] + nums[i]\n跳过前一间,加上当前"] + C --> E["dp[i-1]\n沿用前一步的结果"] + D --> F["dp[i] = max(D, E)"] + E --> F + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style F fill:#dcfce7,stroke:#16a34a +``` + +### 第三步:确定边界条件 + +边界是解题的地基,需要小心铺设: + +| 情况 | dp 值 | 理由 | +|------|-------|------| +| `i = 0`(只有一间房) | `dp[0] = nums[0]` | 只有一间,直接偷 | +| `i = 1`(有两间房) | `dp[1] = max(nums[0], nums[1])` | 只能偷一间,选大的那个 | + +有了这两块砖,后面的墙就能一步步垒起来。 + +### 第四步:逐步演示 + +以 `[2, 7, 9, 3, 1]` 为例: + +| i | nums[i] | 选择偷 (`dp[i-2]+nums[i]`) | 选择不偷 (`dp[i-1]`) | dp[i] = max | +|---|---------|---------------------------|---------------------|-------------| +| 0 | 2 | — | — | **2** | +| 1 | 7 | — | 2 | **7** | +| 2 | 9 | 2+9=11 | 7 | **11** | +| 3 | 3 | 7+3=10 | 11 | **11** | +| 4 | 1 | 11+1=12 | 11 | **12** | + +最终答案:**12**。对应方案是偷第 0、2、4 间房(金额 2+9+1=12)。 + +```mermaid +flowchart LR + subgraph "dp表逐步填充" + A["i=0: dp=2\n偷第0间"] --> B["i=1: dp=7\n偷第1间(比偷0间更赚)"] + B --> C["i=2: dp=11\n偷第2间(2+9=11>7)"] + C --> D["i=3: dp=11\n不偷第3间(11>10)"] + D --> E["i=4: dp=12\n偷第4间(11+1=12>11)"] + end + + style A fill:#dbeafe,stroke:#2563eb + style E fill:#dcfce7,stroke:#16a34a +``` + +> [!note] 📝 关键洞察 +> 注意 `i=3` 时选择不偷——这不是因为你贪心不足,而是因为保留之前的 11 比冒险加 3(需要放弃 9)更划算。**动态规划的精髓就在这种局部最优累积成全局最优的过程**。 + +### 第五步:空间优化 + +观察状态转移方程: + +> `dp[i] = max(dp[i-1], dp[i-2] + nums[i])` + +计算 `dp[i]` 只用到了前两个值,和 [[81-爬楼梯]] 的滚动变量优化思路完全一致。所以可以把空间从 O(n) 降到 O(1): + +| 变量 | 含义 | 初始值 | +|------|------|--------| +| `prev2` | dp[i-2] | 0("虚拟的前前置状态")| +| `prev1` | dp[i-1] | 0("虚拟的前置状态")| + +用一个统一的循环来处理所有情况——**让 `prev1` 始终代表"考虑完前几间房的最优结果"**,遇到新房子时决定要不要加上它的钱。 + +```mermaid +flowchart TD + subgraph "O(1) 空间优化版本" + start("遍历每间房") --> decide{"偷还是 +不偷?"} + decide -->|"偷: prev2 + +当前金额"| take["new = prev2 + +nums[i]"] + decide -->|"不偷: +保持 prev1"| keep["keep = prev1"] + take --> pick["dp[i] = max(take, keep)"] + keep --> pick + pick --> roll["prev2 = prev1
prev1 = new"] + roll --> check{i < n?} + check -->|"是"| back["prev2←prev1
prev1←dp[i]"] + check -->|"否"| endNode["返回 prev1"] + back --> decide + end + + style start fill:#dbeafe,stroke:#2563eb + style endNode fill:#dcfce7,stroke:#16a34a +``` + +## 代码提示 + +在动手写代码之前,想一想这些关键决策点: + +> [!question] 💡 思考 1 +> 边界条件可以统一处理吗?还是需要特殊判断 `n == 1` 的情况? + +可以尝试一种更简洁的方式:初始化 `prev2 = 0`、`prev1 = 0`,然后对所有房子统一执行 `max(prev1, prev2 + num)` 的逻辑。这样即使只有一间房也能正确计算,无需额外分支。 + +> [!question] 💡 思考 2 +> Go 语言中 `max` 函数怎么写?Go 标准库有没有内置的 `max`? + +Go 1.21+ 的标准库已经内置了泛型 `max` 函数。如果项目使用旧版本,手动实现也很简单——比较两个整数取较大值即可。 + +## 技巧 + +### 通用的"选与不选"框架 + +打家劫舍模型可以抽象为一道通用模式——**在一条线上做决策,相邻元素不能同时选取**: + +1. **定义状态**:`dp[i]` = 考虑前 i 个元素的最优解 +2. **列转移**:偷 vs 不偷 → `max(dp[i-1], dp[i-2] + nums[i])` +3. **铺边界**:确定前 1~2 个元素的基准值 +4. **滚空间**:观察是否只需要常数额外空间 + +这个框架直接适用于很多变体问题: + +| 变体 | 变化点 | 调整方式 | +|------|--------|----------| +| House Robber II(环形排列) | 首尾房屋相邻 | 拆成两趟线性 DP | +| House Robber III(二叉树结构) | 树形结构 | 改为树形 DP,每个节点返回[偷/不偷]两个状态 | +| 删除并获得点数 | 数值连续则冲突 | 等价于打家劫舍模型(先统计点数) | + +### 为什么不需要"看三步"? + +有人可能会问:是不是需要考虑 `dp[i-3]`?比如跳过两间再偷? + +不需要!原因是:**如果你跳过了两间(不偷 i-1 也不偷 i-2),那一定偷了 i-3**。而 `dp[i-2]` 本身就已经是考虑前 i-2 间的最优解——它要么包含了偷 i-3 的方案,要么不包含。无论哪种,`dp[i-2] + nums[i]` 都已经覆盖了这个情况。 + +> **动态规划的本质不是穷举所有可能,而是证明:某些"看似遗漏"的路径已经被前面的最优子结构涵盖了。** + +## 代码 + +### Go 语言实现 + +#### 版本一:O(n) 空间(清晰直观) + +```go +// rob returns the maximum amount you can rob from houses arranged in a row. +// You cannot rob two adjacent houses. +func rob(nums []int) int { + n := len(nums) + if n == 0 { + return 0 + } + if n == 1 { + return nums[0] + } + + // dp[i] 表示考虑前 i+1 间房子(第 0 ~ i 间)能偷到的最高金额 + dp := make([]int, n) + + // 边界条件 + dp[0] = nums[0] + dp[1] = max(nums[0], nums[1]) + + // 自底向上填表 + for i := 2; i < n; i++ { + // 选或不选:偷当前间 (dp[i-2]+nums[i]) vs 不偷 (dp[i-1]) + dp[i] = max(dp[i-1], dp[i-2]+nums[i]) + } + + return dp[n-1] +} +``` + +#### 版本二:O(1) 空间(推荐 ✅) + +```go +// rob returns the maximum amount you can rob from houses arranged in a row. +// Space optimized to O(1) using rolling variables. +func rob(nums []int) int { + prev2 := 0 // 相当于 dp[i-2],初始为"不存在的前置状态" + prev1 := 0 // 相当于 dp[i-1],初始为"不存在的前置状态" + + for _, num := range nums { + // 当前最优 = max(不偷这间→prev1, 偷这间→prev2+num) + cur := max(prev1, prev2+num) + // 滚动更新 + prev2 = prev1 + prev1 = cur + } + + return prev1 +} +``` + +### 执行过程演示(版本二,nums = [2, 7, 9, 3, 1]) + +``` +初始: prev2=0, prev1=0 + +num=2: cur=max(0, 0+2)=2 → prev2=0, prev1=2 ← 偷第0间 +num=7: cur=max(2, 0+7)=7 → prev2=2, prev1=7 ← 改偷第1间(更赚) +num=9: cur=max(7, 2+9)=11 → prev2=7, prev1=11 ← 偷第2间(2+9=11) +num=3: cur=max(11, 7+3)=11 → prev2=11, prev1=11 ← 不偷第3间(保留11) +num=1: cur=max(11, 11+1)=12 → prev2=11, prev1=12 ← 偷第4间(11+1=12) + +返回 prev1 = 12 ✅ +``` + +### 复杂度分析 + +- **时间复杂度:O(n)** — 遍历一次数组,每次只做常数次操作 +- **空间复杂度:O(n) → O(1)** — 通过滚动变量将辅助空间降为常数级 + +## 举一反三 + +这道题是 **"线性 DP + 选与不选"** 的经典模板,几乎所有后续变体都建立在此之上: + +- [[81-爬楼梯]] — 同样是线性 DP,转移来自相邻的两个前驱状态 +- House Robber II(LeetCode 213)— 房屋变成环形排列,需要将问题拆成两趟线性 DP(偷首不偷尾 / 偷尾不偷首) +- House Robber III(LeetCode 337)— 房屋排列成二叉树,改为树形 DP +- 2130. 链表最大孪生和 — 虽然表面不同,但链表中"删与不删"的选择思路与之相通 + +> [!summary] 📌 本节要点 +> 1. 状态定义:dp[i] = 考虑前 i+1 间房子的最优解 +> 2. 状态转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i]) —— 选或不选 +> 3. 空间优化:滚动变量替代数组,O(n)→O(1),与爬楼梯异曲同工 +> 4. 通用框架:线性结构上的"相邻不可同时选取"问题均可套用此模式 diff --git a/动态规划/84-完全平方数.md b/动态规划/84-完全平方数.md new file mode 100644 index 0000000..9750283 --- /dev/null +++ b/动态规划/84-完全平方数.md @@ -0,0 +1,253 @@ +--- +tags: [算法/动态规划, 基础DP, 完全背包, 数学/数论] +create time: 2026-05-16 19:00 +--- + +# 84. 完全平方数(Perfect Squares) + +> [!quote] LeetCode 原题 · 中等 +> 给你一个整数 `n`,返回**和为 n 的完全平方数的最少数量**。 +> +> **完全平方数**是一个整数,其值等于另一个整数的平方。例如:1、4、9、16。 +> +> - 输入范围:`1 <= n <= 10⁴` + +## 题面 + +**示例 1:** + +``` +输入:n = 12 +输出:3 +解释:12 = 4 + 4 + 4 +``` + +**示例 2:** + +``` +输入:n = 13 +输出:2 +解释:13 = 4 + 9 +``` + +## 思路 + +### 第一步:把问题翻译成 DP 语言 + +> [!question] 💡 思考 +> 如果要凑出数字 n,你手里有 {1, 4, 9, 16, ...} 这些面额无限的"硬币"——这和哪类经典模型一致? + +没错,这就是一个**「最小硬币数」版本**的完全背包问题: + +- **物品集合**:所有不超过 n 的完全平方数 → {1², 2², 3², ..., k²},其中 k² ≤ n +- **每种物品可以无限使用** +- **目标**:选若干个物品使其总和恰好为 n,且物品个数最少 + +定义状态: + +> `dp[i]` = 和为 i 的完全平方数的最少数量 + +那状态转移呢?想象你已经凑到了 i,最后一步一定是从某个 i - j² 加上一个 j² 过来的: + +> `dp[i] = min(dp[i - j*j]) + 1`,对所有满足 j² ≤ i 的 j 取最小值 + +> [!tip] 🧠 直观理解 +> 画个例子:`dp[12]` 考虑最后加入的完全平方数可能是 1, 4, 9: +> - 加 1:`dp[12] = dp[11] + 1` +> - 加 4:`dp[12] = dp[8] + 1` +> - 加 9:`dp[12] = dp[3] + 1` +> +> 三者取最小即可。 + +```mermaid +flowchart TD + subgraph "dp[12] 的状态转移" + A["dp[12]\n= min(...) + 1"] --> B["从 dp[11] + 1\n(最后加 1)"] + A --> C["从 dp[8] + 1\n(最后加 4)"] + A --> D["从 dp[3] + 1\n(最后加 9)"] + B --> E["dp[11]=4 → ans=5"] + C --> F["dp[8]=2 → ans=3 ✓最优"] + D --> G["dp[3]=3 → ans=4"] + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style F fill:#dcfce7,stroke:#16a34a +``` + +### 第二步:确定边界条件 + +> [!question] 💡 思考 +> dp[0] 应该是多少?为什么? + +`dp[0] = 0` ——和为 0 不需要任何完全平方数,答案是 0。这是所有状态的"空起点"。没有它,整个递推就没有根基。 + +### 第三步:自底向上填表 + +以 `n = 12` 为例完整走一遍—— + +| i | dp[i] 计算过程 | dp[i] | +|---|--------------|-------| +| 0 | — | **0** | +| 1 | dp[0]+1 | **1** | +| 2 | dp[1]+1 | **2** | +| 3 | dp[2]+1 = dp[0]+1 | **2** | +| 4 | dp[3]+1, dp[0]+1 | **1** ← 本身就是平方数! | +| 5 | dp[4]+1 = dp[1]+1 | **2** | +| 6 | dp[5]+1 = 3, dp[2]+1 = 3 | **3** | +| 7 | dp[6]+1 = dp[3]+1 | **3** | +| 8 | dp[7]+1 = dp[4]+1 | **2** ← 4+4 | +| 9 | dp[8]+1, dp[5]+1, dp[0]+1 | **1** ← 本身是平方数 | +| 10 | dp[9]+1 = dp[6]+1 | **3** | +| 11 | dp[10]+1 = dp[7]+1 | **3** | +| 12 | dp[11]+1 = dp[8]+1 = dp[3]+1 | **3** ← 4+4+4 | + +```mermaid +flowchart LR + subgraph "填表示意 n=12" + A["i=0: 0"] --> B["i=1: 1"] + B --> C["i=2: 2"] + C --> D["i=3: 2"] + D --> E["i=4: 1 ★"] + E --> F["i=5: 2"] + F --> G["i=6: 3"] + G --> H["i=7: 3"] + H --> I["i=8: 2 ★"] + I --> J["i=9: 1 ★"] + J --> K["i=10: 3"] + K --> L["i=11: 3"] + L --> M["i=12: 3 ★答案"] + end + + style A fill:#f3f4f6,stroke:#6b7280 + style E fill:#fef3c7,stroke:#f59e0b + style I fill:#fef3c7,stroke:#f59e0b + style J fill:#fef3c7,stroke:#f59e0b + style M fill:#dcfce7,stroke:#16a34a +``` + +> 表中 ★ 标记表示该位置本身就是一个完全平方数,直接取 1。 + +## 代码提示 + +在动手写代码之前,想一想这些关键决策点: + +> [!question] 💡 思考 1 +> 内层循环中,j 的范围应该是多少?怎么高效地枚举"所有不超过 i 的完全平方数"? + +j 从 1 开始,只要 `j*j ≤ i` 就继续。这样 `dp[i - j*j]` 不会越界。每次循环只用一次乘法和一次比较,效率很高。 + +> [!question] 💡 思考 2 +> Go 中没有内置的 min 对多个值取最小——你会怎么写? + +可以先用一个变量 `ans` 初始化为无穷大(比如 `i` 本身,因为最坏情况是全用 1),然后在循环中逐个更新最小值。这样既简洁又避免了引入额外依赖。 + +## 技巧 + +### 剪枝:提前识别完全平方数 + +> [!tip] ⚡ 小优化 +> 如果 i 本身就是一个完全平方数(即存在某个 j 使得 j*j = i),那么 `dp[i] = 1`,可以直接跳过内层循环。 + +这个判断只需一行:`if int(math.Sqrt(float64(i)))*int(math.Sqrt(float64(i))) == i { dp[i] = 1; continue }`。虽然不影响渐近复杂度,但能加速一半以上的填充。 + +### Lagrange 四平方定理(进阶) + +> [!abstract] 🔢 数论彩蛋:Lagrange 四平方定理 +> **任意正整数都可以表示为不超过 4 个完全平方数之和。** +> +> 这意味着答案永远 ∈ {1, 2, 3, 4}。配合 Legendre 三平方定理,可以做到 O(√n) 时间直接求解: +> +> 1. 先消去因子 4(反复除以 4) +> 2. 检查余数是否为 `4ᵏ(8m+7)`——如果是,答案就是 4 +> 3. 否则尝试能否拆成两个平方数之和——能则是 2 +> 4. 其余情况都是 3 + +这个数学解法速度极快,但在面试中可能不容易当场推导出来。掌握 DP 方法已足够通过绝大多数场景。 + +| 方法 | 时间 | 空间 | 评价 | +|------|------|------|------| +| DP 填表(推荐) | O(n√n) | O(n) | 通用易懂,面试首选 | +| BFS | O(n√n) | O(n) | 第一层到达的答案必是最优,同样高效 | +| 数学法(四平方定理) | O(√n) | O(1) | 最快,但需记忆定理 | + +## 代码 + +### Go 语言实现 + +```go +// numSquares returns the minimum number of perfect squares that sum to n. +func numSquares(n int) int { + // dp[i] = 和为 i 的完全平方数的最少数量 + dp := make([]int, n+1) + + for i := 1; i <= n; i++ { + // 初始化:最坏情况是全用 1 来凑 + dp[i] = i + + // 尝试每一种可能的最后一步 j*j + for j := 1; j*j <= i; j++ { + sum := dp[i-j*j] + 1 + if sum < dp[i] { + dp[i] = sum + } + } + } + + return dp[n] +} +``` + +> [!summary] 💡 代码说明 +> - **外层循环**:从左到右依次计算 dp[1], dp[2], ..., dp[n],保证每个子问题在用到时已经被解决 +> - **内层循环**:枚举所有可能的 j(1, 2, 3, ...),直到 j² > i 为止。每次考虑"最后一步加了 j²"这个选择 +> - **初始值 dp[i] = i**:最坏情况是用 i 个 1 来凑(如 7 = 1+1+1+1+1+1+1)。内层循环一定能找到更优解 + +### 执行过程演示(n = 12) + +``` +i=1: j=1: dp[0]+1=1 → dp[1]=1 ← 1=1 +i=2: j=1: dp[1]+1=2 → dp[2]=2 ← 2=1+1 +i=3: j=1: dp[2]+1=3 → dp[3]=3 ← 3=1+1+1 (j=2时 4>3,停止) +i=4: j=1: dp[3]+1=4 + j=2: dp[0]+1=1 → dp[4]=1 ← 4=4 ★ +i=5: j=1: dp[4]+1=2 + j=2: dp[1]+1=2 → dp[5]=2 ← 5=4+1 +i=6: j=1: dp[5]+1=3 + j=2: dp[2]+1=3 → dp[6]=3 ← 6=4+1+1 +i=7: j=1: dp[6]+1=4 + j=2: dp[3]+1=4 → dp[7]=4 ← 7=4+1+1+1 +i=8: j=1: dp[7]+1=5 + j=2: dp[4]+1=2 → dp[8]=2 ← 8=4+4 ★ +i=9: j=1: dp[8]+1=3 + j=2: dp[5]+1=3 + j=3: dp[0]+1=1 → dp[9]=1 ← 9=9 ★ +i=10: j=1: dp[9]+1=2 + j=2: dp[6]+1=4 + j=3: dp[1]+1=2 → dp[10]=2 ← 10=9+1 +i=11: j=1: dp[10]+1=3 + j=2: dp[7]+1=5 + j=3: dp[2]+1=3 → dp[11]=3 ← 11=9+1+1 +i=12: j=1: dp[11]+1=4 + j=2: dp[8]+1=3 → dp[12]=3 ← 12=4+4+4 + +答案:dp[12] = 3 ✅ +``` + +### 复杂度分析 + +- **时间复杂度:O(n√n)** — 外层循环 n 次,内层循环最多 √n 次,总操作量约为 Σ(√i) ≈ (2/3)n^(3/2) +- **空间复杂度:O(n)** — dp 数组占用 n+1 个整型空间 + +## 举一反三 + +这道题是 **线性 DP + 枚举最后一项** 的典型范式,同时也是「完全背包」思想的第一道落地应用: + +- [[81-爬楼梯]] — 同样是线性 DP,但这里的"选择"不是固定两种,而是从一组可选值中选 +- [[83-打家劫舍]] — 同样是逐状态递推,但约束不同(不能相邻 vs 无约束) +- LeetCode 322. 零钱兑换 — 完全背包的标准题,模板几乎一模一样(只是面额换成 coin value) + +> [!summary] 📌 本节要点 +> 1. 定义 dp[i] = 和为 i 的完全平方数最少个数 +> 2. 状态转移:dp[i] = min(dp[i - j²]) + 1,枚举所有 j² ≤ i +> 3. 边界 dp[0] = 0,初始值 dp[i] = i(全用 1) +> 4. 复杂度 O(n√n),可以用 Lagrange 四平方定理做到 O(√n) diff --git a/动态规划/85-零钱兑换.md b/动态规划/85-零钱兑换.md new file mode 100644 index 0000000..8ee00ee --- /dev/null +++ b/动态规划/85-零钱兑换.md @@ -0,0 +1,324 @@ +--- +tags: [算法/动态规划, 完全背包, 基础DP] +create time: 2026-05-16 19:00 +--- + +# 322. 零钱兑换(Coin Change) + +> [!quote] LeetCode 原题 · 中等 +> 给你一个整数数组 `coins`,表示不同面额的硬币;以及一个整数 `amount`,表示总金额。 +> +> 计算并返回可以凑成总金额所需的 **最少的硬币个数**。如果没有任何一种硬币组合能组成总金额,返回 -1 。 +> +> 你可以认为每种硬币的数量是无限的。 +> +> - 输入范围:`1 <= coins.length <= 12`,`1 <= coins[i] <= 2³¹ - 1`,`0 <= amount <= 10⁴` + +## 题面 + +**示例 1:** + +``` +输入:coins = [1, 2, 5], amount = 11 +输出:3 +解释:11 = 5 + 5 + 1 +``` + +**示例 2:** + +``` +输入:coins = [2], amount = 3 +输出:-1 +``` + +**示例 3:** + +``` +输入:coins = [1], amount = 0 +输出:0 +``` + +## 思路 + +### 第一步:识别问题模型 + +> [!question] 💡 思考 +> 题目说"每种硬币数量无限",目标是"最少硬币数凑出 amount"——这和之前哪道题的内核一致? + +没错!**[[84-完全平方数]]** 和这道题的本质一模一样:都是给定一组"面额",每种可以无限取用,要求凑出目标值的最小件数。 + +我们可以把它看作一道**「最小物品数」版本的完全背包**: + +| DP 要素 | 含义 | +|---------|------| +| **物品集合** | 所有硬币面额 coins[0..m-1] | +| **背包容量** | amount | +| **每种物品数量** | 无限 | +| **价值定义** | 每件物品的"代价"为 1(一枚硬币算一次) | +| **优化目标** | 总代价最小 | + +### 第二步:定义状态与转移方程 + +定义状态: + +> `dp[i]` = 凑出金额 i 所需的最少硬币个数 + +那状态怎么转移?考虑"最后一步拿的是哪枚硬币"——假设最后拿的是 `coins[j]`,那么之前的金额就是 `i - coins[j]`,需要的硬币数是 `dp[i - coins[j]]`。所以: + +> **`dp[i] = min(dp[i - coins[j]]) + 1`**,对所有满足 `coins[j] ≤ i` 的 j 取最小值 + +核心直觉很简单: + +> 要凑到金额 i,你一定会拿起最后一枚硬币。枚枚举这枚硬币是什么,剩下交给子问题。 + +```mermaid +flowchart TD + subgraph "dp[11] 的状态转移 (coins=[1,2,5])" + A["dp[11]\n= min(...) + 1"] --> B["最后拿 1:\ndp[10] + 1"] + A --> C["最后拿 2:\ndp[9] + 1"] + A --> D["最后拿 5:\ndp[6] + 1"] + B --> E["dp[10]=2 → ans=3"] + C --> F["dp[9]=2 → ans=3"] + D --> G["dp[6]=2 → ans=3 ✓"] + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style E fill:#dcfce7,stroke:#16a34a + style F fill:#dcfce7,stroke:#16a34a + style G fill:#dcfce7,stroke:#16a34a +``` + +> [!note] 📝 为什么这是正确的? +> 因为"完全背包"有一个关键性质:**最优子结构**。凑出金额 i 的最优方案中,去掉最后一枚硬币后,剩余部分一定也是对应金额的最优方案。否则我们可以用更优的子方案替换它,得到更好的整体解——矛盾。 + +### 第三步:确定边界条件 + +| 情况 | dp 值 | 理由 | +|------|-------|------| +| `dp[0]` | **0** | 凑出金额 0 不需要任何硬币,这是递推的"空起点" | +| 其他 `dp[i]` | **+∞(不可达)** | 初始化为无穷大,表示还未找到可行方案 | + +> [!warning] ⚠️ 注意"不可达"的处理 +> 如果最终 `dp[amount]` 仍然是无穷大,说明无法凑出该金额,应返回 **-1**。例如 coins = [2], amount = 3 时,dp[3] 永远不会被更新。 + +### 第四步:自底向上填表 + +以 `coins = [1, 2, 5], amount = 11` 为例: + +| i | dp[i] 计算过程 | dp[i] | +|---|--------------|-------| +| 0 | — | **0** | +| 1 | min(dp[0]+1) | **1** | +| 2 | min(dp[1]+1, dp[0]+1) | **1** | +| 3 | min(dp[2]+1, dp[1]+1) | **2** | +| 4 | min(dp[3]+1, dp[2]+1, dp[-1]无效) | **2** | +| 5 | min(dp[4]+1, dp[3]+1, dp[0]+1) | **1** ← 正好有面额 5! | +| 6 | min(dp[5]+1, dp[4]+1, dp[1]+1) | **2** | +| 7 | min(dp[6]+1, dp[5]+1, dp[2]+1) | **2** | +| 8 | min(dp[7]+1, dp[6]+1, dp[3]+1) | **3** | +| 9 | min(dp[8]+1, dp[7]+1, dp[4]+1) | **3** | +| 10 | min(dp[9]+1, dp[8]+1, dp[5]+1) | **2** | +| 11 | min(dp[10]+1, dp[9]+1, dp[6]+1) | **3** ← 答案 | + +```mermaid +flowchart LR + subgraph "填表示意" + A["dp[0]=0"] --> B["dp[1]=1"] + B --> C["dp[2]=1"] + C --> D["dp[3]=2"] + D --> E["dp[4]=2"] + E --> F["dp[5]=1 ★"] + F --> G["dp[6]=2"] + G --> H["dp[7]=2"] + H --> I["dp[8]=3"] + I --> J["dp[9]=3"] + J --> K["dp[10]=2"] + K --> L["dp[11]=3\n★答案"] + end + + style A fill:#f3f4f6,stroke:#6b7280 + style F fill:#fef3c7,stroke:#f59e0b + style L fill:#dcfce7,stroke:#16a34a +``` + +> 表中 ★ 标记表示可以直接用一枚硬币凑出(amount 本身在硬币列表中)。 + +## 代码提示 + +在动手写代码之前,想一想这些关键决策点: + +> [!question] 💡 思考 1 +> Go 语言中没有"无穷大"常量——你会用什么值来表示不可达? + +可以用 `amount + 1`:因为最多只需要 amount 个 1 元硬币就能凑出任意 amount,所以 **超过 amount 的硬币数一定是无意义的**。这个巧妙的设计让判断"是否可达"变得非常简单——只需检查 `dp[amount] > amount`。 + +> [!question] 💡 思考 2 +> 内层循环中,如何避免负索引越界? + +对每个硬币 `coin`,只有当 `i >= coin` 时才考虑从 `dp[i-coin]` 转移。这样天然避免了索引问题。 + +## 技巧 + +### "无穷大"的巧妙取值 + +> [!tip] ⚡ 小技巧:为什么选 amount+1 作为无穷大? +> +> 如果全部使用 1 元硬币,凑出 amount 需要恰好 amount 枚。**任何合理的方案都不会超过这个数字**。因此: +> - `dp[i] > amount` → 说明没有找到可行方案(不可达) +> - `dp[i] <= amount` → 这就是真正的最优解 + +这样既避免了引入额外常量,又简化了最终结果的判断逻辑。 + +### BFS 替代方案 + +> [!abstract] 🔍 进阶视角:BFS 求最短路径 +> +> 另一种等价的建模方式是把金额看作节点、把每次加一枚硬币看作边。从 amount 开始做 BFS,第一次到达 0 时的层数就是答案。 +> +> - **优势**:BFS 天然保证第一层到达的就是最优解,无需全部填完表格 +> - **劣势**:最坏空间复杂度 O(amount),不如 DP 稳定 +> +> 面试中推荐 DP 解法,但知道 BFS 思路可以增加灵活性。 + +### 剪枝优化 + +> [!tip] ⚡ 小优化:先排序硬币 +> +> 将硬币按面额从大到小排序后,内层循环可以先尝试大面额硬币。虽然不影响渐近时间复杂度,但在实际测试数据中往往能更快找到较优的中间解,减少后续比较的次数。 + +| 方法 | 时间 | 空间 | 评价 | +|------|------|------|------| +| DP 填表(推荐) | O(amount × m) | O(amount) | 直观通用,面试首选 | +| BFS | O(amount × m) | O(amount) | 第一层到达即最优,略快 | +| 带记忆化的 DFS | O(amount × m) | O(amount) | 递归写法,代码简洁但有栈溢出风险 | + +其中 m = coins.length。 + +## 代码 + +### Go 语言实现 + +#### 版本一:自底向上 DP(推荐 ✅) + +```go +// coinChange returns the minimum number of coins to make up the given amount. +// If it's impossible, returns -1. +func coinChange(coins []int, amount int) int { + // maxCoins 充当"无穷大":最多只用 amount 个 1 元硬币 + maxCoins := amount + 1 + + // dp[i] = 凑出金额 i 所需的最少硬币个数 + dp := make([]int, amount+1) + + // 初始化:全部设为"不可达" + for i := 1; i <= amount; i++ { + dp[i] = maxCoins + } + // 边界:凑出金额 0 不需要硬币 + dp[0] = 0 + + // 自底向上填表:先枚举金额,再枚举硬币 + for i := 1; i <= amount; i++ { + for _, coin := range coins { + if i < coin { + continue // 当前硬币面额大于目标金额,跳过 + } + // 选或不选这枚硬币:dp[i] = min(不用这枚, 用这枚 + 1) + used := dp[i-coin] + 1 + if used < dp[i] { + dp[i] = used + } + } + } + + // 如果 dp[amount] 仍为无穷大,说明无法凑出 + if dp[amount] > amount { + return -1 + } + return dp[amount] +} +``` + +> [!summary] 💡 代码结构拆解 +> - **外层 for i**:从左到右依次计算 dp[1]...dp[amount],确保每个子问题在用到时已解决 +> - **内层 for coin**:枚举"最后一枚硬币可能是哪个",从多个可能的前驱状态中选最小值 +> - **空间顺序**:先金额后硬币(也可以反过来,因为是完全背包,两种遍历顺序都正确) + +#### 版本二:精简写法 + +```go +// coinChange returns the minimum number of coins to make up the given amount. +// Space and code simplified version. +func coinChange(coins []int, amount int) int { + dp := make([]int, amount+1) + + // Go 的 slice 零值初始化为 0,恰好 dp[0]=0 符合预期 + // 其余位置需要手动标记为"不可达" + for i := range dp { + if i > 0 { + dp[i] = amount + 1 + } + } + + for i := 1; i <= amount; i++ { + for _, c := range coins { + if i >= c { + v := dp[i-c] + 1 + if v < dp[i] { + dp[i] = v + } + } + } + } + + if dp[amount] > amount { + return -1 + } + return dp[amount] +} +``` + +### 执行过程演示(coins = [1, 2, 5], amount = 11) + +``` +初始: dp[0]=0, 其余均为 ∞(=12) + +i=1: coin=1: dp[0]+1=1 → dp[1]=1 +i=2: coin=1: dp[1]+1=2 → dp[2]=2 + coin=2: dp[0]+1=1 → dp[2]=1 ★更新 +i=3: coin=1: dp[2]+1=2 → dp[3]=2 + coin=2: dp[1]+1=2 → 不更新(相等) +i=4: coin=1: dp[3]+1=3 → dp[4]=3 + coin=2: dp[2]+1=2 → dp[4]=2 ★更新 +i=5: coin=1: dp[4]+1=3 → dp[5]=3 + coin=2: dp[3]+1=3 → 不更新 + coin=5: dp[0]+1=1 → dp[5]=1 ★更新(正好面额5!) +i=6: coin=5: dp[1]+1=2 → dp[6]=2 +...(以此类推)... +i=10: coin=5: dp[5]+1=2 → dp[10]=2 +i=11: coin=5: dp[6]+1=3 → dp[11]=3 + +结果: dp[11] = 3 ✅ (11 = 5+5+1) +``` + +### 复杂度分析 + +- **时间复杂度:O(amount × m)** — 外层循环 amount 次,内层循环 m 次(m 为硬币种类数),每次只做常数次操作。代入题目约束:最多 10⁴ × 12 = 1.2 × 10⁵ 次操作 +- **空间复杂度:O(amount)** — dp 数组占用 amount+1 个整型空间 + +## 举一反三 + +这道题是 **"完全背包"** 思想的第一道落地应用,也是 DP 入门必刷的经典: + +- [[84-完全平方数]] — 本质相同的"最小件数"问题,只是面额集合换成完全平方数 +- [[83-打家劫舍]] — 同样是逐状态递推,但约束是"不相邻"而非"无限选取" +- LeetCode 279. 完全平方数 — 同上一题 +- LeetCode 518. 零钱兑换 II —— **变种**:改为求"组合数"而非"最少个数",转移方程变为累加而非取 min +- LeetCode 377. 组合总和 Ⅳ —— **变种**:排列数和组合数的区别(外层枚举目标和 vs 外层枚举物品) + +> [!summary] 📌 本节要点 +> 1. 定义 dp[i] = 凑出金额 i 的最少硬币个数 +> 2. 状态转移:dp[i] = min(dp[i - coin]) + 1,枚举每枚可能的最后硬币 +> 3. 边界 dp[0] = 0,初始化 dp[i] = amount + 1(表示不可达) +> 4. 最终判断:若 dp[amount] > amount 则返回 -1 +> 5. 复杂度 O(amount × m),属于典型的完全背包最小化问题 diff --git a/动态规划/86-单词拆分.md b/动态规划/86-单词拆分.md new file mode 100644 index 0000000..ab6623a --- /dev/null +++ b/动态规划/86-单词拆分.md @@ -0,0 +1,335 @@ +--- +tags: [算法/动态规划, 字符串DP, 哈希表优化] +create time: 2026-05-16 19:30 +--- + +# 139. 单词拆分(Word Break) + +> [!quote] LeetCode 原题 · 中等 +> 给你一个字符串 `s` 和一个字符串列表 `wordDict` 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 `s`,则返回 `true`。 +> +> **不要求**字典中出现的单词全部都使用,并且字典中的单词可以重复使用。 +> +> - 输入范围:`1 <= s.length <= 300`,`1 <= wordDict.length <= 1000`,`1 <= wordDict[i].length <= 20` +> - `s` 和 `wordDict[i]` 仅由小写英文字母组成 +> - `wordDict` 中的所有字符串互不相同 + +## 题面 + +**示例 1:** + +``` +输入:s = "leetcode", wordDict = ["leet", "code"] +输出:true +解释:"leetcode" 可以由 "leet" 和 "code" 拼接成。 +``` + +**示例 2:** + +``` +输入:s = "applepenapple", wordDict = ["apple", "pen"] +输出:true +解释:"applepenapple" 可以由 "apple" "pen" "apple" 拼接成(单词可重复使用)。 +``` + +**示例 3:** + +``` +输入:s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] +输出:false +解释:虽然 "cats"、"dog" 等都能匹配到部分子串,但无法完整拼出整个字符串。 +``` + +## 思路 + +### 第一步:识别问题模型 + +> [!question] 💡 思考 +> 把字符串从某个位置切开——左边能用字典拼出来,右边恰好也是一个字典里的词——整段就能拼出来。这种"大问题拆成更小的同类子问题"的结构,指向哪类经典算法模型? + +没错,这是 **线性 DP + 枚举分割点** 的经典范式。核心直觉: + +> 要判断前 i 个字符能否被拆分,我们只需枚举最后一个词的起始位置 j —— 如果前 j 个字符能拆,且第 j+1 到第 i 个字符组成的子串在字典里,那么前 i 个字符也能拆。 + +### 第二步:定义状态与转移方程 + +定义状态: + +> `dp[i]` = 字符串 `s` 的前 i 个字符能否用字典中的词拼接(布尔值) + +状态转移: + +> **`dp[i] = OR(dp[j] && s[j..i-1] ∈ wordDict)`**,对所有 `0 ≤ j < i` + +其中 `s[j..i-1]` 表示从索引 j 开始、长度为 i-j 的子串。 + +```mermaid +flowchart TD + subgraph "dp[8] 的拆解过程 s = 'leetcode'" + A["dp[8]\n='leetcode'是否能拆分?"] --> B["j=0:\ndp[0]&&s[0..7]='leetcode'\nT && 不在字典 → ✗"] + A --> C["j=4:\ndp[4]&&s[4..7]='code'\nT && 在字典 ✓"] + A --> D["j=其他位置\n均无法同时满足两个条件\n→ ✗"] + B --> E["dp[8]=✗暂不成立"] + C --> F["dp[8]=✓\n找到一种可行拆分!\nleet + code"] + D --> G["继续检查..."] + F --> H["结果为 true ✅"] + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style C fill:#dcfce7,stroke:#16a34a + style F fill:#dcfce7,stroke:#16a34a + style H fill:#dcfce7,stroke:#16a34a +``` + +> [!note] 📝 为什么这样是正确的? +> 因为每个合法拆分都可以唯一地看作"前面若干个词 + 最后一个词"的结构。我们枚举的就是这个"最后一个词"的起始位置。只要存在一种枚举使得两边都满足条件,答案就是 true。这体现了最优子结构性质——即使这里是布尔值而非数值。 + +### 第三步:确定边界条件 + +| 情况 | dp 值 | 理由 | +|------|-------|------| +| `dp[0]` | **true** | 空字符串显然可以被拆分——它是所有状态的"起点"。没有它为 false,递推就无法启动。 | +| 其他 `dp[i]` | **false** | 初始化为不可达,只有在找到有效拆分时才翻转为 true。 | + +> [!warning] ⚠️ 关键细节:dp[0] = true +> 很多初学者会把 dp[0] 设为 false,这是致命错误。考虑 s = "leet", wordDict = ["leet"]——如果 dp[0] = false,那么 dp[4] 永远无法变为 true,因为唯一合法的拆分就是 dp[0] && "leet"。 + +### 第四步:自底向上填表 + +以 `s = "applepenapple", wordDict = ["apple", "pen"]` 为例,逐步走一遍: + +| i | 子串 s[0..i) | 可能的 j | 判定过程 | dp[i] | +|---|-------------|---------|---------|-------| +| 0 | "" | — | 边界 | **true** | +| 1 | "a" | j=0 | dp[0] && "a" → T && ✗ | **false** | +| 2 | "ap" | j=0,1 | 均无匹配 | **false** | +| 3 | "app" | j=0,1,2 | 均无匹配 | **false** | +| 4 | "appl" | j=0,1,2,3 | 均无匹配 | **false** | +| 5 | "apple" | j=0…4 | j=0: dp[0]&&"apple" → **T && ✓** | **true** ← 第一个匹配的完整词! | +| 6 | "applep" | j=0…5 | j=5: dp[5]&&"p" → T && ✗ | **false** | +| 7 | "applepe" | j=0…6 | j=5: dp[5]&&"pe" → T && ✗ | **false** | +| 8 | "applepen" | j=0…7 | j=5: dp[5]&&"pen" → **T && ✓** | **true** ← apple + pen | +| 9 | "applepena" | j=0…8 | 均无新增匹配 | **false** | +| 10 | "applepenap" | j=0…9 | 均无新增匹配 | **false** | +| 11 | "applepenapp" | j=0…10 | 均无新增匹配 | **false** | +| 12 | "applepenappl" | j=0…11 | 均无新增匹配 | **false** | +| 13 | "applepenapple" | j=0…12 | j=8: dp[8]&&"apple" → **T && ✓** | **true** ← apple + pen + apple | + +```mermaid +flowchart LR + A["dp[0]=true\n''"] -->|"加 apple"| B["dp[5]=true\n'apple'"] + B -->|"加 pen"| C["dp[8]=true\n'applepen'"] + C -->|"加 apple"| D["dp[13]=true\n'applepenapple' ★答案"] + + style A fill:#f3f4f6,stroke:#6b7280 + style B fill:#fef3c7,stroke:#f59e0b + style C fill:#fef3c7,stroke:#f59e0b + style D fill:#dcfce7,stroke:#16a34a +``` + +> 表中展示了**成功路径上的关键节点**。实际上在计算每个 dp[i] 时,算法会尝试所有 j 值,而不仅仅是这条成功路径上的跳跃点。 + +## 代码提示 + +在动手写代码之前,想一想这些关键决策点: + +> [!question] 💡 思考 1 +> 内层循环需要遍历所有 j < i,每次都要判断 `s[j..i-1]` 是否在字典中。Go 语言中怎么快速判断一个子串是否存在于集合中? + +可以用 `map[string]bool` 将字典构建成 O(1) 查找的哈希集合。然后用 Go 的切片操作 `s[j:i]` 提取子串并直接 map 查询。 + +> [!question] 💡 思考 2 +> 外层遍历 i 和内层遍历 j 的顺序能调换吗?先枚举 j 再枚举 i 是否仍然正确? + +不行。因为计算 `dp[i]` 时需要依赖所有 `dp[j]`(j < i)的结果,必须保证子问题的结果已被计算完毕。所以外层必须是 i,内层是 j。这也决定了这是一个**自底向上**的填表顺序。 + +## 技巧 + +### 提前终止优化 + +> [!tip] ⚡ 小优化:一旦 dp[n] 为 true 就可以提前退出 +> +> 如果在填表过程中发现 `dp[s.Len()] == true`,说明已经找到了可行解,可以直接返回,无需继续计算后续无关状态。这在大部分可解案例中能节省不少时间。 + +### 最大词长剪枝 + +> [!tip] ⚡ 进阶优化:j 的范围可以限制在 `[i-maxLen, i)` 之间 +> +> 字典中最长单词的长度是 maxLen。任何超过 maxLen 的子串都不可能在字典中找到。因此 j 不必从 0 开始枚举,而是从 `max(0, i-maxLen)` 开始即可。 +> +> 结合题目约束 `maxLen ≤ 20`,内层循环最多只遍历 20 次,而不是 i 次。对于长字符串效果显著。 + +### Trie(前缀树)视角 + +> [!abstract] 🔍 进阶视角:从后往前用 Trie 加速 +> +> 另一种思路是从右往左用 Trie 匹配:对每个 i,沿着 Trie 从 s[i] 开始往下走,每到一个标记为"单词结尾"的节点 k,就检查 `dp[k]` 是否为 true。这样可以跳过大量无效的子串比对。 +> +> 空间代价更高(需要构建 Trie),但在字典很大、字符串很长的场景下优势明显。面试中推荐先用哈希表方案,如需优化再提 Trie。 + +| 方法 | 时间 | 空间 | 评价 | +|------|------|------|------| +| 哈希表 + DP(推荐) | O(n² · L) | O(n + Σ|w|) | 简洁直观,面试首选,L 为子串比较成本 | +| Trie + DP | O(n · maxLen) | O(n + 字典总字符数) | 适合大数据量场景 | + +> 注:O(n² · L) 中的 L 来自 Go 中 `s[j:i]` 创建新字符串的成本,平均约等于子串长度的一半。实际评测中 n ≤ 300,完全轻松通过。 + +## 代码 + +### Go 语言实现 + +#### 版本一:哈希表 + DP(推荐 ✅) + +```go +// wordBreak determines if s can be segmented into a space-separated sequence +// of one or more dictionary words. +func wordBreak(s string, wordDict []string) bool { + // 将字典转为哈希集合,实现 O(1) 子串查找 + ws := make(map[string]bool) + for _, w := range wordDict { + ws[w] = true + } + + n := len(s) + + // dp[i] = s[:i] 能否被字典中的词拼接 + dp := make([]bool, n+1) + + // 边界:空字符串可以被拆分 + dp[0] = true + + // 自底向上填表 + for i := 1; i <= n; i++ { + for j := 0; j < i; j++ { + // 如果前 j 个字符可以拆分,且 s[j:i] 在字典中 + if dp[j] && ws[s[j:i]] { + dp[i] = true + break // 找到一个合法拆分就够了 + } + } + } + + return dp[n] +} +``` + +> [!summary] 💡 代码结构拆解 +> - **建哈希集合**:O(Σ|w|),把数组字典转为 O(1) 查找的 map,这一步是整个优化的基石 +> - **外层 for i**:从左到右依次计算 dp[1] 到 dp[n],确保用到 dp[j] 时它已经被算好了 +> - **内层 for j**:枚举最后一个词的分割点。`dp[j] && ws[s[j:i]]` 双条件:前半截已拆好 + 后半截是合法词汇 +> - **break**:一旦发现一个合法的 j 就让 dp[i]=true 并跳出内层循环——不需要枚举更多了 + +#### 版本二:带最大词长剪枝 + +```go +// wordBreakWithPruning is an optimized version using the maximum word length +// to reduce the inner loop range. +func wordBreakWithPruning(s string, wordDict []string) bool { + ws := make(map[string]bool) + maxLen := 0 + for _, w := range wordDict { + ws[w] = true + if len(w) > maxLen { + maxLen = len(w) + } + } + + n := len(s) + dp := make([]bool, n+1) + dp[0] = true + + for i := 1; i <= n; i++ { + // j 的下限:子串 s[j:i] 的长度不能超过字典中最长单词 + start := i - maxLen + if start < 0 { + start = 0 + } + for j := start; j < i; j++ { + if dp[j] && ws[s[j:i]] { + dp[i] = true + break + } + } + } + + return dp[n] +} +``` + +> [!summary] 💡 两种版本的差异 +> - 版本二的内层循环最多只遍历 maxLen(≤20)次,而非全部 i 次。当 n=300 且 maxLen=20 时,内层次数从 300 降到 20,性能提升约 15 倍 +> - 代码略微复杂,但对本题 n ≤ 300 的限制来说两者都轻松 AC,优先使用版本一 + +### 执行过程演示(s = "leetcode") + +``` +wordDict = ["leet", "code"] → 哈希集合: {"leet":true, "code":true} +n = 8, dp 初始: [T, F, F, F, F, F, F, F, F] + +i=1: s[0:1]="l" + j=0: dp[0]&&ws["l"] → T && F → 不更新 + +i=2: s[0:2]="le" + j=0: dp[0]&&ws["le"] → T && F + j=1: dp[1]&&ws["e"] → F && ... → 跳过 + +i=3: s[0:3]="lee" + j=0: dp[0]&&ws["lee"] → T && F + j=1: dp[1]&&ws["ee"] → F && ... → 跳过 + j=2: dp[2]&&ws["e"] → F && ... → 跳过 + +i=4: s[0:4]="leet" ← 注意! + j=0: dp[0]&&ws["leet"] → T && T → dp[4]=true ✓! break + +i=5: s[0:5]="leetc" + j=0: dp[0]&&ws["leetc"] → T && F + j=1: dp[1]&&... → F 跳过 + j=2: dp[2]&&... → F 跳过 + j=3: dp[3]&&... → F 跳过 + j=4: dp[4]&&ws["c"] → T && F → 不更新 + +i=6: s[0:6]="leetcod" + j=4: dp[4]&&ws["cod"] → T && F → 不更新 + (其余 j 对应 dp[j]=F,跳过) + +i=7: s[0:7]="leetcode" + j=4: dp[4]&&ws["code"] → T && T → dp[7]=true ✓! break + + 等等——上面是 i=7 子串只有6个字母,修正: + +i=7: s[0:7]="leetcode" ← 错了,是7个字符 "leetco" + ... 都不匹配 → dp[7]=false + +i=8: s[0:8]="leetcode" + j=0: dp[0]&&ws["leetcode"] → T && F + j=1: dp[1]&&... → F 跳过 + ... + j=4: dp[4]&&ws["code"] → T && T → dp[8]=true ✓! break + +最终: dp[8] = true ✅ +拆分方式: "leet" + "code" +``` + +### 复杂度分析 + +- **时间复杂度:O(n² · L)** — 两层循环共 n(n+1)/2 次迭代,每次 substring 切割和哈希查找耗时 O(L)(L 为子串长度)。代入题目约束 n ≤ 300,最坏约 4.5 × 10⁴ 次操作 +- **空间复杂度:O(n + Σ|w|)** — dp 数组占用 O(n),哈希集合存储字典所有单词占用 O(Σ|w|) + +> 使用版本二(maxLen 剪枝)后时间复杂度降为 **O(n · maxLen · L)**,即 O(n · maxLen²),上限约 300 × 20² = 1.2 × 10⁵ 次操作。 + +## 举一反三 + +这道题是 **"线性 DP + 枚举分割点"** 的经典代表,也是理解字符串 DP 的入口题: + +- [[84-完全平方数]] — 同样是"枚举最后一项做决策"的 DP 模式,只是这里选的是"词"而非"数" +- [[85-零钱兑换]] — 本质相通:都是给定一组可选元素,判断/求是否能拼出目标。零钱兑换求最小数量,本题只求可行性(布尔版) +- LeetCode 140. 单词拆分 II —— **变种**:不只返回 true/false,而是返回所有可能的拆分结果,需要回溯枚举 +- LeetCode 464. 我能赢吗 —— 博弈论 + 状态压缩 DP,思想有相似之处 + +> [!summary] 📌 本节要点 +> 1. 定义 dp[i] = s[:i] 能否用字典词拼接 +> 2. 状态转移:dp[i] = OR(dp[j] && s[j:i] ∈ wordDict),枚举分割点 j +> 3. 边界 dp[0] = true,初始化其余为 false +> 4. 用哈希集合将字典查询降到 O(1),否则退化为 O(n² · m · L) +> 5. 可优化:maxLen 剪枝让内层循环最多跑 20 次;一旦 dp[n]=true 可提前终止 +> 6. 复杂度 O(n² · L),n ≤ 300 时非常高效 diff --git a/动态规划/87-最长递增子序列.md b/动态规划/87-最长递增子序列.md new file mode 100644 index 0000000..20422de --- /dev/null +++ b/动态规划/87-最长递增子序列.md @@ -0,0 +1,400 @@ +--- +tags: [算法/动态规划, 线性DP, 二分查找, 贪心] +create time: 2026-05-16 19:00 +--- + +# 300. 最长递增子序列(Longest Increasing Subsequence) + +> [!quote] LeetCode 原题 · 中等 +> 给你一个整数数组 `nums`,找到其中 **最长严格递增子序列** 的长度。 +> +> **子序列** 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。 +> +> - 输入范围:`1 <= nums.length <= 2500`,`-10⁴ <= nums[i] <= 10⁴` + +## 题面 + +**示例 1:** + +``` +输入:nums = [10,9,2,5,3,7,101,18] +输出:4 +解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。 +``` + +**示例 2:** + +``` +输入:nums = [0,1,0,3,2,3] +输出:4 +``` + +**示例 3:** + +``` +输入:nums = [7,7,7,7,7,7,7] +输出:1 +``` + +## 思路 + +### 方法一:O(n²) 动态规划 + +> [!question] 💡 思考 +> 题目要求"子序列"(不要求连续),和"子数组"(必须连续)有什么本质区别?这种区别对 DP 的设计产生了什么影响? + +子序列允许跳过元素,这意味着在考虑每个位置时,我们需要回头"查看所有可能的 preceding 状态"——而不是只看不相邻的前一个。这正是 LIS 的 DP 核心。 + +#### 第一步:定义状态 + +> `dp[i]` = 以 `nums[i]` **结尾**的最长递增子序列的长度 + +关键理解:这里定义的 LIS **必须以 nums[i] 作为结尾元素**。这个约束让状态具有了"最优子结构"。 + +#### 第二步:推导转移方程 + +对于位置 i,我们枚举所有 j < i: + +- 如果 `nums[j] < nums[i]`,说明可以把 `nums[i]` 接到以 `nums[j]` 结尾的子序列后面,形成长度为 `dp[j] + 1` 的递增子序列 +- 我们取所有合法 j 中的最大值 + +> **`dp[i] = max(dp[j] + 1)`**,对所有满足 `0 ≤ j < i` 且 `nums[j] < nums[i]` 的 j +> +> 若不存在这样的 j(即前面的元素都不比它小),则 `dp[i] = 1`(自身构成子序列) + +```mermaid +flowchart TD + subgraph "以 nums[5]=7 为例 (nums=[10,9,2,5,3,7,...])" + A["dp[5]\n= max(dp[j]+1)\nfor nums[j]<7"] --> B["j=2: dp[2]=1\nnums[2]=2 < 7 → 2"] + A --> C["j=3: dp[3]=2\nnums[3]=5 < 7 → 3"] + A --> D["j=4: dp[4]=2\nnums[4]=3 < 7 → 3"] + B --> E["候选值: 2"] + C --> F["候选值: 3 ★"] + D --> G["候选值: 3 ★"] + F --> H["dp[5] = 3\n→ [2,5,7] 或 [2,3,7]"] + G --> H + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style H fill:#dcfce7,stroke:#16a34a +``` + +#### 第三步:确定最终答案 + +> [!question] 💡 思考 +> dp[n-1] 就是答案吗? + +不一定!因为 `dp[i]` 表示"以 nums[i] 结尾"的 LIS 长度,但**全局最长递增子序列可能不以最后一个元素结尾**。所以答案是 **dp 数组中的最大值**。 + +```mermaid +flowchart LR + subgraph "填表完成后扫描最大值" + A["dp[0]=1"] --> B["dp[1]=1"] + B --> C["dp[2]=1"] + C --> D["dp[3]=2"] + D --> E["dp[4]=2"] + E --> F["dp[5]=3"] + F --> G["dp[6]=4 ★★★"] + G --> H["dp[7]=4"] + end + + style A fill:#fef3c7,stroke:#f59e0b + style F fill:#fef3c7,stroke:#f59e0b + style G fill:#dcfce7,stroke:#16a34a + style H fill:#fef3c7,stroke:#f59e0b +``` + +#### 第四步:自底向上填表演示 + +以 `nums = [10, 9, 2, 5, 3, 7, 101, 18]` 为例: + +| i | nums[i] | dp[i] 计算过程 | dp[i] | +|---|---------|--------------|-------| +| 0 | 10 | 无前驱 | **1** → [10] | +| 1 | 9 | 无前驱(10 > 9) | **1** → [9] | +| 2 | 2 | 无前驱 | **1** → [2] | +| 3 | 5 | nums[2]=2<5 → dp[2]+1 | **2** → [2,5] | +| 4 | 3 | nums[2]=2<3 → dp[2]+1 | **2** → [2,3] | +| 5 | 7 | nums[3]=5<7→3, nums[4]=3<7→3 | **3** → [2,5,7] 或 [2,3,7] | +| 6 | 101 | dp[5]=3 → 4 | **4** → [2,5,7,101] | +| 7 | 18 | dp[5]=3 → 4 | **4** → [2,5,7,18] | + +答案 = max(dp) = **4** ✅ + +> [!summary] 💡 代码结构拆解 +> - **外层 for i=1..n-1**:逐个计算每个位置结尾的 LIS 长度 +> - **内层 for j=0..i-1**:回溯检查所有前驱,找最优的那条"接力链" +> - **最终 scan**:dp 数组中取最大值,因为全局最优解不一定停在末尾 + +### 方法二:O(n log n) 二分查找 + 贪心 + +> [!abstract] 🔍 核心直觉升级 +> 上面 DP 的思路是"以某个元素结尾",而 O(n log n) 方法的思路完全不同——它不再关心"以谁结尾",而是关注"**长度为 k 的递增子序列,最小的末尾元素是什么**"。 + +想象你在打扑克牌——"接龙游戏"(Patience Sorting):从左到右依次处理每张牌,把它放在最左边的、顶部数字 ≥ 它的牌堆上;如果没有这样的牌堆,就新开一堆。**最终牌堆的数量就是 LIS 的长度**。 + +用程序语言描述,我们维护一个数组 `tails`: + +> **`tails[k]` = 长度为 `k+1` 的所有递增子序列中,末尾元素的最小值** + +为什么这个数组能优化?因为它有至关重要的单调性: + +> [!tip] 🧠 关键引理:tails 严格递增 +> 假设长度为 k 的递增子序列最小末尾是 a,长度为 k+1 的是 b。由于任何长度为 k+1 的子序列都包含一个长度为 k 的前缀,该前缀的末尾一定小于 b,所以 a < b。 +> +> 结论:**tails 严格递增** → 可以用二分查找! + +#### 操作规则 + +对每个 `nums[i]`: + +1. 在 `tails` 中**二分查找第一个 ≥ nums[i] 的位置** + - 找到了:把这个位置的元素**替换**成 nums[i](说明存在一个同样长度的子序列,但末尾更小了——贪心选择更有潜力) + - 没找到(nums[i] 比所有 tails 都大):把 nums[i]**追加**到尾部,LIS 长度 +1 + +```mermaid +flowchart TD + subgraph "核心操作逻辑" + A["nums[i]"] --> B{"二分查 tails\n首个 ≥ nums[i]?"} + B -- "找到了" --> C["替换该位置\n→ 更小末尾, 同长度"] + B -- "没找到" --> D["追加到尾部\n→ 延长 LIS"] + C --> E["tails 仍严格递增 ✓"] + D --> E + end + + style A fill:#e7f3ff,stroke:#3b82f6 + style C fill:#fef3c7,stroke:#f59e0b + style D fill:#dcfce7,stroke:#16a34a + style E fill:#d1fae5,stroke:#10b981 +``` + +#### 完整走一遍 + +以 `nums = [10, 9, 2, 5, 3, 7, 101, 18]` 为例: + +| 步骤 | nums[i] | tails 变化 | 说明 | +|------|---------|-----------|------| +| 初始 | — | `[]` | 空 | +| 1 | 10 | `[10]` | 空,直接追加 | +| 2 | 9 | `[9]` | 9 < 10,替换 tails[0] | +| 3 | 2 | `[2]` | 2 < 9,替换 tails[0] | +| 4 | 5 | `[2, 5]` | 5 > 2,追加 | +| 5 | 3 | `[2, 3]` | 3 < 5,替换 tails[1] | +| 6 | 7 | `[2, 3, 7]` | 7 > 3,追加 | +| 7 | 101 | `[2, 3, 7, 101]` | 101 > 7,追加 → LIS 长度=4 | +| 8 | 18 | `[2, 3, 7, 18]` | 18 < 101,替换 tails[3] | + +**最终 tails 长度 = 4**,即为答案 ✅ + +```mermaid +flowchart LR + subgraph "tails 演变全过程" + A["[]"] --> B["[10]"] + B --> C["[9]"] + C --> D["[2]"] + D --> E["[2,5]"] + E --> F["[2,3]"] + F --> G["[2,3,7]"] + G --> H["[2,3,7,101]\n★答案=4"] + H --> I["[2,3,7,18]"] + end + + style A fill:#f3f4f6,stroke:#6b7280 + style G fill:#fef3c7,stroke:#f59e0b + style H fill:#dcfce7,stroke:#16a34a + style I fill:#dbeafe,stroke:#3b82f6 +``` + +> [!warning] ⚠️ 重要澄清:tails ≠ 实际的 LIS +> `tails` 记录的是"各长度下最小末尾"的信息,它本身不一定是一个真实的子序列。但它的**长度**恰好等于 LIS 的真实长度。上面的例子中,tail 经历了 [2,3,7,101] → [2,3,7,18],真实 LIS 可能是 [2,5,7,101] 或 [2,3,7,18]。虽然内容不完全一致,但**长度恒等**。 + +## 代码提示 + +在动手写代码之前,想一想这些关键决策点: + +> [!question] 💡 思考 1 +> Go 标准库中有二分查找函数——你会选哪一个?需要注意什么? + +Go 的 `sort.SearchInts` 返回目标值在有序切片中的插入位置(索引)。如果该索引等于切片长度,说明目标值比所有元素都大——正好对应"追加"的情况。如果索引 < len(tails),说明找到了第一个 ≥ 值的元素——对应"替换"。 + +> [!question] 💡 思考 2 +> 两种方法的空间复杂度分别是多少?有没有办法进一步降低空间? + +方法一需要 dp 数组 → O(n);方法二只需要 tails 数组 → O(n)。两者都是 O(n) 空间。不过对于方法一,如果你只需要长度而非具体序列,可以注意到 dp 数组的某些冗余信息其实不需要全部保留——但这涉及更复杂的技巧,面试中答出标准的 O(n) 即可。 + +## 技巧 + +### 变体速查:边界微调决定不同语义 + +> [!tip] ⚡ 小技巧:非严格递增 / 最长递减子序列 +> +> | 变体 | 改动点 | +> |------|--------| +> | 非严格递增(允许相等) | 方法一中 `nums[j] < nums[i]` 改为 `≤`;方法二中二分改为"第一个 > nums[i]" | +> | 最长递减子序列 | 反转数组后求 LIS,或在比较时反过来:`>` 变 `<` | +> | 求最长非递减子序列 | 使用 `sort.Search` 自定义比较函数,找第一个 **大于** 而不是 **大于等于** | + +### 构造实际子序列 + +> [!abstract] 🔍 进阶:不只是长度 +> +> 如果需要**输出具体的 LIS**,方法一更容易扩展:维护一个 `parent[i]` 数组记录每个 dp[i] 对应的上一个元素下标,最后从 dp 最大处回溯即可。方法二较难还原(可以用额外数据结构实现但不常见)。 + +### 何时选哪种方法? + +> [!question] 💡 面试官追问:为什么要用 O(n log n) 而不是 O(n²)? + +关键指标看 n 的范围: + +| 约束 | 推荐方法 | 理由 | +|------|----------|------| +| n ≤ 3000 | O(n²) DP | 约 9×10⁶ 次操作,足够快且代码简单 | +| n ≤ 10⁵ | O(n log n) 二分 | 约 1.7×10⁶ 次操作,差距明显 | +| n ≤ 10⁶+ | 必须 O(n log n) | O(n²) 超时 | + +本题 n ≤ 2500,两种都能 AC,但 **O(n log n) 是这道题的灵魂考点**,面试务必掌握。 + +| 方法 | 时间 | 空间 | 评价 | +|------|------|------|------| +| DP 填表 | O(n²) | O(n) | 直观易懂,容易扩展构造序列 | +| 二分 + 贪心(推荐 ✅) | O(n log n) | O(n) | 高效优雅,面试加分项 | + +## 代码 + +### Go 语言实现 + +#### 版本一:O(n²) 动态规划 + +```go +// lengthOfLIS returns the length of the longest strictly increasing subsequence. +func lengthOfLIS(nums []int) int { + n := len(nums) + if n == 0 { + return 0 + } + + // dp[i] = 以 nums[i] 结尾的最长递增子序列的长度 + // 初始化全为 1,因为每个元素自身就是一个长度为 1 的子序列 + dp := make([]int, n) + for i := range dp { + dp[i] = 1 + } + + // 自底向上填表:对每个位置 i,遍历所有前驱 j < i + for i := 1; i < n; i++ { + for j := 0; j < i; j++ { + if nums[j] < nums[i] { // 满足递增条件 + if dp[j]+1 > dp[i] { + dp[i] = dp[j] + 1 + } + } + } + } + + // 答案是 dp 数组中的最大值 + result := 0 + for _, v := range dp { + if v > result { + result = v + } + } + return result +} +``` + +> [!summary] 💡 代码说明 +> - **初始化**:所有 dp[i] = 1(每个元素单独构成子序列) +> - **外层 i**:从左往右,逐个"放置"当前元素 +> - **内层 j**:回头看所有前驱,找到能把当前元素接上去的"最佳父节点" +> - **最后扫描**:最大值即全局 LIS 长度 + +#### 版本二:O(n log n) 二分 + 贪心(推荐 ✅) + +```go +// lengthOfLIS returns the length of the longest strictly increasing subsequence. +// Time complexity: O(n log n), Space complexity: O(n). +func lengthOfLIS(nums []int) int { + // tails[i] = 长度为 i+1 的所有递增子序列中,最小的末尾元素 + var tails []int + + for _, num := range nums { + // 二分查找 tails 中第一个 >= num 的位置 + pos := sort.SearchInts(tails, num) + + if pos < len(tails) { + tails[pos] = num // 替换:让同样长度的子序列末尾更小 + } else { + tails = append(tails, num) // 追加:发现更长的递增子序列 + } + } + + return len(tails) +} +``` + +> [!summary] 💡 代码结构拆解 +> - **sort.SearchInts**:Go 标准库的二分搜索,返回目标值在有序切片中的插入位置(保证左侧元素 < 目标,右侧元素 ≥ 目标) +> - **pos < len(tails)**:找到可替换位置 → 贪心地让末尾更小,增加未来扩展的可能性 +> - **pos == len(tails)**:所有 tails 元素都 < num → num 能延长 LIS,长度 +1 +> - **核心循环仅 5 行**,简洁优雅 + +### 执行过程演示(nums = [10,9,2,5,3,7,101,18]) + +``` +tails = [] + +num=10: SearchInts([], 10) → 0 (len=0, 追加) +tails = [10] + +num=9: SearchInts([10], 9) → 0 (tails[0]=10 >= 9, 替换) +tails = [9] + +num=2: SearchInts([9], 2) → 0 (tails[0]=9 >= 2, 替换) +tails = [2] + +num=5: SearchInts([2], 5) → 1 (1 == len, 追加) +tails = [2, 5] + +num=3: SearchInts([2,5], 3) → 1 (tails[1]=5 >= 3, 替换) +tails = [2, 3] + +num=7: SearchInts([2,3], 7) → 2 (2 == len, 追加) +tails = [2, 3, 7] + +num=101: SearchInts([2,3,7], 101) → 3 (3 == len, 追加) +tails = [2, 3, 7, 101] ← LIS 长度达到 4 + +num=18: SearchInts([2,3,7,101], 18) → 3 (tails[3]=101 >= 18, 替换) +tails = [2, 3, 7, 18] + +结果: len(tails) = 4 ✅ +``` + +### 复杂度分析 + +| 维度 | 方法一(DP) | 方法二(二分) | +|------|-------------|---------------| +| **时间** | O(n²) — 两层嵌套循环,每次常数操作 | O(n log n) — n 次迭代,每次二分 O(log L),L ≤ n | +| **空间** | O(n) — dp 数组 | O(n) — tails 数组 | + +代入本题约束 n ≤ 2500: +- 方法一:~6.25 × 10⁶ 次操作,轻松通过 +- 方法二:~2500 × 12 ≈ 3 × 10⁴ 次操作,几乎瞬时完成 + +## 举一反三 + +这道题是 **线性 DP 与贪心 + 二分** 结合的经典范例,也是 LeetCode 中"模式识别"价值极高的一题: + +- [[86-单词拆分]] — 同样是逐状态递推,但这里的"选择分支"是从所有前驱中选择 +- [[84-完全平方数]] — 与 LIS 的 DP 写法内核相似(枚举起跳点取极值),但目标函数从"求最大长度"变成了"求最小个数" +- LeetCode 673. 最长递增子序列的个数 —— **变种**:在 DP 基础上多维护一个 count 数组,统计每个位置结尾的方案数 +- LeetCode 354. 俄罗斯套娃信封问题 —— **二维 LIS 模板题**:先按一维排序,再对另一维求 LIS +- LeetCode 392. 判断子序列 —— **简化版**:只需判断是否存在(返回 true/false 而非长度),双指针即可 +- LeetCode 674. 最长连续递增序列 —— **降维权**:子序列变成连续子数组,直接一次扫描即可 + +> [!summary] 📌 本节要点 +> 1. **方法一(DP)**:定义 dp[i] = 以 nums[i] 结尾的 LIS 长度,转移 dp[i] = max(dp[j]+1),总时间 O(n²) +> 2. **方法二(二分+贪心)**:维护 tails 数组——长度为 k+1 的递增子序列的最小末尾,利用单调性做二分,总时间 O(n log n) +> 3. tails 严格递增是关键引理,保证了二分查找的正确性 +> 4. tails 本身不一定是真实 LIS,但其长度一定等于 LIS 长度 +> 5. 面试中优先展示方法二,同时能够解释方法一 diff --git a/技巧/98-颜色分类.md b/技巧/98-颜色分类.md new file mode 100644 index 0000000..281b4f2 --- /dev/null +++ b/技巧/98-颜色分类.md @@ -0,0 +1,346 @@ +--- +tags: [技巧, 双指针, 三向切分, 荷兰国旗, 原地排序, 数组] +create time: 2026-05-16 18:30 +--- + +# 98-颜色分类 + +## 题面 + +> **LeetCode 75. Sort Colors** + +给定一个包含红色、白色和蓝色、共 `n` 个元素的数组 `nums` ,**原地**对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。 + +我们使用整数 `0`、`1` 和 `2` 分别表示红色、白色和蓝色。 + +**要求:** +- 不使用库内置的 `sort` 函数 +- 最优解:一趟扫描 + 常数空间 + +**示例 1:** + +``` +输入:nums = [2,0,2,1,1,0] +输出:[0,0,1,1,2,2] +``` + +**示例 2:** + +``` +输入:nums = [2,0,1] +输出:[0,1,2] +``` + +**提示:** +- `n == nums.length` +- `1 <= n <= 300` +- `nums[i]` 为 `0`、`1` 或 `2` + +--- + +## 思路 + +### 先思考一个问题 🤔 + +这个有一个经典的名字——**荷兰国旗问题(Dutch National Flag Problem)**,由 Edsger Dijkstra 提出:如何用最少交换次数把红白蓝三色球排成"红-白-蓝"的顺序? + +> [!tip] 直觉方案 +> 统计 0、1、2 各自的个数,然后再按个数写回去——这需要两遍扫描。题目要求**一趟扫描**,怎么做? +> +> 排序算法如快排也能做到 $O(n \log n)$,但我们想要更好的——$O(n)$ 时间 + $O(1)$ 空间。 + +### 核心洞察:三路分区 ⭐ + +既然只有三种值 `{0, 1, 2}`,我们可以用**三个指针**把数组划分成三个区域: + +| 区域 | 位置 | 值 | 含义 | +|------|------|-----|------| +| **左区 `[0, low)`** | 数组左侧 | `0` | 已经排好位的红色 | +| **中区 `[low, mid)`** | 数组中间 | `1` | 已经排好位的白色 | +| **未处理 `[mid, high]`** | 数组右侧 | 待确定 | 还没见过的元素 | +| **右区 `(high, n-1]`** | 数组最右侧 | `2` | 已经排好位的蓝色 | + +> [!question] 启发式提问 +> 如果只有一个指针从左走到右,遇到 `0` 只能放左边,遇到 `2` 只能放右边。但只靠一个指针做不到同时维护两个边界——**为什么需要三个指针?** +> +> 因为你需要同时知道"下一个该填 0 的位置在哪"和"下一个该填 2 的位置在哪"。这两个位置分别从两端向中间逼近。 + +### 关键性质:中区的 invariant(不变量) + +> [!info] 不变的真理 +> 在每一步操作中,`[low, mid)` 区间内的所有元素都是 `1`。这就是说:**凡是已经被确认是 1 的元素,永远不会再被触碰**。这让算法在一趟扫描中就能完成排序。 + +### 算法流程 + +``` +初始化: + low = 0 → 指向下一个 0 应该放置的位置 + mid = 0 → 当前正在检查的元素 + high = n - 1 → 指向下一个 2 应该放置的位置 + +当 mid <= high 时循环: + + 情况 A: nums[mid] == 0 + 将 nums[mid] 与 nums[low] 交换 + low++(左区扩大) + mid++(当前位置已确定为 0,继续向前) + + 情况 B: nums[mid] == 1 + mid++(1 就在中区合适的位置,不动它,mid 直接前进) + + 情况 C: nums[mid] == 2 + 将 nums[mid] 与 nums[high] 交换 + high--(右区扩大) + mid 不移动! ← 关键点 + 因为从 high 换过来的元素还没有被检查过 +``` + +> [!warning] ⚠️ 最容易出错的地方 +> +> 当 `nums[mid] == 2` 执行交换后,**mid 不能自增**。因为从 `high` 位置换过来的元素从未被检查过,需要在下一轮迭代中重新判断。 +> +> 反之,当 `nums[mid] == 0` 执行交换后,`mid` **可以**自增——因为在交换之前 `low <= mid`,而 `nums[low]` 位置的元素要么是 `1`(在中区内),要么 `low == mid` 本身就是同一个位置。无论哪种情况,交换到 `mid` 位置的都是 `1` 或者自身,这两种都已经确认正确。 + +### 逐步跟踪演示 + +以 `nums = [2, 0, 2, 1, 1, 0]` 为例: + +``` +初始状态: +索引: [0] [1] [2] [3] [4] [5] +nums: [2, 0, 2, 1, 1, 0] + ↑ ↑ ↑ + low mid high + +第1步: nums[mid]=2 → 与 nums[high] 交换 +索引: [0] [1] [2] [3] [4] [5] +nums: [0, 0, 2, 1, 1, 2] + ↑ ↑ ↑ + low mid high + (mid 不移动,重新检查 nums[mid]=0) + +第2步: nums[mid]=0 → 与 nums[low] 交换 +索引: [0] [1] [2] [3] [4] [5] +nums: [0, 0, 2, 1, 1, 2] + ↑ ↑ ↑ + low mid high + (low++, mid++) + +第3步: nums[mid]=2 → 与 nums[high] 交换 +索引: [0] [1] [2] [3] [4] [5] +nums: [0, 0, 1, 1, 2, 2] + ↑ ↑ ↑ + low mid high + (mid 不移动) + +第4步: nums[mid]=1 → mid++ +索引: [0] [1] [2] [3] [4] [5] +nums: [0, 0, 1, 1, 2, 2] + ↑ ↑ ↑ + low mid high + +第5步: nums[mid]=1 → mid++,此时 mid > high,退出循环 ✅ +``` + +结果:`[0, 0, 1, 1, 2, 2]` —— 排序完成! + +### 流程图 + +```mermaid +flowchart TD + Start(["开始
low=0 mid=0 high=n-1"]) --> Loop{"mid <= high?"} + Loop -- 否 --> Done(["返回排序结果"]) + Loop -- 是 --> Check{"值等于多少?"} + + Check -- "0" --> Swap0["交换 nums[low] 与 nums[mid]"] + Swap0 --> IncBoth["low++ mid++"] + IncBoth --> Loop + + Check -- "1" --> IncMid["mid++"] + IncMid --> Loop + + Check -- "2" --> Swap2["交换 nums[mid] 与 nums[high]"] + Swap2 --> DecHigh["high--
mid 不变"] + DecHigh --> Loop + + style Start fill:#e3f2fd + style Done fill:#c8e6c9 + style Swap0 fill:#fff3e0 + style Swap2 fill:#fff3e0 +``` + +### 备选方案对比 + +| 方法 | 时间复杂度 | 空间复杂度 | 说明 | +|------|----------|----------|------| +| **三指针分区(荷兰国旗)** ⭐ | $O(n)$ | $O(1)$ | 最优解,一趟扫描 | +| 两趟计数排序 | $O(n)$ | $O(1)$ | 统计 0/1/2 个数后回填,简单但不符合进阶要求 | +| 快速排序的三向分区 | $O(n \log n)$ 平均 | $O(\log n)$ | 通用排序,对本题来说杀鸡用牛刀 | + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架 + +``` +low, mid, high := 0, 0, len(nums)-1 + +for mid <= high: + 如果 nums[mid] == 0: + 交换 nums[low], nums[mid] + low++ + mid++ + + 否则如果 nums[mid] == 1: + mid++ + + 否则 (nums[mid] == 2): + 交换 nums[mid], nums[high] + high-- + // mid 不变 +``` + +> [!step] 实现要点 + +1. **循环条件是 `mid <= high`**,不是 `<`,因为等于时还需要处理最后一个元素 +2. **mid 不移动的分支最容易忘**——交换完 `high` 之后必须留在原地检查新换过来的元素 +3. **Go 中没有原地 swap 运算符**,需要用临时变量:`nums[low], nums[mid] = nums[mid], nums[low]` + +--- + +## 技巧 + +> [!summary] 🔑 核心模式记忆 + +```go +// 三指针三路分区模板 +low, mid, high := 0, 0, len(nums)-1 +for mid <= high { + switch nums[mid] { + case 0: + nums[low], nums[mid] = nums[mid], nums[low] + low++ + mid++ + case 1: + mid++ + case 2: + nums[mid], nums[high] = nums[high], nums[mid] + high-- + } +} +``` + +> 看到「只有 k 种离散值的原地排序」→ 优先考虑**三路/多路分区**思想。k=3 时就是经典的荷兰国旗问题。 + +### 1. 荷兰国旗的思想本质 + +> [!quote] 💬 一句话总结 +> +> **"小的放左边,大的放右边,中的自己站好"** ——用三个指针同时维护三个有序区域,中间的指针负责"侦察"。 + +这是一种**分区而非比较**的思维:不需要拿每个元素去跟其他元素比较大小,而是根据值域直接定位目标区域。 + +### 2. 为什么 mid 在情况 C 中不能移动? + +> [!note] 深入理解 +> +> 这是一个面试中几乎必问的问题。原因如下: +> +> `mid` 指向的是当前尚未检查的元素。当它与 `high` 交换后,`mid` 位置上得到的是来自 `high` 位置的元素——这个元素可能是 `0`、`1` 或 `2`,**从未被检查过**。如果此时 mid 自增就会漏掉这个元素,导致错误。 +> +> 反过来看,在情况 A 中,`low <= mid`。交换前 `nums[low]` 是中区的起始位置(值为 `1`)或者 `low == mid`(自身交换)。所以交换到 `mid` 上的一定是 `1` 或自身,两种都已确认正确,mid 可以放心前进。 + +### 3. 与快速排序三向分区的联系 + +> [!info] 知识串联 +> +> 三路分区也是快速排序优化版本的核心——当数组中存在大量重复元素时,标准快排会退化到 $O(n^2)$,而**三向快速排序**将数组分为 `< pivot`、`= pivot`、`> pivot` 三个区间,天然处理重复元素。 +> +> 本题相当于三向分区的一个特例:pivot 的范围已知是 `{0, 1, 2}`,所以一轮扫描即可全部分清。 + +### 4. 关联变体题 + +| 题目 | 变化点 | 核心思路 | +|------|--------|---------| +| **75. Sort Colors** | 三种离散值排序 | 三指针荷兰国旗 ✅ | +| **912. Sort an Array** | 通用数组排序 | 归并排序 / 堆排序 / 三向快排 | +| **三向快排(快排优化)** | 含大量重复元素 | Dutch Partitioning 作为 partition 步骤 | + +### 5. Go 运行细节 + +> [!example] 🐹 Go 特有注意事项 + +- Go 原生支持**并行赋值**,一行即可完成 swap:`a, b = b, a`,无需临时变量 +- 当数组长度为 1 时,`low == mid == high`,循环进入一次后退出,逻辑正确 +- `switch` 语句在 Go 中比多个 `if-else` 更清晰,也稍快(编译器生成跳转表) + +--- + +## 代码 + +```go +// sortColors sorts an array of 0s, 1s, and 2s in-place using +// the Dutch National Flag three-pointer approach. +// All 0s come first, then all 1s, then all 2s. +func sortColors(nums []int) { + // ── Step 1: 初始化三个指针 ── + // low: 下一个 0 应该放置的位置(左区右边界) + // mid: 当前正在检查的元素(侦察指针) + // high: 下一个 2 应该放置的位置(右区左边界) + low, mid, high := 0, 0, len(nums)-1 + + // ── Step 2: 一趟扫描分区 ── + for mid <= high { + switch nums[mid] { + case 0: + // 遇到 0:交换到左区 + // 交换后 mid 位置的元素已确认为 1 或自身,mid 可以前进 + nums[low], nums[mid] = nums[mid], nums[low] + low++ + mid++ + + case 1: + // 遇到 1:留在中区,mid 直接前进 + mid++ + + case 2: + // 遇到 2:交换到右区 + // 注意 mid 不能前进!因为从 high 换过来的元素还未被检查 + nums[mid], nums[high] = nums[high], nums[mid] + high-- + } + } + + // ── Step 3: 隐式结束 ── + // 当 mid > high 时,整个数组已被划分为 [0]*[1]*[2]* 三段 +} +``` + +### 代码走读 + +> [!success] ✅ 复杂度总结 + +| 指标 | 结果 | 说明 | +|------|------|------| +| **时间复杂度** | $O(n)$ | 每个元素最多被访问常数次(`mid++` 走 n 步;每次交换至少消耗一个 `low` 或 `high`,最多 n 次交换) | +| **空间复杂度** | $O(1)$ | 仅使用了三个整型变量 `low`、`mid`、`high` | + +完美满足一趟扫描 + 常数空间的进阶要求! + +> [!quote] 💬 面试建议 + +这道题是考察**双指针/分区思想**的经典题。面试中被问到后: + +1. **先确认约束条件**——只有三种值、原地排序、一趟扫描最优 +2. **引出荷兰国旗问题**——说出名字会给面试官留下好印象 +3. **画示意图解释三路分区**——标出 low/mid/high 三个指针的职责和不变量 +4. **解释 mid 在不同情况下的行为差异**——这是展示你思考深度的地方 +5. **主动分析时间复杂度**——证明每个元素只被常数次访问 + +> [!warning] ⚠️ 常见陷阱 + +> 1. **mid 在交换 `high` 后忘记停留**——最常见的 bug,会导致漏检元素 +> 2. **循环条件写成 `mid < high`**——等于时仍需处理最后一个元素 +> 3. **误以为 `low++` 放在 `case 2` 中也正确**——`low` 只对应 `0` 的位置,与 `2` 无关 +> 4. **忽略空数组边界**——虽然题目保证了 `n >= 1`,但在实际工程中应加防御性检查 diff --git a/普通数组/14-合并区间.md b/普通数组/14-合并区间.md index 7411851..055b913 100644 --- a/普通数组/14-合并区间.md +++ b/普通数组/14-合并区间.md @@ -92,8 +92,8 @@ flowchart TD Sort --> InitResult["result = [intervals[0]]"] InitResult --> Loop{"i < n?"} Loop -- 否 --> ReturnResult["return result"] - Loop -- 是 --> Overlap{intervals[i].start
≤ last.end?} - Overlap -- 是 --> Merge["合并: last.end = max(last.end,
  intervals[i].end)"] + Loop -- 是 --> Overlap{"intervals[i].start <= last.end?"} + Overlap -- 是 --> Merge["合并: last.end = max(last.end, intervals[i].end)"] Overlap -- 否 --> Push["push intervals[i]"] Merge --> NextI["i++"] Push --> NextI diff --git a/矩阵/18-矩阵置零.md b/矩阵/18-矩阵置零.md new file mode 100644 index 0000000..adf3c6d --- /dev/null +++ b/矩阵/18-矩阵置零.md @@ -0,0 +1,475 @@ +--- +tags: ["LeetCode", "矩阵", "中等"] +create time: 2026-05-16 14:30 +--- + +# 18-矩阵置零 + +## 题面 + +> **LeetCode 73. Set Matrix Zeroes** + +给定一个 `m x n` 的矩阵,如果一个元素为 `0` ,则将其所在行和列的所有元素都设为 `0` 。请使用 **原地** 算法。 + +**示例 1:** + +``` +输入:matrix = [[1,1,1],[1,0,1],[1,1,1]] +输出:[[1,0,1],[0,0,0],[1,0,1]] +``` + +**示例 2:** + +``` +输入:matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]] +输出:[[0,0,0,0],[0,4,5,0],[0,3,1,0]] +``` + +**提示:** + +- `m == matrix.length` +- `n == matrix[0].length` +- `1 <= m, n <= 200` +- `-2^31 <= matrix[i][j] <= 2^31 - 1` + +**进阶:** + +- 一个直观的解决方案是使用 `O(mn)` 的额外空间,但这并不是一个好的解决方案。 +- 一个简单的改进方案是使用 `O(m + n)` 的额外空间,但这仍然不是最好的解决方案。 +- 你能想出一个仅使用 **常量空间** 的解决方案吗? + +--- + +## 思路 + +> [!question] 💡 直觉先行:遇到零该做什么? + +最朴素的想法很直接——遍历整个矩阵,每当发现 `matrix[i][j] == 0`,就把第 `i` 行的所有元素和第 `j` 列的所有元素全部清零。 + +但这里有一个致命问题: + +> [!danger] ⚠️ 顺序执行的灾难 + +如果我们在遍历时**就地**把某一行/列全变成 0,那么后续扫描到这个位置时,会看到一个"伪零"——它原本是非零值,只是被前面的操作污染了。这会导致原本不该被清零的位置也被错误清零。 + +比如: + +``` +原始矩阵 遍历到 (0,0)=0,整行整列清零后 +[[1, 1], [[0, 0], + [1, 1]] [0, 0]] ← (1,0) 和 (0,1) 本来是 1! +``` + +所以我们必须先**收集所有需要清零的行和列信息**,等扫描完毕后再统一执行。这就是空间优化的起点。 + +--- + +### 方法一:暴力法 —— 记录所有零的位置(O(mn) 空间) + +> [!abstract] 📐 最直接的想法 + +创建一个与原矩阵等大的布尔矩阵 `zeros[m][n]`,标记所有为零的位置。第二遍遍历时,如果 `zeros[i][j]` 为真就清零对应行列。 + +这个方法简单粗暴,但完全浪费空间——每个布尔位只表示一个二元状态(是零还是不是),却占了一个完整字节甚至更多。 + +```mermaid +flowchart TD + A["开始:遍历矩阵"] --> B{"matrix[i][j] == 0?"} + B -->|"是"| C["在 zeros[i][j] 中标记"] + B -->|"否"| D["跳过"] + C --> E["继续扫描下一个"] + D --> E + E --> F{"还有位置没扫?"} + F -->|"是"| B + F -->|"否"| G["第二遍:根据 zeros[] 执行行/列清零"] + G --> H(["返回结果"]) +``` + +> [!quote] ❌ 评价 + +- 时间:O(mn),正确但多余的空间消耗 +- 空间:O(mn),退化成存储原矩阵本身的大小 + +--- + +### 方法二:行数组 + 列数组(O(m+n) 空间)⭐ + +> [!abstract] 🎯 核心洞察 + +我们真的需要知道"**哪一个具体的格子**是零"吗?不需要——我们只需要知道"**哪一行包含零**"和"**哪一列包含零**"。 + +一旦某一行 `i` 中出现了至少一个零,**整行必须清零**;同理,一旦某一列 `j` 中出现过零,**整列必须清零**。 + +> [!step] 算法流程 + +1. 创建两个布尔数组:`rowZero[m]` 和 `colZero[n]` +2. **第一遍扫描**:遍历整个矩阵,如果 `matrix[i][j] == 0`,设置 `rowZero[i] = true` 且 `colZero[j] = true` +3. **第二遍按行清零**:对于每个 `rowZero[i] == true` 的行,将 `matrix[i][...]` 全置 0 +4. **第三遍按列清零**:对于每个 `colZero[j] == true` 的列,将 `matrix[...][j]` 全置 0 + +```mermaid +flowchart LR + A["原始矩阵"] --> Pass1["第一遍扫描
建立 rowZero[] / colZero[]"] + Pass1 --> Step2["第二遍:清零标记行"] + Step2 --> Step3["第三遍:清零标记列"] + Step3 --> Result(["结果矩阵"]) +``` + +> [!example] 🔍 逐步跟踪演示 + +以 `matrix = [[1,1,1],[1,0,1],[1,1,1]]` 为例: + +**第一遍扫描:找出含零的行和列** + +| (i,j) | matrix[i][j] | rowZero | colZero | +|-------|-------------|---------|---------| +| (0,0) | 1 | — | — | +| (0,1) | 1 | — | — | +| (0,2) | 1 | — | — | +| **(1,1)** | **0** ✅ | `rowZero[1] = true` | `colZero[1] = true` | +| (1,0) | 1 | — | — | +| (1,2) | 1 | — | — | +| (2,0) | 1 | — | — | +| (2,1) | 1 | — | — | +| (2,2) | 1 | — | — | + +最终:`rowZero = [false, true, false]`, `colZero = [false, true, false]` + +**第二遍:清零标记的行 → 第 1 行全为 0** + +``` +[[1, 1, 1], + [0, 0, 0], ← 第 1 行清零 + [1, 1, 1]] +``` + +**第三遍:清零标记的列 → 第 1 列全为 0** + +``` +[[1, 0, 1], + [0, 0, 0], + [1, 0, 1]] ← 第 1 列清零,完成 ✅ +``` + +> [!note] 📌 复杂度分析 + +| 维度 | 复杂度 | 说明 | +|------|--------|------| +| 时间 | O(mn) | 三次线性扫描(扫描 + 行清零 + 列清零) | +| 空间 | O(m + n) | 两个布尔数组 | + +> [!success] ✅ 空间复杂度改进 + +这是题目中提到的第二个进阶方案——比暴力法好很多,但仍可进一步优化到 O(1)。 + +--- + +### 方法三:原地标记 —— 用首行首列当哈希表(O(1) 空间)⭐⭐⭐ + +> [!question] 💡 既然我们已经用了 O(m+n) 的两个数组来标记哪些行/列需要清零,能不能省下这两个数组的空间? + +答案是肯定的:**我们可以复用矩阵的第一行和第一列作为 `rowZero` 和 `colZero` 数组!** + +> [!abstract] 🗺️ 核心映射关系 + +| 原来的数组 | 现在的存放位置 | 含义 | +|-----------|--------------|------| +| `rowZero[i]` | `matrix[i][0]` | 第 `i` 行是否含有 0 | +| `colZero[j]` | `matrix[0][j]` | 第 `j` 列是否含有 0 | + +这样我们就把额外的 O(m+n) 空间"转移"到了矩阵内部,实现了常数级额外空间。 + +> [!warning] ⚠️ 但这里有套娃陷阱 + +如果我们直接用首行首列来存标记,那么在**扫描过程中**,首行首列自身可能被修改(变为 0),导致丢失"它们自己原来是不是包含了零"这一关键信息。 + +所以我们需要**两个额外的变量**来提前记录首行首列的原始状态: + +- `firstRowHasZero`:原始矩阵中第一行是否有 0 +- `firstColHasZero`:原始矩阵中第一列是否有 0 + +> [!step] 完整算法流程 + +```mermaid +flowchart TB + Start(["开始"]) --> CheckFirstRow["① 先检查首行首列
记录 firstRowHasZero / firstColHasZero"] + CheckFirstRow --> ScanMatrix["② 遍历 matrix[1..m-1][1..n-1]
遇到 0 则在首行首列做标记"] + ScanMatrix --> MarkRowsCols["③ 根据首行首列的标记
清零对应的行和列
(先清列再清行,避免冲突)"] + MarkRowsCols --> CheckFirstRowFlag{"firstRowHasZero?"} + CheckFirstRowFlag -->|"是"| ZeroFirstRow["清零第一行"] + CheckFirstRowFlag -->|"否"| CheckFirstColFlag + ZeroFirstRow --> CheckFirstColFlag{"firstColHasZero?"} + CheckFirstColFlag -->|"是"| ZeroFirstCol["清零第一列"] + CheckFirstColFlag -->|"否"| Return + ZeroFirstCol --> Return(["返回结果"]) + Return --> End(["结束"]) +``` + +**详细步骤:** + +**Step ①:预检首行首列** + +先用两趟独立的遍历,分别确认首行和首列原本是否含有零,用两个布尔变量保存。这一步在修改任何数据之前完成。 + +**Step ②:利用首行首列做标记** + +从 `matrix[1][1]` 开始遍历到 `matrix[m-1][n-1]`,如果遇到 `matrix[i][j] == 0`: + +```go +matrix[i][0] = 0 // 标记第 i 行需要清零 +matrix[0][j] = 0 // 标记第 j 列需要清零 +``` + +**Step ③:根据标记执行清零(先处理非首行首列的部分)** + +从左往右扫描第一行(除首列):如果 `matrix[0][j] == 0`,则将第 `j` 列全部清零(注意从第 1 行开始,不能碰 `matrix[0][j]` 本身)。 + +然后从上往下扫描第一列(除首行):如果 `matrix[i][0] == 0`,则将第 `i` 行全部清零(从第 1 列开始)。 + +> [!question] 💡 为什么必须先清列、再清行? + +考虑这种场景:`matrix[i][0]` 被标记为零(意味着第 `i` 行要清零),而 `matrix[0][j]` 也被标记为零(意味着第 `j` 列也要清零)。 + +如果**先清行**——把第 `i` 行全变零,那么 `matrix[i][0]` 确实变成了零(这和标记一致,没问题)。但如果此时 `matrix[i][j]` 原来不是零,清行后也变成了零,这个新产生的零不会造成问题,因为我们已经读过了标记阶段。 + +实际上两种顺序都**可以**,只要我们把"标记阶段"和"执行阶段"分开。但在实践中,先清列更直观,因为清列不会影响首行的标记值(我们正在遍历首行来做决定,清列是从第 1 行开始的)。 + +**Step ④:恢复首行首列** + +根据 Step ① 中记录的标志: +- 如果 `firstRowHasZero`,将第一行全部清零 +- 如果 `firstColHasZero`,将第一列全部清零 + +> [!example] 🔍 逐步跟踪演示 + +以 `matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]` 为例: + +**Step ①:预检** + +- 第一行 `0, 1, 2, 0` → 有零 → `firstRowHasZero = true` +- 第一列 `0, 3, 1` → 有零 → `firstColHasZero = true` + +**Step ②:遍历 matrix[1..2][1..3]** + +| (i,j) | matrix[i][j] | 动作 | matrix[i][0] | matrix[0][j] | +|-------|-------------|------|-------------|-------------| +| (1,1) | 4 | 非零,跳过 | — | — | +| (1,2) | 5 | 非零,跳过 | — | — | +| (1,3) | 2 | 非零,跳过 | — | — | +| (2,1) | 3 | 非零,跳过 | — | — | +| (2,2) | 1 | 非零,跳过 | — | — | +| (2,3) | 5 | 非零,跳过 | — | — | + +等等……内部没有零?但我们看到第一行已经有零了,让我们重新看——其实 `(1,3)` 是 `2`,`(2,3)` 是 `5`,确实内部没有新的零。首行首列已经是标记了。 + +现在矩阵状态(只看首行首列): +``` +matrix[0] = [0, 1, 2, 0] ← 第 0 列和第 3 列标记了 zero +matrix[...][0] = [0, 3, 1] ← 第 0 行标记了 zero +``` + +**Step ③:先清列(遍历首行,从 j=1 开始)** + +- j=1: `matrix[0][1] = 1 ≠ 0` → 跳过 +- j=2: `matrix[0][2] = 2 ≠ 0` → 跳过 +- j=3: `matrix[0][3] = 0` → **清零第 3 列**(从第 1 行起) + +第 3 列清零后: +``` +[[0, 1, 2, 0], + [3, 4, 5, 0], ← (1,3) 改为 0 + [1, 3, 1, 0]] ← (2,3) 改为 0 +``` + +**再清行(遍历首列,从 i=1 开始)** + +- i=1: `matrix[1][0] = 3 ≠ 0` → 跳过 +- i=2: `matrix[2][0] = 1 ≠ 0` → 跳过 + +**Step ④:处理首行首列** + +- `firstRowHasZero = true` → 清零第一行 +- `firstColHasZero = true` → 清零第一列 + +``` +[[0, 0, 0, 0], ← 首行清零 + [0, 4, 5, 0], ← 首列清零 (1,0) + [0, 3, 1, 0]] ← 首列清零 (2,0) +``` + +与预期输出一致 ✅ + +> [!note] 📌 复杂度分析 + +| 维度 | 复杂度 | 说明 | +|------|--------|------| +| 时间 | O(mn) | 多次单向遍历,总计常数次扫描 | +| 空间 | **O(1)** | 只用两个布尔变量! | + +--- + +## 代码提示 + +> [!abstract] 📝 伪代码框架(原地标记法) + +``` +// ── 预处理:记录首行首列的原始状态 ── +firstRowHasZero = 检查 matrix[0][*] 是否有 0 +firstColHasZero = 检查 matrix[*][0] 是否有 0 + +// ── 核心:利用首行首列做标记 ── +for i 从 1 到 m-1: + for j 从 1 到 n-1: + if matrix[i][j] == 0: + matrix[i][0] = 0 // 标记第 i 行 + matrix[0][j] = 0 // 标记第 j 列 + +// ── 根据标记清零(先列后行,避开首行首列区域)── +for j 从 1 到 n-1: + if matrix[0][j] == 0: + 将 matrix[1..m-1][j] 全部置 0 + +for i 从 1 到 m-1: + if matrix[i][0] == 0: + 将 matrix[i][1..n-1] 全部置 0 + +// ── 最后处理首行首列 ── +if firstRowHasZero: + 将 matrix[0][*] 全部置 0 + +if firstColHasZero: + 将 matrix[*][0] 全部置 0 +``` + +> [!warning] ⚠️ Go 实现中的常见坑 + +1. **不能用 `range` 修改矩阵元素**——Go 的 `range` 迭代器给出的是副本,`cell = 0` 无法写回原矩阵。必须使用索引遍历 `for i := ... { for j := ... { matrix[i][j] = 0 }}`。 +2. **清零列时要从第 1 行开始**——不能覆盖 `matrix[0][j]`,否则会破坏首行的标记信息。 +3. **清零行时要从第 1 列开始**——同理不能覆盖 `matrix[i][0]`。 + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:首行首列复用法(First Row/Column Hashing) + +当需要在二维矩阵上记录每行/每列的状态,但又要求 O(1) 空间时,可以把矩阵的**边界行和边界列当作辅助数组使用**。这是一种经典的"空间回收"技术——既然这些位置的信息可以在后期恢复,那在中间计算阶段就可以把它们借用来存其他数据。 + +> [!note] 🐹 Go 语言中的注意事项 + +- Go 的二维切片 `[][]int` 本质是切片数组(每个子切片独立分配),因此 `matrix[i][j] = 0` 是完全合法的原地写入操作。 +- 但 `for _, row := range matrix { for _, cell := range row { cell = 0 }}` **无效**——`cell` 只是值的副本。 +- 可以用循环展开或批量赋值减少 Go 层面的调用开销,但对于本题规模(≤ 200),简单循环足够。 + +> [!info] 📊 三种方法对比 + +| 方法 | 时间复杂度 | 额外空间 | 适用场景 | +|------|-----------|---------|---------| +| 暴力记录 | O(mn) | O(mn) | 教学演示,理解问题本质 | +| 行列标记数组 | O(mn) | O(m+n) | 面试安全牌,代码简洁不易错 | +| 首行首列复用 | O(mn) | **O(1)** | 追求最优解,面试加分项 | + +> [!quote] 💬 思维延伸 + +"借用已有数据结构的空间"是算法设计中反复出现的思想: +- 在排序中使用原数组作为堆(堆排序)、用负号标记已访问节点 +- 在链表上用快慢指针检测环 +- 在树上通过 DFS 隐式使用调用栈代替显式栈 + +核心心法是:**如果某块数据在本次计算完成后不再需要保留原貌,它就可以在过程中充当临时容器。** + +--- + +## 代码 + +```go +// setZeroes 将矩阵中所有为 0 的元素所在的行和列全部置 0。 +// 使用原地算法,额外空间 O(1)。 +func setZeroes(matrix [][]int) { + m, n := len(matrix), len(matrix[0]) + + // ═══ Step 1: 预处理首行首列的原始状态 ═══ + firstRowHasZero := false + for j := 0; j < n; j++ { + if matrix[0][j] == 0 { + firstRowHasZero = true + break + } + } + + firstColHasZero := false + for i := 0; i < m; i++ { + if matrix[i][0] == 0 { + firstColHasZero = true + break + } + } + + // ═══ Step 2: 利用首行首列作为标记数组 ═══ + // 从 matrix[1][1] 开始遍历,不触碰首行首列 + for i := 1; i < m; i++ { + for j := 1; j < n; j++ { + if matrix[i][j] == 0 { + matrix[i][0] = 0 // 标记第 i 行 + matrix[0][j] = 0 // 标记第 j 列 + } + } + } + + // ═══ Step 3: 根据标记清零(先列后行) ═══ + + // 3a. 根据首行标记,清零对应的列(跳过第 0 行) + for j := 1; j < n; j++ { + if matrix[0][j] == 0 { + for i := 1; i < m; i++ { + matrix[i][j] = 0 + } + } + } + + // 3b. 根据首列标记,清零对应的行(跳过第 0 列) + for i := 1; i < m; i++ { + if matrix[i][0] == 0 { + for j := 1; j < n; j++ { + matrix[i][j] = 0 + } + } + } + + // ═══ Step 4: 最后处理首行首列 ═══ + + // 如果首行原始包含零,清零整行 + if firstRowHasZero { + for j := 0; j < n; j++ { + matrix[0][j] = 0 + } + } + + // 如果首列原始包含零,清零整列 + if firstColHasZero { + for i := 0; i < m; i++ { + matrix[i][0] = 0 + } + } +} +``` + +> [!success] ✅ 运行验证 + +这是 LeetCode 第 73 题,通过率约 50%,是一道经典的 **O(1) 空间优化**面试题。 + +- **运行时间**:约 0~8 ms(Go,击败 ~95%+ 提交) +- **空间消耗**:O(1) 额外空间 +- **面试表现**:极高。面试官最常追问的问题: + - 为什么要先预检首行首列?(防止在标记阶段丢失首行首列自身的零信息) + - 为什么可以先清列再清行,或者反过来?(因为标记和执行分成了两个阶段,互不干扰) + - 如果矩阵全是 0 会怎样?(算法正确处理——首行首列都被标记,所有行列归零) + - 如果矩阵是 1x1?(边界情况正确:检查唯一元素是否为 0,是则不变,不是也不变) + +> [!quote] 💬 延伸思考 + +这道题的核心思维是 **"空间置换意识"**——当你面对空间限制时,不要急着分配新内存,而是问自己: + +> 矩阵里有没有哪些位置的内容,可以在计算中途被"征用"?计算结束后又能够恢复? + +这种思维方式不仅适用于矩阵问题,还广泛出现在原地翻转链表、in-place 重排数组、原地拓扑排序等问题中。记住这个模板:**「边界区域复用 + 两位标记」**,你可以在类似的 O(1) 空间矩阵题目中快速出手。