vault backup: 2026-05-14 23:30:50

This commit is contained in:
2026-05-14 23:30:50 +08:00
parent 4e2001939e
commit 712dcbd6f8
7 changed files with 2326 additions and 0 deletions
+296
View File
@@ -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["初始化<br/>maxSoFar = nums[0]<br/>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,但它启发了一个重要概念:**归并排序式的区间划分**。当数据存储在多个节点上时,每个节点可以先算出自己的局部最优,然后合并时处理跨节点的情况——这正是大规模数据处理中的经典范式。如果你感兴趣,可以尝试实现分治版本作为练习。
+287
View File
@@ -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<br/>≤ last.end?}
Overlap -- 是 --> Merge["合并: last.end = max(last.end,<br/>&nbsp;&nbsp;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) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。
+609
View File
@@ -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<br/>'原始': [1,2,3, 4,5,6,7]"] --> S1["Step 1<br/>reverse(0,6)<br/>[7,6,5, 4,3,2,1]"]
S1 --> S2["Step 2<br/>reverse(0,2)<br/>[5,6,7, 4,3,2,1]"]
S2 --> S3["Step 3<br/>reverse(3,6)<br/>[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 元对称群)中构造了一个特定的排列。当你遇到更多"仅凭交换操作重排数组"的问题时,思考能否将目标排列分解为若干个对合操作的乘积,这会是一个通用的解题利器。
@@ -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["从左往右扫描<br/>计算 prefix[i]"]
Pass1 --> InitSuffix["初始化后缀数组"]
InitSuffix --> Pass2["从右往左扫描<br/>计算 suffix[i]"]
Pass2 --> Pass3["合并结果<br/>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["第一步:从左往右<br/>answer[i] 存左边累积"]
Step1 --> B["左累积结果"]
B --> Step2["第二步:从右往左<br/>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] 💬 延伸思考
"前缀聚合"不仅仅是一种解题技巧,更是一种**降维工具**。当你面对"每个位置都要聚合周围所有其他信息"的问题时,试着问自己:
> 这些信息能否按方向拆解?能不能用两次单向遍历替代暴力嵌套?
这类思维可以迁移到很多场景中,比如字符串匹配中的边界处理、图像滤波中的可分离卷积等。掌握了「正向铺、反向乘」的思路,你就拥有了一套通用的解题模板。
+145
View File
@@ -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)$ 的时间复杂度。