动态规划 Dynamic Programming
1. 名称理解
Programming 是编程的意思,个人认为翻译成规划,有点不太合适。那有人就说了,人家的算法实现方式就是规划的啊。我们看看英文的解释:Simplifying a complicated problem by breaking it down into simpler sub-problems in a recursive manner. 翻译过来就是:用递归的方式将一个复杂的问题分解为简单的子问题。这不就是分治嘛。所以 Dynamic Programming 准确的理解应该是动态递推才对。
2. 动态规划与分治
通过这个名字的理解,我们已经可以大概知道动态递归的本质了。不过前面抛出的问题,还是要处理的。那动态递归与分治有什么区别?一般来说,动态规划的问题,都是让你求一个最小值 / 最大值。参考 leetcode 上打家劫舍三件套。正因为存在最优解,那你在递归的过程中是不需要把每一步的状态都保存下来,只需要存最优值。你需要用缓存(就是你定义的变量、数组,但是不在循环中的)将每一步的最优解保存下来,最终推导出全局的最优解。分治就是将所有的子问题都需要计算、保留全部的值,最终得出一个全局的值。
3. 关键点
- 动态规划 和 递归或者分治 没有根本上的区别(关键看有无最优解)
- 共性:找到重复子问题
- 差异性:最优子结构、中途可以淘汰次优解
4. 实战解题1 斐波拉契数
斐波拉契数: https://leetcode-cn.com/problems/fibonacci-number/
分析:斐波拉契数是将前两项相加得出当前数的值 F(n) = F(n - 1) + F(n - 2)。首先我们想到的是,用傻递归来做。一行代码解决,优雅帅气
class Solution {
public int fib(int n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
}
一顿操作猛如虎,一看击败5%
秀一下代码可以,但是呢,我们是要看速度的。
来分析一下,傻递归为什么会慢。
举个例子,我们要算 f(5) ,把递归树画出来就是这样子
通过递归树我们可以看到,里面做了大量的 F(1) 和 F(0) 的运算,重复的运算过多。比如说 F(3) 在左边明明都算过一次了,在后边还算一次。于是我们就像,能否可以将计算过的值保存下来?这个好办,就用数组吧。通过一个数组 dp[i] = dp[i - 1] + dp[i - 2] ,这样就可以每次计算都保存计算过的值,不需要重复运算。这个思路,其实就是动态规划了。前面的值对后面的值发生影响,也是可以用动态规划来做。
class Solution {
public int fib(int n) {
if (n <= 1) return n;
// 可能会疑惑,为什么是 n+1 ?因为我们前面还有f(0),我们要算的是第n项,dp[n]项实际数组的长度是dp[n+1]
int[] dp = new int[n + 1];
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i -2];
}
return dp[n];
}
}
当然这道题用这个解法不是最合适的,但是这是动态规划入门理解的最好案例。
5. 二维dp实战题目
题目:https://leetcode-cn.com/problems/2AoeFn/
这题目一看,无从下手的感觉。首先要放弃人肉递归的习惯,别一碰到问题自己就想着一步步递归下去是怎么算的。我们要用分治的思想,将大问题化成小问题去思考。我们从左上角到右下角有多少种走法这很难下手,那可不可以换一个思路,每一个网格有多少种走法?
比如说Start这个格就有了两种走法,然后临近的格子都是有 右 和 下 两个格的走法,那我如果知道了右和下两个格分别都有几种走法,目前所在的格的走法不就是右下之和吗?所以我们需要知道最右和最下的格的走法先,不用算,都是1,因为只能往右或者往下走。
那按照刚才说的,目前所在的格,等于右下之和,应该就是这样
这样的话,答案就很明显了。现在我们来写算法
class Solution {
public int uniquePaths(int m, int n) {
// 首先新建一个 m * n 的网格数组
int[][] dp = new int[m][n];
// 给边界赋初始值 1
for (int i = 0; i < n; i++) dp[m - 1][i] = 1;
for (int i = 0; i < m; i++) dp[i][n - 1] = 1;
// 将网格上的每个格子对应的走法计算出来
for (int i = m - 2; i >= 0; i--) {
for (int j = n - 2; j >= 0; j--) {
dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
}
}
// 返回左上角的格
return dp[0][0];
}
}
6. 最长公共子序列
题目:https://leetcode-cn.com/problems/longest-common-subsequence/
看到这道题,这道题是的字符子序虽然说是有相对顺序的,但是我们压根没办法得知什么时候要,什么时候不要某些字符,所以枚举出每一个子序字符串的思路是行不通的。只能采用计数法,出现相同字符的时候就记录下来。由于是两个字符串的对比,每个字符串都要拆分字符来对比,这时候我们需要用到二维数组来记录出现过相同字符的计数。这时候,你会发现这题目与上面那道题目异曲同工。
我们手工画出来一个最长子序表格,这并不难。
a | b | c | d | e | |
---|---|---|---|---|---|
a | 1 | 1 | 1 | 1 | 1 |
c | 1 | 1 | 2 | 2 | 2 |
e | 1 | 1 | 2 | 2 | 3 |
如果你看不懂这表格怎么来的,看以下说明
图片中的两个子串 ac 与 ab 出现的最长子串就是 a,那就是只有一个,我们就写个1。
子串abc 与 ac 出现的最长公共子串是 ac ,那就是2.
右下角的位置数就是两个字符串的最长公共子序了。
我们没办法立刻获得右下角位置的数值,根据上面那道题的经验,我们得一步步推导,前面的值对后面的计算发生影响,需要记录,这就是动态规划的思想。化繁为简,寻找共性。我们探讨一下每个计数是怎么来的,相互之间有那些规律。首先,a 与 a 相同,记录 1 ,a 与 ab 只有 a ,记录 1 …… abc 与 ac 有 ac ,取2;abc 与 ace 有 ac ,取2……;我们会发现,目标位置的数值,其实与对应x轴与y轴的值是否相等有关,相等的时候,取临近格左上角的值 +1 ,不相等的时候直接取左与上中的最大值。
所以 dp 方程为:dp [i] [j] = Math.max(dp[i-1] [j], dp[i] [j - 1]);
偏移方程都出来了,写个算法还不容易?
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int n1 = text1.length();
int n2 = text2.length();
int[][] dp = new int[n1 + 1][n2 + 1];
for (int i = 1; i <= n1; i++) {
char c1 = text1.charAt(i - 1);
for (int j = 1; j <= n2; j++) {
char c2 = text2.charAt(j - 1);
if (c1 == c2) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[n1][n2];
}
}
7. 爬楼梯
问题:https://leetcode-cn.com/problems/climbing-stairs/
如果前面的几道题你有认真做的话,这道题一拿到题目你就有感觉了。按照惯例,我们没办法直接得知 n阶台阶有多少种走法,我们需要每一阶台阶有多少种走法这样递推上来。第1阶,只有一种;第2阶,有1+1,或者直接跨二阶;第 3 阶,可以是在第2阶跨一阶上来,也可以是第1阶跨二阶上来……以此类推,我们发现了,当 n >= 2时,n 阶台阶的走法等于前面 n - 1 与 n - 2的走法之和。 所以dp方程就出来了:f(n) = f(n - 1) + f(n - 2);
有些同学就问了,怎么就 f(n - 1) + f(n - 2)了呢?那他们不是都还要跨一阶或者两阶才到目标位置吗?同学你看清楚题目了,人家问的是你有多少种走法。f(n) 求的是当前阶有多少种走法,f(n - 2)就只能走两阶才能到目标阶梯。不能走一阶再走一阶吗?那就是f(n - 1)再走一阶的事了。
到这里,你应该清楚dp方程怎么来的了,那写个程序还不是有手就行?
class Solution {
public int climbStairs(int n) {
if (n <= 2) return n;
int[] dp = new int[n];
dp[0] = 1;
dp[1] = 2;
// 从第三项开始就要用递推来算了 下标为2是第三项
for (int i = 2; i < n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n - 1];
}
}
8. 三角形最小路径和
问题:https://leetcode-cn.com/problems/triangle/
这道题其实是和前面的不同路径那道题非常相似。做完前面那道题,再做这个,哪怕是不会、做不出来,都能加深你对动态规划的理解。
2
3 4
6 5 7
4 1 8 3
我们来看一下题目,按照传统的思路,自顶向下地取看,先有一个2,然后再 3、 4中挑出最小值再往下挑……这样子我们压根没办法得知下面的值,也不知道保存哪些值作为计算最小值。这样就陷入了人肉递归,这种方式是不可取的。马上停下来,换一个思路。
记住,动态递归,化繁为简,我们没办法一下子得出,从顶到低的最小路径,但是如果我们能够得知每一个节点到最底下的最小路径,那我们就可以挑出最小的节点得出最小的路径了。举个例子:6 5 7 这层,我们可以轻易得知 6到底要1 + 6,5到底要1 + 5,7到底要3 + 7;3 4 这层,3到底只要6 + 3, 4到底只要6 + 4……
这就如同上面的不同路径那道题,将问题转化成最开始的两个格子的走法之和。而这道题将每个数对应的最小值得出即可。
得到dp方程为:dp[i] [j] = Math.min(dp[i+1] [j] , dp[i + 1] [j + 1]) + dp[i] [j];
编码,有手就行
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
// 先为最下面的那行赋值
for (int i = 0; i < n; i++) dp[n - 1][i] = triangle.get(n - 1).get(i);
// 接下来就分别为每行赋值
for (int i = n - 2; i >= 0; i--) {
for (int j = i; j >= 0; j--) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle.get(i).get(j);
}
}
return dp[0][0];
}
}
9. 最大子序和
题目:https://leetcode-cn.com/problems/maximum-subarray/
如果前面的你都能理解的话,那么这道题你会感觉非常轻松。找到一个具有最大和的连续子数组,首先丢掉人肉遍历的思想,想想有什么办法可以解答。第一,这道题可以用暴力,O(n2)的时间复杂度,打扰了。第二,我们可不可以在每个对应数组的位置上,记录当前元素的最大子序和?那我要求当前元素的时候,就可以引用上一个的位置的值来和目前的值来对比。
[-2,1,-3,4,-1,2,1,-5,4]
-2 位置上的最大子序和是 -2
1位置上的最大子序和是1
-3位置上最大的子序和是 -2 (1、-3)
4位置上的最大子序和是4
-1位置上的最大子序和是3 (4、-1)
2位置上的最大子序和是5 (4、-1、2)
……
以此类推,当前元素的最大子序和就是上一个元素+当前元素和 与 当前元素对比,取最大值。
能得出dp方程:dp[i] = Math.max(nums[i] + dp[i - 1], nums[i]);
class Solution {
public int maxSubArray(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
// 第一个元素的值是固定的
dp[0] = nums[0];
int max = dp[0];
for (int i = 1; i < n; i++) {
// 当前元素的最大子序就是 上一个元素与当前元素的和 与 当前元素 中的最大值
dp[i] = Math.max(nums[i] + dp[i - 1], nums[i]);
max = Math.max(max, dp[i]);
}
return max;
}
}
10. 打家劫舍
问题:https://leetcode-cn.com/problems/house-robber/
根据动态规划的思想,我们很难知道要偷哪家跳过哪家,所以用暴力和傻递归很难取解决这个问题。我们换一个思路,将大问题划分为子问题。我们有没有办法能够知道当前位置能偷到的最大金额呢?其实这和上面的最大子序和这道题很像,只是多了一个条件,不是连续的而已。
[2,7,9,3,1]
2的位置 能偷到 2
7的位置 能偷到 7 (不能连续)
9的位置 能偷到 2 + 9
3的位置 能偷到 2 + 9 (偷 7 + 3 才10,肯定不偷啊)
1的位置 能偷到 2 + 9 + 1
根据3的位置,我们很容易能知道每个位置的最大金额是 上一个位置 与 上上个位置+当前位置 中的最大值。
dp方程为:dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
代码有手就行
class Solution {
public int rob(int[] nums) {
int n = nums.length;
// 只有一家偷一家
if (n == 1) return nums[0];
// 只有两家偷最多那家
if (n == 2) return Math.max(nums[0], nums[1]);
int[] dp = new int[n];
dp[0] = nums[0];
// 注意 : dp记录的是当前位置能偷到的最多金额,第二家的位置要和第一家比较
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[n - 1];
}
}
11. 零钱兑换
题目:https://leetcode-cn.com/problems/coin-change/
这道题是我觉得动态规划入门题目中的最难的一题。它的解题思路非常难想到。子问题难发现,共性隐蔽,dp方程难找,由于解法所用的资源不多,只能定位为入门题目。这道题目,用动态规划去解答的话,比较考验答题者的答题经验,为了避免吃闭门羹,希望在好好练习前面的题目后再来挑战。
这道题,一看上去就感觉用递归,我能解。然后一顿操作后发现超出了时间限制……
冷静分析,计算并返回可以凑成总金额所需的 最少的硬币个数,按照前面的递推经验,我们可以这样去思考,这个总金额我们很难直接拿得到它的最少硬币数,那我们可不可以得到从 0 - 总金额之间的每个金额的最少硬币数呢?Ok,你能这么想的话,子问题你已经找到了,我们可以求得组成每个金额的最少硬币数。
coins = [1, 2, 5], amount = 11
1: 1
2: 1
3: 2
4: 2
5: 1
6: 2
7: 2
8: 3
9: 3
10: 2
11: 3
看着这个列表,我们思考一下,这个最小值是怎么来的,是我们用当前的金额对coins数组中的每个值进行相减,取其中的最小值。
举个例子,比如6,coins = [1, 2, 5]:
dp[6] = dp[6 - 1] + 1 = dp[5] + 1 = 2;
dp[6] = dp[6 - 2] + 1 = dp[4] + 1 = 3;
dp[6] = dp[6 - 5] + 1 = dp[1] + 1 = 2;
取最小值就是2;
这后面为什么要 +1 ? 就好比爬楼梯那道题,你可以在n - 1阶的时候,爬一阶到了n;在n - 2阶的时候,爬2阶到了n;在n - 5阶的时候,爬5阶到了n;这里的 + 1代表你还要加上当前这枚硬币才刚好凑够。
所以dp方程就出来了
dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
Math.min(dp[i - coins[j]] + 1, dp[i])还要和自身对比一次是因为dp[i]的值会发生变化,拿上面的例子来说,dp[6]要取多种组合方式中硬币数最少的那种,而它每尝试一次不同的组合就可能会被覆盖不同的值。
编码,这次不是有手就行了,要注意点细节
class Solution {
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
// 创建一个长度为amount的dp数组,里面有0 到 amount个数
int[] dp = new int[amount];
// 由于下面要比较的是最小值,数组未初始化的话,默认是0,0肯定比其他值小,所以要赋比amount还大的值
Arrays.fill(dp, amount + 1);
for (int i = 0; i < amount; i++) {
for (int j = 0; j < coins.length; j++) {
// 当前面额硬币等于金额
if ((coins[j] - 1) == i) { dp[i] = 1; continue; }
// 当前面额硬币小于等于金额
if ((coins[j] - 1) < i) dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
}
}
return dp[amount - 1] > amount ? -1 : dp[amount - 1];
}
}
总结
上面用动态规划做的这些题,都只是入门题目,解法也是最原始的动态规划,不是最优的解法,但是可以说是最容易理解的解法。没有特意去进行空间与时间调优,仅供入门学习参考。