一、什么是动态规划
动态规划听起来挺高大上的,其实就是一种解决问题的思路。简单来说,就是把一个大问题拆分成一个个小问题,先解决小问题,再根据小问题的解去得到大问题的解。而且这些小问题之间是有联系的,解决后面的小问题可以利用前面小问题的结果,这样就能避免重复计算,提高效率。
举个生活中的例子,假如你要爬楼梯,每次可以走 1 步或者 2 步,问爬到第 n 级台阶有多少种方法。我们可以把这个大问题拆成小问题,比如先算爬到第 1 级台阶有几种方法,再算爬到第 2 级台阶有几种方法,然后依次类推,后面的台阶的方法数可以根据前面台阶的方法数得到。
二、最优子结构
最优子结构是动态规划的核心概念之一。它的意思是,一个大问题的最优解包含了它的子问题的最优解。也就是说,我们可以通过解决子问题的最优解来得到大问题的最优解。
还是以爬楼梯为例,要爬到第 n 级台阶,我们可以从第 n - 1 级台阶走 1 步上来,也可以从第 n - 2 级台阶走 2 步上来。那么爬到第 n 级台阶的方法数就等于爬到第 n - 1 级台阶的方法数加上爬到第 n - 2 级台阶的方法数。这里,爬到第 n - 1 级台阶和第 n - 2 级台阶就是子问题,而爬到第 n 级台阶的最优解(方法数)就包含了这两个子问题的最优解。
三、用动态规划解决斐波那契数列问题
斐波那契数列介绍
斐波那契数列是一个很经典的数列,它的定义是:$F(0) = 0$,$F(1) = 1$,$F(n) = F(n - 1) + F(n - 2)$($n \geq 2$)。也就是说,从第三项开始,每一项都等于前两项之和。
普通递归解法
我们先来看一下用普通递归的方法来实现斐波那契数列:
# Python 技术栈
def fibonacci_recursive(n):
# 如果 n 为 0,返回 0
if n == 0:
return 0
# 如果 n 为 1,返回 1
elif n == 1:
return 1
# 否则,返回前两项之和
else:
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
# 测试
print(fibonacci_recursive(5)) # 输出 5
这种方法虽然简单,但是效率很低,因为会有大量的重复计算。比如计算 $F(5)$ 时,会计算 $F(4)$ 和 $F(3)$,而计算 $F(4)$ 时又会计算 $F(3)$ 和 $F(2)$,这样 $F(3)$ 就被重复计算了。
动态规划解法
我们可以用动态规划的方法来优化这个问题,避免重复计算。
# Python 技术栈
def fibonacci_dp(n):
# 如果 n 为 0,返回 0
if n == 0:
return 0
# 如果 n 为 1,返回 1
elif n == 1:
return 1
# 创建一个长度为 n + 1 的列表,用于存储中间结果
dp = [0] * (n + 1)
# 初始化前两项
dp[0] = 0
dp[1] = 1
# 从第 2 项开始计算
for i in range(2, n + 1):
# 当前项等于前两项之和
dp[i] = dp[i - 1] + dp[i - 2]
# 返回第 n 项的值
return dp[n]
# 测试
print(fibonacci_dp(5)) # 输出 5
在这个方法中,我们用一个列表 dp 来存储中间结果,避免了重复计算,时间复杂度从指数级降到了线性级。
四、用动态规划解决背包问题
背包问题介绍
背包问题是一个经典的组合优化问题。假设有一个背包,它的容量为 $W$,有 $n$ 个物品,每个物品有自己的重量 $w_i$ 和价值 $v_i$。我们要在不超过背包容量的前提下,选择一些物品放入背包,使得背包中物品的总价值最大。
0 - 1 背包问题
0 - 1 背包问题是背包问题的一种特殊情况,每个物品只能选择放入或者不放入背包。
我们可以用动态规划来解决 0 - 1 背包问题。设 $dp[i][j]$ 表示前 $i$ 个物品放入容量为 $j$ 的背包中所能获得的最大价值。
状态转移方程为: $dp[i][j] = \begin{cases} dp[i - 1][j] & \text{if } w_i > j \ \max(dp[i - 1][j], dp[i - 1][j - w_i] + v_i) & \text{if } w_i \leq j \end{cases}$
下面是用 Python 实现的代码:
# Python 技术栈
def knapsack(weights, values, capacity):
n = len(weights)
# 创建一个二维数组 dp,用于存储中间结果
dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
# 遍历每个物品
for i in range(1, n + 1):
# 遍历每个容量
for j in range(1, capacity + 1):
# 如果当前物品的重量大于当前容量
if weights[i - 1] > j:
# 不选择当前物品
dp[i][j] = dp[i - 1][j]
else:
# 选择当前物品和不选择当前物品的最大值
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])
# 返回最大价值
return dp[n][capacity]
# 测试
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print(knapsack(weights, values, capacity)) # 输出 10
在这个代码中,我们用一个二维数组 dp 来存储中间结果,通过状态转移方程来更新 dp 数组,最终得到最大价值。
五、应用场景
动态规划在很多领域都有广泛的应用,比如:
- 资源分配问题:在有限的资源下,如何分配资源以达到最大的效益。例如,在项目管理中,如何分配人力、物力和财力,使得项目的收益最大。
- 路径规划问题:在地图上找到从一个点到另一个点的最短路径。例如,在导航系统中,如何规划最优的行车路线。
- 序列比对问题:在生物信息学中,比较两个 DNA 序列的相似度。
六、技术优缺点
优点
- 避免重复计算:通过存储中间结果,避免了重复计算,提高了效率。
- 可以解决复杂问题:可以解决一些复杂的组合优化问题,如背包问题、最长公共子序列问题等。
缺点
- 空间复杂度较高:通常需要使用额外的空间来存储中间结果,对于大规模问题,可能会占用大量的内存。
- 状态转移方程较难确定:对于一些复杂的问题,状态转移方程可能比较难确定,需要一定的数学基础和经验。
七、注意事项
- 状态定义要准确:状态的定义直接影响到状态转移方程的确定,所以要准确地定义状态。
- 边界条件要处理好:在动态规划中,边界条件非常重要,要确保边界条件的正确性。
- 空间优化:如果空间复杂度较高,可以考虑进行空间优化,如将二维数组优化为一维数组。
八、文章总结
动态规划是一种非常实用的算法思想,它通过把大问题拆分成小问题,利用最优子结构的性质,避免了重复计算,提高了效率。我们通过斐波那契数列和背包问题这两个经典例子,详细介绍了动态规划的应用。在实际应用中,我们要根据具体问题准确地定义状态和状态转移方程,处理好边界条件,同时注意空间复杂度的问题。
评论