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)$ 的时间复杂度。