Files
leetcode-go/普通数组/13-最大子数组和.md
T

297 lines
10 KiB
Markdown
Raw Normal View History

2026-05-14 23:30:50 +08:00
---
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,但它启发了一个重要概念:**归并排序式的区间划分**。当数据存储在多个节点上时,每个节点可以先算出自己的局部最优,然后合并时处理跨节点的情况——这正是大规模数据处理中的经典范式。如果你感兴趣,可以尝试实现分治版本作为练习。