1. 动态规划算法入门:从斐波那契数列说起
我第一次接触动态规划(Dynamic Programming,简称DP)是在解决斐波那契数列问题时。当时我按照传统递归方式写出了这样的代码:
python复制def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
这个看似优雅的解法在n=40时就需要数秒才能完成计算。通过分析递归树,我发现存在大量重复计算——fib(3)被计算了数十次,fib(2)更是被计算了上百次。这正是动态规划要解决的核心问题:重叠子问题。
改进后的DP解法只需要O(n)时间:
python复制def fib(n):
dp = [0]*(n+1)
dp[1] = 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
这个简单的例子揭示了动态规划的三个关键特征:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:不同决策序列会到达相同的子问题
- 状态转移方程:dp[i] = dp[i-1] + dp[i-2]
注意:在实际工程中,可以进一步优化空间复杂度到O(1),但教学时建议先写出标准DP形式,再考虑优化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典DP问题解析:背包问题的两种变体
2.1 0-1背包问题:装与不装的抉择
假设我们有一个容量为W的背包和N件物品,每件物品有重量w[i]和价值v[i]。如何在不超过背包容量的情况下,使装入物品的总价值最大?
这个问题的状态定义非常经典:dp[i][j]表示考虑前i件物品,背包容量为j时的最大价值。状态转移方程需要考虑是否装入第i件物品:
python复制dp[i][j] = max(
dp[i-1][j], # 不装第i件
dp[i-1][j-w[i]] + v[i] if j >= w[i] else 0 # 装第i件
)
这里有个关键技巧:为什么倒序遍历容量可以保证每件物品只选一次?因为dp[j]依赖于dp[j-w[i]],正序遍历会导致dp[j-w[i]]可能已经包含当前物品,从而造成重复选择。
2.2 完全背包问题:无限选择的奥秘
当每件物品可以选择无限次时,状态转移方程看似只需简单修改:
python复制dp[i][j] = max(
dp[i-1][j],
dp[i][j-w[i]] + v[i] if j >= w[i] else 0
)
但更精妙的是空间优化后的实现差异:
python复制# 0-1背包(倒序)
for j in range(W, w[i]-1, -1):
dp[j] = max(dp[j], dp[j-w[i]] + v[i])
# 完全背包(正序)
for j in range(w[i], W+1):
dp[j] = max(dp[j], dp[j-w[i]] + v[i])
这个微妙的差别正是理解DP遍历顺序的关键——正序时,dp[j-w[i]]可能已经包含当前物品的选取,因此实现了无限次选择的效果。
3. 序列问题双雄:LIS与LCS的DP解法
3.1 最长递增子序列(LIS)
给定一个数组nums,找到其中最长的严格递增子序列的长度。例如[10,9,2,5,3,7,101,18]的LIS是[2,3,7,101],长度为4。
定义dp[i]为以nums[i]结尾的LIS长度:
python复制dp = [1]*n
for i in range(1, n):
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j]+1)
return max(dp)
这个O(n²)解法可以通过二分查找优化到O(nlogn),但DP版本更容易理解问题本质。实际工程中,我们常用贪心+二分的优化版本:
python复制tails = []
for num in nums:
idx = bisect.bisect_left(tails, num)
if idx == len(tails):
tails.append(num)
else:
tails[idx] = num
return len(tails)
3.2 最长公共子序列(LCS)
给定两个字符串text1和text2,返回它们的最长公共子序列的长度。例如"abcde"和"ace"的LCS是"ace",长度为3。
定义dp[i][j]为text1前i个字符和text2前j个字符的LCS长度:
python复制m, n = len(text1), len(text2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
这个问题的状态转移方程非常典型:
- 当字符匹配时,LCS长度+1
- 不匹配时,继承左侧或上侧的最大值
4. DP问题解题框架与实战技巧
4.1 五步解题法
根据我的刷题经验,解决DP问题可以遵循以下步骤:
- 定义状态:明确dp数组的含义,通常与问题求解目标直接相关
- 确定初始条件:边界情况的处理往往决定程序正确性
- 建立状态转移方程:问题的核心难点,需要分析子问题关系
- 确定遍历顺序:特别是空间优化时,顺序/逆序影响重大
- 举例验证:用简单测试案例手动推导dp数组
4.2 常见错误与调试技巧
在ACM竞赛和面试中,我总结出这些易错点:
-
数组越界:特别是当状态定义使用前驱状态时
- 解决方案:统一使用1-based索引,或在边界处特殊处理
-
初始化不完整:比如背包问题中忘记初始化dp[0]=0
- 建议:画出初始dp表格,明确每个元素的含义
-
空间优化导致的顺序错误:
- 记忆口诀:"0-1背包倒序,完全背包正序,组合问题先物品后容量,排列问题先容量后物品"
-
状态转移条件遗漏:
- 例如在LCS问题中忘记考虑text1[i]≠text2[j]的情况
调试技巧:打印出完整的dp表格,与手动计算的结果对比。对于二维DP,可以使用pandas DataFrame更直观地查看:
python复制import pandas as pd
print(pd.DataFrame(dp))
4.3 从经典问题到变形题
掌握了基础DP模型后,可以尝试这些常见变种:
- 背包问题:分组背包、多维费用背包
- 序列问题:带权值的LIS、多个序列的LCS
- 路径问题:带障碍物的网格路径计数
- 状态压缩DP:如旅行商问题(TSP)
以打家劫舍问题为例,原始版本是线性排列的房屋,变种包括:
- 环形排列:首尾相连
- 二叉树结构:房屋排列成二叉树形式
- 带权值的限制条件
每种变种都需要重新思考状态定义和转移方程,但核心的DP思维不变。
