动态规划是一种算法思想,刚入门的时候可能感觉十分难以掌握,总是会有看了题不知道怎么做,但是一看答案就恍然大悟的感觉。结合这一段时间的学习,在这里做一下总结。
解题思路
在解题的过程中,首先可以主动寻找递推关系,比如对当前数组进行逐步拉伸,看新的元素和已有结果是否存在某种关系。
对于没有思路的题目,求解可以分为暴力递归(回溯),记忆性搜索,递归优化,时间或空间最终优化四个阶段。
在碰到一道可以使用动态规划的题目的时候,如果还不知道怎么下手,那么第一步,一定要去想如何递归求解。
所谓递归求解,说的简单点,就是一种穷举,文艺一点,也可以叫回溯。是的,在学习动态规划之前,一定要对回溯法有所了解。
backtracking(member){
//如果已经不可能再得到结果,直接返回。也叫剪枝,分支限界。
if(is_invalid) return;
//如果得到最终结果,处理显示。
if(is_solution) print_result();
//递归即将进入下一层级,如果有数据在下一层级中需要使用,更新它们。
move_ahead();
//准备要进入递归的元素。
candidate[] candidates = get_candidates;
for(candidate in candidates){
backtracking(candidate)
}
//递归回到当前层级,将数据更新回当前层级所需数据。
move_back();
}
上面就是回溯法的基本模板,看清来可能有点模糊,下面的第一道题目的第一个步骤,就将对此作出详细解释。
题目1
给一个非负数组,你一开始处在数组收尾(index=0),数组中元素代表你能从当前位置向后跳的**最大**步数,问能否达到数组末尾。比如:
A = [2,3,1,1,4], return true.
A = [3,2,1,0,4], return false.
递归求解
最为直观的回溯法求解如下:
思路十分直观,当我们到了每个位置,在此位置上,可以向后跳1到最大步数,在每一跳之后进行递归,依次类推穷举出所有情况,一旦有一种可以到达最终位置,那么我们就可以得到最终结果。
public class Solution {
public boolean canJumpFromPosition(int position, int[] nums) {
if (position == nums.length - 1) {
return true;
}
int furthestJump = Math.min(position + nums[position], nums.length - 1);
for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) {
if (canJumpFromPosition(nextPosition, nums)) {
return true;
}
}
return false;
}
public boolean canJump(int[] nums) {
return canJumpFromPosition(0, nums);
}
}
首先先进行一下简单的优化,在每一步判断下一跳位置的时候,为了尽快的到达最后的位置,我们很明显应该尽可能多走步数,一旦发现最后无法到达再减少步数。
// 原始代码
for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++)
// 新的代码
for (int nextPosition = furthestJump; nextPosition > position; nextPosition--)
记忆化搜索(自顶向下动态规划)
可以看到,上面的递归基本就是暴力解法,那么进一步的优化,就是在递归上面应用存储,已经计算过的分支不再继续进行计算。
public class Solution {
Index[] memo;
public boolean canJumpFromPosition(int position, int[] nums) {
//存储已经计算过的分支
if (memo[position] != Index.UNKNOWN) {
return memo[position] == Index.GOOD ? true : false;
}
int furthestJump = Math.min(position + nums[position], nums.length - 1);
for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) {
if (canJumpFromPosition(nextPosition, nums)) {
memo[position] = Index.GOOD;
return true;
}
}
memo[position] = Index.BAD;
return false;
}
public boolean canJump(int[] nums) {
memo = new Index[nums.length];
for (int i = 0; i < memo.length; i++) {
memo[i] = Index.UNKNOWN;
}
memo[memo.length - 1] = Index.GOOD;
return canJumpFromPosition(0, nums);
}
}
去递归(自底向上动态规划)
去递归的过程,其实就是人为的分析并指定计算过程的过程。
首先分析递归过程中的可变参数,这个可变参数就是循环中遍历的变量。这里很明显是当前位置 position。
然后需要分析递归的运行顺序,这里可以人为画递归树。我们可以发现,运算实质是从右向左进行的。一个点能否达到某一个点,取决于它右边点的运算结果。
enum Index {
GOOD, BAD, UNKNOWN
}
public class Solution {
public boolean canJump(int[] nums) {
Index[] memo = new Index[nums.length];
for (int i = 0; i < memo.length; i++) {
memo[i] = Index.UNKNOWN;
}
memo[memo.length - 1] = Index.GOOD;
for (int i = nums.length - 2; i >= 0; i--) {
int furthestJump = Math.min(i + nums[i], nums.length - 1);
//去当前点的右边看是否有可达点。
for (int j = i + 1; j <= furthestJump; j++) {
if (memo[j] == Index.GOOD) {
memo[i] = Index.GOOD;
break;
}
}
}
return memo[0] == Index.GOOD;
}
}
贪心优化(贪心策略)
上面的时间复杂度为O(mn),m是数组中最大值,n是数组个数。在分析上面循环的过程中,我们发现找到的第一个点可以到达一个可达点,那么当前位置就不需要再判断后面的步数。也就是说,一个点只要找到离他最近的可达点,那么它就变成了下一轮的可达点。下一轮一旦有一个点可以达到它,那么该点又成为下一轮新的可达点。
这也就告诉我们,对于每个点,只要找到它右边第一个可达点即可。
这也就是典型的贪心策略。
我们可以从右向左,在某个可达点左边找一个最近的点可以达到它,更新该最近点为新的可达点,以此类推,知道最后的一个可达点是起始点。
public class Solution {
public boolean canJump(int[] nums) {
int lastPos = nums.length - 1;
for (int i = nums.length - 1; i >= 0; i--) {
if (i + nums[i] >= lastPos) {
lastPos = i;
}
}
return lastPos == 0;
}
}
题目2
给一个非负数组,从数组中选取任意个数字求和,要求所选元素均不相邻,求最大和。
直接寻找递归关系
对于比较简单的dp,也可以寻找递推关系求解:
这到题的递推关系在于,对于每一个新的元素,都可以选择取或者不取,用一个数组dp记录前面不同长度数组的最大和,那么对当前元素dp[i],如果不取则最大和为dp[i-1],如果取则最大值为dp[i-2]+num[i];可以很轻易的根据递推关系写出动态规划:
public class Solution {
public int rob(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length+1];
dp[0] = 0;
dp[1] = nums[0];
for(int i = 2;i<nums.length;i++){
dp[i] = Math.max(dp[i-1],nums[i]+dp[i-2]);
}
return dp[nums.length];
}
同经典的钢条切割背包问题一样, 对于一个新出现的元素,选与不选是构成递归的重要策略。比如leetcode两道题目 :https://leetcode.com/problems/house-robber/,
https://leetcode.com/problems/house-robber-ii/,
都是对于一个新出现的元素,进行选与不选两种决策去寻找递推关系,动态规划可能的O(N)解法基本也只会出现在这种决策中。
空间优化
到这里还不算完,我们看见,对于每个dp[i]的计算,仅和dp[i-1],dp[i-2]有关,这也告诉我们根本不需要一个数组,因为以前用过的值在后面不会再使用。这样,仅仅使用两个变量就可以达到效果,空间复杂度也从O(N)降到了O(1)。
public class Solution {
public int rob(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int a =0,b = nums[0];
for(int i=1;i<nums.length;i++){
int temp = b;
b = Math.max(b,a+nums[i]);
a = temp;
}
return b;
}
}
题目3
一个二维非负数组,找出从最左上到最右下的最小距离,只可以向右或者向下移动。
直接寻找递推关系
这道题基本是二维中最简单的了,直接看到某一点(i,j)的最短距离怎么求就可以。用二维数组记录到每个点的最短距离dp[i][j],可以直接根据递推关系 dp[i][j] = min{dp[i-1][j],dp[i][j-1]}就可以求解。
二维空间优化
一维动态规划可以通过空间优化达到常数级别的空间复杂度,同样二维动态规划也可以进一步优化。
首先,根据递归关系,我们发现每个位置只和上面i-1和左边j-1的值有关,于是可以采用数组滚动的方法。
在计算第i行的时候,只存储第i-1行的最短距离,比如计算(i,j)点,数组中dp[j]到右边的元素是二维表中(i-1,j)右边的元素。而数组中 dp[j-1]以及其左边的元素,是 二维表中 (i,j-1)及其左边的元素。
其实,就是计算将第i行计算过的结果存在数组前半部分,而后半部分是之前计算上一行存储的最短距离,用于以后计算使用。相当于通过滚动,覆盖了不再被需要的值。
如下面的简图,其实就是把一个数组分成两半,左边存储dp[i][j-1]所要用的数据,右边是dp[i-1][j]使用的数据。
优化过的代码如下,空间复杂度降到了O(n).
public class Solution {
public int minPathSum(int[][] grid) {
//空间压缩,数组滚动方法。
int m = grid.length,n = grid[0].length;
int[] dp = new int[n];
dp[0] = grid[0][0];
for(int i=1;i<n;i++)
dp[i] =dp[i-1] + grid[0][i];
for(int i=1;i<m;i++)
for(int j=0;j<n;j++)
dp[j] = (j>0?Math.min(dp[j - 1],dp[j]):dp[j]) + grid[i][j];
return dp[n-1];
}
}
题目4、5:
这两道题目是一维的动态规划,对于一维的动态规划很难从基本的暴力解法逐步推导过去,更多的是寻找递推关系,类似于钢条切割问题。个人还是比较头疼的。
第一个题目:
地址:https://leetcode.com/problems/maximum-subarray/
题目是在一个数组中,寻找连续的数,获得最大和。
比如:[-2,1,-3,4,-1,2,1,-5,4]数组,最大和是子数组[4,-1,2,1]为6。
一维动态规划,寻找递推关系。为了表明是dp问题,设置一个数组,dp[i]表示包含nums[i]的子数组的最大和。从左到右遍历数组,每新添一个数时,计算dp[i],可以知道新添的数要么和前面最大和子数组累加,得到dp[i]+nums[i],要么自己作为一个新的子数组的唯一元素,和是nums[i],则有递推关系 dp[i] = max(nums[i],dp[i-1] * nums[i])。
注意,dp[i]是包含第i个元素的局部最优解,全局最优解每次获得局部最优解比较一下就行。
代码如下:
public class Solution {
//空间可以被优化
public int maxSubArray(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int[] dp = new int[nums.length];
int r = nums[0];
dp[0] = nums[0];
for(int i=1;i<nums.length;i++){
int n = nums[i];
dp[i] = Math.max(n,dp[i-1]+n);
r = Math.max(dp[i],r);
}
return r;
}
}
第二个题目类似,只不过是乘法最大值。乘法就是要跟踪一下局部的最大值和最小值即可,因为乘法最小值乘以负数也可能出现最大值。代码如下:
public class Solution {
public int maxProduct(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int[] max = new int[nums.length];
int[] min = new int[nums.length];
int r = nums[0];
max[0] = r;
min[0] = r;
for (int i = 1;i<nums.length;i++) {
int n = nums[i];
int a = max[i - 1] * n;
int b = min[i - 1] * n;
max[i] = Math.max(n, Math.max(a,b));
min[i] = Math.min(n, Math.min(a, b));
r = Math.max(max[i], r);
}
return r;
}
}
很明显,两个题目都可以优化成O(1)空间,这里为了表示明显不进行优化,读者可以自己尝试一下。