引言
在本章中,我们将尝试解决那些使用其他技术(例如分治法和贪心法)未能得到最优解的问题。动态规划(DP)是一种简单的技术,但掌握起来可能比较困难。识别和解决DP问题的一个简单方法就是尽可能多地解决各种问题。“编程”一词与编码无关,而是源自文献,意思是填充表格,类似于线性规划。
什么是动态规划策略?
动态规划和记忆化搜索是相辅相成的。分治法和动态规划的主要区别在于,分治法中的子问题是相互独立的,而在DP中子问题可能会重叠。通过使用记忆化搜索(维护一个已解决子问题的表格),动态规划将许多问题的指数级复杂度降低到多项式级复杂度(O(n²)、O(n³)等)。DP的主要组成部分包括:
-
递归:递归地解决子问题。
-
记忆化:将已计算的值存储在表格中(记忆化意味着缓存)。 动态规划 = 递归 + 记忆化
动态规划策略的特性
能够判断DP是否能解决给定问题的两个特性是:
-
最优子结构:一个问题的最优解包含其子问题的最优解。
-
重叠子问题:递归解中包含少量不同的子问题,这些子问题被重复多次。
动态规划能否解决所有问题?
和贪心法与分治法一样,DP并不能解决所有问题。有些问题无法通过任何算法技术(贪心法、分治法和动态规划)来解决。动态规划与直接递归的区别在于递归调用的记忆化。如果子问题是相互独立且没有重复,那么记忆化就没有帮助,因此动态规划并非所有问题的解决方案。
动态规划方法
解决DP问题主要有两种方法:
-
自底向上动态规划
-
自顶向下动态规划
自底向上动态规划
在这种方法中,我们从最小的可能输入参数值开始评估函数,然后逐步增加输入参数值。在计算过程中,我们将所有计算出的值存储在表格(内存)中。当评估较大参数值时,可以使用之前计算出的较小参数值。
示例:斐波那契数列
斐波那契数列中,当前数字是前两个数字的和。斐波那契数列定义如下: F(n)=F(n−1)+F(n−2) 其中,F(0)=0,F(1)=1。
观察斐波那契数列可以发现,当前值仅是前两个计算值的和。这意味着我们无需存储所有先前的值,只需存储最后两个值即可计算当前值。
def fib(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n+1):
a, b = b, a + b
return b
这种实现的时间复杂度为O(n),空间复杂度为O(1)。
自顶向下动态规划
在这种方法中,我们将问题分解为子问题,解决每个子问题,并记住解决方案,以防需要再次解决。我们还将每个计算出的值作为递归函数的最后一个动作保存,并在第一个动作中检查是否存在预先计算的值。
自底向上与自顶向下编程对比
在自底向上编程中,程序员需要选择要计算的值并决定计算顺序。在这种情况下,所有可能需要的子问题都提前解决,然后用来构建更大问题的解决方案。在自顶向下编程中,原始代码的递归结构得以保留,但避免了不必要的重复计算。问题被分解为子问题,这些子问题被解决并记住,以防需要再次解决。
示例:阶乘问题
阶乘问题:n! 是 n 和 1 之间所有整数的乘积。递归阶乘的定义可以表示为: n!=n×(n−1)! 其中,0!=1。
我们可以使用动态规划来降低复杂度。从递归定义可以看出,fact(n) 是通过 fact(n-1) 和 n 计算得出的。我们可以将之前计算出的值存储在表格中,并使用这些值来计算新值。
def factorial(n, memo={}):
if n in memo:
return memo[n]
if n == 0 or n == 1:
return 1
memo[n] = n * factorial(n-1, memo)
return memo[n]
这种实现将复杂度降低到O(max(m,n))。
动态规划算法示例
-
许多字符串算法,包括最长公共子序列、最长递增子序列、最长公共子串、编辑距离等。
-
图算法可以高效解决:Bellman-Ford算法用于在图中查找最短距离,Floyd的全对最短路径算法等。
-
链式矩阵乘法
-
子集和问题
-
0/1背包问题
-
旅行商问题等
理解动态规划
在深入问题之前,让我们通过示例了解DP的工作原理。
1)斐波那契数列
斐波那契数列中,当前数字是前两个数字的和。斐波那契数列定义如下: F(n)=F(n−1)+F(n−2) 其中,F(0)=0,F(1)=1。
递归实现
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
这种递归实现的时间复杂度为指数级,因为它会重复计算许多子问题。
记忆化搜索
为了避免重复计算,我们可以使用记忆化搜索。具体做法是:从递归函数开始,添加一个表格,将函数