动态规划
最优子结构:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。即一个问题的最优解包含其子问题的最优解。
独立求解的理解:如算法导论P218中所说,要保证分解后得到的子问题之间可以独立求解,也即同一分解中不同子问题所用资源之间没有交集
动态规划与分治
动态规划与分治法很相似,都是通过组合子问题的解来解原问题,但是分治法的子问题之间是不相交的,没有公共子子问题,比如归并排序,每一次分割数组的时候,子数组都是不想交的;而动态规划在分解子问题的时候,不同的子问题拥有公共的子子问题,比如爬楼梯问题,将n规模的问题分解为了n-1规模和n-2规模的问题,而n-1规模的子问题中包含n-2规模的问题的解。
动态规划与递归
由于动态规划分解得到的子问题之间含有公共的子子问题,所以如果只是普通的递归处理的话,会进行很多重复的计算,时间复杂度会成指数级增长,动态规划在求解的时候,会保存每个子问题的解,这样当再次遇到该子问题的时候直接拿来使用就可,从而节省了时间。
爬楼梯问题
链接:爬楼梯
经典的爬楼梯问题:由于一次只能上一个台阶或者两个台阶,所以对于N阶楼梯的问题可以分为两个子问题,“(N - 2) + 2”或者“(N - 1) + 1”两中划分;而显然对于(N - 1)的子问题中,包含(N - 2)这个子问题的解。所以如果使用分治法的话,会产生大量的重复计算:
分治法:
public int climbStairs(int n) {
if (n == 1)
return 1;
if (n == 2)
return 2;
return climbStairs(n - 1) + climbStairs(n - 2);
}
对n = 36的情况进行测试,分治法所需的时间为70ms
动态规划:
public int climbStairs(int n) {
int[] results = new int[n + 1];
results[1] = 1;
results[0] = 1;
for (int i = 2; i <= n; i ++) {
results[i] = results[i - 1] + results[i - 2];
}
return results[n];
}
对n = 36的情况进行测试,动态规划所需的时间为0ms
上面采取的动态规划的策略是:自底向上。也就是,当求解一个问题的时候,其所有子问题、子子问题等都已经被求出、保存了下来,这样才能够当再次遇到该子问题的时候直接使用。
上面的递归法效率低下的问题在于,对于子问题存在重复计算的情况,如果我们也能够保存遇到过的子问题的解,当我们分解问题的时候可以查看该子问题对应的解是否已经有了,如果有的话,我们直接使用,而不是不管不顾地一直递归下去,这就引出了动态规划另一策略:自顶向下。我们在递归的代码上修改一下:
class Solution {
int[] results;
public int climbStairs(int n) {
results = new int[n + 1];
results[0] = 1;
results[1] = 1;
return r(n);
}
// 方法三:动态规划自顶向下
public int r(int n) {
if (results[n] != 0)
return results[n];
results[n] = r(n - 1) + r(n - 2);
return results[n];
}
}
自顶向下和自底向上的区别在于,在求解问题的时候其子问题的解不一定已知,但是我们对同一个子问题只会求一次;而自底向上法中,我们先求的是子问题的解,所以当我们求解规模更大的问题的时候,子问题一定已知了。
最小代价的爬楼梯问题
链接:使用最小花费爬楼梯
我们来通过这个问题来学习一下最优子结构的分析。
对于N介台阶的问题,我们设情况A为最后一步刚好到第N阶,情况B为倒数第二步到N-1阶之后直接一次迈两步(由于直接登顶,所以这次的代价为0)到达第N阶。
照这么分析,那么递推关系式为P(N) = min{P(N-1), P(N-2)+cost[N]},不同于之前那个朴素的爬楼梯问题,这里的P(N)被分成了两种情况A和B,也就是说每一个P(N)、P(N-1)...等都是两种情况中的较优的那一个,这看起来没什么问题,但却有一个漏洞,那就是P(N)隐含的意义是最终你的脚必须踩到第N阶上,但是根据题意,我们可以一次迈两步,也就是情况A下我们最终没有踩到第N阶上,而是就在第N-1阶上,这样一来就产生了逻辑错误,因为P(N)有可能等于P(N - 1)问题的规模减小了,但是代价却一样?这显然违背逻辑,因此便失败了:
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] results = new int[cost.length];
results[0] = cost[0];
results[1] = cost[1];
for (int i = 2; i < results.length; i ++) {
results[i] = Math.min(results[i - 1], results[i - 2] + cost[i]);
}
return results[results.length - 1];
}
}
上面的代码就是根据其思路来的,但是是错的。比如cost为[10, 15, 20]显然最小代价为15,但是上面的代码算得的是10,原因在于对于子问题cost[10, 15]其最优解为"10",即情况B,但是原问题的最优解是"15"即是子问题cost[10, 15]的情况A。所以这么分解的话,原问题的最优解不能用子问题的最优解合成,动态规划便失效了。
正确的分解
上面的错误的问题分解在于原问题的最优解不能够用子问题的最优解合成;而如果我们不去一步到位,我们先只考虑情况A,即每一个问题的最后一步一定恰好在N、N-1、N-2...:
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] results = new int[cost.length];
results[0] = cost[0];
results[1] = cost[1];
for (int i = 2; i < results.length; i ++) {
results[i] = Math.min(results[i - 1] + cost[i], results[i - 2] + cost[i]);
}
}
}
这样的话,results数组中就保存了所有情况A的最小代价,那么原问题的最优解不光有情况A,还有情况B,我们还要思考情况B呢!仔细分析原问题发现情况B即为N-1下的情况A,所以完整的代码是:
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] results = new int[cost.length];
results[0] = cost[0];
results[1] = cost[1];
for (int i = 2; i < results.length; i ++) {
results[i] = Math.min(results[i - 1] + cost[i], results[i - 2] + cost[i]);
}
return Math.min(results[cost.length - 1], results[cost.length - 2]);
}
}
在返回值上做了手脚。
最大子序和
链接:最大子序和
我们再来一个例子来看看子问题的分析。对于这个问题,假如我们只是简单的将P(N)看做数组从1到N段的最优解,那么对于子问题的最优解P(N-1)我们便不能够得到P(N)因为此时P(N)的最优解可能会与P(N-1)有不同的元素。
同样,假如我们将P(N)看做是在包含第N个元素情况下的解,那么P(N)与P(N-1)之间的关系就能够确认为P(N) = max{P(N-1) + nums[N], nums[N]}。这样的话,原问题的解便为从1到N的所有P的最大的一个:
class Solution {
public int maxSubArray(int[] nums) {
int[] results = new int[nums.length];
if (nums.length == 0)
return 0;
results[0] = nums[0];
int max = results[0];
for (int i = 1; i < nums.length; i ++) {
if(results[i - 1] >= 0)
results[i] = nums[i] + results[i - 1];
else
results[i] = nums[i];
max = Math.max(results[i], max);
}
return max;
}
}
实战演练
动态规划的重点和难点在于对子问题的定义,有些题目中,题干所问并不能直接作为子问题的定义或者很难去将它进行分解。
1. 动态规划与数组
对于动态规划背景下的数组的某些问题,我们通常是将数组的从第一个元素开始的、连续的子数组作为子问题规模的指标,即通常是dp[i]代表数组从1到i这个子数组
例1:最大子序和该题目中,我们将dp[i]定义为从数组的第1到第i这个连续子数组的最大和(PS:最大和必须包含数组的第i个元素)。这样一来,状态转移方程为
dp[i] = max{dp[i - 1] + nums[i], nums[i]}
这样一来,问题的答案就是max{dp[i]}
例2:乘积最大子序列该题目中,我们依旧将dp[i]定义为[0...i]这个连续的子数组的最大乘积的话,我们把子问题的解向后应用的时候就会发生问题,如果nums[i]是一个负数的话,那么dp[i - 1]如果是正的,那么与nums[i]相乘会变为最小的,此时如果我们把状态转移方程依然写成:
dp[i] = max{dp[i - 1] + nums[i], nums[i]}
那么以[2, -2, -3]为例,dp[3]理应为12,但由于dp[2] = -2,此时dp[3]成为了6.那么宣告我们dp[i]的定义失败了。
本例题的核心是正负数的变化,一个负数再乘上一个负数之后可能变为乘积最大的了,一个原本是子问题的最大乘积,乘上一个负数之后可能变成了乘积最小的了。正是这种特点,我们需要将最大和最小都保存起来,因为他们的地位可能会发生多次互换
if (a < 0) {
min[i] = max[i - 1] == 0 ? a : max[i - 1] * a;
max[i] = min[i - 1] * a;
} else if (a > 0){
min[i] = a * min[i - 1];
max[i] = max[i - 1] == 0 ? a : max[i - 1] * a;
} else {
max[i] = 0;
min[i] = 0;
}
这样一来,问题的答案就是max{max[i]}
感悟:同时这个例题也给了我们一个提醒,不是所有的题目都是可以一步到位的,就像这里的min[i]用来保存最小的数,起辅助作用的工具在很多题目中都可以看到。我们要敢于突破,敢于否定自己
例3:最长上升子序列这里我们依旧将dp[i]定义为子数组[i...i]中包含nums[i]元素的最长上升子序列。但此时子问题的最优解往后利用的形式发生了变化:
dp[i] = max{dp[j] + i}(j < i, nums[j] < nums[i])
这样一来,问题的答案就是max{dp[i]}
例4:最长重复子数组这里由于涉及到两个数组,所以肯定是要使用dp[i][j]这种形式了,我们尝试吧其定义为数组A中[0...i]与B中[0...j]中包含A[i]与B[j]最长公共子数组的长度,那么我们的状态转移方程就为:
dp[i][j] = dp[i - 1][j - 1] + i (A[i] == B[j])
dp[i][j] = 0 (A[i] != B[j])
那么问题的答案是max{dp[i][j]}