动态规划(Dynamic Programming):简称 DP,是一种通过把原问题分解为相对简单的子问题的方式而求解复杂问题的方法。
动态规划最早由理查德 · 贝尔曼于 1957 年在其著作「动态规划(Dynamic Programming)」一书中提出。这里的 Programming 并不是编程的意思,而是指一种表格处理方法,即将每一步计算的结果存储在表格中,供随后的计算查询使用。
动态规划方法与分治算法类似,却又不同于分治算法。
「动态规划的核心思想」是:
- 把「原问题」分解为「若干个重叠的子问题」,每个子问题的求解过程都构成一个 「阶段」。在完成一个阶段的计算之后,动态规划方法才会执行下一个阶段的计算。
- 在求解子问题的过程中,按照自底向上的顺序求解出「子问题的解」,把结果存储在表格中,当需要再次求解此子问题时,直接从表格中查询该子问题的解,从而避免了大量的重复计算。
「动态规划方法与分治算法的不同点」在于:适用于动态规划求解的问题,在分解之后得到的子问题往往是相互联系的,会出现若干个重叠子问题。使用动态规划方法会将这些重叠子问题的解保存到表格里,供随后的计算查询使用,从而避免大量的重复计算。
能够使用动态规划方法解决的问题必须满足下面三个特征:「最优子结构性质」、「重叠子问题性质」和「无后效性」。
「最优子结构」:指的是一个问题的最优解包含其子问题的最优解。
举个例子,如下图所示,原问题
也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。
「重叠子问题性质」:指的是在求解子问题的过程中,有大量的子问题是重复的,一个子问题会在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对求解一次,然后用表格将结果存储下来,以后使用时可以直接查询,不需要再次求解。
举个例子,比如斐波那契数列的定义是:f(1) = 1, f(2) = 2, f(n) = f(n - 1) + f(n - 2)
。对应的递推过程如下图所示,其中 f(1)
、f(2)
、f(3)
、f(4)
都进行了多次重复计算。而如果我们在第一次计算 f(1)
、f(2)
、f(3)
、f(4)
时就将其结果存入表格,则再次使用时可以直接查询,从而避免重复求解相同的子问题,提升效率。
「无后效性」:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。换句话说,一旦某一个子问题的求解结果确定以后,就不会再被修改。
其实我们也可以把动态规划方法的求解过程,看做是有向无环图的最长(最短)路的求解过程。每个状态对应有向无环图上的一个节点,决策对应图中的一条有向边。
如果一个问题具有「后效性」,则可能需要将其转化或者逆向求解来消除后效性,然后才可以使用动态规划方法。
如下图所示,我们在使用动态规划方法解决某些最优化问题时,可以将解决问题的过程按照一定顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。然后按照顺序对每一个阶段做出「决策」,这个决策既决定了本阶段的效益,也决定了下一阶段的初始状态。依次做完每个阶段的决策之后,就得到了一个整个问题的决策序列。
这样就将一个原问题分解为了一系列的子问题,然后通过逐步求解从而获得最终结果。
这种前后关联、具有链状结构的多阶段进行决策的问题也叫做「多阶段决策问题」。
通常我们使用动态规划方法来解决多阶段决策问题,其基本思路如下:
- 划分阶段:将原问题按顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。划分后的阶段⼀定是有序或可排序的,否则问题⽆法求解。
- 这里的「阶段」指的是⼦问题的求解过程。每个⼦问题的求解过程都构成⼀个「阶段」,在完成前⼀阶段的求解后才会进⾏后⼀阶段的求解。
- 定义状态:将和子问题相关的某些变量(位置、数量、体积、空间等等)作为一个「状态」表示出来。状态的选择要满⾜⽆后效性。
- 一个「状态」对应一个或多个子问题,所谓某个「状态」下的值,指的就是这个「状态」所对应的子问题的解。
- 状态转移:根据「上一阶段的状态」和「该状态下所能做出的决策」,推导出「下一阶段的状态」。或者说根据相邻两个阶段各个状态之间的关系,确定决策,然后推导出状态间的相互转移方式(即「状态转移方程」)。
- 初始条件和边界条件:根据问题描述、状态定义和状态转移方程,确定初始条件和边界条件。
- 最终结果:确定问题的求解目标,然后按照一定顺序求解每一个阶段的问题。最后根据状态转移方程的递推结果,确定最终结果。
动态规划相关的问题往往灵活多变,思维难度大,没有特别明显的套路,并且经常会在各类算法竞赛和面试中出现。
动态规划问题的关键点在于「如何状态设计」和「推导状态转移条件」,还有各种各样的「优化方法」。这类问题一定要多练习、多总结,只有接触的题型多了,才能熟练掌握动态规划思想。
下面来介绍几道关于动态规划的基础题目。
描述:给定一个整数 n
。
要求:计算第 n
个斐波那契数。
说明:
- 斐波那契数列的定义如下:
f(0) = 0, f(1) = 1
。f(n) = f(n - 1) + f(n - 2)
,其中n > 1
。
示例:
输入 n = 2
输出 1
解释 F(2) = F(1) + F(0) = 1 + 0 = 1
我们可以按照整数顺序进行阶段划分,将其划分为整数 0
~ n
。
定义状态 dp[i]
为:第 i
个斐波那契数。
根据题目中所给的斐波那契数列的定义 f(n) = f(n - 1) + f(n - 2)
,则直接得出状态转移方程为 dp[i] = dp[i - 1] + dp[i - 2]
。
根据题目中所给的初始条件 f(0) = 0, f(1) = 1
确定动态规划的初始条件,即 dp[0] = 0, dp[1] = 1
。
根据状态定义,最终结果为 dp[n]
,即第 n
个斐波那契数为 dp[n]
。
class Solution:
def fib(self, n: int) -> int:
if n <= 1:
return n
dp = [0 for _ in range(n + 1)]
dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 2] + dp[i - 1]
return dp[n]
-
时间复杂度:$O(n)$。一重循环遍历的时间复杂度为
。 -
空间复杂度:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为
。因为 dp[i]
的状态只依赖于dp[i - 1]
和dp[i - 2]
,所以可以使用3
个变量来分别表示dp[i]
、dp[i - 1]
、dp[i - 2]
,从而将空间复杂度优化到。
描述:假设你正在爬楼梯。需要 n
阶你才能到达楼顶。每次你可以爬 1
或 2
个台阶。现在给定一个整数 n
。
要求:计算出有多少种不同的方法可以爬到楼顶。
说明:
-
。
示例:
输入 n = 3
输出 3
解释 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
我们按照台阶的阶层划分阶段,将其划分为 0
~ n
阶。
定义状态 dp[i]
为:爬到第 i
阶台阶的方案数。
根据题目大意,每次只能爬 1
或 2
个台阶。则第 i
阶楼梯只能从第 i - 1
阶向上爬 1
阶上来,或者从第 i - 2
阶向上爬 2
阶上来。所以可以推出状态转移方程为 dp[i] = dp[i - 1] + dp[i - 2]
。
- 第
0
层台阶方案数:可以看做1
种方法(从0
阶向上爬0
阶),即dp[1] = 1
。 - 第
1
层台阶方案数:1
种方法(从0
阶向上爬1
阶),即dp[1] = 1
。 - 第
2
层台阶方案数:2
中方法(从0
阶向上爬2
阶,或者从1
阶向上爬1
阶)。
根据状态定义,最终结果为 dp[n]
,即爬到第 n
阶台阶(即楼顶)的方案数为 dp[n]
。
虽然这道题跟上一道题的状态转移方程都是 dp[i] = dp[i - 1] + dp[i - 2]
,但是两道题的考察方式并不相同,一定程度上也可以看出来动态规划相关题目的灵活多变。
class Solution:
def climbStairs(self, n: int) -> int:
dp = [0 for _ in range(n + 1)]
dp[0] = 1
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
-
时间复杂度:$O(n)$。一重循环遍历的时间复杂度为
。 -
空间复杂度:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为
。因为 dp[i]
的状态只依赖于dp[i - 1]
和dp[i - 2]
,所以可以使用3
个变量来分别表示dp[i]
、dp[i - 1]
、dp[i - 2]
,从而将空间复杂度优化到。
描述:给定两个整数 m
和 n
,代表大小为 m * n
的棋盘, 一个机器人位于棋盘左上角的位置,机器人每次只能向右、或者向下移动一步。
要求:计算出机器人从棋盘左上角到达棋盘右下角一共有多少条不同的路径。
说明:
-
。 - 题目数据保证答案小于等于
。
示例:
输入 m = 3, n = 7
输出 28
按照路径的结尾位置(行位置、列位置组成的二维坐标)进行阶段划分。
定义状态 dp[i][j]
为:从左上角到达 (i, j)
位置的路径数量。
因为我们每次只能向右、或者向下移动一步,因此想要走到 (i, j)
,只能从 (i - 1, j)
向下走一步走过来;或者从 (i, j - 1)
向右走一步走过来。所以可以写出状态转移方程为:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
,此时 i > 0,j > 0
。
- 从左上角走到
(0, 0)
只有一种方法,即dp[0][0] = 1
。 - 第一行元素只有一条路径(即只能通过前一个元素向右走得到),所以
dp[0][j] = 1
。 - 同理,第一列元素只有一条路径(即只能通过前一个元素向下走得到),所以
dp[i][0] = 1
。
根据状态定义,最终结果为 dp[m - 1][n - 1]
,即从左上角到达右下角 (m - 1, n - 1)
位置的路径数量为 dp[m - 1][n - 1]
。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[0 for _ in range(n)] for _ in range(m)]
for j in range(n):
dp[0][j] = 1
for i in range(m):
dp[i][0] = 1
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]
-
时间复杂度:$O(m * n)$。初始条件赋值的时间复杂度为
,两重循环遍历的时间复杂度为 ,所以总体时间复杂度为 。 -
空间复杂度:$O(m * n)$。用到了二维数组保存状态,所以总体空间复杂度为
。因为 dp[i][j]
的状态只依赖于上方值dp[i - 1][j]
和左侧值dp[i][j - 1]
,而我们在进行遍历时的顺序刚好是从上至下、从左到右。所以我们可以使用长度为m
的一维数组来保存状态,从而将空间复杂度优化到。
- 【文章】动态规划基础 - OI Wiki
- 【文章】动态规划 1 ——基本概念 - 知乎
- 【文章】动态规划算法 | 曹世宏的博客
- 【文章】动态规划之初识动规:有了四步解题法模板,再也不害怕动态规划! - 知乎
- 【文章】第 6 节 最优子结构、重复子问题、无后效性 | 算法吧
- 【书籍】算法训练营 陈小玉 著
- 【书籍】趣学算法 陈小玉 著
- 【书籍】算法竞赛进阶指南 - 李煜东 著
- 【书籍】ACM-ICPC 程序设计系列 - 算法设计与实现 - 陈宇 吴昊 主编