目录
1. 动态规划算法简介
动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解成更小的子问题来求解的算法设计方法。它适用于求解具有重叠子问题和最优子结构性质的问题。动态规划通过记录已经计算过的子问题的解,避免了重复计算,从而大大提高了计算效率。
2. 动态规划的基本思想
这里以经典的爬楼梯问题为例子,来阐述动态规划的基本思想。我们给出一个数字 n(这里假设 n = 10)
,表示楼梯的总阶数,我们每次可以选择爬 1 层或者 2 层台阶,然后需要计算出有多少种方法可以从楼梯的底部走到顶部 (第10层台阶)。具体步骤如下:
-
拆解问题: 首先来定义整个问题(爬到第10层台阶)的子问题,把问题拆解。【爬到第10阶的走法】 = 【爬到第9阶的走法 + 1步】 + 【爬到第8阶的走法 + 2步】 。同理,【爬到第9阶的走法】 = 【爬到第8阶的走法 + 1步】 + 【爬到第7阶的走法 + 2步】
-
定义状态:然后我们定义一个状态来表示子问题的解。例如,
dp[5] = 8
表示爬到第5阶的走法有8种。同理:
dp [ 1 ] = 1, 爬一次1层台阶
dp [ 2 ] = 2, 爬两次1层台阶 或者 爬一次2层台阶
dp [ 3 ] = 3, 爬一次1层台阶和一次2层台阶,爬一次2层台阶和一次1层台阶,爬三次1层台阶
dp [ 4 ] = 5, ... -
递推关系:通过已知的子问题的解,推导出更大问题的解。即,通过找到当前问题与其子问题之间的关系,利用已经解决的子问题来求解更大的问题。针对爬楼梯问题,我们可知递推关系为 dp [ n ] = dp [ n - 1 ] + dp [ n - 2 ]。
-
边界条件: 明确递推关系的起始点,通常是一些最简单的子问题,它们的解是直接已知的。比如爬楼梯问题的起点是 dp [1] = 1 和 dp [ 2 ] = 2。
-
返回结果:根据定义的目标状态(从
dp[1]
和dp[2]
开始),递推计算并返回最终的解,通常是dp[n]
或dp[target]。
3. 动态规划的三大关键
3.1. 重叠子问题
问题可以拆解成多个重复的小问题,比如”爬到第10阶“可以拆解为”爬到第9阶 + 1步“或者“爬到第8阶 + 2步”。重叠子问题是使用动态规划(DP)算法的基础。动态规划通过“记忆化”避免重复计算 (比如“爬到第5阶”的方法被记录在 dp [ 5 ] ,避免重复多次计算)。这里需要和"分治"算法进行对比一下,分治问题中的子问题是互不重叠,直接用递归解决。
3.2. 最优子结构
大问题的最优解依赖小问题的最优解(比如“第10阶的走法数”由第8、9阶决定)。通过小问题的解组合出大问题的解。所以子问题有最优解,是使用动态规划的另一个关键。
3.3. 状态转移方程
明确如何从小问题推导出大问题(递推公式)。比如爬楼梯问题:dp[i] = dp[i - 1] + dp[i - 2]。状态转移方程,或者说递推公式,其实是动态规划算法的难点,这里给出的例子比较简单,但有时候状态转移方程是很难推导的。找到了状态转移方程,算法已经基本完成了。没有捷径,只能多多练习,培养对算法的敏锐度。
4. 动态规划的应用
这里我们先讲解两个最长见的动态规划的问题,斐波那契数列和 0/1 背包问题。其它的常见问题,我们留在下一篇博文讲解。
4.1. 斐波那契数列
斐波那契数列(Fibonacci Sequence)是一个在数学和计算机科学中非常经典的数列,它的定义非常简单:从第3项开始,每一项都是前两项的和。具体公式为:
F(0)=0,F(1)=1,F(n)=F(n−1)+F(n−2)(for n≥2)
斐波那契数列的前几项 0,1,1,2,3,5,8,13,21,34,55,89,144, ... 。计算斐波那契数列的动态规划代码如下:
def fibonacci_dp(n):
if n <= 1:
return 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]
# 示例
print(fibonacci_dp(10))
# 输出
55
我们可以看到,斐波那契数列是以递推形式给出的,所以递推公式可以直接用于动态规划的程序实现,只要注意 dp[ 0 ] = 0 和 dp[ 1 ] = 1 这两个起始条件就好。程序时间复杂度为O(n),空间复杂度为O(n)。空间复杂度可以通过优化进一步减少,我们之后再讨论。
4.2. 0/1 背包问题
我再看一个稍微复杂点的情况,也就是 0/1 背包问题。给定一组物品和一个背包,每个物品都有一个重量和价值,背包有一个最大承载重量。要求选择若干个物品,每个物品只能选择一次,使得它们的总价值最大,且总重量不超过背包容量。
-
状态定义:
dp[i][w]
表示前i
个物品,背包容量为w
时能够获得的最大价值。 -
状态转移方程:dp [ i ][ w ] = max(dp [ i−1 ][ w ], dp [ i−1 ][ w − weight [ i ] ] + value [ i ])
-
初始化:
dp[0][w] = 0
(没有物品时价值为 0)。
解释
-
不选择当前物品(即
dp[i-1][w]
):如果我们不选择当前的第i
个物品,背包的容量w
就保持不变,那么最优解就应该是之前考虑的前i-1
个物品在背包容量为w
时的最优解,即dp[i-1][w]
。 -
选择当前物品(即
dp[i-1][w - weight[i]] + value[i]
):如果我们选择当前的第i
个物品,那么背包的容量w
将减少weight[i]
。此时,问题就变成了前i-1
个物品,背包容量为w - weight[i]
时的最优解,再加上当前物品的价值value[i]
。 -
进行比较 (即 max(dp [
i-
1 ][ w ], dp [i-
1 ][ w − weight [i
] ] + value [i
])):如果我们选择当前的第i
个物品,我们需要比较,看选择的当前物品是否能够为我们获得更大的 value.
例子
假设我们有 3 个物品,背包容量为 4:
物品 | 重量 | 价值 |
---|---|---|
1 | 3 | 4 |
2 | 2 | 3 |
3 | 1 | 2 |
计算 0/1 背包问题的动态规划代码如下:
def knapsack(weights, values, capacity):
# 获取物品的数量
n = len(weights)
# 创建 DP 表,初始化为 0
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
# 填充 DP 表
# Loop through the item 【0, 1, 2】
for i in range(1, n + 1):
# Loop through the capacity 【0, 1, 2, 3, 4】
for c in range(1, capacity + 1):
# 如果当前背包容量足够装下第 i 个物品
if weights[i - 1] <= c:
# 选择是否放入当前物品,取放与不放的最大价值
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - weights[i - 1]] + values[i - 1])
else:
# 如果当前背包容量不足以放入第 i 个物品
dp[i][c] = dp[i - 1][c]
# dp[n][capacity] 存储着最大价值
return dp[n][capacity]
# 示例数据
weights = [3, 2, 1] # 物品的重量
values = [4, 3, 2] # 物品的价值
capacity = 4 # 背包的容量
# 调用 knapsack 函数
max_value = knapsack(weights, values, capacity)
print(f"最大价值是: {max_value}")
运行结果如下:
最大价值是: 6
这里给出具体的填表/递推过程供参考:
-
dp[0][w] = 0
:没有物品时,最大价值为 0。 -
对于第 1 个物品,考虑每个背包容量:
-
dp[1][0] = 0
:背包容量为 0 时,不能放物品。 -
dp[1][1] = 0
:背包容量为 1 时,无法放第一个物品。 -
dp[1][2] = 0
:背包容量为 2 时,无法放第一个物品。 -
dp[1][3] = 4
:背包容量为 3 时,可以放第一个物品,价值为 4。 -
dp[1][4] = 4
:背包容量为 4 时,可以放第一个物品,价值为 4。
-
-
对于第 2 个物品,考虑每个背包容量:
-
dp[2][0] = 0
:背包容量为 0 时,最大价值为 0。 -
dp[2][1] = 0
:背包容量为 1 时,无法放第 二 个物品。 -
dp[2][2] = 3
:背包容量为 2 时,可以放第 二 个物品,价值为 3。 -
dp[2][3] = 4
:背包容量为 3 时,选择第一个物品,价值为 4。 -
dp[2][4] = 6
:背包容量为 4 时,选择第一个物品,价值为 4。
-
-
对于第 3 个物品,考虑每个背包容量:
-
dp[3][0] = 0
:背包容量为 0 时,最大价值为 0。 -
dp[3][1] = 2
:背包容量为 1 时,可以放第 三 个物品,价值为 2。 -
dp[3][2] = 3
:背包容量为 2 时,选择第 二 个物品,价值为 3。 -
dp[3][3] = 4
:背包容量为 3 时,选择第 一 个物品,价值为 4。 -
dp[3][4] = 6
:背包容量为 4 时,选择第 一 和第 三 个物品,价值为 6。
-
最终结果是 dp[3][4] = 6
,表示最大价值为 6,背包容量为 4 时,可以选择第 一 个和第 三 个物品。
5. 总结
本文给出了动态规划算法的基本思想与过程,并给出了两个非常常用的例子。我们可以看到,动态规划的关键是找出子问题并列出递推关系,然后根据起始条件和递推关系来进行计算得出最终的结果。但是并不是每个问题的递推关系都很好找出,还是需要多加练习。