[动态规划]62. 63.不同路径 I II(回溯法、动态规划 + 优化)115. 不同的子序列(双序列动态规划)
62. 不同路径
分类:回溯法、动态规划
思路1:回溯法
机器人每到达一个节点都有两个选择:向下或向右。
关键问题:如何用代码表示向下移动或向右移动?
机器人起点(1,1),设移动过程中机器人位置=(i,j),向下移动=(i,j+1),向右移动=(i+1,j),当i=m,j=n时即得到一条路径。
移动过程中i,j不能超过下边界和右边界。
实现代码
class Solution {
int res;
public int uniquePaths(int m, int n) {
this.res = 0;
backtrack(m, n, 1, 1);
return res;
}
//回溯递归实现
public void backtrack(int m, int n, int i, int j){
if(i == m && j == n){
res++;
return;
}
else{
//向下移动
if(j + 1 <= n)
backtrack(m,n,i,j+1);
//向右移动
if(i + 1 <= m)
backtrack(m,n,i+1,j);
}
}
}
- 存在的问题:效率过低,运行超时。
思路2:动态规划(推荐)
构造一个二维数组dp[m][n],
dp[i][j]表示从(1,1)到达(i,j)的路径数量,其中,向下移动=(i,j+1),向右移动=(i+1,j),所以:
到达(i,j)的最大路径数量=到达(i,j-1)的路径数量+到达(i-1,j)的路径数量,
即:
dp[i][j] = dp[i][j - 1] + dp[i - 1][j]
例如:m=3,n=2(m表示列数,n表示行数)
(0,0)=1,(0,1)=1,(0,2)=1
(1,0)=1,(1,1)=2,(1,2)=3
dp数组初始化:
两条边路:从起点一直往右到右边界;从起点一直往下到下边界。到达两条边路上所有节点的路径始终只有1条,所以dp[i][0]=1,dp[0][j]=1,
最终的结果保存在dp[m-1][n-1]上。
实现代码:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
//dp数组初始化
for(int i = 0; i < m; i++) dp[i][0] = 1;
for(int i = 0; i < n; i++) dp[0][i] = 1;
//构造dp数组过程
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
}
- 空间复杂度: O(MN)
思路2的空间优化:按行处理。
观察状态转移方程可以发现,计算dp[i][j]只需要考虑dp[i-1][j]和dp[i][j-1]即可,也就是计算第i,j处的值,只需要知道当前行和上一行的结果即可,所以可以使用两个大小为n的数组pre和cur,其中pre存放上一行结果,cur存放当前行的结果,因此:
- dp[i - 1][j] 表示的是上一行的第j列,所以更换为pre[j];
- dp[i][j - 1] 表示的是当前行的第j-1列,所以更换为cur[j-1];
所以状态转移方程修改为:
cur[j] = pre[j] + cur[j-1]
最终结果就存放在cur[n-1]上。
关键问题:
1、pre和cur的更新问题:
首先,明确构造pre和cur数组的动态规划过程代码框架:两层for循环,外层遍历m行,内层对每一行都遍历n列:
for(int i = 1; i < m; i++){//i表示行序号
for(int j = 0; j < n; j++){//j表示列序号
...
}
}
接着,cur数组代表当前行,计算cur[j]只需要知道cur[j-1],所以在内层for-j循环遍历每一列时就更新cur[j];pre数组代表上一行,在计算完当前行cur的所有列后,且在进入下一行之前,就把当前行的结果复制给pre数组,使用函数clone():
//开始计算下一行
for(int i = 1; i < m; i++){//i表示行序号
for(int j = 0; j < n; j++){//j表示列序号
if(j == 0) cur[j] = 1;
else cur[j] = pre[j] + cur[j - 1];
}
//在进入下一行之前复制当前行cur给pre
pre = cur.clone();
}
2、pre、cur数组的初始化:
pre代表上一行,初始时pre表示第一行,第一行的初始化和原版动态规划dp[0][j]相同,全部赋值为1即可。
cur代表当前行,初始时cur表示第二行,第二行的初始化只需要第0列赋值为1即可,但可能出现一个特殊情况:例如m=1,n=2时,因为最终结果存放在cur[1]上,如果不对cur置1,当m=1时不会进入for循环,cur[1]初始为0,会导致错误,所以cur初始时每一列也置1:
Arrays.fill(pre, 1);
Arrays.fill(cur, 1);
实现代码
class Solution {
public int uniquePaths(int m, int n) {
int[] pre = new int[n];//存放上一行
int[] cur = new int[n];//存放当前行
//第一行全为1
Arrays.fill(pre, 1);
Arrays.fill(cur,1);//对cur也填充1,这是为了解决例如m=1,n=2的情况,最终结果存放在cur[1]上,如果不对cur置1,当m=1时不会进入for循环,cur初始为0所以导致错误。
//开始计算下一行
for(int i = 1; i < m; i++){//i表示行序号
for(int j = 0; j < n; j++){//j表示列序号
if(j == 0) cur[j] = 1;
else cur[j] = pre[j] + cur[j - 1];
}
//更新pre
pre = cur.clone();
}
return cur[n - 1];
}
}
- 空间复杂度从O(MN)降低为O(2N)
63. 不同路径 II(增加障碍物)
分类:回溯法、动态规划
第63题和第62的区别在于,本题给出了要移动的网格二维数组,同时在网格中增加了障碍物,如果遇到障碍物则说明当前路径是无效的,在此基础上返回有效的最大路径数量。
所以比起第62题需要多考虑障碍物的问题。
思路1:动态规划
动态规划的思路和第62题完全相同,不同点在于对dp数组的构造。
dp数组的初始化:
对于两条边路:右边路和下边路,如果障碍物存在于这两条边路上时,障碍物之后的节点上的值要全部置0,表示没有有效路径会经过该节点。因为边路的有效路径最多只有1条,如果边路出现障碍物,那在障碍物之后的节点都是不可达的,所以dp数组上障碍物之后的元素都需要置0;如果边路不存在障碍物,则与第62题的初始化相同,边路全部置1:
//初始化dp数组时需要考虑边路上是否有障碍物
for(int i = 0; i < n; i++){
if(obstacleGrid[0][i] == 0) dp[0][i] = 1;
else break;
}
for(int i = 0; i < m; i++){
if(obstacleGrid[i][0] == 0) dp[i][0] = 1;
else break;
}
在构造dp数组的过程中,也需要考虑出现障碍物的情况,如果(i,j)处出现障碍物,则该点的有效路径数量变为0,即dp[i][j]=0,无论他的上边、左边的dp元素数值是多少。
思路1的空间优化:(原数组用作dp数组)
可以直接把原始二维数组obstacleGrid当做dp数组.
关键问题:dp数组的初始化
先处理右边路:
如果遇到元素1,就将当前所在边路其后的所有元素置0,直到右边界,然后退出右边路的初始化过程;
如果遇到元素0就置1。
for(int i = 0; i < n; i++){
if(obstacleGrid[0][i] == 0) obstacleGrid[0][i] = 1;
else{
//如果遇到元素1,则其后所有元素都置0
while(i < n){
obstacleGrid[0][i] = 0;
i++;
}
break;
}
}
再处理下边路:(易错点)
因为此时下边路的第一个元素obs[0][0]就是右边路的第一个元素obs[0][0],此时该元素的值表示的是dp数组元素的含义,所以此时obs[0][0]=1表示有1条有效路径,=0表示没有有效路径。
在下边路的第二个元素开始,每一个元素就取它的前一个元素的值,这样一来:
- 如果当前元素值=1,说明遇到障碍物,则置当前元素值=0;
- 如果当前元素值=0,就沿用前一个元素的值,如果前一个元素值=0,说明前一个元素没有有效路径,则当前元素也自然没有有效路径,置0;如果前一个元素值=1,则下边路接下来的元素也取1,表示有一条有效路径。
for(int i = 1; i < m; i++){
if(obstacleGrid[i][0] == 1) obstacleGrid[i][0] = 0;
else obstacleGrid[i][0] = obstacleGrid[i - 1][0];
}
边路以外:遇到1就置0,遇到0就等于[i-1][j]+[i][j-1]
- 空间复杂度:降低为O(1)
实现代码
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;//行数
if(m < 1) return 0;
int n = obstacleGrid[0].length;//列数
//int[][] dp = new int[m][n];
//初始化dp数组时需要考虑边路上是否有障碍物
for(int i = 0; i < n; i++){
if(obstacleGrid[0][i] == 0) obstacleGrid[0][i] = 1;
else{
while(i < n){
obstacleGrid[0][i] = 0;
i++;
}
break;
}
}
for(int i = 1; i < m; i++){
if(obstacleGrid[i][0] == 1) obstacleGrid[i][0] = 0;
else obstacleGrid[i][0] = obstacleGrid[i - 1][0];
}
for(int i = 1; i < m; i++){
for(int j = 1; j < n; j++){
if(obstacleGrid[i][j] == 1) obstacleGrid[i][j] = 0;
else obstacleGrid[i][j] = obstacleGrid[i - 1][j] + obstacleGrid[i][j - 1];
}
}
return obstacleGrid[m - 1][n - 1];
}
}
115. 不同的子序列(双序列动态规划)
题目链接:https://leetcode-cn.com/problems/distinct-subsequences/
分类:回溯法、动态规划
这是一题很典型的回溯 -> 动态规划 的题目,可以很好地锻炼这两方面的能力。其实不难,动态规划的状态转移方程就是基于回溯法的思路而来的。
思路1:回溯法(超时)
设置两个指针ps,pt分别作为字符串s,t的工作指针:
- 如果 s[ps] 和 t[pt] 上的字符匹配,则ps++,pt++;
- 如果s[ps] 和 t[pt] 不匹配或匹配成功,可以选择跳过s[i]字符,取下一个s[ps+1]字符继续和 t[pt] 匹配,所以ps++,pt不变.(注意:字符匹配成功时也可以跳过该字符)
直到pt到达t末尾时说明匹配成功,得到一个解。
能这样做的前提是题目明确了s上的子串字符相对位置是不变的,这大大降低的难度。
- 存在的问题:效率低,超时。
实现代码:
class Solution {
int res = 0;
public int numDistinct(String s, String t) {
//预处理
if(s.length() < t.length()) return 0;
backtrack(s, 0, t, 0);
return res;
}
//回溯函数:
public void backtrack(String s, int ps, String t, int pt){
//到达t末尾
if(pt == t.length()){
res++;
return;
}
if(ps == s.length()) return;
//字符匹配成功:pt,ps同步+1
if(s.charAt(ps) == t.charAt(pt)) backtrack(s, ps + 1, t, pt + 1);
//字符匹配失败:pt不变,ps+1;(匹配成功也可以做这一步))
backtrack(s, ps + 1, t, pt);
}
}
思路2:动态规划(推荐)
思路1存在很多重复计算,考虑用辅助数组把前面计算的结果保存下来供后面的计算直接使用,因此可以使用动态规划。
状态设置:f(i,j) 表示t的前j个字符在s的前i个字符中的子序列中出现的个数,如下表格(状态设置之后举例画表格是很好的找状态转移方程的方法):
s\t "" b a g
"" 1 0 0 0
b 1 1 0 0
a 1 1 1 0
b 1 2 1 0
g 1 2 1 1
b 1 3 1 1
a 1 3 4 1
g 1 3 4 5
状态转移方程:
当s.charAt(i) == t.charAt(j) 时,f(i,j) = f(i-1,j-1)+f(i-1,j),
- 因为当si和tj匹配时,可以选择把这个字符考虑在内i+1,j+1继续匹配,也可以跳过该字符i+1而j不动。其中,f(i-1,j-1)表示t的前j-1个字符在s的前i-1个字符中出现的次数,f(i-1,j)表示t的前j个字符在s的前i-1个字符中出现的次数。
例如:s=“abcc”,t=“abc”,i=3,j=2时,s[3] == ‘c’ == t[2],当前字符可以匹配成功,但是有两种选择:- 一种是把s[i]归为最终结果子串里的字符,认为它和t[j]相匹配,而字符串剩余部分的匹配就变为需要在s="abc"中寻找t="ab"出现的次数,即dp[i-1][j-1];
- 另一种是虽然两字符相等,但不取s[i]加入最终结果子串,所以字符串剩余部分的匹配变为在s="abc"中寻找t="abc"出现的次数,即dp[i-1][j]。
两字符匹配时,这两种选择都可以考虑在内,所以dp[i][j]=dp[i-1][j-1]+dp[i-1][j]
当s.charAt(i) != t.charAt(j) 时,f(i,j) = f(i-1,j),
- s[i],t[j]不相等时,只有一种选择就是跳过s的第i个字符取i+1,而j不动,相当于s[i]==t[j]情况下的第二种选择。
例如:s = “abcd”,t = “abc” ,i = 3,j = 2
s[i]=‘d’ != t[j],所以第i个字符的加入不会增加t[0~j]在s[0~i-1]中的出现次数,所以沿用t的前j个字符在s的前i-1个字符中出现的次数。
dp数组:
- 大小设置为[s.lenght+1][t.length+1],+1的目的是为了让dp[0][j]表示s="",dp[i][0]表示t=""的情况。
- 初始化:dp[i][0]表示t="“在s的出现次数,因为只在s=”“是出现一次,所以dp[i][0]=1
dp[0][j]表示t在s=”"的出现次数,除了dp[0][0]=1以外,其他都=0. - 最终返回值:dp[s.length][t.length]
实现代码:
class Solution {
public int numDistinct(String s, String t) {
//预处理
if(s.length() < t.length()) return 0;
int[][] dp = new int[s.length() + 1][t.length() + 1];
//dp数组初始化
for(int i = 0; i < dp.length; i++) dp[i][0] = 1;
//构造dp数组过程
for(int i = 1; i < dp.length; i++){
for(int j = 1; j < dp[i].length; j++){
if(s.charAt(i - 1) == t.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
else dp[i][j] = dp[i - 1][j];
}
}
return dp[s.length()][t.length()];
}
}
120. 三角形最小路径和(滚动数组优化)
题目链接:https://leetcode-cn.com/problems/triangle/
分类:回溯法、动态规划(滚动数组做空间优化)
思路1:动态规划 O(N^2)
这题很明显可以用回溯法求解,因此也可以考虑用动态规划优化回溯法。
状态设置:f(i,j)表示从(0,0)到(i,j)的最小路径和
每一行都满足j<=i,
- 如果j == 0或j == i,即当前列是这一行的首尾列时,f(i,j)=f(i-1,j)+triangle[i][j](第0列) 或f(i-1,j-1)+triangle[i][j] (最后一列,其中j-1是第i-1行的最后一列)
- 如果j>0 && j < i,即当前列是这一行的中间列时,f(i,j)=min{f(i-1,j)+triangle[i][j] , f(i-1,j-1) + triangle[i][j]}
dp数组:
- 大小设置:dp[triangle.length][triangle[triangle.length-1].length],因为双重列表triangle是三角形,所以构造的二维数组行数 = triangle的行数,列数 = 三角形底边的列数。
- 初始化:dp[0][0]=triangle[0][0]
- 最终返回值:设置一个min,当行序号==triangle.length-1时,说明到达最后一行,每得到一个路径和就拿来和min对比,取其中的较小值,最后返回min即可。
实现代码:
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
if(triangle == null || triangle.size() == 0 || triangle.get(triangle.size() - 1).size() == 0) return 0;
int rows = triangle.size(), cols = triangle.get(rows - 1).size();//获取triangle的行数、列数
int[][] dp = new int[rows][cols];
dp[0][0] = triangle.get(0).get(0);
int min = dp[0][0];//min初始化为dp[0][0]是为了处理用例只有一行一列,即一个元素的情况,此时的min=这个元素本身。
//构造dp数组
for(int i = 1; i < rows; i++){
for(int j = 0; j < triangle.get(i).size(); j++){
//第0列时
if(j == 0){
dp[i][j] = dp[i - 1][j] + triangle.get(i).get(j);
//当i==rows-1时,说明这是最后一行,取dp数组第0列元素作为min基准
if(i == rows - 1) min = dp[i][j];
}
//最后一列时
else if(j == triangle.get(i).size() - 1) dp[i][j] = dp[i - 1][j - 1] + triangle.get(i).get(j);
//中间列时
else{
dp[i][j] = Math.min(dp[i - 1][j] + triangle.get(i).get(j), dp[i - 1][j - 1] + triangle.get(i).get(j));
}
//如果已经到了最后一行,则在最后一轮for循环中找出这一行的min作为最终结果
if(i == rows - 1){
min = Math.min(min, dp[i][j]);
}
}
}
return min;
}
}
思路2:动态规划 + 空间优化 O(N)
观察状态转移方程可以发现:使用滚动数组可以对思路1的二维dp数组进行优化。
因为f(i,j)状态下只和f(i-1,j),f(i-1,j-1)有关,所以把dp数组换成一维数组,大小=cols。
一维数组在动态规划中如何滚动?一维数组 + 2个变量(!!!)
在计算第 i 行时,dp数组保存的是第i - 1行的结果,且每求出第j列的结果,就会覆盖掉dp数组原j下标处的元素。(这部分最好画图举例结合下面的分析来理解)
具体为:在计算f(i,j-1)后就会覆盖掉f(i-1,j-1),计算f(i,j)后就会覆盖掉f(i-1,j),所以要用两个变量temp1,temp2分别备份f(i-1,j-1)和f(i-1,j),此后j++,在计算f(i,j+1)时还会用到f(i-1,j),将此时的f(i,j+1)看做f(i,j),则f(i-1,j)也相应看做f(i-1,j-1),由此可见随着j++,要不断拿temp2的值更新temp1,而temp2则更新为dp[j+1],为的是在dp[j+1]被覆盖之前先把这一位上的值备份下来。
同时,观察到:
-
当j==0时,计算f(i,j)只使用到了f(i-1,j),dp[j]被覆盖前就表示f(i-1,j),被覆盖后表示f(i,j),所以dp[0]的更新不需要用到temp,因此也不需要更新temp1、temp2;
-
当j>0时,才每求得一个dp[j],就更新temp1=temp2、temp2=dp[j+1]。
-
取dp[j+1]之前记得做越界判断。(易遗漏)
实现代码:
除了dp数组的不同和两个变量的引入,其他代码框架和思路1相同。
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
if(triangle == null || triangle.size() == 0 || triangle.get(triangle.size() - 1).size() == 0) return 0;
int rows = triangle.size(), cols = triangle.get(rows - 1).size();//获取triangle的行数、列数
int[] dp = new int[cols];//一维dp数组按行滚动
dp[0] = triangle.get(0).get(0);
int min = dp[0];//当用例只有一行一列时min=这个元素本身
//构造dp数组
for(int i = 1; i < rows; i++){//按行滚动
int temp1 = dp[0];//备份f(i-1,j-1)
int temp2 = dp[1];//备份f(i-1,j)
for(int j = 0; j <= i; j++){
if(j == 0){
dp[j] = dp[j] + triangle.get(i).get(j);//等号右边的dp[j]表示dp[i-1][j],等号左边的dp[j]表示dp[i][j]
if(i == rows - 1) min = dp[j];
}
else if(j == i) dp[j] = temp1 + triangle.get(i).get(j);
else dp[j] = Math.min(dp[j] + triangle.get(i).get(j), temp1 + triangle.get(i).get(j));
if(j > 0){//j==0时不需要更新temp1,temp2
temp1 = temp2;
if(j < i) temp2 = dp[j + 1];//j<i是防止越界。
}
if(i == rows - 1) min = Math.min(min, dp[j]);
}
}
return min;
}
}