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

10 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
动态规划
分治
中等
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 图表示决策流程:

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 往右累加,记录过程中达到的最大后缀和
  • 两者相加即为跨中点的最大和
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 算法)

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 之前),需要使用手写函数或条件表达式:
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] ✅ 记忆口诀

前缀为正就牵手,前缀为负就分手。
一路走一路记住最高峰。


代码

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