动态规划问题思考(DP)
什么叫动态规划?有时候看名称真的十分困惑,如AOP、脑裂、雪崩、击穿这些名称WC怎么都这么高大上,我不会哎。其实理解了以后,用白话解释是很简单的。
什么叫动态规划?在知乎上看到的一个英文回答很有意思。
how should I explain dynamic programming to a 4-year-old?
write down "1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = ?" on a sheet of parper
what's that euqal to ?
counting "8"
writes down another “1 +” on the left
what about that?
quickly said 9!
how‘d you know it was nine so fast?
you just added one more
so you didn't need to recount beacuse you remembered there were eight! dynamic programming is just a fancy way to say 'remember stuff and save time later'
动态规划大体上是把比较难分解的问题,分解成一个个递进的小问题,该小问题的逻辑处理是重复的(每个小问题都是相同的处理逻辑),并且记住过程中每个小问题的结果,然后计算出最终的解。
之前一直不知道怎么处理动态规划的,后面无意中看到一篇博文,感觉总结的思路很好,解决动态规划问题大体分成三个步骤:
1、定义dp数组,其中dp[i] 代表到i的某个含义的结果,即你想求什么dp[i]就代表你想求的含义。
2、找出数组元素之间的关系,如理解dp[i]、dp[i-1]、dp[i-2]这种数组元素之间的关系,有点类似数学归纳法里面的找规律。
3、初始值,如i为0,1,2等这种初始值时候值是什么。
通过上面三个步骤的阐述,下面用案例来佐证,方便理解,耐心看完希望对你有帮助。
案例一(一维dp)
问题:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
我们尝试套用上面三个步骤去解决这个简单的dp问题。
1、定义dp数组,该问题是求解跳上一个n级台阶的所有跳法,我们定义长度为你的一维数组dp[n],元素dp[n]代表青蛙跳上n级台阶有dp[n]种跳法。
2、找出dp[n],dp[n-1],dp[n-2]等的关系,根据题目得知跳上n级台阶仅有两种跳法,从n-1级台阶跳上去,从n-2级台阶跳上去。那跳上n级台阶的方法是前面所有跳上n-1级台阶的方法 + 所有跳上n-2级别台阶的方法两个方式的和。即 dp[n] = dp[n-1] + dp[n-2];
3、获取初始值。毋庸置疑当n=0时,压根就没有给你跳的机会dp[0] = 0,当n=1时,你只有一种跳的方式,只能跳一个台阶,跳两个台阶就兴奋过度,跳过了,dp[1] = 1,当n=2时,此时青蛙可以有选择的跳了(激动的搓搓小手),可以一级一级跳,也可以直接跳两级,那dp[2] = 2,当n=3时怎么跳呢?用原始的计算方式,青蛙可以一级一级跳,也可以先跳两个再跳一个,也可以先跳一个再跳两个,还有没有可能性了?肯定穷举完了,不信你再按规则举例试试?即dp[3] = 3; 我们反过来用关系式的方式也可以得到答案,dp[3] = dp[1] + dp[2],即我们帮青蛙记住之前跳过的方式有多少种,青蛙从台阶1和台阶2跳上台阶3一共有三种。也可以反验证我们得出的关系式。
具体代码->
/** * 计算青蛙跳n级台阶有多少种跳的方式 * @param n 台阶数 * @return int */ public int getFogJumps(int n){ //不合法方式 if(n<0){ return 0; } //先定义dp数组 int[] dp = new int[n+1]; //初始值 dp[0] = 0; dp[1] = 1; dp[2] = 2; for(int i=2; i<=n; i++){ //具体的关系 dp[i] = dp[i-1] + dp[i-2]; //注意此时dp[n] n无负数,所以起始值需要从2开始 } return dp[n]; //返回第n个台阶的所有跳法 }
案例二(二维dp)
问题:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下。
老样子,还是三步走啊
1、定义dp数组,dp[m][n]代表机器人走到{m,n}的所有路径数
2、找出关系,因为机器人只能向下或者向右走,所以到{m,n}所有路径中必定是由{m-1,n}和{m,n-1}过去的 {m-1,n}向下走一步到达,{m,n-1}向右走一步到达,朝一个方向一条路走到黑仅属于一条路径,即dp[m][n] = dp[m-1][n] + dp[m][n-1];
3、找出初始值,当机器人一直向下或者一直向右走时(即贴着边一直走),此时只有一条路走到黑,分别起始点往右走然后向下或者起始点往下走然后再往右,所以得出初始值dp[1][n] = 1,dp[m][1] = 1(可以想象把网格放到二维坐标的(x,-y)区域辅助理解,但该区域无正负号区分,仅用于辅助理解dp[1][n]和dp[m][1])
具体代码->
/** * 用于获取机器人有多少不同的行走路径 * @param m 列数 * @param n 行数 * @return int */ public int robotPathNums(int m, int n){ //防御性检查 if( m <= 0 || n <= 0) return 0; //定义dp数组 dp[m][n]代表机器人走到{m,n}时候的行走路径数 int[][] dp = new int[m+1][n+1]; //找出关系,因为机器人只能向下或者向右走,所以到{m,n}所有路径中会经过两个点{m-1,n}和{m,n-1}到达 //{m-1,n}向下走一步到达,{m,n-1}向右走一步到达,朝一个方向一条路走到黑仅属于一条路径,即dp[m][n] = dp[m-1][n] + dp[m][n-1] //找出初始值,当机器人一直向下走或者一直向右走时(即贴着网格边走),此时路径仅有一条,分别时起始点往右走到头再往下和其实点向下走到头再往右一直走 //所以得出初始值dp[1][n] = 1, dp[m][1] = 1;(可以想象把网格放到二维坐标轴的(x,-y)区域辅助理解,但是该区域无正负区分,仅用于辅助理解dp[1][n],dp[m][1]) for(int i=1; i<=m; i++){ dp[i][1] = 1; //初始值 } for(int j=1; j<=n; j++){ dp[1][j] = 1; //初始值 } for(int i=2; i<=m; i++){ for(int j=2; j<=n; j++){ //具体的关系式子 dp[i][j] = dp[i-1][j] + dp[i][j-1]; } } return dp[m][n]; //最终的路径数量 }
案例三(二维dp)
问题:给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步
举例:
输入arr[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出:7
因为路径1->3->1->1->1的总和最小,即计算最优路径权重和。
加油!坚持下去你一定会有所理解DP的。
老生常谈三件套:
1、定义dp数组,这个dp具体某个元素的含义很重要,需要个人理解好,这道题目dp[m][n]代表从左到右走到{m,n}点所有路径中权重和的最小值。
2、定义关系 即dp[m][n]有哪些方式可以到它?仅有dp[m-1][n]和dp[m][n-1],因为题目要求仅能向下或者向右走,但他们与dp[m][n]有什么关系呢?不难看出dp[m][n] = min(dp[m-1][n],dp[m][n-1]) + {m,n}点的权重。
3、初始值,dp[1][1]为{1,1}点的权重,dp[1][2]为{1,1}的权重 + {1,2}的权重,又dp[2][1]为do[1][1] + {2,1}的权重按照这个规律我们可以计算出dp[1][1],dp[1][2]...dp[1][m]的权重和dp[1][1],dp[2][1],dp[3][1]...dp[m][1]的权重(思路边界路径,这个最容易找的两条路径了)
具体代码->
/** * @param arr 入参二维数组 * @return int */ public static int getMinPathWeigth(int[][] arr){ //防御性检查 if( arr.length <= 0) return 0; int m = arr.length; //行长度 int n = arr[0].length; //列长度 //初始化dp数组 int[][] dp = new int[m][n]; //获取初始化值 dp[0][0] = arr[0][0]; for(int i=1; i<m; i++){ dp[i][0] = dp[i-1][0] + arr[i][0]; } for(int j=1; j<n; j++){ dp[0][j] = dp[0][j-1] + arr[0][j]; } for(int i=1; i<m; i++){ for(int j=1; j<n; j++){ //dp关系=》两种方式最小值 + 当前节点权重 dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + arr[i][j]; } } return dp[m-1][n-1]; }
案例四(二维dp)
问题:给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入一个字符 删除一个字符 替换一个字符
举例:
输入word1 = "horse",word2 = "ros";
输出:3
解释:horse -> rorse ('h' 替换成 'r')
rorse -> rose(删除 'r')
rose -> ros(删除'e')
依旧老样子(应该找到规律了吧兄弟们,再找不到手要废了/(ㄒoㄒ)/~~),三步走战略
这个题目有点抽象,看到过知乎一篇文章的回答说,大约百分之90左右的字符串问题可以用dp去解决,以后大家看到最少,最多,一共等关键词字眼时,解决该题可以给dp一个上场献丑的机会,万一它能解决呢(✿◡‿◡)
1、定义dp数组,dp[i][j]表示长度为i的单词变成长度为j的单词所使用的最少操作数。
2、定义关系式,这个关系式比较抽象,因为有删除、替换、新增等三种操作,对于长度为i的word1来说可能i-1新增一个到j,可能需要在i中删除一个到j,可能i中替换一个到j,得出关系式,dp[i][j] = min( min(dp[i-1][j] + 1, dp[i-1][j-1] + 1), dp[i][j-1] + 1)。其中dp[i-1][j]表示i删除一个到j,dp[i-1][j-1]表示i需要替换到j,dp[i][j-1]表示i需要新增一个到j,这个大家好好体会。需要注意的是当word1的i处的字符与word2的j处的字符相同的话是不需要变更的,dp[i][j] = dp[i-1][j-1],因为向前不需要进行变更操作。
3、定初始值,这个转换大家想到最容易得是什么?sourceStr为空 targetStr不为空,直接增加就行还有targetStr为空sourceStr不为空直接删除就行。即这种情况下dp[i][0] = i,dp[0][j] = j。
具体代码->
/** * @param sourceStr 原始字符串 * @param targetStr 目标字符串 * @return int 最终结果 */ public static int getMinConvertNums(String sourceStr, String targetStr){ if(sourceStr == null || targetStr == null) return 0; //获取长度 int m = sourceStr.length(); int n = targetStr.length(); //定义dp数组 int[][] dp = new int[m+1][n+1]; //初始化相关值 for(int i=0; i<=m; i++){ dp[i][0] = i; //表示一直删除 } for(int j=0; j<=n; j++){ dp[0][j] = j; //表示一直新增 } for(int i=1; i<=m; i++){ for(int j=1; j<=n; j++){ //当i位字符与j位字符相同时 dp[i][j] = dp[i-1][j-1] 字符串下标从0开始 if(sourceStr.charAt(i-1) == targetStr.charAt(j-1)){ dp[i][j] = dp[i-1][j-1]; }else{ dp[i][j] = Math.min(Math.min(dp[i-1][j], dp[i-1][j-1]) + 1, dp[i][j-1] + 1); } } } return dp[m][n]; }
终于肝完了,大家可以尝试找几个dp的问题,硬生生的按照这个套路做几遍,理解这个解决题目的思想跟步骤,按照这个套路感觉可以做百分之八十左右得dp题目,不信你试试?