最大子数组和(动态规划与分治)
问题简述
1 问题背景
在金融交易市场中,投资者常常需要分析股票价格的波动以寻找最佳的买入和卖出时机。假设已知某支股票连续几日的每日价格变化量(相对于前一天的涨跌幅),投资者希望找到一段连续的时间区间,使得这段时间内价格变化的总和达到最大,从而获得最高收益。
2 问题抽象
给定一个包含 个整数的数组,要求在其中找到一个具有最大和的连续子数组。
- 空间设定:输入为一个一维线性数组。
- 初始状态:数组中的每个元素代表每日的价格变化值(正数表示上涨,负数表示下跌)。
- 求解目标:找到一个子数组
nums[i..j],使得sum(nums[i..j])的值最大,并确定对应的起始索引 和结束索引 。
3 算法思维逻辑
解决最大子数组和问题的核心在于如何高效处理搜索空间:
- 规模递减:通过分治法将大数组划分为左右两个子数组,递归求解规模更小的子问题。
- 状态转移:利用动态规划思想,通过前一个位置的最优解推导出当前位置的最优解,避免冗余计算。
- 终止准则:在分治递归中,当子数组只剩一个元素时停止;在动态规划中,遍历完整个数组即结束。
4 数学表达
为了通过编程实现快速求解,建立如下递推关系。
- 动态规划状态转移方程:假设 表示以第 个元素结尾的最大子数组和:
- 分治法合并规则:最大和可能出现在左半部、右半部或跨越中间点:
基础知识
1 分治算法
分治法是一种重要的算法设计范式。其核心思想是递归地将一个复杂问题分解为多个规模更小、类型相似的子问题,直到子问题变得足够简单可以直接求解,最后将子问题的解合并为原问题的解。
1.1 核心三步骤
一个典型的分治过程包含以下三个阶段:
- 分解(Divide):将原问题 划分为 个规模较小的子问题 。
- 解决(Conquer/Solve):递归地求解各子问题。若子问题规模足够小,则直接计算。
- 合并(Combine):将各子问题的解 合并成原问题的解 。
2 时间复杂度分析:主定理
当使用分治法解决问题时,其运行时间通常可以用递归方程 表示。主定理(Master Theorem) 提供了一种通用的数学方法来快速确定这些方程的渐近复杂度。
2.1 针对划分函数(Dividing Function)的应用
对于形式为 的递归式(其中 ),我们通过比较 与 的增长速度来判定:
- :子问题的数量 产生的开销远超合并开销,则:
- :每层分解与合并的开销平衡,则:
- :合并操作的开销占主要地位,则:
2.2 针对递减函数(Decreasing Function)的应用
对于形式为 的递归式,其复杂度取决于系数 与 的比较:
- :复杂度呈指数级增长,。
- :复杂度呈线性或多项式增长,。
- :复杂度由合并项决定,。
2.3 严格推导:递归树证明
主定理的有效性可以通过递归树进行严格推导。
- 推导过程:
- 将递归方程展开为求和形式:,其中 是树的高度。
- 令 (假设 ),则方程可转化为等比数列求和:。
- 三种结果的数学本质:
- 当 (),级数收敛,首项 决定结果。
- 当 (),所有层开销相等,结果为 。
- 当 (),最后一层叶子节点数 决定结果。
2.4 应用实例
Karatsuba 大整数乘法 当我们需要计算两个 位大整数 和 的乘积时,传统的竖式乘法需要 的复杂度。Karatsuba 算法通过代数变换减少了乘法操作的次数。
- 分解策略:将 和 分别拆分为高位和低位两部分:
- 其中 。
- 数学展开:
- 。
- 优化逻辑:
- 直接计算:需要 4 次乘法()。
- Karatsuba 优化:只需要 3 次 乘法:计算 、 以及 。
- 关键公式:利用已知的 和 来求中间项:。
- 复杂度分析:
- 递推方程:。
- 主定理判定:根据 vs ,由于 ,复杂度为 。
Toom-Cook 3 算法 Toom-Cook 算法是 Karatsuba 的推广,通过将整数拆分为更多的部分来进一步降低复杂度。
- 分解策略:将整数拆分为 个部分。以 Toom-3 为例,将大整数表示为二次多项式:
- 乘法过程:
- 结果为 。
- 优化效果:
- 标准做法:直接进行块分解需要 次乘法。
- Toom-3 优化:通过对多项式进行取点求值(Interpolation),将乘法次数减少到 5 次(公式为 ),。
- 复杂度分析:
- 递推方程:。
- 主定理判定:根据 vs ,由于 ,复杂度为 。
Strassen 矩阵乘法 两个 矩阵 和 的标准乘法需要 的时间复杂度。Strassen 利用分治思想将矩阵划分为 的分块进行优化。
- 优化方案:Strassen 构造了 7 个中间变量 ,每个变量只涉及 1 次 子矩阵乘法:
- 结果合并:利用这 7 个 值通过加减法(复杂度为 )合成结果矩阵 的四个分块:
- 复杂度分析:
- 递推方程:。
- 主定理判定:比较 (约 )与合并开销 ,复杂度为 。
- 对比:标准的递归矩阵乘法方程为 ,复杂度为 。
2.5 省流表格
| 算法 | 关键技术点 | 乘法/分支次数减少 | 最终复杂度 |
|---|---|---|---|
| 标准大数乘法 | 逐位相乘 | 4 次 (n/2 规模) | |
| Karatsuba | 代数恒等式替换 | 3 次 (n/2 规模) | |
| Toom-Cook 3 | 多项式取点插值 | 5 次 (n/3 规模) | |
| 标准矩阵乘法 | 行列加权求和 | 8 次 (n/2 规模) | |
| Strassen | 构造 7 个辅助中间量 | 7 次 (n/2 规模) |
核心启示:在分治算法设计中,合并阶段(Combine)引入的加减法开销(通常是 或 )远远低于递归分支中的乘法开销。通过数学手段减少乘法次数(减小参数 )是提升算法性能的最强动力。
解决思路
1 暴力与优化方案
这类方案侧重于通过枚举子数组的边界来寻找结果。
1.1 基础暴法
- 实现原理:通过嵌套两层循环枚举所有可能的起点 和终点 。
- 代价分析:时间复杂度为 ,效率极低。
1.2 前缀和优化
- 实现原理:预先计算前缀和数组
prefix_sum。 - 优势:可以将任意子数组之和的计算简化为
prefix_sum[j+1] - prefix_sum[i],虽然仍需 枚举,但单次计算更快。
2 动态规划方案(Kadane算法)
2.1 动态规划(Kadane算法)的状态转移推导
为了实现 的线性求解,我们需要通过子问题的最优解来构建全局最优解。
- 状态定义:设 (或代码中的
max_ending_here)表示以第 个元素结尾的所有连续子数组中的最大和。 - 逻辑拆解:对于数组中的任意位置 ,以它结尾的连续子数组必然包含 。此时存在两种可能的情况:
- 子数组只包含自己:即 独立成为一个子数组。这种情况通常发生在之前的子数组和为负数,只会拉低当前总和时。
- 子数组包含之前的序列:即在以 结尾的最大子数组之后接上 。其和为 。
- 递推公式推导: 为了求得 的最大值,我们只需在上述两种情况中取较大者:
- 全局最优解:由于最大子数组可能在数组的任何位置结尾,因此最终答案是所有 中的最大值:
3 分治方案
分治法通过将问题递归分解为互不重叠的子问题来解决。
- 实现步骤:
- 分解:将数组从中间点一分为二。
- 解决:递归计算左、右半部的最大子数组和。
- 合并:计算必须跨越中点的最大和,并取三者中的最大值。
3.1 分治法(跨越中点)的合并逻辑推导
分治法的难点在于如何处理“跨越中点”的子数组,因为它们不完全属于左半部或右半部。
- 场景划分:给定区间 和中点 最大子数组的和 必然处于以下三者之一:
- 完全在左半部:。
- 完全在右半部:。
- 跨越中点:即子数组包含 和 。
- 跨中点子数组的构造:
一个跨越中点的子数组 可以拆分为两部分:
- 左半部分:(左半部的某个后缀)。
- 右半部分:(右半部的某个前缀)。
- 最优性推导:
为了使跨中点的总和 最大,必须分别使左半部分的后缀和右半部分的前缀达到最大。
- 左侧最大后缀:从 开始向左遍历至 ,记录出现过的最大累加和
left_cross_max。 - 右侧最大前缀:从 开始向右遍历至 ,记录出现过的最大累加和
right_cross_max。
- 左侧最大后缀:从 开始向左遍历至 ,记录出现过的最大累加和
- 最终合并公式: 跨越中点的最大和即为两者之和: 最后通过比较 得到当前区间的最终解。
代码实现
1 暴力法
1.1 直接暴力
def max_subarray_bruteforce(nums: List[int]) -> int:
"""
直接暴力法:O(n^2)
固定左边界,扩展右边界,累加计算所有子数组和。
"""
n = len(nums)
max_sum = float('-inf') # 初始化负无穷,处理全负数情况
for i in range(n):
current_sum = 0
for j in range(i, n):
current_sum += nums[j] # 逐步扩展子数组 [i..j]
if current_sum > max_sum:
max_sum = current_sum
return max_sum
1.2 前缀和
def max_subarray_prefix(nums: List[int]) -> int:
"""
前缀和暴力法:O(n^2)
利用 prefix[j+1] - prefix[i] 快速获取任意子数组和。
"""
n = len(nums)
# 构建前缀和数组,长度为 n+1,prefix[0] = 0
prefix = [0] * (n + 1)
for i in range(n):
prefix[i + 1] = prefix[i] + nums[i]
max_sum = float('-inf')
for i in range(n): # 子数组起点
for j in range(i, n): # 子数组终点
# 子数组 nums[i..j] 的和
current_sum = prefix[j + 1] - prefix[i]
if current_sum > max_sum:
max_sum = current_sum
return max_sum
2 动态规划法
该算法通过一次遍历即可完成求解。
def max_subarray_kadane(nums: List[int]) -> int:
"""
Kadane 算法(DP):O(n)
max_ending_here: 以当前元素结尾的最大子数组和
max_so_far: 全局最大子数组和
"""
max_so_far = nums[0]
max_ending_here = nums[0]
for i in range(1, len(nums)):
# 核心逻辑:要么自立门户,要么加入前面的队伍
max_ending_here = max(nums[i], max_ending_here + nums[i])
# 更新全局最优
max_so_far = max(max_so_far, max_ending_here)
return max_so_far
2.1 核心逻辑
利用 max_so_far 记录全局最优解,max_ending_here 记录当前位置的局部状态。
3 分治实现:基于递归的合并策略
def max_subarray_divide_conquer(nums: List[int]) -> int:
"""
分治法:O(n log n)
递归处理左右区间,并合并跨越中点的最大和。
"""
def max_crossing_sum(left: int, mid: int, right: int) -> int:
"""
计算跨越中点 mid 的最大子数组和。
必须包含 nums[mid] 和 nums[mid+1]。
"""
# 从中点向左扫描(包含 mid)
left_sum = float('-inf')
current = 0
for i in range(mid, left - 1, -1):
current += nums[i]
if current > left_sum:
left_sum = current
# 从中点向右扫描(包含 mid+1)
right_sum = float('-inf')
current = 0
for i in range(mid + 1, right + 1):
current += nums[i]
if current > right_sum:
right_sum = current
# 跨中点最大和 = 左半部分最大(必须包含 mid)+ 右半部分最大(必须包含 mid+1)
return left_sum + right_sum
def divide_and_conquer(left: int, right: int) -> int:
"""递归处理区间 [left, right]"""
# 递归基:只有一个元素
if left == right:
return nums[left]
# 分
mid = (left + right) // 2
# 治(递归求解左右两半)
left_max = divide_and_conquer(left, mid)
right_max = divide_and_conquer(mid + 1, right)
# 合(计算跨中点情况)
cross_max = max_crossing_sum(left, mid, right)
# 返回三者最大值
return max(left_max, right_max, cross_max)
3.1 递归函数定义
定义 divide_and_conquer(left, right) 处理指定区间的子问题。
分析与总结
算法对比
| 维度 | 暴力 | 分治法 | 动态规划 | 备注 |
|---|---|---|---|---|
| 时间效率 | DP 在线性时间内完成。 | |||
| 空间效率 | 分治法涉及递归调用的栈内存。 | |||
| 编程难度 | 低 | 中 | 中 | 分治法需处理跨越中点的特殊逻辑。 |
| 子问题性质 | N/A | 独立 | 重叠 | DP通过存储重叠子问题结果避免冗余。 |