在这一章中,我们将会为你介绍以下内容:
动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划不是某一种具体的算法,而是一种算法思想:若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
应用这种算法思想解决问题的可行性,对子问题与原问题的关系,以及子问题之间的关系这两方面有一些要求,它们分别对应了最优子结构和重复子问题。
最优子结构规定的是子问题与原问题的关系
动态规划要解决的都是一些问题的最优解,即从很多解决问题的方案中找到最优的一个。当我们在求一个问题最优解的时候,如果可以把这个问题分解成多个子问题,然后递归地找到每个子问题的最优解,最后通过一定的数学方法对各个子问题的最优解进行组合得出最终的结果。总结来说就是一个问题的最优解是由它的各个子问题的最优解决定的。
将子问题的解进行组合可以得到原问题的解是动态规划可行性的关键。在解题中一般用状态转移方程描述这种组合。例如原问题的解为 f ( n ) f(n) f(n),其中 f ( n ) f(n) f(n) 也叫状态。状态转移方程 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n - 1) + f(n - 2) f(n)=f(n−1)+f(n−2) 描述了一种原问题与子问题的组合关系 。在原问题上有一些选择,不同选择可能对应不同的子问题或者不同的组合方式。例如
f ( n ) = { f ( n − 1 ) + f ( n − 2 ) n = 2 k f ( n − 1 ) n = 2 k + 1 f(n) = \left\{ \begin{array}{lr} f(n - 1) + f(n - 2) \qquad n = 2k \\ f(n - 1) \qquad\qquad\qquad n = 2k + 1 \\ \end{array} \right. f(n)={f(n−1)+f(n−2)n=2kf(n−1)n=2k+1
n = 2 k n = 2k n=2k 和 n = 2 k + 1 n = 2k + 1 n=2k+1 对应了原问题 n n n 上不同的选择,分别对应了不同的子问题和组合方式。
找到了最优子结构,也就能推导出一个状态转移方程 f ( n ) f(n) f(n),通过这个状态转移方程,我们能很快的写出问题的递归实现方法。
重复子问题规定的是子问题与子问题的关系。
当我们在递归地寻找每个子问题的最优解的时候,有可能会会重复地遇到一些更小的子问题,而且这些子问题会重叠地出现在子问题里,出现这样的情况,会有很多重复的计算,动态规划可以保证每个重叠的子问题只会被求解一次。当重复的问题很多的时候,动态规划可以减少很多重复的计算。
重复子问题不是保证解的正确性必须的,但是如果递归求解子问题时,没有出现重复子问题,则没有必要用动态规划,直接普通的递归就可以了。
例如,斐波那契问题的状态转移方程 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n - 1) + f(n - 2) f(n)=f(n−1)+f(n−2)。在求 f ( 5 ) f(5) f(5) 时,需要先求子问题 f ( 4 ) f(4) f(4) 和 f ( 3 ) f(3) f(3),得到结果后再组合成原问题 f ( 5 ) f(5) f(5) 的解。递归地求 f ( 4 ) f(4) f(4) 时,又要先求子问题 f ( 3 ) f(3) f(3) 和 f ( 2 ) f(2) f(2) ,这里的 f ( 3 ) f(3) f(3) 与求 f ( 5 ) f(5) f(5) 时的子问题重复了。
解决动态规划问题的核心:找出子问题及其子问题与原问题的关系
找到了子问题以及子问题与原问题的关系,就可以递归地求解子问题了。但重叠的子问题使得直接递归会有很多重复计算,于是就想到记忆化递归法:若能事先确定子问题的范围就可以建表存储子问题的答案。
动态规划算法中关于最优子结构和重复子问题的理解的关键点:
让我们先从一道例题开始
题目:300.最长上升子序列
描述:
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是4。
将问题规模减小的方式有很多种,一些典型的减小方式是动态规划分类的依据,例如线性,区间,树形等。这里考虑数组上常用的两种思路:
每次减少一半:如果每次将问题规模减少一半,原问题有[10,9,2,5],和[3,7,101,18],两个子问题的最优解分别为 [2,5] 和 [3,7,101],但是找不到好的组合方式将两个子问题最优解组合为原问题最优解 [2,5,7,101]。
每次减少一个:记 f ( n ) f(n) f(n) 为以第 n n n 个数结尾的最长子序列,每次减少一个,将原问题分为 f ( n − 1 ) f(n-1) f(n−1), f ( n − 2 ) f(n-2) f(n−2), …, f ( 1 ) f(1) f(1),共 n − 1 n - 1 n−1 个子问题。 n − 1 = 7 n - 1 = 7 n−1=7 个子问题以及答案如下:
[10, 9, 2, 5, 3, 7, 101] -> [2, 5, 7, 101]
[10, 9, 2, 5, 3, 7] -> [2, 5, 7]
[10, 9, 2, 5, 3] -> [2, 3]
[10, 9, 2, 5] -> [2, 5]
[10, 9, 2] -> [2]
[10, 9] -> [9]
[10] -> [10]
已经有 7 个子问题的最优解之后,可以发现一种组合方式得到原问题的最优解: f ( 6 ) f(6) f(6) 的结果 [2,5,7], 7 < 18 7 < 18 7<18,同时长度也是 f ( 1 ) f(1) f(1) ~ f ( 7 ) f(7) f(7) 中,结尾小于 18 的结果中最长的。 f ( 7 ) f(7) f(7) 虽然长度为 4 比 f ( 6 ) f(6) f(6) 长,但结尾是不小于 18 的,无法组合成原问题的解。
以上组合方式可以写成一个式子,即状态转移方程
f ( n ) = m a x f ( i ) + 1 其 中 i < n 且 a [ i ] < a [ n ] f(n) = max f(i) + 1\ 其中\ i < n\ 且\ a[i] < a[n] f(n)=maxf(i)+1 其中 i<n 且 a[i]<a[n]
这种思考如何通过 f ( 1 ) . . . f ( n − 1 ) f(1)...f(n-1) f(1)...f(n−1) 求出 f ( n ) f(n) f(n) 的过程实际就是在思考状态转移方程怎么写。
总结: 解决动态规划问题最难的地方有两点:
有了状态转移方程,实际上已经可以直接用递归进行实现了。
int f(vector<int>& nums, int i)
{
int a = 1;
for(int j = 0; j < i; ++j)
{
if(nums[j] < nums[i])
¦ a = max(a, f(nums, j) + 1);
}
return a;
}
递归的解法需要非常多的重复计算,如果有一种办法能避免这些重复计算,可以节省大量计算时间。记忆化就是基于这个思路的算法。在递归地求解子问题 f ( 1 ) , f ( 2 ) . . . f(1), f(2)... f(1),f(2)... 过程中,将结果保存到一个表里,在后续求解子问题中如果遇到求过结果的子问题,直接查表去得到答案而不计算。
int f(vector<int>& nums, int i, vector<int>& dp)
{
if(dp[i] != -1) return dp[i];
int a = 1;
for(int j = 0; j < i; ++j)
{
if(nums[j] < nums[i])
¦ a = max(a, f(nums, j) + 1);
}
dp[i] = a;
return dp[i];
}
对于这种将问题规模不断减少的做法,我们把它称为自顶向下的方法。
在自顶向下的算法中,由于递归的存在,程序运行时有额外的栈的消耗。
有了状态转移方程,我们就知道如何从最小的问题规模入手,然后不断地增加问题规模,直到所要求的问题规模为止。在这个过程中,我们同样地可以记忆每个问题规模的解来避免重复的计算。这种方法就是自底向上的方法,由于避免了递归,这是一种更好的办法。
但是迭代法需要有一个明确的迭代方向,例如线性,区间,树形,状态压缩等比较主流的动态规划问题中,迭代方向都有相应的模式。参考后面的例题。但是有一些问题迭代法方向是不确定的,这时可以退而求其次用记忆化来做,参考后面的例题。
这一章我们将会介绍分治和贪心算法的核心思想,并与动态规划算法进行比较。
解决分治问题的时候,思路就是想办法把问题的规模减小,有时候减小一个,有时候减小一半,然后将每个小问题的解以及当前的情况组合起来得出最终的结果。
例如归并排序和快速排序,归并排序将要排序的数组平均地分成两半,快速排序将数组随机地分成两半。
然后不断地对它们递归地进行处理。
这里存在有最优的子结构,即原数组的排序结果是在子数组排序的结果上组合出来的,但是不存在重复子问题,因为不断地对待排序的数组进行对半分的时候,两半边的数据并不重叠,分别解决左半边和右半边的两个子问题的时候,
没有子问题重复出现,这是动态规划和分治的区别。
分治 | 动态规划 | 贪心 | |
---|---|---|---|
适用类型 | 通用 | 优化 | 优化 |
子问题 | 每个都不同 | 有很多重复 | 只有一个 |
最优子结构 | 没有要求 | 必须满足 | 必须满足 |
子问题数 | 全部都要解 | 全部都要解 | 只解一个 |
这一章将会介绍线性动态规划的相关概念和经典问题,并给出一些练习题供大家演练。
用动态规划解决问题的过程有以下几个关键点:状态定义,状态的转移,初始化和边界条件。
状态定义 就是定义子问题,如何表示目标规模的问题和更小规模的问题。例如常见的方法:定义状态 d p [ n ] dp[n] dp[n],表示规模为 n n n 的问题的解, d p [ n − 1 ] dp[n - 1] dp[n−1] 就表示规模为 n − 1 n - 1 n−1 的子问题的解。在实战中 d p [ n ] dp[n] dp[n] 的具体含义需要首先整理清楚再往下做。
状态转移 就是子问题之间的关系,例如定义好状态 d p [ n ] dp[n] dp[n],此时子问题是 d p [ n − 1 ] dp[n-1] dp[n−1] 等,并且大规模的问题的解依赖小规模问题的解,此时需要知道怎样通过小规模问题的解推出大规模问题的解。这一步就是列状态转移方程的过程。一般的状态转移方程可以写成如下形式
d p [ n ] = f ( d p [ i ] ) , 其 中 i < n dp[n] = f(dp[i]) ,其中 i < n dp[n]=f(dp[i]),其中i<n
按照状态定义和状态转移的常见形式,可以对动态规划进行分类,可以参考上一章的内容。
其中线性动态规划的主要特点是状态的推导是按照问题规模 i i i 从小到大依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
这里问题规模为 i i i 的含义是考虑前 i i i 个元素 [ 0 … i ] [0 \dots i] [0…i] 时问题的解。
线性动态规划的主要特点是状态的推导是按照问题规模 i i i 从小到大依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
这里问题规模为 i i i 的含义是考虑前 i i i 个元素 [ 0 … i ] [0 \dots i] [0…i] 时问题的解。
状态定义:
d p [ n ] : = [ 0 … n ] 上 问 题 的 解 dp[n] := [0 \ldots n] 上问题的解 dp[n]:=[0…n]上问题的解
状态转移:
d p [ n ] = f ( d p [ n − 1 ] , … , d p [ 0 ] ) dp[n] = f(dp[n-1], \ldots, dp[0]) dp[n]=f(dp[n−1],…,dp[0])
从以上状态定义和状态转移可以看出,大规模问题的状态只与较小规模的问题有关,而问题规模完全用一个变量 i i i 表示, i i i 的大小表示了问题规模的大小,因此从小到大推 i i i 直至推到 n n n,就得到了大规模问题的解,这就是线性动态规划的过程。
按照问题的输入格式,线性动态规划解决的问题主要是单串,双串,矩阵上的问题,因为在单串,双串,矩阵上问题规模可以完全用位置表示,并且位置的大小就是问题规模的大小。 因此从前往后推位置就相当于从小到大推问题规模。
线性动态规划是动态规划中最基本的一类。问题的形式、dp 状态和方程的设计、以及与其它算法的结合上面变化很多。按照 dp 方程中各个维度的含义,可以大致总结出几个主流的问题类型,见后面的小节。除此之外还有很多没有总结进来的变种问题,小众问题,和困难问题,这些问题的解法更多地需要结合自己的做题经验去积累,除此之外,常见的,主流的问题和解法都可以总结成下面的四个小类别。
单串 dp[i]
线性动态规划最简单的一类问题,输入是一个串,状态一般定义为 dp[i] := 考虑[0..i]上,原问题的解,
其中 i i i 位置的处理,根据不同的问题,主要有两种方式:
dp[i] := 考虑[0..i]上,且取 i,原问题的解;
大部分的问题,对 i i i 位置的处理是第一种方式,例如力扣:
线性动态规划中单串 dp[i]
的问题,状态的推导方向以及推导公式如下
d p [ n ] dp[n] dp[n] 只与常数个小规模子问题有关,状态的推导过程 d p [ i ] = f ( d p [ i − 1 ] , d p [ i − 2 ] , . . . ) dp[i] = f(dp[i - 1], dp[i - 2], ...) dp[i]=f(dp[i−1],dp[i−2],...)。时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n) 可以优化为 O ( 1 ) O(1) O(1),例如上面提到的 70, 801, 790, 746 都属于这类。
如图所示,虽然绿色部分的 d p [ i − 1 ] , d p [ i − 2 ] , . . . , d p [ 0 ] dp[i-1], dp[i-2], ..., dp[0] dp[i−1],dp[i−2],...,dp[0] 均已经计算过,但计算橙色的当前状态时,仅用到 d p [ i − 1 ] dp[i-1] dp[i−1],这属于比 i i i 小的 O ( 1 ) O(1) O(1) 个子问题。
例如,当 f ( d p [ i − 1 ] , . . . ) = d p [ i − 1 ] + n u m s [ i ] f(dp[i-1], ...) = dp[i-1] + nums[i] f(dp[i−1],...)=dp[i−1]+nums[i] 时,当前状态 d p [ i ] dp[i] dp[i] 仅与 d p [ i − 1 ] dp[i-1] dp[i−1] 有关。这个例子是一种数据结构前缀和的状态计算方式,关于前缀和的详细内容请参考下一章。
dp[n] 与此前的更小规模的所有子问题 d p [ n − 1 ] , d p [ n − 2 ] , . . . , d p [ 1 ] dp[n - 1], dp[n - 2], ..., dp[1] dp[n−1],dp[n−2],...,dp[1] 都可能有关系。
状态推导过程如下:
d p [ i ] = f ( d p [ i − 1 ] , d p [ i − 2 ] , . . . , d p [ 0 ] ) dp[i] = f(dp[i - 1], dp[i - 2], ..., dp[0]) dp[i]=f(dp[i−1],dp[i−2],...,dp[0])
依然如图所示,计算橙色的当前状态 d p [ i ] dp[i] dp[i] 时,绿色的此前计算过的状态 d p [ i − 1 ] , . . . , d p [ 0 ] dp[i-1], ..., dp[0] dp[i−1],...,dp[0] 均有可能用到,在计算 d p [ i ] dp[i] dp[i] 时需要将它们遍历一遍完成计算。
其中 f f f 常见的有 m a x / m i n max/min max/min, 可能还会对 i − 1 , i − 2 , . . . , 0 i-1,i-2,...,0 i−1,i−2,...,0 有一些筛选条件,但推导 d p [ n ] dp[n] dp[n] 时依然是 O ( n ) O(n) O(n) 级的子问题数量。
例如:
以 min 函数为例,这种形式的问题的代码常见写法如下
for i = 1, ..., n
for j = 1, ..., i-1
dp[i] = min(dp[i], f(dp[j])
以下内容将涉及到的知识点对应的典型问题进行讲解,题目和解法具有代表性,可以从一个问题推广到一类问题。
1. 依赖比 i 小的 O(1) 个子问题
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
一个数组有很多个子数组,求哪个子数组的和最大。可以按照子数组的最后一个元素来分子问题,确定子问题后设计状态
d p [ i ] : = [ 0.. i ] 中 , 以 n u m s [ i ] 结 尾 的 最 大 子 数 组 和 dp[i] := [0..i] 中,以 nums[i] 结尾的最大子数组和 dp[i]:=[0..i]中,以nums[i]结尾的最大子数组和
状态的推导是按照 i i i 从 0 0 0 到 n − 1 n - 1 n−1 按顺序推的,推到 dp[i]
时,dp[i - 1], ..., dp[0]
已经计算完。因为子数组是连续的,所以子问题 dp[i]
其实只与子问题 dp[i - 1]
有关。
[0..i-1]
上以 nums[i-1]
结尾的最大子数组和(缓存在 dp[i-1]
)为非负数,则以 nums[i]
结尾的最大子数组和就在 dp[i-1]
的基础上加上 nums[i]
就是 dp[i]
的结果按照以上的分析,状态的转移可以写出来,如下
d p [ i ] = n u m s [ i ] + m a x ( d p [ i − 1 ] , 0 ) dp[i] = nums[i] + max(dp[i - 1], 0) dp[i]=nums[i]+max(dp[i−1],0)
这个是单串 dp[i]
的问题,状态的推导方向,以及推导公式如下
在本题中,f(dp[i-1], ..., dp[0])
即为 max(dp[i-1], 0) + nums[i]
,dp[i]
仅与 dp[i-1]
1 个子问题有关。因此虽然绿色部分的子问题已经计算完,但是推导当前的橙色状态时,只需要 dp[i-1]
这一个历史状态。
2. 依赖比 i 小的 O(n) 个子问题
给定一个无序的整数数组,找到其中最长上升子序列的长度。
输入是一个单串,首先思考单串问题中设计状态 dp[i]
时拆分子问题的方式:枚举子串或子序列的结尾元素来拆分子问题,设计状态 dp[i] := 在子数组 [0..i]
上,且选了 nums[i]
时的最长上升子序列。
因为子序列需要上升,因此以 i i i 结尾的子序列中,nums[i]
之前的数字一定要比 nums[i]
小才行,因此目标就是先找到以此前比 nums[i]
小的各个元素,然后每个所选元素对应一个以它们结尾的最长子序列,从这些子序列中选择最长的,其长度加 1 就是当前的问题的结果。如果此前没有比 nums[i]
小的数字,则当前问题的结果就是 1 。
按照以上的分析,状态的转移方程可以写出来,如下
d p [ i ] = m a x j ( d p [ j ] ) + 1 dp[i] = max_{j}(dp[j]) + 1 dp[i]=maxj(dp[j])+1
其中 0 ≤ j < i , n u m s [ j ] < n u m s [ i ] 0 \leq j < i, nums[j] < nums[i] 0≤j<i,nums[j]<nums[i]。
本题依然是单串 dp[i]
的问题,状态的推导方向,以及推导公式与上一题的图示相同,
状态的推导依然是按照 i i i 从 0 0 0 到 n − 1 n-1 n−1 推的,计算 dp[i]
时,dp[i-1], dp[i-2], ..., dp[0]
依然已经计算完。
但本题与上一题的区别是推导 dp[i]
时,dp[i-1]. dp[i-2], ..., dp[0]
均可能需要用上,即,因此计算当前的橙色状态时,绿色部分此前计算过的状态都可能需要用上。