1. 动态规划问题解析:从最长递增子序列到最大乘积子数组
动态规划(Dynamic Programming, DP)是算法设计中解决最优化问题的经典方法。今天我想通过两个典型问题——最长递增子序列(LIS)和最大乘积子数组,来深入探讨DP问题的状态定义、转移方程和空间优化技巧。
1.1 最长递增子序列的标准解法
最长递增子序列问题要求找出给定数组中严格递增的子序列的最大长度。比如数组[10,9,2,5,3,7,101,18]的最长递增子序列是[2,3,7,101],长度为4。
标准DP解法如下:
python复制def longest(nums) -> int:
n = len(nums)
dp = [1] * n # 初始化dp数组
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
这个解法有几个关键点:
- dp[i]表示以nums[i]结尾的最长递增子序列长度
- 初始值为1,因为每个元素本身就是一个长度为1的子序列
- 通过双重循环比较所有前面的元素来更新dp值
注意:这里的内层循环必须遍历i之前的所有元素,因为递增子序列可能来自任意前面的较小元素,这是理解后续空间优化的关键。
1.2 最大乘积子数组的DP解法
最大乘积子数组问题要求找出数组中乘积最大的连续子数组。例如数组[2,3,-2,4]的最大乘积子数组是[2,3],乘积为6。
标准DP解法如下:
python复制class Solution:
def maxProduct(self, nums: List[int]) -> int:
max_prod = min_prod = ans = nums[0]
for i in range(1, len(nums)):
x = nums[i]
temp_max = max_prod
max_prod = max(x, x * max_prod, x * min_prod)
min_prod = min(x, x * temp_max, x * min_prod)
ans = max(ans, max_prod)
return ans
这个解法与LIS有明显不同:
- 没有显式的dp数组,只用几个变量保存状态
- 只需要单层循环
- 同时维护最大和最小乘积(因为负数乘负数可能得到最大乘积)
2. 状态定义与转移方程的深度解析
2.1 LIS的状态转移分析
LIS问题的状态转移特点是:
- 每个dp[i]依赖于前面所有满足nums[j]<nums[i]的dp[j]
- 必须保存所有中间状态,因为无法预知后续元素会依赖哪个前面的状态
- 空间复杂度O(n)无法优化,因为需要回溯所有历史信息
python复制# 状态转移方程
dp[i] = max(dp[j] + 1 for j in range(i) if nums[j] < nums[i])
2.2 最大乘积子数组的状态转移分析
最大乘积问题的状态转移特点是:
- 当前状态只依赖于前一个状态(max_prod和min_prod)
- 不需要保存更早的历史状态
- 可以通过滚动变量实现空间优化
python复制# 状态转移方程
new_max = max(nums[i], nums[i] * max_prod, nums[i] * min_prod)
new_min = min(nums[i], nums[i] * max_prod, nums[i] * min_prod)
2.3 关键差异对比
| 特性 | LIS | 最大乘积子数组 |
|---|---|---|
| 状态依赖范围 | 所有前面的状态 | 仅前一个状态 |
| 空间优化潜力 | 无法优化 | 可优化为O(1) |
| 时间复杂度 | O(n²) | O(n) |
| 是否需要dp数组 | 是 | 可以不用 |
| 状态转移复杂度 | 双重循环 | 单层循环 |
3. 空间优化技巧详解
3.1 为什么最大乘积可以优化空间?
最大乘积问题的空间优化之所以可行,是因为它具有"无后效性"的特点:
- 当前状态的计算只需要前一个状态的值
- 计算完成后,更早的状态就不再需要
- 可以用固定数量的变量(通常是2-3个)来滚动更新状态
这种优化技巧称为"状态压缩"或"滚动数组",是DP问题中常见的优化手段。
3.2 状态压缩的实现方式
在最大乘积问题中,状态压缩是这样实现的:
- 用max_prod和min_prod两个变量保存前一个位置的状态
- 计算当前位置时,先用临时变量保存旧值
- 更新新状态后,旧状态就可以丢弃了
python复制temp_max = max_prod # 保存旧状态
max_prod = max(x, x * max_prod, x * min_prod) # 计算新状态
min_prod = min(x, x * temp_max, x * min_prod) # 计算新状态
3.3 为什么LIS不能这样优化?
LIS无法进行类似优化的根本原因是:
- 每个dp[i]可能依赖于前面任意一个dp[j]
- 无法预知后续元素会需要哪个历史状态
- 必须保留完整的dp数组以供回溯
4. 复杂度分析与算法选择
4.1 时间复杂度对比
-
LIS的O(n²)复杂度来自于双重循环:
- 外层循环n次
- 内层循环平均n/2次
- 总计约n²/2次操作
-
最大乘积的O(n)复杂度:
- 单层循环n-1次
- 每次循环做固定数量的比较和乘法
- 总计约3n次操作
4.2 空间复杂度对比
- LIS必须使用O(n)空间存储dp数组
- 最大乘积可以优化到O(1)空间
- 未优化的最大乘积也需要O(n)空间
4.3 算法选择建议
-
对于LIS问题:
- 小规模数据(n≤1000):使用标准DP解法
- 大规模数据:考虑O(nlogn)的贪心+二分法
-
对于最大乘积问题:
- 总是使用空间优化版本
- 特殊情况(如需要记录所有中间状态)才用未优化版
5. 常见误区与注意事项
5.1 关于最大乘积的常见错误
-
错误认为不是DP问题:
- 最大乘积确实是DP问题,只是做了空间优化
- 状态转移方程符合DP特征
-
忽略负数情况:
- 必须同时维护max和min
- 负数乘负数可能得到最大乘积
-
错误的空间优化:
- 直接复用变量会导致状态覆盖
- 必须用临时变量保存旧值
5.2 关于LIS的常见错误
-
错误初始化:
- dp数组必须初始化为1
- 不能初始化为0或其他值
-
错误的状态转移:
- 必须比较所有前面的元素
- 不能只比较前一个元素
-
错误的最优解:
- 最后要取max(dp),不是dp[-1]
5.3 调试技巧
-
打印中间状态:
- 对于LIS,打印完整的dp数组
- 对于最大乘积,打印每步的max_prod和min_prod
-
小规模测试用例:
- LIS测试用例:[1,3,2,4] → 3
- 最大乘积测试用例:[2,-5,-2,-4,3] → 24
-
边界情况检查:
- 空数组
- 单元素数组
- 全正/全负数组
6. 扩展思考:为什么不能用区间DP?
有同学问为什么最大乘积子数组不能用区间DP解决。这是因为:
- 区间DP通常用于解决"区间划分"类问题
- 最大乘积具有最优子结构特性
- 但它的状态转移只依赖前一个状态,不需要划分区间
- 用区间DP会导致不必要的复杂度
区间DP的典型特征是:
- 状态定义为dp[i][j],表示区间i到j的最优解
- 通常需要三重循环
- 适合矩阵链乘法、石子合并等问题
相比之下,最大乘积问题的线性DP解法已经是最优的。