[动态规划]62. 63.不同路径 I II(回溯法、动态规划 + 优化)115. 不同的子序列(双序列动态规划)120. 三角形最小路径和(滚动数组优化)

9 篇文章 0 订阅
7 篇文章 0 订阅

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;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值