动态规划入门题目详解

本文介绍了动态规划的基本概念,与分治的区别,并通过实例如斐波那契数、二维dp、最长公共子序列等深入剖析动态规划在解决最优化问题中的应用,包括如何利用缓存存储最优解,以及如何通过化繁为简的思路找出重复子问题和最优子结构。
摘要由CSDN通过智能技术生成

动态规划 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/

看到这道题,这道题是的字符子序虽然说是有相对顺序的,但是我们压根没办法得知什么时候要,什么时候不要某些字符,所以枚举出每一个子序字符串的思路是行不通的。只能采用计数法,出现相同字符的时候就记录下来。由于是两个字符串的对比,每个字符串都要拆分字符来对比,这时候我们需要用到二维数组来记录出现过相同字符的计数。这时候,你会发现这题目与上面那道题目异曲同工。

我们手工画出来一个最长子序表格,这并不难。

abcde
a11111
c11222
e11223

如果你看不懂这表格怎么来的,看以下说明
在这里插入图片描述

图片中的两个子串 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];
    }
}

总结

上面用动态规划做的这些题,都只是入门题目,解法也是最原始的动态规划,不是最优的解法,但是可以说是最容易理解的解法。没有特意去进行空间与时间调优,仅供入门学习参考。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值