动态规划作为一类非常重要的算法设计思想,其在各大公司的面试中基本上是必定会被考察到的。于是,本文针对动态规划的一些问题,做一个初始学习阶段的总结(从2019年6月17日一直看到2019年6月20日)
动态规划程序设计是对解最优化问题的一种途径、一种方法,而不是一种特殊算法。不像搜索或数值计算那样,具有一个标准的数学表达式和明确清晰的解题方法。动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有各具特色的解题方法,而不存在一种万能的动态规划算法,可以解决各类最优化问题。一般,问题需要满足如下特点,才能使用动态规划的思想解决:
最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,那么我们称此问题具有最优子结构性质。其中最优子结构性质为动态规划思想解决问题提供了非常重要的线索,往往通过它能够找到问题的状态方程。
无后效性:子问题的解一旦确定就不再改变,其不受到在这之后,包含在更大问题的求解决策影响。
子问题重叠性质:指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不是新问题,有些子问题是会被重复计算多次的。动态规划利用该性质,对每一个问题只计算一次,然后将其结果保存在一个表格之中,当再次需要完成对已计算过的问题进行计算时,只是在表格中简单查看结果即可。
对于动态规划类型的算法题,通常会出现什么最大、最小、最多、最少等关键词,如果你在实际算法题中遇到这些关键词,可根据上述的问题特征性质进一步判断是否为动态规划问题。在了解到那些问题能够利用动态规划的思想解决后,下一步我们需要如何通过动态规划解决这些问题。一般来说,完成一个动态规划的分析过程为:
- 首先需要分析问题,思考如何去表示问题的状态;
- 在完成状态的表示后,开始寻找不同子问题之间的联系,以获取问题间的状态转移方程;
- 初始化状态,并通过状态转移方程推断问题的最优解;
由于上述过程太过抽象,所以我们继续以实际问题刨析动态规划的算法思想。通常,动态规划问题主要分为以下几种类型:
- 线性动态规划
- 区间动态规划
- 背包问题
- 状态压缩动态规划
- 树形动态规划
以下举例几个基本的问题来分析动态规划的处理过程:
- 线性动态规划:孩子过桥问题
题目:在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少?
在考虑该题的解决方案时,很容易采用贪心算法来实现,直接找到小朋友里面过桥时间最短的一位,每次让他护送一个人过桥,然后在将手电筒送回来,但是这么考虑存在问题,并不能达到最优的结果。例如:有四个小朋友过桥时间分别为 1 2 8 10
如果采用贪心算法得到的结果为:(2 + 1) + (8 + 1) + (10) = 22
但是实际最优的结果为:第一次1 2过桥花费时间为2 ,之后1 返回花费时间1
第二次8 10过桥花费时间为10,之后2返回花费时间2
第三次1 2过桥花费时间为2,得到最后的结果为:2 + 1 + 10 + 2 + 2 = 17
由于两个人过桥时间是按照过桥时间较大的人来决定的,如果使用贪心算法每次让过河时间最短的小朋友带上另外一个小朋友过河,然后再让这个过河时间最短的小朋友返回送手电,由于两人过河时间是由过河时间长的那位小朋友决定的,这样处理势必存在的问题是需要遍历每一个小朋友的过桥时间,从而造成过河花费时间较长。
那么此题应该如何用动态规划的思想考虑才能达到最短的过桥时间呢?
我们需要先将所有人按照过桥时间进行递增排序,得到的数组为T1[],设定前i个人过桥时间最少为dp[i], 考虑前i-1个人过河的情况,也就是只有一个人还没有过河,其他全都过河。要想在最短的时间让最后一个人过河,只需要让过河时间最短的人把手电送过来,然后和最后一个人一块过河即可。得到状态转移方程为:dp[i] = dp[i - 1] + T1[1] + T1[i];
如果还有两个人没有过河,那么需要先让过河时间最短的人将手电送回来,让后将这两人一起送过河,之后再派过河时间最短的人过来送手电,最后这两人一块过河即可。得到状态转移方程为:dp[i] = dp[i - 2] + T1[1] + T1[i] + T1[2] + T1[2];
所以,最后得到的过河最短时间为:dp[i] = min(dp[i -1] + T1[1] + T1[i], dp[i - 2] + T1[1] + T1[i] + 2 * T1[2])
- 最大子段和 | 最大子矩阵和
题目1:给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
针对如何解决该类型动态规划题,首先需要分析其状态变化情况。当我们从头遍历该数组时,对于数组中的一个整数num[i],它存在两种选择,第一种是先前的子数组的和大于0,那么直接加上num[i];另一种是先前的子数组的和小于0,说明其对后续的结果是有害的,那么需要中断与前一子数组的联系,以nums[i]为起始点来计算一个新的子序列和;根据上面的描述,设置状态为dp[i],表示以nums[i]结尾的最大连续子序列,进而得到状态转移方程为:
dp[i] = max(dp[i-1], 0) + nums[i];
设置初始状态为dp[0] = nums[0],计算dp[nums.length - 1]则为整个数组的最大子序列和
下面给出实现程序:
class Solution {
public int maxSubArray(int[] nums) {
if(nums.length == 0)
return 0;
if(nums.length == 1)
return nums[0];
int n = nums.length;
int max = nums[0];
int[] dp = new int[n];
for(int i = 1; i < n; i++){
dp[i] = Math.max(dp[i - 1], 0) + nums[i];
max = Math.max(dp[i], max);
}
return max;
}
}
题目2:给定一个矩阵,都是整数,其中(n≤500),求出其中的最大子矩阵,并返回其最大子矩阵的和;
计算最大子矩阵的和实质上就是计算二维方向的最大子序列和,以矩阵的第一行为例开始计算:首先将二位矩阵的第一行看作为一个序列,计算该序列的最大子序列和,然后再把下一行与其对应的序列值加到第一行的数据中,如果相比之前的第一行的最大子序列和要大,则保留;反之,则从第二行重复上述步骤,循坏直到最后一行结束。
实现程序如下:
class Solution{
public int getMatrixMax(int [][] a) {
if(a.length == 0 || a[0].length == 0)
return 0;
int n = a.length;
int max = Integer.MIN_VALUE;
for(int i = 0; i < n; i++) {
int[] temp = a[i];
max = Math.max(max, getMax(temp));
for(int j = i+1; j < n; j++) {
for(int k = 0; k < n; k++) {
temp[j] += a[j][k];
}
max = Math.max(max, getMax(temp));
}
}
return max;
}
public int getMax(int[] a) {
int n = a.length;
int[] dp = new int[n];
dp[0] = a[0];
int max = nums[0];
for(int i = 1; i < n; i++) {
dp[i] = Math.max(dp[i-1], 0) + a[i];
max = Math.max(dp[i], max);
}
return max;
}
}
- 最大上升子序列(LIS) :导弹拦截问题
题目:给定一个无序的整数数组,找到其中最长上升子序列的长度。
分析:利用动态规划解题,最重要的是定义状态和找出状态之间的联系。首先从状态扩展方面来看,对于数组中的一个元素,其往后走时只要遇到比它大的元素都可以作为下一步,所以无法找到突破口。
我们换一个角度,以结果来入手,由于题目给定要求是寻找最长递增子序列,凡是一个子序列都存在首尾两个端点,假设我们定义dp[i]为以第i个元素为起点的最长递增子序列,那么dp[i]和dp[j]之间并没有必然联系,得出该状态不好用;假设定义dp[i]为以第i个元素为终点的最长上升子序列,如果i < j 并且nums[i] < nums[j] ,则dp[i] 必定是dp[j]的一部分,进而发现这种状态能够表示子问题间的关系。
现在正式开始定义,我们定义状态 dp[i] 为第 i 个元素为终点的最长递增子序列的长度,那么状态转移方程是:
dp[i] = max(dp[j], 0 <= j < i && nums[j] < nums[i]) + 1
实现程序如下:
class Solution {
//设置j为上一状态的终点,那么如果nums[j] < nums[i], j < i,下一状态则可通过上一状态得到:
//dp[i] = max{dp[j], j < i & nums[j] < nums[i]} + 1
public int lengthOfLIS(int[] nums) {
if(nums.length == 0)
return 0;
if(nums.length == 1)
return 1;
int[] dp = new int[nums.length];
int num_max = 1;
for(int i = 0; i < nums.length; i++){
dp[i] = 1;
for(int j = 0; j < i; j++){
if(nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
num_max = Math.max(dp[i], num_max);
}
return num_max;
}
}
- 最长公共子序列(LCS)
题目:给出两个字符串,求出这样的一个最长的公共子序列的长度:子序列中的每个字符都能在两个原串中找到, 而且每个字符的先后顺序和原串中的先后顺序一致。
分析:设定dp[i][j]为一个序列以i为终点的子序列与另一个序列以j终点的子序列的最长公共子序列。如果nums1[i] == nums2[j],那么可以明确递推关系是:dp[i][j] = dp[i -1][j - 1] + 1; 如果nums1[i] ≠ nums2[j],得到状态方程为:dp[i][j] = max(dp[i- 1][j], dp[i][j- 1]),联合这两种情况,得到最长公共子序列的状态方程为:
实现程序如下:
class Solution{
public int getLCS(int[] nums1,int[] nums2){
int n = nums1.length;
int m = nums2.length;
int[][] dp = new int[n][m];
dp[0][0] = (nums1[0] == nums2[0] ? 1 : 0);
dp[1][0] = (nums1[0] == nums2[0] || nums1[1] == nums2[0] ? 1 : 0);
dp[0][1] = (nums1[0] == nums2[1] || nums1[0] == nums2[0] ? 1 : 0);
for(int i = 1; i < n; i++){
for(int j = 1;j < m; j++){
//字符相同
if(nums1[i] == nums2[j])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[n - 1][m - 1];
}
}
- 区间动态规划:
区间动态规划实则是求一个区间中的最优解。通过将一个大的区间分为很多个小的区间,求其小区间的解,然后一个一个的组合成一个大的区间而得出最终解。通常利用动态规划解题思路如下:
1、区间状态表达:利用dp[i][j]表示区间(i, j)中的最优解;
2、列出状态转移方程:一般的通用公式为dp[i][j] = max/min(dp[i][j], dp[i, k] + dp[k + 1, j] + extra_math),可以将该状态转移方程理解为:区间(i,j)的最优解就等于区间(i,j)同(i,k)和区间(k+1,j)合并后的值,并比较谁的值更优。
3、初始化状态:dp[i][i] = 0
区间动态规划常用的区间合并方式:
//第一种合并方式:
for(r = 2; r <= n; r++)//r:区间右端点,l:区间左端点
for(l = 1; l < r; l++)
for(k = l; k < r; k++)
dp[l][r] = max/min(dp[l][r],dp[l][k]+dp[k+1][r]+something);
//第二种区间合并方式:
for (int len = 1;len < n; len++)//len:长度 ,l:左区间断点,r:又区间端点
for (int l = 0; l + len < n; l++) //保证(l,r)的长度为len,且不越界
{
int r=l + len;
for (int k = l; k <= r; k++)
dp[l][r]=max/min(dp[l][r],dp[l][k]+dp[k+1][r]+something)
}
题目:摆放N堆石子,现要将石子有次序地合并成一堆.规定每次只能选相邻的2堆合并成新的一堆,并将新的一堆的石子数,记为该次合并的得分,请问如何合并石子堆才能得分最小,并求出最小得分。
分析:由题易可直接获取状态方程为:
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + num[i][j])
实现程序如下:
class Solution{
public int minCost(int[] a) {
if(a.length == 0)
return 0;
if(a.length == 1)
return a[0];
if(a.length == 2)
return a[1] + a[0];
int n = a.length;
int[][] dp = new int [n][n];
int[] sum = new int [n];
sum[0] = a[0];
for(int i = 1; i < n; i++) {
sum[i] = sum [i - 1] + a[i];
}
for(int len = 1; len < n; len++) {
for(int i = 0; i < n - len; i++) {
int j = i + len;
//dp[i][j] = Integer.MAX_VALUE;
dp[i][j] = Integer.MIN_VALUE;
int sum1 = sum[j] - (i > 0 ? sum[i - 1] : 0);
for(int k = i; k < j; k++) {
//dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum1);
dp[i][j] = Math.max(dp[i][j], dp[i][k] + dp[k + 1][j] + sum1);
}
}
}
return dp[0][n-1];
}
}
- 树型动态规划
树型DP主要分为两个方向:根节点转向叶子节点,但方法实际运用较少;叶子节点转向根节点,将根的子节点传递有用信息给根,然后根得出最优解的过程,一般的树形动态规划题采用此种方式实现。
树型动态规划的实现步骤与通常类型的动态规划过程相同:
确立状态:几乎所有的问题都要保存以某结点为根的子树的情况。
状态转换方程:状态转移的变化比较多。
实现方式:记忆化搜索和递推 。
例题:有一棵苹果树,如果树枝有分叉,一定是分2叉(就是说没有只有1个儿子的结点),这棵树共有N个结点(叶子点或者树枝分叉点),编号为1-N,树根编号一定是1。我们用一根树枝两端连接的结点的编号来描述一根树枝的位置。下面是一颗有4个树枝的树
2 5
\ /
3 4
\ /
1
现在这颗树枝条太多了,需要剪枝。但是一些树枝上长有苹果。给定需要保留的树枝数量,求出最多能留住多少苹果。
分析:由于该题的权值设置在边上,不太好处理。所以先将问题转化为选择M + 1个节点,并且节点必须要包括根节点,使该树中能留住尽量多的苹果。首先定义状态,设置dp[i][j]为以i为根的子树上保留j个节点时的最大苹果树,那么该题需要求的就是以1为根节点,选择M+1个节点获得的最大苹果树:dp[1][M+1]。在明确问题的状态表达后,接下来是如何获得状态转移方程。由于该树是二叉树,根据其根节点的左右子节点,可以得到递推关系:
dp[i][j] = max(dp[i][j], dp[j.left][k] + dp[j.right][j - k - 1] + a[i]},其中0 < k < j )
- 01背包问题
例题:给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 wi,其价值为 vi 。那么应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?
分析:设置状态为dp[i][j],表示在当前背包容量为j时,放入前i个物品后得到的最大价值。可以寻找发现状态转移方程为:
dp[i][j] = max(dp[i - 1][j] , dp[i][j - w[i]] + v[i]) ,dp[i - 1][j]表示无法放入第i个物品所得到的最大价值, dp[i][j - w[i]] + v[i]表示能够放入第i个物品所得到的最大价值。
实现程序如下:
class Solution{
void FindMax(int[] V, int[] w){
int n = V.length;
int m = w.length;
int[][] dp = new int[n+1][m+1];
for(int i = 1;i <= n; i++){
for(int j = 1; j <= C; j++){
if(j < w[i])
dp[i][j] = dp[i-1][j];
else
dp[i][j] = Math.max(dp[i -1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
return dp[n][m];
}
}