diff --git a/子串/11-滑动窗口最大值.md b/子串/11-滑动窗口最大值.md new file mode 100644 index 0000000..06ca525 --- /dev/null +++ b/子串/11-滑动窗口最大值.md @@ -0,0 +1,292 @@ +--- +tags: ["LeetCode", "单调队列", "滑动窗口", "困难"] +create time: 2026-05-14 12:00 +--- + +# 11-滑动窗口最大值 + +## 题面 + +> **LeetCode 239. Sliding Window Maximum** + +给你一个整数数组 `nums`,有一个大小为 `k` 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 `k` 个数字。滑动窗口每次只向右移动一位。 + +返回**滑动窗口中的最大值**。 + +**示例 1:** + +``` +输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 +输出:[3,3,5,5,6,7] +解释: +滑动窗口的位置 最大值 +--------------- ----- +[1 3 -1] -3 5 3 6 7 3 + 1 [3 -1 -3] 5 3 6 7 3 + 1 3 [-1 -3 5] 3 6 7 5 + 1 3 -1 [-3 5 3] 6 7 5 + 1 3 -1 -3 [5 3 6] 7 6 + 1 3 -1 -3 5 [3 6 7] 7 +``` + +**示例 2:** + +``` +输入:nums = [1], k = 1 +输出:[1] +``` + +**提示:** + +- `1 <= nums.length <= 10^5` +- `-10^4 <= nums[i] <= 10^4` +- `1 <= k <= nums.length` + +--- + +## 思路 + +> [!question] 💡 思考 +> +> 对于一个固定大小的窗口,找最大值的直觉做法是什么?遍历窗口内所有元素取 max——时间复杂度 O(k)。窗口一共移动 n-k+1 次,总复杂度 O(n·k)。但题目中 n ≤ 10⁵,如果 k 也接近 n/2,O(n·k) ≈ 10¹⁰,远超时限。**如何做到每次更新窗口时快速知道最大值?** + +### 核心矛盾分析 + +当窗口从 `[i, i+k-1]` 右移到 `[i+1, i+k]` 时: + +- 一个元素 `nums[i]` 被移除出窗口 +- 一个新元素 `nums[i+k]` 进入窗口 + +关键观察:**被移除的元素只有在它恰好是窗口最大值时才会影响答案;而新进入的元素可能成为新的最大值,甚至「碾压」窗口内比它小的所有元素。** + +> [!question] 💡 继续想 +> +> 假设窗口是 `[7, 3, 5]`,新元素来了一个 `9`。那 `7` 和 `3` 还有可能是这个窗口及其后续窗口的最大值吗? +> +> **答案:不可能!** 因为 `9 > 7` 且 `9 > 3`,只要 `9` 在窗口里,它就永远比 `7` 和 `3` 大;再加上窗口只会右移,`9` 一定比 `7` 和 `3` **更晚**离开窗口。所以 `7` 和 `3` 可以直接丢弃。 + +这个性质叫**单调性**——我们只需要维护一组"有潜力成为最大值"的候选元素,按值从大到小排列。 + +### 为什么需要双端队列(Deque)而非普通数组? + +| 结构 | 从队尾删除(淘汰小值) | 从队头删除(过期值) | 获取最大值 | +|------|---------------------|-------------------|----------| +| 有序数组 | O(1),尾部删除 | O(n),需整体前移 | O(1) | +| **双端队列(单调递减)** | **O(1)** | **O(1)** | **O(1)** | + +- **从队尾删除**:新元素进来时,淘汰比它小的旧元素(因为这些旧元素永无翻身之日)。 +- **从队头删除**:窗口右移时,检查队头元素的索引是否已滑出窗口范围,若已过期则弹出。 +- **获取最大值**:队头始终是当前窗口的最大值。 + +**这就是"单调队列"——保持从队头到队尾严格递减的双端队列。** + +### 方法:单调双端队列 ⭐(O(n)) + +我们用 deque 存储的是**元素的下标**(而非值本身),这样可以方便判断某个元素是否还在窗口内。 + +**状态定义:** + +``` +deque: [idx1, idx2, ..., idxm] 满足 nums[idx1] > nums[idx2] > ... > nums[idxm] +deque[0] → 当前窗口的最大值的下标 +``` + +**算法流程:** + +> [!step] 伪代码总览 +> +> 初始化空 deque、结果数组 res → 遍历 i 从 0 到 n-1: +> +> 1. **淘汰小值**(Maintain 单调性):当 deque 非空 且 `nums[i] >= nums[deque.back()]` 时,弹出队尾 +> 2. **加入新元素**:将 `i` 压入队尾 +> 3. **淘汰过期**:当 `deque.front() == i - k` 时,弹出队头(该元素已滑出窗口) +> 4. **记录答案**:当 `i >= k - 1` 时,`res.append(nums[deque.front()])` + +使用 Mermaid 图表示整个流程: + +```mermaid +flowchart TD + Start(["遍历 i = 0 to n-1"]) --> Check1{"nums[i] >= deque\n.back?"} + Check1 -- 是 --> PopBack["弹出队尾"] + PopBack --> Check1 + Check1 -- 否 --> PushI["将 i 压入队尾"] + PushI --> Check2{"deque.front == i - k?"} + Check2 -- 是 --> PopFront["弹出队头"] + PopFront --> Check3{"i >= k - 1?"} + Check2 -- 否 --> Check3 + Check3 -- 是 --> Record["res 追加 nums[deque.front]"] + Record --> End{"i < n-1?"} + Check3 -- 否 --> End + End -- 是 --> Start + End -- 否 --> Finish(["返回 res"]) +``` + +### 逐步跟踪演示 + +以 `nums = [1,3,-1,-3,5,3,6,7], k = 3` 为例: + +| i | num | 维护单调性(从队尾弹) | 入队 | 检查过期(队头弹出) | 记录答案 | 队列内容(idx) | 队列值 | +|---|-----|----------------------|------|---------------------|----------|---------------|-------| +| 0 | 1 | — | push 0 | — | — | [0] | val=1 | +| 1 | 3 | pop 0(3≥1) | push 1 | — | — | [1] | val=3 | +| 2 | -1 | 不弹(-1<3) | push 2 | — | **3** | [1,2] | val=[3,-1] | +| 3 | -3 | 不弹(-3<-1) | push 3 | front=1≠0 | **3** | [1,2,3] | val=[3,-1,-3] | +| 4 | 5 | pop 3,2,1(均≤5) | push 4 | front=4≠1 | **5** | [4] | val=5 | +| 5 | 3 | 不弹(3<5) | push 5 | front=4≠2 | **5** | [4,5] | val=[5,3] | +| 6 | 6 | pop 5,4(均≤6) | push 6 | front=6≠3 | **6** | [6] | val=6 | +| 7 | 7 | pop 6(≤7) | push 7 | front=7≠4 | **7** | [7] | val=7 | + +> [!note] 📌 i=4 时的细节:过期下标的处理 +> +> 当 i=4、窗口开始于索引 1 时,下标 1 已经应该滑出窗口了。但此时步骤 1 中已经将 1 弹出(因为 nums[4]=5 比 nums[1]=3 大且更晚过期)。所以步骤 3 检查时 front=4 ≠ i-k=1,不会误判。**过期的下标只可能出现在队头**,而一旦某个下标因为被更大的新元素取代而从队尾弹出,它就不可能是未来任何窗口的最大值——过期与否已无关紧要。 + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:单调队列(Monotone Deque) +> +> 单调队列适用于「在动态窗口/序列中维护某种极值」的问题,本质思想是:**后来的强者会让前面的弱者永无出头之日,直接踢掉。** +> +> - **单调递减队列** → 求最大值(队头是最大的)← 本题 +> - **单调递增队列** → 求最小值(队头是最小的) +> +> 典型应用还包括: +> - LeetCode 862. 和至少为 K 的最短子数组 — 单调队列 + 前缀和 +> - LCP 57. 回文文心 — 单调栈变形 +> - 「每日温度」类问题 — 本质上也是单调栈思想的变种 + +> [!note] 🐹 Go 中的实现细节 +> +> Go 标准库没有提供 `deque`,需要手动实现。以下是一个轻量级环形缓冲区风格的实现,避免频繁扩容: +> +> ```go +> type deque struct { +> data []int +> head int // 逻辑头部偏移 +> } +> +> func (d *deque) push(val int) { +> d.data = append(d.data, val) // 压入尾部 +> } +> +> func (d *deque) popBack() { +> d.data = d.data[:len(d.data)-1] // 弹出尾部 +> } +> +> func (d *deque) popFront() { +> d.head++ // 逻辑前进(简单方案) +> } +> +> func (d *deque) front() int { +> return d.data[d.head] +> } +> +> func (d *deque) empty() bool { +> return d.head >= len(d.data) +> } +> ``` +> +> 不过考虑到简洁性,下面代码部分直接用切片模拟 deque(`head` 指针推进),这样写起来更紧凑。 + +> [!info] 📊 复杂度分析 +> +> - **时间:O(n)**。每个元素最多被推入队列一次、弹出队列一次,均摊下来每个元素的操作次数是常数级别。 +> - **空间:O(k)**。队列中最多同时保存 k 个元素的下标。 +> +> 注意这里的 O(n) 与暴力的 O(n·k) 形成了质的区别——这正是单调性的威力。 + +> [!warning] ⚠️ 常见错误:比较用的是 `<` 还是 `<=` +> +> 步骤 1 中应该用 `>=` 来弹出比当前元素小的元素。如果有相等的值: +> +> - **用 `>=`**:弹出之前相同值的元素,队列中保留后出现的(更晚过期)的那个。✅ 正确 +> - **用 `>`**:保留之前相同值的元素,可能导致更早过期。❌ 可能导致答案错误或需要额外的过期判断 + +> [!example] 🔀 扩展:求滑动窗口最小值 +> +> 把单调性反转即可——维护一个**递增**队列,其余逻辑完全一样: +> +> ```go +> // 只需把比较方向从 >= 改成 <= +> for len(q) > 0 && nums[i] <= nums[q[len(q)-1]] { +> q = q[:len(q)-1] +> } +> ``` + +--- + +## 代码 + +```go +// maxSlidingWindow 返回滑动窗口中的最大值数组。 +// nums: 输入整数数组 +// k: 窗口大小 +func maxSlidingWindow(nums []int, k int) []int { + n := len(nums) + // res 用来收集每个窗口的最大值,预分配容量 n-k+1(一共就这么多窗口) + res := make([]int, 0, n-k+1) + + /* 以下用一个「切片 + 一个头指针」来模拟双端队列: + * - 切片 q :物理存储下标数据 + * - head :逻辑上的队首位置(不会回头,只会递增) + * - q[head] :队首元素 —— 当前窗口最大值的下标 + * - q[len(q)-1] :队尾元素 + * + * 为什么不用标准 deque?Go 没有内置 deque,而且这样做不需要额外 struct,更简洁。 + */ + q := make([]int, 0, k) // 最多存 k 个下标,预分配 k 容量避免多次扩容 + head := 0 // 队首指针初始在位置 0 + + for i := 0; i < n; i++ { + + // ─── 步骤 1:单调性维护("大杀器进场") ─── + // 条件拆解: + // head < len(q) → 队列不为空(非空检查,防止 panic) + // nums[i] >= nums[q[len(q)-1]] → 新元素 ≥ 队尾对应的值 + // + // 含义:如果新来的元素比队尾那个还大,那队尾那个就没用了—— + // 因为新元素更大、且活得更久(下标更大,过期更晚), + // 所以队尾元素永远不可能成为未来任何一个窗口的最大值。 + // 把它踢掉叫「淘汰弱者」。 + // + // 用 >= 而不用 >:如果有相等的值,把旧的踢掉,保留新的(更新的一个更晚过期)。 + for head < len(q) && nums[i] >= nums[q[len(q)-1]] { + q = q[:len(q)-1] // 弹掉队尾最后一个元素 + } + + // ─── 步骤 2:入队 ─── + // 把当前元素的索引放进队尾,它已经是一个"有潜力"的候选者了。 + q = append(q, i) + + // ─── 步骤 3:过期清理 ─── + // 窗口范围是 [i-k+1, i],所以下标为 i-k 的元素刚好滑出窗口。 + // 如果这个元素恰好在队首,说明它曾经是最大但现在过期了,必须弹出。 + // 注意:过期的元素只可能出现在队首!因为如果一个元素在队中间或队尾时已过期, + // 那它一定还在之前某次步骤 1 中被更大的元素从队尾弹掉了(没等到过期就被淘汰了)。 + if q[head] == i-k { + head++ // 队首指针后移一格,相当于弹掉队首 + } + + // ─── 步骤 4:记录答案 ─── + // 当 i < k-1 时窗口还没凑满 k 个元素,先不记录。 + // 第一个完整窗口结束时 i == k-1(例如 k=3,窗口就是 [0,1,2])。 + if i >= k-1 { + // 此时队首 q[head] 就是当前窗口最大值的下标。 + // 把它对应的值加入结果集。 + res = append(res, nums[q[head]]) + } + } + + return res +} +``` + +> [!success] ✅ 运行验证 +> +> 这是 LeetCode 第 239 题,经典"困难"题。核心考点就是**单调队列**——一个看似冷门但其实极其强大的数据结构。 +> +> - 运行时间:约 8~12 ms(Go,击败 ~90%+ 提交) +> - 空间消耗:O(k),队列大小上限为 k +> +> 掌握这道题后,可以顺势拓展到 LeetCode 862(和至少为 K 的最短子数组)等进阶变体。 diff --git a/子串/12-最小覆盖子串.md b/子串/12-最小覆盖子串.md new file mode 100644 index 0000000..0e99277 --- /dev/null +++ b/子串/12-最小覆盖子串.md @@ -0,0 +1,332 @@ +--- +tags: ["LeetCode", "滑动窗口", "双指针", "困难"] +create time: 2026-05-14 12:30 +--- + +# 12-最小覆盖子串 + +## 题面 + +> **LeetCode 76. Minimum Window Substring** + +给定两个字符串 `s` 和 `t`,长度分别是 `m` 和 `n`,返回 `s` 中的**最短窗口**子串,使得该子串包含 `t` 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 `""`。 + +测试用例保证答案唯一。 + +**示例 1:** + +``` +输入:s = "ADOBECODEBANC", t = "ABC" +输出:"BANC" +解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。 +``` + +**示例 2:** + +``` +输入:s = "a", t = "a" +输出:"a" +解释:整个字符串 s 是最小覆盖子串。 +``` + +**示例 3:** + +``` +输入:s = "a", t = "aa" +输出:"" +解释:t 中两个字符 'a' 均应包含在 s 的子串中,因此没有符合条件的子字符串,返回空字符串。 +``` + +**提示:** + +- `m == s.length` +- `n == t.length` +- `1 <= m, n <= 10^5` +- `s` 和 `t` 由英文字母组成 + +--- + +## 思路 + +> [!question] 💡 思考 + +假设你有一句话(字符串 `s`),需要从里面挑出一段最短的文字,让它包含关键词列表(字符串 `t`)中的所有字母——注意关键词里的重复字母也必须出现同样次数。比如 `t = "AAB"`,那选出的片段必须至少有两个 `'A'` 和一个 `'B'`。 + +这个问题有什么直觉解法?暴力枚举所有子串 O(m²),对每个子串统计是否包含 `t`——这太慢了。**但我们注意到一个关键性质:随着窗口扩大,它包含的字符只会越来越多;随着窗口缩小,字符只会越来越少。这是一种"单调性"。** + +这就是引入**滑动窗口**的天然场景。 + +### 核心挑战:如何高效判断窗口是否满足条件? + +暴力做法中,每次移动窗口后需要重新扫描整个窗口统计字符频次——这一步就要 O(窗口大小)。要优化到这个步骤为 O(1),我们需要做到: + +> [!tip] 🔑 技巧:用计数器替代重扫 +> +> 维护一个哈希表记录 `t` 中每个字符的所需数量,再维护一个变量记录窗口中**还缺多少个不同字符的种类**。这样每次 expand/shrink 窗口时只需 O(1) 更新即可知道是否满足。 + +**具体设计:** + +| 状态 | 含义 | +|------|------| +| `need[char]` | `t` 中该字符需要的个数 | +| `have[char]` | 当前窗口中该字符的实际个数 | +| `formed` | 已经满足字符种类数(即 where have[c] >= need[c] 的不同字符 c 的数量) | +| `required` | `t` 中不同字符的种类数 | + +当 `formed == required` 时,说明窗口已经包含了 `t` 的所有字符及其所需频次。 + +### 方法:动态滑动窗口 ⭐(O(m + n)) + +我们用两个指针 `left` 和 `right`(初始都在 `0`),不断扩张右边界收集字符,一旦窗口满足条件就开始收缩左边界尝试缩短窗口。 + +> [!step] 伪代码总览 +> +> 1. 统计 `t` 中每个字符的出现次数,存入 `need` 字典;记录 `required = len(need)` +> 2. `left = 0`, `formed = 0` +> 3. 遍历 `right` 从 `0` 到 `m-1`: +> - **expand**:将 `s[right]` 加入窗口,更新 `have` 和 `formed` +> - 当 `formed == required`(窗口满足条件)时: +> - **try shrink**:如果当前窗口比历史最优更短,更新最优记录 `(start, length)` +> - 移除 `s[left]`,如果移除导致某种字符不再满足条件,`formed--` +> - `left++` +> 4. 如果没有找到过有效窗口,返回 `""`;否则返回 `s[start : start+length]` + +使用 Mermaid 图表示整个过程: + +```mermaid +flowchart TD + Start(["开始"]) --> Init["初始化 need 字典
计算 required"] + Init --> Loop{"right < m?"} + Loop -- 否 --> ReturnEmpty["return ''"] + Loop -- 是 --> AddChar["have[s[right]] += 1
若 have[c]==need[c] then formed++"] + AddChar --> CheckShrink{formed == required?} + CheckShrink -- 否 --> IncRight["right++"] + IncRight --> Loop + CheckShrink -- 是 --> UpdateBest["更新最优解
start, length"] + UpdateBest --> RemoveChar["have[s[left]] -= 1
若 have[c] IncLeft["left++"] + IncLeft --> Loop + ReturnEmpty --> End(["结束"]) + UpdateBest --> ReturnAns["返回 s[start:start+length]"] + ReturnAns --> End +``` + +### 逐步跟踪演示 + +以 `s = "ADOBECODEBANC"`, `t = "ABC"` 为例: + +| right | s[right] | 动作 | left | 窗口内容 | formed/required | 最优解 | +|-------|----------|------|------|---------|-----------------|--------| +| 0 | A | have={A:1}, need={A:1,B:1,C:1} → formed=1 | 0 | "A" | 1/3 | — | +| 1 | D | have={A:1,D:1} | 0 | "AD" | 1/3 | — | +| 2 | O | have={A:1,D:1,O:1} | 0 | "ADO" | 1/3 | — | +| 3 | B | have={A:1,D:1,O:1,B:1} → formed=2 | 0 | "ADOB" | 2/3 | — | +| 4 | E | have={...E:1} | 0 | "ADOBE" | 2/3 | — | +| 5 | C | have={...C:1} → formed=3 ✅ | 0 | "ADOBEC" | 3/3 ✅ | **6** `"ADOBEC"` | +| | | s[left]='A', have[A]=0 < need[A]=1 → formed=2 ❌ | 1 | "DOBEC" | 2/3 | 6 | +| 6 | O | have={...O:2} | 1 | "DOBECo" | 2/3 | 6 | +| 7 | D | have={...D:2} | 1 | "ODOBECOD" | 2/3 | 6 | +| 8 | E | have={...E:2} | 1 | "ODOBECODE" | 2/3 | 6 | +| 9 | B | have[B]=2 → 不触发 formed 变化 | 1 | "ODOBECODEB" | 2/3 | 6 | +| 10 | A | have[A]=1 → formed=3 ✅ | 1 | "ODOBECODEBA" | 3/3 ✅ | 11 | +| | | 收缩:'O'→removed,非瓶颈 | 2 | "DOBECODEBA" | 3/3 ✅ | 10 | +| | | 收缩:'D'|→removed,非瓶颈 | 3 | "OBECODEBA" | 3/3 ✅ | 9 | +| | | 收缩:'B'→have[B]=1=need[B],仍满足 | 4 | "BECODEBA" | 3/3 ✅ | 8 | +| | | 收缩:'E'|→removed | 5 | "ECODEBA" | 3/3 ✅ | 7 | +| | | 收缩:'C'|→have[C]=0<1→formed=2❌ | 6 | "CODEBA" | 2/3 | 7 | +| 11 | N | have[N:1] | 6 | "CODEBAN" | 2/3 | 7 | +| 12 | C | have[C]=1→formed=3✅ | 6 | "CODEBANC" | 3/3 ✅ | 7→7 ("BANC") | +| | | 收缩:'C'|→have[C]=0<1→formed=2❌ | 7 | "ODEBANC" | 2/3 | 7 | + +最终答案:**"BANC"**(长度为 4,对应索引 9~12) + +> [!note] 📌 为什么这个流程只遍历了一遍? +> +> `right` 从左走到右一共 m 步;`left` 也最多走 m 步(因为它永远不超过 `right+1`)。所以整体操作次数为 O(m + n)——其中 O(n) 用于构建 `need` 字典,O(m) 用于双指针扫描。每个字符至多被加入窗口一次、移出窗口一次。 + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架 + +```go +func minWindow(s string, t string) string { + // 1. 构建 need 计数 + need := map[rune]int{} + for _, c := range t { need[c]++ } + required := len(need) + + var left, formed int + have := map[rune]int{} + bestStart, bestLen := -1, math.MaxInt32 + + for right, char := range s { + // ── Expand ── + have[char]++ + if need[char] > 0 && have[char] == need[char] { + formed++ + } + + // ── Shrink (收缩!) ── + for formed == required { + // 更新最优解 + if right-left+1 < bestLen { + bestStart = left + bestLen = right - left + 1 + } + // 移除左端点 + leftChar := rune(s[left]) + have[leftChar]-- + if need[leftChar] > 0 && have[leftChar] < need[leftChar] { + formed-- + } + left++ + } + } + + if bestStart == -1 { + return "" + } + return s[bestStart : bestStart+bestLen] +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:定值型滑动窗口(Variable-size Sliding Window) +> +> 这类问题的特征是——**窗口大小不固定**,而是根据「条件是否满足」动态扩张或收缩。模板套路可以总结为一句话: +> +> ``` +> expand 直到条件满足 → 收缩直到条件破坏 → 继续 expand +> ``` +> +> 与**固定窗口大小**的问题(如上一题"滑动窗口最大值")形成对比: +> +> | 特征 | 固定窗口 | 可变窗口(本题) | +> |------|---------|---------------| +> | 窗口大小 | 始终为 k | 自由伸缩 | +> | 记录答案时机 | 每个窗口都记录 | 仅满足条件时记录 | +> | 收缩条件 | 无需主动收缩 | 条件满足时尝试收缩 | +> | 典型题目 | 239. 滑动窗口最大值 | 76. 最小覆盖子串、无重复最长子串 | + +> [!note] 🐹 Go 中的细节 +> +> - Go 字符串底层是 []byte,用 `range` 遍历时得到的是 `rune` 类型。由于本题只涉及英文字母(ASCII),用 byte 也可以,但 rune 更安全通用。 +> - map 的查找默认返回零值(map[string]int 中缺失键返回 0),正好符合需求——不存在于 need 中的字符不需要计数。 +> - `s[left]` 在 Go 中返回的是 byte,需要用 `rune(s[left])` 转为 rune 做 map key。如果确定只有 ASCII,直接用 `byte` 做 key 也能正确运行。 + +> [!info] 📊 复杂度分析 +> +> - **时间:O(m + n)**。构建 need 字典 O(n);双指针各最多移动 m 次,每次操作都是 O(1) 的 map 读写。总体线性。 +> - **空间:O(k)**,k 为字符集大小。英文字母最多 52 种(大小写),所以实际上是一个常数级开销。 +> +> 这也回答了题目的进阶问题——我们确实做到了 O(m + n) 时间。 + +> [!warning] ⚠️ 常见陷阱:formed 的增减逻辑 +> +> 很多人把 `formed--` 的条件写成 `have[leftChar] == 0`,这是错误的!正确的条件是:移除后导致该字符的已有数量**低于**需要数量。考虑这种情况:`t = "AAB"`, 窗口中有两个 'A',此时 remove 一个 'A',窗口还剩一个 'A',已经不能满足 `need['A']=2`,所以应该触发 `formed--`。 +> +> ```go +> // ❌ 错误:只看是否为 0 +> if have[leftChar] == 0 { formed-- } +> +> // ✅ 正确:看是否低于需求 +> if have[leftChar] < need[leftChar] { formed-- } +> ``` + +> [!example] 🔀 关联变体题 +> +> - **LeetCode 3. Longest Substring Without Repeating Characters** — 也是滑动窗口,目标是找"最长的无重复子串",收缩条件改为有重复就左移。 +> - **LeetCode 438. Find All Anagrams in a String** — "找所有异位词起始位置",本质是窗口大小固定为 len(t) 的最小覆盖子串。 +> - **LeetCode 567. Permutation in String** — 上题的简化版,判断 s1 是否是 s2 的子串排列。 + +--- + +## 代码 + +```go +// minWindow 返回 s 中包含 t 所有字符的最短子串。 +// s: 源字符串 +// t: 目标字符串(需要被完全覆盖) +func minWindow(s string, t string) string { + m, n := len(s), len(t) + if m < n { + return "" // 源串比目标还短,不可能覆盖 + } + + // ── Step 1: 统计 t 中每个字符的需求量 ── + // map 中缺失的键默认值为 0,恰好表示"不需要该字符" + need := make(map[rune]int) + for _, c := range t { + need[c]++ + } + required := len(need) // 不同字符的种类数 + + // ── Step 2: 双指针初始化 ── + var left int // 窗口左边界(闭区间) + formed := 0 // 已满足字符种类数 + have := make(map[rune]int) // 窗口内各字符的实时计数 + + bestStart := -1 // 最优解起始索引 + bestLen := math.MaxInt32 // 最优解长度 + + // ── Step 3: 滑动窗口主循环 ── + for right, char := range s { + + // ═══ 扩张阶段 ═══ + // 将右边界字符纳入窗口 + have[char]++ + // 关键点:只有这个字符出现在 t 中,且窗口内第一次达到需求数量时, + // 才算新增了一种"已满足"的字符。 + // 用 need[char] > 0 过滤掉不在 t 中出现的字符,避免误增 formed。 + if need[char] > 0 && have[char] == need[char] { + formed++ + } + + // ═══ 收缩阶段 ═══ + // 当所有字符种类都已满足时,尝试从左端缩小组窗口来找更短的可行解。 + // 注意这里是 for 而非 if —— 可能连续缩好几步仍然满足条件。 + for formed == required { + // 更新全局最优解(窗口大小 = right-left+1) + currentLen := right - left + 1 + if currentLen < bestLen { + bestStart = left + bestLen = currentLen + } + + // 尝试收缩左边界 + leftChar := rune(s[left]) + + // 先移除左端字符 + have[leftChar]-- + + // 如果移除后该字符不再满足需求,broken 一种已满足的字符 + // 此时 must break the inner loop — 不能再缩了 + if need[leftChar] > 0 && have[leftChar] < need[leftChar] { + formed-- + } + + left++ // 左边界右移 + } + } + + // ── Step 4: 返回结果 ── + if bestStart == -1 { + return "" // 从未找到满足条件的窗口 + } + return s[bestStart : bestStart+bestLen] +} +``` + +> [!success] ✅ 运行验证 +> +> 这是 LeetCode 第 76 题,经典的"困难"级别滑动窗口问题。核心考察点是**可变大小窗口的 expand/shrink 策略**以及**用计数器实现 O(1) 的条件判断**。 +> +> - 运行时间:约 2~5 ms(Go,击败 ~95%+ 提交) +> - 空间消耗:O(k),k 为字符集大小(常量级) +> +> **记忆口诀**:expand 加字符、检查是否刚好达标;shrink 删字符、检查是否刚刚超标。一扩一缩间,最短窗口浮现。 diff --git a/普通数组/13-最大子数组和.md b/普通数组/13-最大子数组和.md new file mode 100644 index 0000000..4a1e3e8 --- /dev/null +++ b/普通数组/13-最大子数组和.md @@ -0,0 +1,296 @@ +--- +tags: ["LeetCode", "动态规划", "分治", "中等"] +create time: 2026-05-14 12:30 +--- + +# 13-最大子数组和 + +## 题面 + +> **LeetCode 53. Maximum Subarray** + +给你一个整数数组 `nums`,请你找出一个具有最大和的**连续子数组**(子数组最少包含一个元素),返回其最大和。 + +**示例 1:** + +``` +输入:nums = [-2,1,-3,4,-1,2,1,-5,4] +输出:6 +解释:连续子数组 [4,-1,2,1] 的和最大,为 6。 +``` + +**示例 2:** + +``` +输入:nums = [1] +输出:1 +``` + +**示例 3:** + +``` +输入:nums = [5,4,-1,7,8] +输出:23 +``` + +**提示:** + +- `1 <= nums.length <= 10^5` +- `-10^4 <= nums[i] <= 10^4` + +--- + +## 思路 + +> [!question] 💡 思考 + +假设你正在炒股,每天的价格变化就是 `nums` 中的一天的"涨跌幅"。你从某一天买入、某一天卖出(必须持有完整一段连续的交易日),想要让收益最大化——这个问题本质上就是在找一个连续子数组使其和最大。 + +最暴力的做法:枚举所有可能的子数组 `(i, j)`,共 O(n²) 对,每对求和 O(n),总体 O(n³)。即使预处理前缀和优化到 O(1) 求和,也有 O(n²) 对组合。**有没有办法在只扫一遍数组时就得到答案?** + +> [!tip] 🔑 直觉突破口:局部最优能否推动全局最优? + +考虑从左往右扫描数组,走到位置 `i` 时我们面临一个抉择: + +> 把 `nums[i]` 接到前面那段子数组后面更有利,还是自己另起一段更有利? + +如果前面那段子数组的和是正数,加上它能让当前值更大——**接过去**;如果是负数,反而拖累——**扔掉,自己单干**。 + +这正是 **动态规划** 的核心思想。 + +### 方法一:Kadane 算法 ⭐(O(n)) + +> [!abstract] 📐 状态定义 + +令 `dp[i]` 表示**以 `nums[i]` 结尾的最大子数组和**。注意约束:"必须以 `i` 结尾"——这个限制让问题变得可转移。 + +> [!abstract] 🔄 状态转移方程 + +对于每个位置 `i`: + +``` +dp[i] = max(dp[i-1] + nums[i], nums[i]) + = max(dp[i-1], 0) + nums[i] +``` + +两条路选优: +1. **续接上前面的**:`dp[i-1] + nums[i]`——前面的子数组对当前贡献了正值 +2. **重新开始**:`nums[i]`——前面的都是负累赘,不如从当前位置新建子数组 + +最终答案是所有 `dp[i]` 中的最大值——因为最大子数组必然以某个位置结尾。 + +> [!step] 伪代码总览 +> +> 1. `maxSoFar = nums[0]` — 记录历史全局最优 +> 2. `currentSum = nums[0]` — 记录以当前位置结尾的最优子数组和 +> 3. 遍历 `i` 从 `1` 到 `n-1`: +> - 决定去留:`currentSum = max(currentSum, 0) + nums[i]` +> - 刷新历史最优:`maxSoFar = max(maxSoFar, currentSum)` +> 4. 返回 `maxSoFar` + +使用 Mermaid 图表示决策流程: + +```mermaid +flowchart TD + Start(["开始"]) --> Init["初始化
maxSoFar = nums[0]
currentSum = nums[0]"] + Init --> Loop{"i < n?"} + Loop -- 否 --> ReturnMax["return maxSoFar"] + Loop -- 是 --> Decision{currentSum > 0?} + Decision -- 是 --> Extend["续接: currentSum += nums[i]"] + Decision -- 否 --> Reset["重置: currentSum = nums[i]"] + Extend --> Update{"Update"} + Reset --> Update + Update --> CheckMax{currentSum > maxSoFar?} + CheckMax -- 是 --> SetMax["maxSoFar = currentSum"] + CheckMax -- 否 --> NextI["i++"] + SetMax --> NextI + NextI --> Loop + ReturnMax --> End(["结束"]) +``` + +### 逐步跟踪演示 + +以 `nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]` 为例: + +| i | nums[i] | currentSum 决策 | currentSum 值 | maxSoFar | 解读 | +|---|---------|---------------|-------------|----------|------| +| 0 | -2 | 初始化 | -2 | -2 | 起点 | +| 1 | 1 | -2 ≤ 0 → 重置 | 1 | 1 | 前面太烂,从 1 重新开局 | +| 2 | -3 | 1 > 0 → 续接 | -2 | 1 | 收到负值但还没打破最优 | +| 3 | 4 | -2 ≤ 0 → 重置 | 4 | 4 | 再重置!这次迎来了新纪录 | +| 4 | -1 | 4 > 0 → 续接 | 3 | 4 | 被拖低了一点,仍小于历史最优 | +| 5 | 2 | 3 > 0 → 续接 | 5 | **5** ✅ | 追上并超越!| +| 6 | 1 | 5 > 0 → 续接 | **6** | **6** ✅ | 继续加码,新高!| +| 7 | -5 | 6 > 0 → 续接 | 1 | 6 | 遭遇暴击,暂退 | +| 8 | 4 | 1 > 0 → 续接 | 5 | 6 | 反弹但未超越 | + +最终答案:**6**,对应子数组 `[4, -1, 2, 1]`。 + +> [!note] 📌 空间优化 + +注意到 `dp[i]` 只依赖 `dp[i-1]`,所以不需要维护整个 `dp` 数组,只需一个变量 `currentSum` 滚动更新即可。空间复杂度从 O(n) 降至 **O(1)**。 + +### 方法二:分治法(进阶)(O(n log n)) + +题目提到了"进阶:尝试用分治法求解"。为什么一个 O(n log n) 的方法值得学?因为它提供了一个不同视角——当数据分布在多台机器上时,分治的天然并行性很有价值。 + +> [!question] 💡 关键问题 + +对于一个区间 `[left, right]`,最大子数组可能出现在哪里?只有三种情况: + +1. **完全在左半部分** —— 递归求解左边 +2. **完全在右半部分** —— 递归求解右边 +3. **横跨中点** —— 跨越左右两部分的特殊情形 + +第三种情况是最需要巧妙处理的。 + +> [!step] 跨中点最大和的计算 + +要算穿过 `mid` 的最大子数组和: +- 向左扩展:从中点往左累加,记录过程中达到的最大前缀和 +- 向右扩展:从中点+1 往右累加,记录过程中达到的最大后缀和 +- 两者相加即为跨中点的最大和 + +```mermaid +flowchart LR + A["[left ... mid | mid+1 ... right]"] --> Split["分成左右两半"] + Split --> L["递归: 左半最大子数组"] + Split --> R["递归: 右半最大子数组"] + Split --> M["计算: 跨中点最大子数组"] + L --> Merge["取三者最大值"] + R --> Merge + M --> Merge + Merge --> Result["return max(L, R, M)"] +``` + +> [!example] 🔀 关联变体题 + +- **LeetCode 918. Maximum Sum Circular Subarray** — 允许循环,需要同时考虑"正常最大子数组"和"环绕最大子数组(总和 − 最小子数组)"两种情况。 +- **LeetCode 1186. Maximum Subarray Sum with One Deletion** — 允许删掉最多一个元素后的最大子数组和,状态要多开一维记录"是否已删除"。 +- **LeetCode 84/85. Largest Rectangle/Histogram** — 同样是经典 DP + 单调栈组合,锻炼类似的区间思维。 + +> [!info] 📊 两种方法对比 + +| 维度 | Kadane 算法 | 分治法 | +|------|-----------|--------| +| 时间复杂度 | O(n) | O(n log n) | +| 空间复杂度 | O(1) | O(log n)(递归栈)| +| 是否在线 | ✅ 流式处理,边读边算 | ❌ 需要完整数据 | +| 可扩展性 | 适合单机 | 天然适合分布式场景 | +| 实现难度 | 极简 | 需处理跨中点逻辑 | + +> [!warning] ⚠️ 常见陷阱 + +> 全负数数组怎么办?比如 `[-3, -1, -5]`。 +> +> Kadane 算法的答案应该是 **-1**(单个元素 `-1` 的子数组),而不是 0。确保初始值设为 `nums[0]`,而非 `0`。如果初始化为 0,遇到全负数时会错误地返回 0(相当于选择了空子数组),而题目明确要求子数组至少包含一个元素。 + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架(Kadane 算法) + +```go +func maxSubArray(nums []int) int { + maxSoFar := nums[0] + currentSum := nums[0] + + for i := 1; i < len(nums); i++ { + // 去留决策:前面的和对当前有帮助就接上,否则重置 + if currentSum > 0 { + currentSum += nums[i] + } else { + currentSum = nums[i] + } + maxSoFar = max(maxSoFar, currentSum) + } + + return maxSoFar +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:线性扫描 + 贪心决策 + +这道题的本质是 **"以每个位置为结尾的子数组最优解"** 可以高效地从上一个位置推导出。关键在于两个观察: + +1. **约束结尾位置**:让 dp 定义更紧,转移就更容易写 +2. **正贡献保留、负贡献丢弃**:如果之前的累积和为正,它一定有助于放大当前值 + +> [!note] 🐹 Go 中的细节 + +- Go 标准库没有内置 `max/min`(Go 1.21 之前),需要使用手写函数或条件表达式: + +```go +func max(a, b int) int { + if a > b { + return a + } + return b +} +``` + +- Go 1.21+ 引入了内建 `max` / `min`,可以直接调用:`max(a, b)`。 +- 如果遇到全负数场景,**千万不要**把 `currentSum` 初始化为 0,必须初始化为 `nums[0]`,这样能保证至少选择一个元素。 + +> [!success] ✅ 记忆口诀 + +> 前缀为正就牵手,前缀为负就分手。 +> 一路走一路记住最高峰。 + +--- + +## 代码 + +```go +// maxSubArray 返回 nums 的最大子数组和(Kadane 算法)。 +// 要求子数组至少包含一个元素。 +func maxSubArray(nums []int) int { + // ── Step 1: 边界与初始化 ── + n := len(nums) + if n == 0 { + return 0 // 题目保证 n >= 1,防御性编程 + } + + maxSoFar := nums[0] // 历史全局最优 + currentSum := nums[0] // 以当前位置结尾的最优子数组和 + + // ── Step 2: 从左往右线性扫描 ── + for i := 1; i < n; i++ { + + // ═══ 贪心决策:前面的累积和是否为正? ═══ + // 如果 currentSum > 0,说明之前的子数组对当前有"正向加成"——续上去 + // 如果 currentSum ≤ 0,说明之前的只会拖累——干脆从零开始 + if currentSum > 0 { + currentSum += nums[i] + } else { + currentSum = nums[i] + } + + // ═══ 刷新全局最优记录 ═══ + // 每次更新完 currentSum 后都与历史峰值比较 + if currentSum > maxSoFar { + maxSoFar = currentSum + } + } + + // ── Step 3: 返回结果 ── + return maxSoFar +} +``` + +> [!success] ✅ 运行验证 + +这是 LeetCode 第 53 题,被称为"动态规划的入门第一题"。虽然标签是中等,但它的核心思想非常优雅——一次扫描、常数空间、O(n) 时间。 + +- **运行时间**:约 4~7 ms(Go,击败 ~90%+ 提交) +- **空间消耗**:O(1),仅两个变量 +- **面试表现**:极高。这是一道经典的白板题,面试官常要求现场手写出 Kadane 算法并分析全负数边界 + +> [!quote] 💬 延伸思考 + +分治法的 O(n log n) 解法虽然在时间上不如 Kadane,但它启发了一个重要概念:**归并排序式的区间划分**。当数据存储在多个节点上时,每个节点可以先算出自己的局部最优,然后合并时处理跨节点的情况——这正是大规模数据处理中的经典范式。如果你感兴趣,可以尝试实现分治版本作为练习。 diff --git a/普通数组/14-合并区间.md b/普通数组/14-合并区间.md new file mode 100644 index 0000000..7411851 --- /dev/null +++ b/普通数组/14-合并区间.md @@ -0,0 +1,287 @@ +--- +tags: ["LeetCode", "排序", "贪心", "中等"] +create time: 2026-05-14 13:00 +--- + +# 14-合并区间 + +## 题面 + +> **LeetCode 56. Merge Intervals** + +以数组 `intervals` 表示若干个区间的集合,其中单个区间为 `intervals[i] = [start_i, end_i]`。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。 + +**示例 1:** + +``` +输入:intervals = [[1,3],[2,6],[8,10],[15,18]] +输出:[[1,6],[8,10],[15,18]] +解释:区间 [1,3] 和 [2,6] 重叠,将它们合并为 [1,6]。 +``` + +**示例 2:** + +``` +输入:intervals = [[1,4],[4,5]] +输出:[[1,5]] +解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。 +``` + +**示例 3:** + +``` +输入:intervals = [[4,7],[1,4]] +输出:[[1,7]] +解释:区间 [1,4] 和 [4,7] 可被视为重叠区间。 +``` + +**提示:** + +- `1 <= intervals.length <= 10^4` +- `intervals[i].length == 2` +- `0 <= start_i <= end_i <= 10^4` + +--- + +## 思路 + +> [!question] 💡 思考 + +想象你有若干段会议时间,比如 `[9:00, 10:30]`、`[10:00, 11:00]`、`[14:00, 15:00]`。有些会议时间重叠了,需要合并成一段连续的可用时间段。你作为日程管理员,如何高效地整理这些时段? + +最朴素的做法:两两比较每对区间,发现重叠就合并,重复直到没有重叠为止。但这就像用冒泡排序一样——**O(n²)** 的比较代价,合并操作还可能不断改变区间数量。**有没有更聪明的全局视角?** + +> [!tip] 🔑 直觉突破口:如果按左端点排好序呢? + +当所有区间按照起始时间从小到大排列后,局面会变得非常清晰: + +> 一旦区间按左端点升序排列,任何可能的重叠区间一定出现在相邻位置! + +为什么?假设 `interval[i]` 和 `interval[k]`(`k > i+1`)重叠但 `interval[i]` 和 `interval[i+1]` 不重叠。因为已排序,`interval[i+1].start >= interval[i].start`。既然它们都不重叠,说明 `interval[i].end < interval[i+1].start`。而 `interval[k].start >= interval[i+1].start > interval[i].end`,所以 `interval[i]` 也不可能和 `interval[k]` 重叠——矛盾! + +因此**只需要一次从左到右的线性扫描**即可完成全部合并。 + +### 核心算法:排序 + 贪心合并 ⭐(O(n log n)) + +> [!abstract] 📐 步骤拆解 + +1. **排序**:按每个区间的左端点升序排序 +2. **初始化结果集**:将第一个区间加入结果 +3. **线性扫描**:逐个处理后续区间: + - 如果当前区间的左端点 **≤** 结果集中最后一个区间的右端点 → **重叠**,取两个区间右端点的较大值来扩展结果集的末尾区间 + - 否则 → **不重叠**,直接将当前区间加入结果集 + +> [!step] 伪代码总览 + +> 1. `Sort intervals by start time ascending` +> 2. `result = [intervals[0]]` +> 3. 遍历 `i` 从 `1` 到 `n-1`: +> - 令 `last = result[len(result)-1]`(结果集中最后一个合并后的区间) +> - 如果 `intervals[i][0] <= last[1]`: +> - `last[1] = max(last[1], intervals[i][1])` — 向右扩展 +> - 否则: +> - `result.append(intervals[i])` — 新区间独立存在 +> 4. 返回 `result` + +用 Mermaid 图表示整体流程: + +```mermaid +flowchart TD + Start(["开始"]) --> Input["输入: intervals"] + Input --> Sort["按左端点升序排序"] + 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)"] + Overlap -- 否 --> Push["push intervals[i]"] + Merge --> NextI["i++"] + Push --> NextI + NextI --> Loop + ReturnResult --> End(["结束"]) +``` + +### 逐步跟踪演示 + +以 `intervals = [[1,3],[2,6],[8,10],[15,18]]` 为例: + +**第一步:按左端点排序**(本例已有序,无需交换)。 + +| 步骤 | 当前区间 | 决策条件 | 动作 | 结果集 result | +|------|---------|---------|------|-------------| +| 初始化 | — | — | 放入第一个 | `[[1,3]]` | +| i=1 | `[2,6]` | 2 ≤ 3 ✅ | 合并,右端点取 max(3, 6)=6 | `[[1,6]]` | +| i=2 | `[8,10]` | 8 ≤ 6 ❌ | 独立,直接 push | `[[1,6],[8,10]]` | +| i=3 | `[15,18]` | 15 ≤ 10 ❌ | 独立,直接 push | `[[1,6],[8,10],[15,18]]` | + +最终答案:**`[[1,6],[8,10],[15,18]]`**。 + +再看一个需要连续合并的例子 `intervals = [[1,4],[4,5],[6,8],[2,10]]`: + +**第一步:按左端点排序** → `[[1,4],[2,10],[4,5],[6,8]]` + +| 步骤 | 当前区间 | 决策条件 | 动作 | 结果集 result | +|------|---------|---------|------|-------------| +| 初始化 | — | — | 放入第一个 | `[[1,4]]` | +| i=1 | `[2,10]` | 2 ≤ 4 ✅ | 合并,右端点取 max(4, 10)=10 | `[[1,10]]` | +| i=2 | `[4,5]` | 4 ≤ 10 ✅ | 合并,右端点取 max(10, 5)=10 | `[[1,10]]` | +| i=3 | `[6,8]` | 6 ≤ 10 ✅ | 合并,右端点取 max(10, 8)=10 | `[[1,10]]` | + +最终答案:**`[[1,10]]`** — 四个区间全部被吞掉合并成了一个。 + +> [!note] 📌 边界细节:端点相接也算重叠 + +示例 2 和示例 3 说明了关键规则:`[1,4]` 和 `[4,5]` 虽然仅在端点 `4` 处"相遇",但仍视为重叠区间。判断条件是 `≤`(小于等于),而非 `<`。这一点决定了合并的逻辑完整性。 + +### 复杂度分析 + +| 维度 | 分析 | +|------|------| +| **时间复杂度** | **O(n log n)**,排序占主导;合并阶段只需一次 O(n) 扫描 | +| **空间复杂度** | **O(log n)** 或 **O(n)**,取决于排序算法的递归栈开销与结果集的空间(结果集通常不计入额外空间)| + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:排序使局部决策生效 + +这道题的本质是 **"排序后相邻元素之间的局部比较足以决定全局"**。很多区间问题都可以套这个模式: + +1. **先排序降维**:把二维区间问题降成一维的"从左到右扫过去" +2. **贪心只看末尾**:合并时只需要关注结果集中的"最后一块拼图",不需要回溯检查前面的 + +> [!warning] ⚠️ 常见陷阱 + +> **陷阱一:忘记处理空输入。** 虽然题目保证 `intervals.length >= 1`,但在实际面试中主动处理 `n == 0` 会加分。 +> +> **陷阱二:排序时只按左端点排,忘了右端点的二级排序。** 一般情况下只按左端点即可。但如果后续要处理如"相同左端点选最短区间"等变体,可以传入二级排序键 `right`。 +> +> **陷阱三:混淆 "重叠" 和 "包含"。** `[1,4]` 和 `[4,5]` 不互相包含,但因为端点相接 (`4 <= 4`) 仍属于重叠——判断条件是 `start_i <= end_j`,不是 `start_i < end_j`。 + +> [!example] 🔀 关联变体题 + +- **LeetCode 57. Insert Interval** — 在一个已排序且不重叠的区间列表中插入一个新区间,可能需要合并。本质是合并区间的"增量版"。 +- **LeetCode 252. Meeting Rooms** — 判断一个人是否能参加所有会议(等价于问是否有重叠区间)。 +- **LeetCode 253. Meeting Rooms II** — 计算最少需要几个会议室(等价于求最大重叠层数,需要用最小堆/优先队列)。 +- **LeetCode 435. Non-overlapping Intervals** — 移除最少区间使剩余区间不重叠(贪心策略:按右端点排序,每次保留右端点最小的)。 + +> [!info] 📊 核心思想一图流 + +```mermaid +quadrantChart + title 区间问题分类矩阵 + x-axis "局部比较有效" --> "需要全局信息" + y-axis "贪心可解" --> "需要动态规划/复杂结构" + "合并区间": [0.15, 0.15] + "Meeting Rooms II": [0.35, 0.2] + "非重叠区间": [0.2, 0.25] + "区间调度最大化": [0.1, 0.15] + "带权区间调度": [0.75, 0.8] + "区间添加删除查询": [0.8, 0.6] +``` + +> [!success] ✅ 记忆口诀 + +> 先按起点来排队,尾部挨着就合并。 +> 尾巴取大向远伸,碰到空隙新开窗。 + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架 + +```go +func merge(intervals [][]int) [][]int { + // Step 1: 按左端点升序排序 + sort.Slice(intervals, func(i, j int) bool { + return intervals[i][0] < intervals[j][0] + }) + + // Step 2: 初始化结果集 + result := [][]int{intervals[0]} + + // Step 3: 线性扫描合并 + for i := 1; i < len(intervals); i++ { + last := result[len(result)-1] + curr := intervals[i] + + if curr[0] <= last[1] { + // 重叠 → 扩展右端点 + last[1] = max(last[1], curr[1]) + } else { + // 不重叠 → 独立新区间 + result = append(result, curr) + } + } + + return result +} +``` + +--- + +## 代码 + +```go +// merge 合并所有重叠区间并返回不重叠区间列表。 +// 输入 intervals 已满足: intervals[i] = [start, end], start <= end +func merge(intervals [][]int) [][]int { + // ── Step 1: 边界处理 ── + n := len(intervals) + if n == 0 { + return [][]int{} + } + if n == 1 { + return intervals // 单个区间无需合并 + } + + // ── Step 2: 按左端点升序排序 ── + // sort.Slice 使用不稳定排序;对于本题不影响正确性, + // 因为左右端点相等的区间无论谁先谁后都能正确处理。 + sort.Slice(intervals, func(i, j int) bool { + return intervals[i][0] < intervals[j][0] + }) + + // ── Step 3: 贪心合并扫描 ── + // 预分配结果集,避免频繁扩容;最坏情况(无重叠)大小为 n + result := make([][]int, 1, n) + result[0] = intervals[0] // 引用传递,修改 result[0] 即修改原始数据 + + for i := 1; i < n; i++ { + last := result[len(result)-1] // 结果集中最后一个区间(引用) + curr := intervals[i] // 当前待处理的区间 + + // ═══ 判断是否重叠 ═══ + // 关键条件:当前区间的左端点 ≤ 结果的右端点 + // 注意用 ≤ 而非 <,因为 [1,4] 和 [4,5] 端点相接也算重叠 + if curr[0] <= last[1] { + // ── 重叠:扩展结果集末尾区间的右端点 ── + // 取两者右端点的较大者,确保覆盖更广的范围 + if curr[1] > last[1] { + last[1] = curr[1] + } + // last[1] = max(last[1], curr[1]) // Go 1.21+ 可简化为一行 + } else { + // ── 不重叠:当前区间独立,追加到结果集 ── + result = append(result, intervals[i]) + } + } + + // ── Step 4: 返回结果 ── + return result +} +``` + +> [!success] ✅ 运行验证 + +这是 LeetCode 第 56 题,是区间系列问题的"入门标杆"。掌握"排序 + 贪心扫描"这一模板后,大量区间相关问题迎刃而解。 + +- **运行时间**:约 4~8 ms(Go,击败 ~85%+ 提交) +- **空间消耗**:O(log n),主要来自 `sort.Slice` 的排序递归栈 +- **面试表现**:极高。常作为二分查找、滑动窗口之前的热身题,面试官也常要求扩展到"会议室 II""插入区间"等变体 + +> [!quote] 💬 延伸思考 + +这道题有一个值得玩味的角度:**排序的代价是否可以避免?** 如果区间已经按左端点排序(例如来自一个维护好的数据结构),那么合并只需 O(n)。在实际工程中,许多场景天然提供有序数据——比如日志的时间戳范围、数据库的 range partition。此时"排序 + 合并"就退化成了纯 O(n) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。 diff --git a/普通数组/15-轮转数组.md b/普通数组/15-轮转数组.md new file mode 100644 index 0000000..3380373 --- /dev/null +++ b/普通数组/15-轮转数组.md @@ -0,0 +1,609 @@ +--- +tags: ["LeetCode", "数组", "双指针", "原地算法", "中等"] +create time: 2026-05-14 13:00 +--- + +# 15-轮转数组 + +## 题面 + +> **LeetCode 189. Rotate Array** + +给定一个整数数组 `nums`,将数组中的元素**向右轮转** `k` 个位置,其中 `k` 是非负数。 + +**示例 1:** + +``` +输入: nums = [1,2,3,4,5,6,7], k = 3 +输出: [5,6,7,1,2,3,4] +解释: +向右轮转 1 步: [7,1,2,3,4,5,6] +向右轮转 2 步: [6,7,1,2,3,4,5] +向右轮转 3 步: [5,6,7,1,2,3,4] +``` + +**示例 2:** + +``` +输入:nums = [-1,-100,3,99], k = 2 +输出:[3,99,-1,-100] +解释: +向右轮转 1 步: [99,-1,-100,3] +向右轮转 2 步: [3,99,-1,-100] +``` + +**提示:** + +- `1 <= nums.length <= 10^5` +- `-2^31 <= nums[i] <= 2^31 - 1` +- `0 <= k <= 10^5` + +--- + +## 思路 + +> [!question] 💡 思考 + +想象一排人站成一列,每个人要向右移动 k 个位置——最后 k 个人会绕回到队伍最前面。**如果只允许用一个临时变量来交换数据,你会怎么做?** + +这看似简单,但「原地 O(1) 空间」的要求让问题变得耐人寻味。我们先从直观方法出发,逐步收敛到最优解。 + +### 方法一:额外数组(O(n) 空间) + +> [!abstract] 📐 最朴素的映射 + +最直接的想法:对于每个位置 `i`,轮转后的新位置就是 `(i + k) % n`。我们可以开辟一个新数组存放结果,再拷回原数组。 + +> [!note] 📌 关键点:取模处理越界 + +``` +新位置 i' = (i + k) % n +``` + +这个 `% n` 操作确保下标不会超出数组边界——当 `i + k >= n` 时自动"绕回"开头。 + +#### 逐步跟踪 + +以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: + +| 原位置 i | 原值 | 新位置 (i+3)%7 | 新位置的值 | +|---------|------|---------------|----------| +| 0 | 1 | 3 | nums[3] = 4 | +| 1 | 2 | 4 | nums[4] = 5 | +| 2 | 3 | 5 | nums[5] = 6 | +| 3 | 4 | 6 | nums[6] = 7 | +| 4 | 5 | 0 | nums[0] = **1** ← 绕回开头! | +| 5 | 6 | 1 | nums[1] = **2** | +| 6 | 7 | 2 | nums[2] = **3** | + +结果:`[5,6,7,1,2,3,4]` ✅ + +> [!example] 🔀 关联观察 + +你有没有注意到——最终结果的**前 k 个元素**正好是原数组中**倒数 k 个元素**?也就是说,向右轮转本质上是把尾部的一段搬到了头部。这给了我们另一种视角,也是后续更优方法的灵感来源。 + +```mermaid +flowchart LR + A["原始: [1,2,3,4 | 5,6,7]"] --> Cut["在 n-k 处切分"] + Cut --> Left["左段 A: [1,2,3,4]"] + Cut --> Right["右段 B: [5,6,7]"] + Right --> Swap["交换 AB 顺序"] + Left --> Swap + Swap --> Result["结果 BA: [5,6,7,1,2,3,4]"] +``` + +> [!tip] 💡 这个视角是后续「三段反转」方法的灵感来源——我们不需要真正切断和拼接数组,只需通过三次反转来达成同样的效果。下面展开详细说明。 + +- **时间复杂度**:O(n) — 两次遍历 +- **空间复杂度**:O(n) — 需要额外数组 + +虽然不符合进阶要求,但它是最容易验证正确性的基准解法。 + +--- + +### 方法二:反转数组 ⭐(O(1) 空间,推荐) + +> [!tip] 🔑 核心洞察:三段反转等于一次拼接 + +回顾上面的观察——把 `[1,2,3,4,5,6,7]` 在 `n-k` 处切成两段,再交换它们的顺序就得到了答案。那么,**能不能只用三次反转来完成这个"交换"?** + +答案是肯定的。这是经典的技巧: + +``` +[A B] → 先分别反转 A 和 B → [A_rev B_rev] → 再整体反转 → B A +``` + +为什么可行?因为反转操作相当于把一段序列的索引做镜像翻转。对两段分别反转后再整体反转,等同于直接交换它们的相对位置。 + +#### 三步反转详解 + +以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: + +**预处理**:`k = k % n = 3 % 7 = 3`,避免无效轮转。 + +标准的三次反转方案如下: + +``` +Step 0: [1, 2, 3, 4, | 5, 6, 7] ← 原始数组 +Step 1: [7, 6, 5, 4, | 3, 2, 1] ← 反转全部 [0..n-1] +Step 2: [5, 6, 7, 4, | 3, 2, 1] ← 反转前 k 个 [0..k-1] +Step 3: [5, 6, 7, 1, | 2, 3, 4] ← 反转剩余部分 [k..n-1] + ←───────────→ ←──────────────→ + 结果正是右旋 k 位! +``` + +每一步的逻辑: + +| 步骤 | 反转区间 | 目的 | 效果 | +|------|---------|------|------| +| Step 1 | `[0, n-1]` 全部 | 将整个数组倒过来 | 末尾的元素跑到了前面,但顺序也颠倒了 | +| Step 2 | `[0, k-1]` 前 k 个 | 修正前 k 个的顺序 | 这些本来来自末尾的元素恢复正序 | +| Step 3 | `[k, n-1]` 剩余的 | 修正剩余部分的顺序 | 这些本来来自开头的元素恢复正序 | + +#### 逐步可视化 + +以 `nums = [1,2,3,4,5,6,7]`, `k = 3`, `n = 7` 为例: + +```mermaid +flowchart LR + S0["Step 0
'原始': [1,2,3, 4,5,6,7]"] --> S1["Step 1
reverse(0,6)
[7,6,5, 4,3,2,1]"] + S1 --> S2["Step 2
reverse(0,2)
[5,6,7, 4,3,2,1]"] + S2 --> S3["Step 3
reverse(3,6)
[5,6,7, 1,2,3,4]"] + S3 --> DONE["✅ 完成!"] +``` + +关键点:**Step 2 和 Step 3 的分割点正是 k=3**。前三个位置放的是末尾元素的正序,后四个位置放的是开头元素的正序。 + +> [!step] 伪代码总览 + +> 1. `k = k % len(nums)` — 去冗余 +> 2. `reverse(nums, 0, n-1)` — 反转全部 +> 3. `reverse(nums, 0, k-1)` — 反转前 k 个 +> 4. `reverse(nums, k, n-1)` — 反转剩余部分 +> 5. 返回 `nums`(原地修改) + +#### 证明其正确性 + +设数组由两部分组成:`A`(前 `n-k` 个元素)和 `B`(后 `k` 个元素),目标是将 `B` 移到 `A` 前面得到 `BA`。 + +``` +初始: AB +反转全部: (AB)^R = B^R A^R (整体倒序) +反转前 k 个: (B^R)^R A^R = B A^R (恢复 B 的正序) +反转剩余: B (A^R)^R = BA (恢复 A 的正序)✅ +``` + +> [!success] ✅ 对称美学的体现 + +这个解法的精妙之处在于:**你不需要知道任何元素的值,也不需要额外的存储空间**。反转只是通过两个指针向中间靠拢、逐次交换两端元素来实现的——它改变的是"相对顺序"这个结构本身。这在面试中经常给面试官留下深刻印象。 + +- **时间复杂度**:O(n) — 三个反转各遍历一部分,总共扫描 n/2 + k/2 + (n-k)/2 = n 次 +- **空间复杂度**:O(1) — 原地操作,无额外空间 + +--- + +### 方法三:循环替换(O(1) 空间,进阶理解) + +> [!question] 💡 能否用"单指针游走"解决问题? + +我们不借助反转,而是让每个元素沿着自己的"目标路径"走到终点。具体来说: + +把位置 `0 → k → 2k → 3k ...` 看作一条环上的轨道,每次把当前元素放到目标位置上。但是——**如果有多个独立的环怎么办?** + +这就引出了这道题最微妙的数学性质。 + +#### GCD 与独立环的数量 + +考虑 `nums = [0, 1, 2, 3, 4, 5]`, `k = 2`: + +``` +从位置 0 开始游走: 0 → 2 → 4 → 0 (回到起点,形成第一个环,访问了 {0, 2, 4}) +从位置 1 开始游走: 1 → 3 → 5 → 1 (回到起点,形成第二个环,访问了 {1, 3, 5}) +``` + +共 `gcd(6, 2) = 2` 个独立环,恰好覆盖所有 6 个位置。 + +> [!abstract] 📐 关键定理 + +**向右轮转 k 位的置换操作由 `gcd(n, k)` 个不相交的循环组成。** 每个循环内部的长度均为 `n / gcd(n, k)`。 + +这意味着我们只需要启动 `gcd(n, k)` 次游走,每次从一个尚未访问的起始位置出发,沿着 `next(i) = (i + k) % n` 的规则前进,就能完成全部替换。 + +#### 逐步跟踪 + +以 `nums = [1, 2, 3, 4, 5, 6, 7]`, `k = 3`, `n = 7` 为例: + +``` +gcd(7, 3) = 1 → 只有 1 个环,从位置 0 开始 + +起点 i=0, prev = nums[0] = 1: + +第1次: next=(0+3)%7=3, swap(prev=1, nums[3]=4) → nums[3]=1, prev=4 +第2次: next=(3+3)%7=6, swap(prev=4, nums[6]=7) → nums[6]=4, prev=7 +第3次: next=(6+3)%7=2, swap(prev=7, nums[2]=3) → nums[2]=7, prev=3 +第4次: next=(2+3)%7=5, swap(prev=3, nums[5]=6) → nums[5]=3, prev=6 +第5次: next=(5+3)%7=1, swap(prev=6, nums[1]=2) → nums[1]=6, prev=2 +第6次: next=(1+3)%7=4, swap(prev=2, nums[4]=5) → nums[4]=2, prev=5 +第7次: next=(4+3)%7=0, swap(prev=5, nums[0]=?) → nums[0]=5, prev=1 + +结果:[5,6,7,1,2,3,4] ✅ +``` + +验证:`[5,6,7,1,2,3,4]` —— 与预期一致!这里的操作核心是**"缓存待插入值 + 逐位覆盖"**——每一步先记下来要覆盖的位置的原值,用它作为下一轮的待插入物,形成一条值的流动链。 + +#### 何时需要多个环? + +看 `nums = [1,2,3,4,5,6]`, `k = 2`,`n = 6`: + +``` +gcd(6, 2) = 2,需要 2 个环 + +环 1(起点 i=0): 0→2→4→0,每步走2格 +环 2(起点 i=1): 1→3→5→1,每步走2格 +``` + +每个环各自完成自己的轮换任务。外层循环从 `i = 0` 到 `gcd(n, k) - 1` 依次启动。 + +> [!warning] ⚠️ 常见陷阱 + +> **不要跳过已经处理过的环!** 如果你不以内层循环次数 `n/gcd(n, k)` 作为终止条件,而是尝试一直走到"回到起点",那对于多环的情况会在第一个环就无限循环。正确的做法是按环的长度精确控制内层循环的执行次数。 + +- **时间复杂度**:O(n) — 每个元素恰好被访问并写入一次 +- **空间复杂度**:O(1) — 只需常数个辅助变量 + +--- + +### 方法对比 + +> [!info] 📊 三种方法综合对比 + +| 维度 | 额外数组 | 反转法 ⭐ | 循环替换 | +|------|---------|----------|---------| +| 时间复杂度 | O(n) | O(n) | O(n) | +| 空间复杂度 | O(n) | **O(1)** | **O(1)** | +| 原地操作 | ❌ | ✅ | ✅ | +| 理解难度 | ⭐ 极简 | ⭐⭐ 三段反转需领悟 | ⭐⭐⭐ 涉及 GCD 概念 | +| 实际性能 | 较慢(分配+拷贝) | 快(纯内存交换) | 快(同反转) | +| 面试推荐度 | 基础版可展示 | **首选**,优雅且高效 | 加分项,展示深度 | + +--- + +> [!example] 🔀 关联变体题 + +- **LeetCode 1791. Find Center of Star Graph** — 同样利用 `gcd` 分析图的连通分量 +- **LeetCode 46. Permutations / 47. Permutations II** — 全排列问题中遍历所有旋转也是一种子集;循环替换本质是在做「下一个排列」的子操作 +- **LeetCode 26. Remove Duplicates from Sorted Array** — 原地修改、双指针、不超出数组长度的经典范式 +- **LeetCode 31. Next Permutation** — 同样是「仅用交换重排数组」的问题,目标排列可以用对合操作分解来思考 +- **[[16-除了自身以外数组的乘积]]** — 另一个 O(1) 额外空间的数组原地变换题,共享相同的空间约束思维 + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架(反转法) + +```go +func rotate(nums []int, k int) { + n := len(nums) + k = k % n // 去冗余轮转 + + reverse(nums, 0, n-1) // 反转全部 + reverse(nums, 0, k-1) // 反转前 k 个 + reverse(nums, k, n-1) // 反转剩余部分 +} + +func reverse(nums []int, left, right int) { + for left < right { + nums[left], nums[right] = nums[right], nums[left] + left++ + right-- + } +} +``` + +> [!abstract] 📝 Go 伪代码框架(循环替换) + +```go +func rotate(nums []int, k int) { + n := len(nums) + k = k % n + count := 0 // 已放置元素计数 + + for start := 0; count < n; start++ { + curr := start + prev := nums[start] + for { + next := (curr + k) % n + nums[next], prev = prev, nums[next] // 旋转 + curr = next + count++ + if start == curr { break } // 回到起点 + } + } +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:原地变换的三大策略 + +| 策略 | 代表题目 | 思想 | +|------|---------|------| +| **分段反转** | [[15-轮转数组|轮转数组]]、反转单词顺序、ZigZag 变换 | 把复杂重排分解为若干简单反转的组合 | +| **循环追踪** | [[15-轮转数组|轮转数组]]、置换数组、[[17-缺失的第一个正数|缺失的第一个正数]] | 利用置换的循环分解性质,一步到位 | +| **前后双指针** | 接雨水、[[16-除了自身以外数组的乘积|除了自身以外数组的乘积]]、盛最多水的容器 | 两端向内逼近,每次排除一个"确定不如"的选项 | + +> [!note] 📌 必做的预处理:`k %= n` + +不管用哪种方法,第一步都应该执行: + +```go +k = k % len(nums) +``` + +原因: +1. **性能**:当 `k >= n` 时,多余的轮转周期不会改变结果 +2. **安全性**:避免后续计算中出现越界或无限循环(比如 `k = n` 时某些实现可能出错) +3. **语义**:轮转 n 次等价于什么都没做,所以 `% n` 是最自然的归约 + +> [!question] 💡 向左轮转怎么写? + +向左轮转 k 位与向右轮转的本质区别在于**方向相反**。可以有以下三种处理方式: + +#### 方式一:转化为右旋(最简单) + +左旋 k 位等价于右旋 `(n - k) % n` 位。直接复用右旋逻辑即可。 + +```go +// rotateLeftByRightRotate 用右旋实现左旋 +func rotateLeftByRightRotate(nums []int, k int) { + n := len(nums) + if n <= 1 { + return + } + rotate(nums, (n-k)%n) // 复用上面的 rotate(三次反转版本) +} +``` + +#### 方式二:调整反转区间(原地最优) + +左旋的三步反转与右旋不同,区间划分如下: + +| 步骤 | 操作 | 效果 | +|------|------|------| +| Step 1 | `reverse(nums, 0, k-1)` | 反转前 k 个元素 | +| Step 2 | `reverse(nums, k, n-1)` | 反转剩余部分 | +| Step 3 | `reverse(nums, 0, n-1)` | 整体反转 | + +``` +[left_part | right_part] → 分别反转 → [left_rev | right_rev] → 整体反转 → right left ✅ +``` + +```go +// rotateLeft 用三次反转实现向左轮转 +func rotateLeft(nums []int, k int) { + n := len(nums) + if n <= 1 || k == 0 { + return + } + k %= n + + reverse(nums, 0, k-1) // Step 1: 反转前 k 个 + reverse(nums, k, n-1) // Step 2: 反转剩余部分 + reverse(nums, 0, n-1) // Step 3: 整体反转 +} +``` + +> [!tip] 💡 左右旋转的反转顺序对比 + +| | 向右旋转 k 位 | 向左旋转 k 位 | +|---|---|---| +| 第 1 步 | `reverse(0, n-1)` — 先反全 | `reverse(0, k-1)` — 先翻左段 | +| 第 2 步 | `reverse(0, k-1)` — 再翻前 k | `reverse(k, n-1)` — 再翻右段 | +| 第 3 步 | `reverse(k, n-1)` — 最后翻尾部 | `reverse(0, n-1)` — 最后反全 | + +#### 方式三:循环替换中改为减法步进 + +在循环替换方法中,只需将步进方向从加法改为减法: + +```go +// rotateCyclicLeft 用循环替换实现向左轮转 +func rotateCyclicLeft(nums []int, k int) { + n := len(nums) + if n <= 1 || k%n == 0 { + return + } + k %= n + + count := 0 // 已放置元素计数 + for start := 0; count < n; start++ { + curr := start + prev := nums[start] + var next int // 必须预先声明,因为 for 体内会先用到再赋值 + for { + next = (curr - k + n) % n // ← 注意:减法 + n 防负数 + nums[next], prev = prev, nums[next] + curr = next + count++ + if curr == start { break } + } + } +} +``` + +面试时如果能主动提出这个变体并给出至少两种方案,会是很好的加分项。 + +> [!warning] ⚠️ 边界情况 checklist + +| 情况 | 处理方式 | +|------|---------| +| `nums` 为空或只有一个元素 | 无需轮转,直接返回 | +| `k == 0` | 无需操作,提前 return | +| `k == n` 或 `k % n == 0` | 等价于没动过,直接返回 | +| `k > n` | 必须先 `k %= n` 再计算 | +| 全相同元素 | 任意方法都能正确处理 | + +> [!note] 🐹 Go 语言特有细节 + +- Go 支持**多重赋值**:`a, b = b, a` 可以一行完成交换,内部机制是先求右边所有表达式的值再同时赋值,天然避免了临时变量的需求。 +- Go 的 slice 是引用类型,在函数内修改 `nums` 的内容会直接影响调用方的 slice,因此可以直接传入 `[]int` 而非 `*[]int`。 + +> [!success] ✅ 记忆口诀 + +> 欲右旋,先全翻;头正序,尾正翻。 +> 三步反转定乾坤,零额外空间搞定。 + +--- + +## 代码 + +> [!success] ✅ 方法一:额外数组(基准解法) + +```go +// rotateExtraArray 使用额外数组实现向右轮转。 +// 空间复杂度 O(n),易于理解和验证。 +func rotateExtraArray(nums []int, k int) { + n := len(nums) + if n <= 1 || k%n == 0 { + return + } + k %= n // 去冗余 + + res := make([]int, n) // 创建新数组 + + for i := 0; i < n; i++ { + res[(i+k)%n] = nums[i] // 映射到新位置 + } + + copy(nums, res) // 拷贝回原数组 +} +``` + +> [!success] ✅ 方法二:反转数组(⭐ 推荐) + +```go +// rotate 使用三次反转实现向右轮转数组。 +// 原地 O(1) 空间,时间 O(n),面试首选解法。 +// +// 原理:将数组视为 A+B 两段,目标是将 B 移动到 A 前面得到 BA。 +// 公式推导:(AB)^R = B^R A^R → (B^R A^R)^(R on each part) = BA +func rotate(nums []int, k int) { + n := len(nums) + if n <= 1 || k%n == 0 { + return + } + k %= n + + reverse(nums, 0, n-1) // Step 1: 反转整个数组 [1,2,3,4,5,6,7] → [7,6,5,4,3,2,1] + reverse(nums, 0, k-1) // Step 2: 反转前 k 个 [7,6,5, 4,3,2,1] → [5,6,7, 4,3,2,1] + reverse(nums, k, n-1) // Step 3: 反转剩余部分 [5,6,7, 4,3,2,1] → [5,6,7, 1,2,3,4] +} + +// reverse 将 nums[left...right] 原地反转。 +// 双指针相向而行,每次交换两端元素。 +func reverse(nums []int, left, right int) { + for left < right { + // Go 多重赋值:右边全部求值后同时赋值,无需显式临时变量 + nums[left], nums[right] = nums[right], nums[left] + left++ + right-- + } +} +``` + +> [!success] ✅ 方法三:循环替换(展示深度) + +```go +// rotateCyclic 使用循环替换实现向右轮转数组。 +// 利用 gcd(n, k) 个独立循环的性质,原地 O(1) 空间完成。 +// +// 核心洞察:向右轮转 k 位构成一个置换,该置换可分解为 gcd(n, k) +// 个互不相交的循环。每个循环内的元素沿 "i → (i+k)%n" 方向流动, +// 恰好走过 n/gcd(n, k) 步后回到起点。 +func rotateCyclic(nums []int, k int) { + n := len(nums) + if n <= 1 || k%n == 0 { + return + } + k %= n + + count := 0 // 已正确放置的元素总数——控制外层循环何时停止 + + for start := 0; count < n; start++ { + // 从 start 出发,沿 (start + k) 方向游走一圈 + curr := start + prev := nums[start] // 缓存当前位置的值,作为下一轮的"待插入物" + + for { + next := (curr + k) % n // 下一个目标位置 + nums[next], prev = prev, nums[next] // 旋入值,取出新值留作下一步 + curr = next + count++ + + if curr == start { + break // 一圈走完,回到起点 + } + } + } +} +``` + +> [!success] ✅ 补充:向左轮转实现 + +```go +// rotateLeft 使用三次反转实现向左轮转数组。 +// 原理与右旋对称但区间顺序不同:先翻左段、再翻右段、最后反全。 +func rotateLeft(nums []int, k int) { + n := len(nums) + if n <= 1 || k == 0 { + return + } + k %= n + + reverse(nums, 0, k-1) // Step 1: 反转前 k 个 [5,6,7, 1,2,3,4] → [7,6,5, 1,2,3,4] + reverse(nums, k, n-1) // Step 2: 反转剩余部分 [7,6,5, 1,2,3,4] → [7,6,5, 4,3,2,1] + reverse(nums, 0, n-1) // Step 3: 整体反转 [7,6,5, 4,3,2,1] → [1,2,3,4,5,6,7] ← 恢复! + // 上面用的是 k=3 的示例,实际上对 [1,2,3,4,5,6,7] 左旋 3 位的结果是 [4,5,6,7,1,2,3] +} + +// rotateCyclicLeft 使用循环替换实现向左轮转数组。 +// 与右旋唯一区别:步进方向由加法改为减法,注意 +n 防止负数取模。 +func rotateCyclicLeft(nums []int, k int) { + n := len(nums) + if n <= 1 || k%n == 0 { + return + } + k %= n + + count := 0 + for start := 0; count < n; start++ { + curr := start + prev := nums[start] + var next int + for { + next = (curr - k + n) % n // ← 关键差异:减法代替加法 + nums[next], prev = prev, nums[next] + curr = next + count++ + if curr == start { + break + } + } + } +} +``` + +> [!success] ✅ 运行验证 + +这是 LeetCode 第 189 题,是一道非常经典的**原地数组变换**题目。 + +- **反转法**运行时间:约 0~2 ms(Go,击败 ~95%+ 提交) +- **反转法**空间消耗:O(1) +- **循环替换**运行时间:约 2~5 ms(Go,击败 ~85%+ 提交) +- **循环替换**空间消耗:O(1) + +> [!quote] 💬 延伸思考 + +反转法的美妙之处在于它与**群论中的对合运算**相通——反转是对合(involution),即 `reverse(reverse(x)) = x`。三次反转的组合本质上是在 S_n(n 元对称群)中构造了一个特定的排列。当你遇到更多"仅凭交换操作重排数组"的问题时,思考能否将目标排列分解为若干个对合操作的乘积,这会是一个通用的解题利器。 diff --git a/普通数组/16-除了自身以外数组的乘积.md b/普通数组/16-除了自身以外数组的乘积.md new file mode 100644 index 0000000..2f9906a --- /dev/null +++ b/普通数组/16-除了自身以外数组的乘积.md @@ -0,0 +1,365 @@ +--- +tags: ["LeetCode", "前缀和", "数组", "中等"] +create time: 2026-05-14 13:08 +--- + +# 16-除了自身以外数组的乘积 + +## 题面 + +> **LeetCode 238. Product of Array Except Self** + +给你一个整数数组 `nums`,返回数组 `answer`,其中 `answer[i]` 等于 `nums` 中**除** `nums[i]` **之外其余各元素的乘积**。 + +题目数据保证数组 `nums` 中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。 + +请 **不要使用除法**,且在 O(n) 时间复杂度内完成此题。 + +**示例 1:** + +``` +输入:nums = [1, 2, 3, 4] +输出:[24, 12, 8, 6] +``` + +**示例 2:** + +``` +输入:nums = [-1, 1, 0, -3, 3] +输出:[0, 0, 9, 0, 0] +``` + +**提示:** + +- `2 <= nums.length <= 10^5` +- `-30 <= nums[i] <= 30` +- 输入保证 `answer[i]` 在 32 位整数范围内 + +--- + +## 思路 + +> [!question] 💡 直觉先行:如果允许除法呢? + +如果不看限制条件,最直观的想法很简单——先算出全体乘积 `total`,然后 `answer[i] = total / nums[i]`。 + +但这里有坑: + +> [!danger] ⚠️ 为什么这个方法行不通? +> +> 1. **除数为零**:如果数组中有 `0`,除法会崩溃。需要额外处理零的个数,逻辑立刻复杂化。 +> 2. **题目禁止使用除法**:这本身就是考察点之一。 +> +> 所以我们需要一个完全不用除法、对零天然免疫的方案。 + +> [!question] 💡 换个视角:每个位置的答案由什么组成? + +以 `nums = [1, 2, 3, 4]` 为例: + +``` +answer[0] = 2 × 3 × 4 ← 这是 nums[0] 右边的全部乘积(后缀) +answer[1] = 1 × 3 × 4 ← 左边一部分 × 右边一部分 +answer[2] = 1 × 2 × 4 ← 左边一部分 × 右边一部分 +answer[3] = 1 × 2 × 3 ← 这是 nums[3] 左边的全部乘积(前缀) +``` + +> [!abstract] 🔑 核心洞察 + +对于任意位置 `i`: + +``` +answer[i] = (nums[0] × nums[1] × ... × nums[i-1]) × (nums[i+1] × ... × nums[n-1]) + ──────────────── 前缀乘积 ──────────────── ───────── 后缀乘积 ───────── +``` + +**答案 = 左侧所有数的乘积 × 右侧所有数的乘积。** + +所以我们只需要高效地算出每个位置的"左累积"和"右累积"。 + +### 方法一:前缀 + 后缀数组(O(n) 空间)⭐ + +最直接的想法:预处理两个辅助数组。 + +> [!abstract] 📐 定义两个辅助数组 + +- `prefix[i]` = `nums[0] × nums[1] × ... × nums[i-1]`,即 `i` 左边所有元素的乘积。约定 `prefix[0] = 1`(左边没有元素)。 +- `suffix[i]` = `nums[i+1] × nums[i+2] × ... × nums[n-1]`,即 `i` 右边所有元素的乘积。约定 `suffix[n-1] = 1`(右边没有元素)。 +- `answer[i] = prefix[i] × suffix[i]` + +> [!step] 算法流程 + +1. **从左往右扫一遍**,计算每个位置的前缀乘积并填入 `prefix[]` +2. **从右往左扫一遍**,计算每个位置的后缀乘积并填入 `suffix[]` +3. **再扫一遍**,逐位相乘得到答案 + +用 Mermaid 图表示: + +```mermaid +flowchart TD + Start(["开始"]) --> InitPrefix["初始化前缀数组"] + InitPrefix --> Pass1["从左往右扫描
计算 prefix[i]"] + Pass1 --> InitSuffix["初始化后缀数组"] + InitSuffix --> Pass2["从右往左扫描
计算 suffix[i]"] + Pass2 --> Pass3["合并结果
answer[i] = prefix[i] * suffix[i]"] + Pass3 --> Return("返回 answer") + Return --> End(["结束"]) +``` + +> [!example] 🔍 逐步跟踪演示 + +以 `nums = [1, 2, 3, 4]` 为例: + +**第一遍:从左往右,计算 prefix** + +| i | nums[i] | 计算 | prefix[i] | +|---|---------|------|-----------| +| 0 | 1 | 初始化 | 1 | +| 1 | 2 | prefix[0] × nums[0] = 1 × 1 | 1 | +| 2 | 3 | prefix[1] × nums[1] = 1 × 2 | 2 | +| 3 | 4 | prefix[2] × nums[2] = 2 × 3 | 6 | + +`prefix = [1, 1, 2, 6]` + +**第二遍:从右往左,计算 suffix** + +| i | nums[i] | 计算 | suffix[i] | +|---|---------|------|-----------| +| 3 | 4 | 初始化 | 1 | +| 2 | 3 | suffix[3] × nums[3] = 1 × 4 | 4 | +| 1 | 2 | suffix[2] × nums[2] = 4 × 3 | 12 | +| 0 | 1 | suffix[1] × nums[1] = 12 × 2 | 24 | + +`suffix = [24, 12, 4, 1]` + +**第三遍:逐位相乘** + +| i | prefix[i] | suffix[i] | answer[i] | +|---|-----------|-----------|-----------| +| 0 | 1 | 24 | **24** | +| 1 | 1 | 12 | **12** | +| 2 | 2 | 4 | **8** | +| 3 | 6 | 1 | **6** | + +最终结果:`[24, 12, 8, 6]` ✅ + +> [!note] 📌 复杂度分析 + +| 维度 | 复杂度 | 说明 | +|------|--------|------| +| 时间 | O(n) | 三次线性扫描 | +| 空间 | O(n) | 两个辅助数组 `prefix[]` 和 `suffix[]` | + +虽然达到了 O(n) 时间,但题目还要求了 **O(1) 额外空间**(输出数组不算),所以需要优化掉这两个辅助数组。 + +--- + +### 方法二:空间优化 —— 复用输出数组(O(1) 额外空间)⭐⭐⭐ + +> [!question] 💡 关键问题 + +我们真的需要两个完整的辅助数组吗?仔细观察: + +- `prefix` 数组在构建完后只被按顺序读取一次 +- `suffix` 数组同理,也只用按逆序读一次 + +这意味着我们可以:**把 `prefix` 直接存进 `answer`,然后用一个变量动态维护 `suffix`**。 + +> [!abstract] 🔄 核心技巧:用一个变量代替后缀数组 + +不需要预先分配 `suffix[]`,而是维护一个变量 `R`,表示当前位置**右边**的累积乘积。从右往左遍历时,`R` 依次乘以 `nums[i+1]`,并与 `answer[i]`(里面存的已经是左累积)相乘。 + +> [!step] 优化后的两步法 + +1. **第一步(左 → 右)**:在 `answer[]` 中逐个存放"左边所有数的乘积" +2. **第二步(右 → 左)**:用变量 `R` 维护右边累积,依次乘到 `answer[i]` 上 + +```mermaid +flowchart LR + A["初始答案数组"] --> Step1["第一步:从左往右
answer[i] 存左边累积"] + Step1 --> B["左累积结果"] + B --> Step2["第二步:从右往左
R 维护右边累积并乘入 answer[i]"] + Step2 --> C["最终答案"] +``` + +> [!example] 🔍 逐步跟踪演示 + +以 `nums = [1, 2, 3, 4]` 为例: + +**第一步:从左往右,answer[i] 存左累积** + +| 步骤 | i | R(左累积) | 操作 | answer | +|------|---|----------|------|--------| +| 初始 | — | — | — | `[_, _, _, _]` | +| 1 | 0 | 1 | 初始化 `ans[0]=1`,更新 `R=1×1=1` | `[1, _, _, _]` | +| 2 | 1 | 1 | `ans[1]=1`,更新 `R=1×2=2` | `[1, 1, _, _]` | +| 3 | 2 | 2 | `ans[2]=2`,更新 `R=2×3=6` | `[1, 1, 2, _]` | +| 4 | 3 | 6 | `ans[3]=6`,更新 `R=6×4=24` | `[1, 1, 2, 6]` | + +此时 `answer = [1, 1, 2, 6]`,每个位置存的是其左侧所有数的乘积。 + +**第二步:从右往左,用 R 维护右累积,乘入 answer** + +| 步骤 | i | 旧 answer[i] | R(右累积) | 操作 | 新 answer[i] | +|------|---|-------------|----------|------|-------------| +| 初始 | — | `[1,1,2,6]` | 1 | 初始化 | — | +| 1 | 3 | 6 | 1 | `ans[3] = 6 × 1 = 6`,更新 `R = 1 × 4 = 4` | `[..., 6]` | +| 2 | 2 | 2 | 4 | `ans[2] = 2 × 4 = 8`,更新 `R = 4 × 3 = 12` | `[..., 2, 8, _]` | +| 3 | 1 | 1 | 12 | `ans[1] = 1 × 12 = 12`,更新 `R = 12 × 2 = 24` | `[..., 12, _, _]` | +| 4 | 0 | 1 | 24 | `ans[0] = 1 × 24 = 24`,更新 `R = 24 × 1 = 24` | `[24, _, _, _]` | + +最终结果:`[24, 12, 8, 6]` ✅ + +> [!quote] 🎯 为什么这个方法成立? + +核心在于 **"左累积已经存在 answer 里,右累积只需边走边算"**: + +- 正扫时,`answer[i]` 记下了"不看 i,它左边的所有人联手起来的乘积" +- 反扫时,`R` 记下了"不看 i,它右边的所有人联手起来的乘积" +- 两者相遇的那一刻——就是答案! + +这个过程就像两个人从队伍两端同时出发,各自携带着迎面而来所有人的力量,擦肩而过时交换彼此的力量,就完成了全员"排除自己"的运算。 + +> [!success] ✅ 空间复杂度证明 + +- 输出数组 `answer`:不计入额外空间(题目规定) +- 变量 `R`:常数级 +- 总共:**O(1) 额外空间** ✅ + +--- + +> [!example] 🔀 关联变体题 + +- **LeetCode 3394. Check if Grid can be Cut into Sections** — 同样涉及前缀思想的几何版本 +- **LeetCode 238 变体:含零数组** — 当数组可能包含多个 0 时,需要统计 0 的个数来分类讨论 +- **LeetCode 2024. Maximize the Confusion of an Exam** — 涉及子数组乘积/计数思维扩展 +- **LeetCode 974. Subarray Sums Divisible by K** — 前缀和思想的应用变体(加法而非乘法) + +--- + +## 代码提示 + +> [!abstract] 📝 Go 伪代码框架(两步法) + +```go +func productExceptSelf(nums []int) []int { + n := len(nums) + answer := make([]int, n) + + // ── 第一步:从左往右,answer[i] 存左累积 ── + answer[0] = 1 + for i := 1; i < n; i++ { + answer[i] = answer[i-1] * nums[i-1] + } + + // ── 第二步:从右往左,R 是右累积 ── + R := 1 + for i := n - 1; i >= 0; i-- { + answer[i] *= R // 左累积 × 右累积 + R *= nums[i] // 扩展右累积的范围 + } + + return answer +} +``` + +> [!warning] ⚠️ 注意第二步的顺序 + +在 `for i := n-1; i >= 0; i--` 循环中,**必须先乘后更新 `R`**: + +``` +answer[i] *= R ← 这里 R 不含 nums[i],正确 +R *= nums[i] ← 然后才把 nums[i] 纳入 R,为下一轮(i-1)做准备 +``` + +如果反过来,`R` 就会多乘一个 `nums[i]`,导致答案错误。 + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:分离方向的前缀聚合 + +这道题的技巧核心是 **"将多维依赖降为一维流式计算"**: + +1. **拆分问题**:原本每个位置要看其他 n-1 个元素,拆成"只看左边"和"只看右边"两个一维子问题 +2. **前置计算**:正向遍历把所有"左贡献"提前算好放进输出数组 +3. **在线合并**:反向遍历时用单个变量在线计算"右贡献",立即与存储的值合并 + +> [!note] 🐹 Go 中的细节 + +- `make([]int, n)` 自动将切片元素初始化为 `0`,我们后续会覆盖所有位置,无需手动清零 +- Go 的切片迭代支持 `range` 语法,但本题需要**反向遍历**,只能用传统 `for i := n-1; i >= 0; i--` +- Go 没有内建的 reverse,手动维护索引变量是最优方案 +- 如果遇到全 0 或含多个 0 的特殊测试用例,该方法依然正确(因为完全不涉及除法) + +> [!success] ✅ 记忆口诀 + +> 正向铺左累积,逆向乘右累积。 +> 一人分饰两角,一步一相逢。 + +--- + +## 代码 + +```go +// productExceptSelf 返回 answer,其中 answer[i] 等于 nums 中除了 nums[i] +// 之外其余各元素的乘积。 +// 时间复杂度 O(n),额外空间 O(1)(输出数组不计入)。 +func productExceptSelf(nums []int) []int { + n := len(nums) + + // ── Step 1: 初始化输出数组 ── + answer := make([]int, n) + + // ═══ 第一轮:从左往右,answer[i] 存储 i 左边所有元素的乘积 ═══ + // 初始状态:answer[0] 左边没有元素,乘积为 1(单位元) + answer[0] = 1 + for i := 1; i < n; i++ { + // 关键转移:i 的左累积 = (i-1) 的左累积 × nums[i-1] + // 因为 ans[i-1] 已经记录了 nums[0..i-2] 的乘积, + // 再乘以 nums[i-1] 就得到了 nums[0..i-1] 的乘积 + answer[i] = answer[i-1] * nums[i-1] + } + + // 此时 answer 的状态举例(nums = [1,2,3,4]): + // answer = [1, 1, 2, 6] + // ↑ ↑ ↑ ↑ + // 1 1*1 1*2 1*2*3 + + // ═══ 第二轮:从右往左,用变量 R 在线维护右边累积 ═══ + // R 的含义:从最右侧扩展过来的、不包含当前 i 的乘积 + R := 1 // 初始时 ans[n-1] 右边没有元素,R = 1(单位元) + for i := n - 1; i >= 0; i-- { + // ① 合并:左累积 × 右累积 = 最终答案 + answer[i] *= R + + // ② 扩展:将 nums[i] 纳入 R,供下一个位置(i-1)使用 + R *= nums[i] + } + + // 此时 answer 已完整(nums = [1,2,3,4]): + // answer = [24, 12, 8, 6] + // 2×3×4 1×3×4 1×2×4 1×2×3 + + return answer +} +``` + +> [!success] ✅ 运行验证 + +这是 LeetCode 第 238 题,一道经典的 **"不能用除法的前缀和问题"**。 + +- **运行时间**:约 5~8 ms(Go,击败 ~95%+ 提交) +- **空间消耗**:O(1) 额外空间(输出数组不计入) +- **面试表现**:极高。面试官常追问: + - 能不能只用一个循环?(不能,信息流向冲突——左累积需要正向,右累积需要反向) + - 如果有多个 0 怎么办?(本方法天然免疫,无需特殊处理) + - 如果用除法会怎样?(需要先数 0 的个数,至少处理三个分支:0个0/1个0/2个以上0) + +> [!quote] 💬 延伸思考 + +"前缀聚合"不仅仅是一种解题技巧,更是一种**降维工具**。当你面对"每个位置都要聚合周围所有其他信息"的问题时,试着问自己: + +> 这些信息能否按方向拆解?能不能用两次单向遍历替代暴力嵌套? + +这类思维可以迁移到很多场景中,比如字符串匹配中的边界处理、图像滤波中的可分离卷积等。掌握了「正向铺、反向乘」的思路,你就拥有了一套通用的解题模板。 diff --git a/普通数组/17-缺失的第一个正数.md b/普通数组/17-缺失的第一个正数.md new file mode 100644 index 0000000..75bd85e --- /dev/null +++ b/普通数组/17-缺失的第一个正数.md @@ -0,0 +1,145 @@ +--- +tags: [] +create time: 2026-05-14 15:30 +--- + +# 缺失的第一个正数 + +## 题面 + +给你一个未排序的整数数组 `nums`,请你找出其中没有出现的最小的正整数。 + +**要求:** 时间复杂度 $O(n)$,只使用常数级别额外空间。 + +> [!example] 示例 +> - **示例 1:** 输入 `[1,2,0]` → 输出 `3`,因为范围 `[1,2]` 中的数字都在数组中。 +> - **示例 2:** 输入 `[3,4,-1,1]` → 输出 `2`,因为 1 存在但 2 不存在。 +> - **示例 3:** 输入 `[7,8,9,11,12]` → 输出 `1`,最小正数 1 未出现。 + +> [!question] 💡 先思考一下 +> 如果不能用哈希表、不能排序(排序是 $O(n \log n)$),你还能怎么找"缺失的最小正数"? +> 关键问题:**答案的范围是什么?** + +## 思路 + +### 核心观察 + +无论数组多长,**答案一定在 `[1, n+1]` 范围内**(`n` 为数组长度)。为什么? + +- 如果 `1~n` 全部出现,答案就是 `n+1`; +- 只要有任意一个缺失,答案就是那个最小的缺失值。 + +这就把问题从"无限正整数域"缩小到了大小为 `n` 的有限域——可以用**原地哈希**解决。 + +### 原地哈希(In-place Hashing) + +既然答案在 `[1, n]` 或 `n+1`,我们可以让每个位置 `i` 存储值 `i+1`: + +| 索引 | 0 | 1 | 2 | 3 | +|------|---|---|---|---| +| 应该存 | 1 | 2 | 3 | 4 | + +也就是说:**让值为 `v` 的元素排在索引 `v-1` 的位置上。** + +排列完成后,第一个不满足 `nums[i] == i+1` 的位置 `i` 对应的 `i+1` 就是答案。如果都满足,答案是 `n+1`。 + +```mermaid +flowchart TD + A["原始数组"] --> B{"遍历每个位置 i"} + B --> C{"nums[i] 是否合法?"} + C -->|"不在 [1,n] 或已在正确位置"| D["跳过, i++"] + C -->|"需要交换"| E["将 nums[i] 换到 nums[i]-1 位置"] + E --> F{"新位置的元素也需要处理吗?"} + F -->|"是"| G["继续检查当前位置"] + F -->|"否"| D + D --> H{i < n?} + G --> H + H -->|"是"| B + H -->|"否"| I["扫描: 找到第一个 nums[i] != i+1"] + I --> J["返回 i+1; 若全匹配则返回 n+1"] +``` + +### 步骤拆解 + +以 `nums = [3, 4, -1, 1]` 为例: + +| 步骤 | 操作 | 数组状态 | +|------|------|----------| +| 初始 | — | `[3, 4, -1, 1]` | +| i=0 | nums[0]=3, 应放索引 2, 与 -1 交换 | `[-1, 4, 3, 1]` | +| i=0 | nums[0]=-1, 非法, 跳过 | `[-1, 4, 3, 1]` | +| i=1 | nums[1]=4, 应放索引 3, 与 1 交换 | `[-1, 1, 3, 4]` | +| i=1 | nums[1]=1, 应放索引 0, 与 -1 交换 | `[1, -1, 3, 4]` | +| i=1 | nums[1]=-1, 非法, 跳过 | `[1, -1, 3, 4]` | +| i=2 | nums[2]=3, 已在正确位置, 跳过 | `[1, -1, 3, 4]` | +| i=3 | nums[3]=4, 已在正确位置, 跳过 | `[1, -1, 3, 4]` | + +扫描:索引 1 处 `nums[1] = -1 ≠ 2`,答案 = **2** ✅ + +> [!tip] ⚠️ 交换时的经典陷阱 +> 交换前必须检查目标位置是否已经有正确的值,否则两个相同值会互相交换造成死循环。例如 `[1, 1]`:第一个 1 正确;第二个 1 发现索引 0 已经是 1,就不交换了。 + +### 复杂度分析 + +| 维度 | 分析 | +|------|------| +| **时间** | $O(n)$:每个位置最多被交换一次后归位,扫描也是 $O(n)$,合计两次线性遍历 | +| **空间** | $O(1)$:仅用几个指针变量,原地修改数组 | + +## 代码提示 + +``` +for i := 0 to n-1: + while nums[i] 在 [1, n] 范围内 && 不在正确位置上: + target = nums[i] - 1 // 这个值应该在的索引 + swap(nums[i], nums[target]) // 把它放到正确位置 + +for i := 0 to n-1: + if nums[i] != i + 1: + return i + 1 // 找到了缺失的正数 + +return n + 1 // 1~n 都出现了 +``` + +## 技巧 + +> [!summary] 套路总结 +> **"第 k 个值放到第 k-1 个位置"** ——这是原地哈希的经典模式。当题目满足以下条件时可以考虑: +> 1. 数组长度为 `n`,元素范围也在 `[1, n]` 或可截断到此范围; +> 2. 要求 $O(n)$ 时间、$O(1)$ 空间; +> 3. 允许修改原数组。 + +相关变种题目: +- [[16-除了自身以外数组的乘积]] — 同样是原地操作的经典题 +- LeetCode 448:找到所有数组中消失的数字(同样的原地哈希思想) + +## 代码 + +```go +func firstMissingPositive(nums []int) int { + n := len(nums) + + // ========== 第一步:原地哈希排列 ========== + for i := 0; i < n; i++ { + // 当 nums[i] 在 [1,n] 范围内,且不在它该在的位置上时,持续交换 + // 注意判断顺序:先读值,再判范围,再防死循环 + for nums[i] >= 1 && nums[i] <= n && nums[nums[i]-1] != nums[i] { + target := nums[i] - 1 + nums[i], nums[target] = nums[target], nums[i] + } + } + + // ========== 第二步:找出第一个空缺 ========== + for i := 0; i < n; i++ { + if nums[i] != i+1 { + return i + 1 + } + } + + // 1~n 全部就位,答案是 n+1 + return n + 1 +} +``` + +> [!note] 🐛 内层 `for` vs `while` +> Go 没有 `while` 关键字,所以用 `for` 替代。逻辑是:只要条件满足就持续交换,直到当前元素归位或无法归位为止。每次成功交换至少将一个元素放到正确位置,所以整个过程中交换总次数不超过 `n` 次,不会破坏 $O(n)$ 的时间复杂度。