用LeetCode例题来理解动态规划思想

48 篇文章 1 订阅

【前言】:
一直以来,动态规划算法(dp)对很多人来说都是非常非常难的存在。记得大一做算法题的时候,动态规划就很难理解,看了别人的代码也难理解,因为很多大牛dp代码都写得相当精炼。这几天读了几篇很好的算法文章,并且刷了一些LeetCode中的dp题,感觉对dp思想的理解更加深入了,因此写下一篇博客分享。在这篇文章中,我主要以例题的形式解释dp思想,内容通俗,避开枯燥的概念和数学推导,希望能便于理解。


【例题1】:不同路径(2)

【问题描述】:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

示例 1:

输入:
[
  [0,0,0],
  [0,1,0],
  [0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

【dp解题流程】:
想必大家都听说过,动态规划的题要先用递归的思路去思考。这句话是有道理的。如果你不能用基本的递归做出来这道题,那么你根本不可能用动态规划解!因为动态规划只是对递归的一种优化而已,它也是建立在递归的基础之上的。

所以我总结,dp的解题流程就是:1.用最基本的递归写出来。2.对基本递归进行优化

好,回到上面一题,我们先用纯递归写出这一题的代码:

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
    	return recursion(obstacleGrid, 0, 0);
    }
    
    public int recursion(int[][] obstacleGrid, int x, int y){
        //到达终点
        if(x == obstacleGrid.length - 1 && y == obstacleGrid[0].length - 1) return 1;
        //不合法的位置
        if(x >= obstacleGrid.length || y >= obstacleGrid[0].length || obstacleGrid[x][y] == 1)
            return 0;
        return recursion(obstacleGrid, x + 1, y) + recursion(obstacleGrid, x, y + 1);
    }
}

接下来,我们大概画出递归方法的递归树,这既有利于我们分析递归算法的时间复杂度,又有利于发现重复计算的部分。

调用(0,0)的前四层递归树:
在这里插入图片描述

我们可以分析一下递归的时间复杂度:
每次递归函数里面又有两个递归,相当于一个二叉树,O(2^n)。每次递归函数中做的操作比较少,可以视为常数级别,O(1)。
因此该递归算法的时间复杂度是O(2 ^ n)的!

另外图中的所有黄色部分,都是会产生重复计算的!重复计算会使得递归的效率大大降低,如果我们能用一种办法将重复的计算消除,那么效率将大大提高!因此,我们相当用一个数表(哈希或者数组都可以,思想一样),用它将计算过的结果记录下来,当我们再一次需要这个结果的时候,就不需要计算了,直接查表就可以了呀,以此来节省时间。其实,这不就是动态规划最核心的思想吗?

分析上面那个图,我们发现,重复的部分是(1,2), (2,1)这类的元组,有两个问题参数,所以我们用一个二维数组来存储计算结果就可以了。比如说,可以把recursion(1,1)的计算结果存到dp[1][1]中。

【改进的dp写法】:

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        if(obstacleGrid[0][0] == 1) return 0;
        int row = obstacleGrid.length;int col = obstacleGrid[0].length;
        int[][] dp = new int[row][col];
        //最简单的状态,离目的状态最近的状态
        dp[row - 1][col - 1] = 1;
        for(int i = row - 1;i >= 0;i--){
            //不是障碍物
            if(obstacleGrid[i][col - 1] != 1)    dp[i][col - 1] = 1;
            //如果有一个障碍物,那么前面的都去不了了
            else    break;
        }
        for(int j = col - 1;j >= 0;j--){
            if(obstacleGrid[row - 1][j] != 1)    dp[row - 1][j] = 1;
            else    break;
        }
        for(int i = row - 2;i >= 0;i--){
            for(int j = col - 2;j >= 0;j--){
                if(obstacleGrid[i][j] != 1) dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
            }
        }
        return dp[0][0];
    }
    /*
    public int recursion(int[][] obstacleGrid, int x, int y){
        //到达终点
        if(x == obstacleGrid.length - 1 && y == obstacleGrid[0].length - 1) return 1;
        //不合法的位置
        if(x >= obstacleGrid.length || y >= obstacleGrid[0].length || obstacleGrid[x][y] == 1)
            return 0;
        return recursion(obstacleGrid, x + 1, y) + recursion(obstacleGrid, x, y + 1);
    }
    */
}

这样的话,时间复杂度降到了O(n ^ 2)了,从O(2 ^ n)到O(n ^ 2),降的那可不是一星半点!
网上大多数最终代码都是写成这个样子的,甚至可能更加精简(状态压缩),如果你没有把递归版本写出来,直接去写最终版本,那是很难写出来的。(当然,做多了之后是可以的,初学者还是要一步一步来,体会这个dp的思想过程比较好)

所以,千万不要小瞧最初的递归版本噢!动态规划最难的部分往往就是第一步,因为你要找到正确的状态转移方程(不懂也没关系,就是一种递归规律),如果第一步正确地写出来了,后面要做的事其实就非常简单了!


【总结】:

当然这道题可能属于比较简单的dp题,但只要我们掌握了这个解题流程,那么碰见难的问题我们就有思考的方向了。关于动态规划,多做题还是很有帮助的,像比较难一点的背包问题、矩阵链乘 问题都是有很多很多的变形的。

希望我的总结能对大家有所帮助!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值