详解动态规划(1)


title: 动态规划
date: 2022-03-25 08:53:39
categories: 算法
tags: [动态规划, 算法, leetcode]

1. 什么是动态规划

简单来说动态规划其实就是一种运筹学中的一种数学问题,通过状态转移方程将前一个状态转移给后一个状态,举个例子比如斐波那契数列应该都是很熟悉的了,在刚开始学习编程的时候应该是经常写求斐波那契数列的第n项的,他有很多中写法,其中最基础的应该就是用递归的方式来写了如下:

int fib(int N) {
        if (N < 2) return N;
        return fib(N - 1) + fib(N - 2);
}

用递归的方式写斐波那契数列虽然代码整体是很简洁的,但是我们会发现他的时间复杂的和空间复杂度相对来说还是比较高的,时间复杂度已经达到了O(n^2),所以在我们日常的开发或者算法竞赛中是十分不利的,那么如何将他的时间复杂度降低呢,就可以用上动态规划了。假设我们设置一个DP数组,这个数组表示斐波那契数列第 i + 1 位的值

int fib(int n) {
  int[] DP = new int[n];
  DP[0] = 1;
  DP[1] = 1;
  for (int i = 2; i < n; i++) {
    DP[i] = DP[i - 1] + DP[i - 2];
  }
}

如上述代码,我们知道斐波那契数列的第i项等于第i - 1 项加第i - 2 项,那么就可以得到状态转移方程

DP[i] = DP[i - 1] + DP[i - 2]

有了状态转移方程,还需要考虑一个问题那就是初始化的问题,我们知道斐波那契数列的第1和2项是不遵循状态转移方程的,属于特殊情况,在动态规划中前几项一般都会作为特殊情况来处理,这样我们才方便通过前几项结合动态转移方程来求的后面的结果。通过动态规划将时间复杂度从O(n^2)降低为了O(n),但是在空间复杂度上还是O(n),那么如何做进一步的优化让空间复杂度也下降呢,通过观察可以发现一个问题那就是其实DP数组我们只需要使用三个,然后来回交换即可,就和我们求最大公约数使用的辗转相除法是一样的。

int fib(int n) {
  int[] DP = new int[3];
  DP[0] = 1;
  DP[1] = 1;
  for (int i = 2; i < n; i++) {
    DP[2] = DP[1] + DP[0];
    DP[0] = DP[1];
    DP[1] = DP[2];
  }
}

上述斐波那契数列就是一个相对简单的动态规划问题,而且是属于连续线性的动态规划问题,在写动态规划问题时一定要注重总结和归纳,因为动态规划问题十分灵活,有可能写了100题,在写第101题的时候还是不会,所以一定要根据题目来总结相似的规律,当有了规律之后在解题就会容易的多了。

2. 连续线性动态规划

什么是连续线性动态规划呢,就跟我们上述的斐波那契数列一样,是在一个线性结构上推导最终结果的,当然在这个线性结构中不存在不能走的点,即点 i 会跟前面的每一个点都有关系,前面任何一个点变动都会影响我的最终结果。

2.1 LeetCode70 爬楼梯

原题地址

image-20220325100113953

这道题目是比较经典的连续线性动态规划题,这道题目同样可以用递归的思路解答,但是在此不过多赘述此类方法,直接进入动态规划的分析。我们可以发现因为一次可以爬 1 或 2 个台阶,那么当我爬到第 1 个台阶是一定只有一种爬法,但我爬到第 2 个台阶时就可以有两种爬法了,可以一次爬一个台阶,也可以一次爬 2 个台阶,但是当我们爬到第 3 个台阶时该怎么办呢我们观察可以发现,将爬到第一个台阶和爬到第二个台阶的方法结合一下就可以了,既可以一个台阶一个台阶的爬;也可以先爬两个再爬一个;也可以先爬一个再爬两个,这样以来我们的状态转移方程就有了

DP[i] = DP[i - 1] + DP[i - 2]

那么初始化状态呢,刚刚通过分析也可以得到第 1 和第 2 个台阶不符合状态转移方程所需要的步骤,所以作为特殊情况进行初始化即DP[0] = 1,DP[1] = 2,那么接下来答案自然就出来了

public int climbStairs(int n) {
        int n1 = 1;
        int n2 = 2;
        int n3 = 0;
        for (int i = 3; i <= n; i++) {
            n3 = n1 + n2;
            n1 = n2;
            n2 = n3;
        }
        return n > 3 ? n3 : n;
    }

和斐波那契数列一样对空间复杂度进行了优化,但是最后多了一个比较,其原因就是要对只有一个台阶和两个台阶的情况进行特殊对待。

2.2 LeetCode91 解码方法

image-20220325101513202

原题地址

这题和上一题乍一看好像没什么相似的点,但是为什么会归为一类呢,我们慢慢来分析。对于爬楼梯的题目我们假设目前要爬4层楼梯,从第一楼开始爬一步一步类似二进制一样从小到大列出来

第一级楼梯:1

第二级楼梯:1、1 / 2

第三级楼梯:1、1、1 / 1、2 / 2、1

第四级楼梯:1、1、1、1 / 1、1、2 / 1、2、1 / 2、1、1 / 2、2

对于本题,我们假设字符串是"2222",和上述方法一样逐级列出来

第一个字符:2

第二个字符:2、2 / 22

第三个字符:2、2、2 / 2、22 / 22、2

第四个字符:2、2、2、2/2、2、22/2、22、2/22、2、2/22、22

我们会发现一个很有趣的现象就是都是在上一步的基础上继续添加的,比如上一步有 2、2、2这种走法那么我这一步就是在上一步的基础上添加一个2即可,走楼梯的方法也一样,那么我们就可以推测出本题的状态转移方程也就是

DP[i] = DP[i - 1] + DP[i - 2]

但是我们可以发现这一题明显有个不一样的就是并不是每种都可以添加进去,比如字符"06、67"之类的就不行,那么我们就只需要添加限定条件即可。

 public int numDecodings(String s) {
        int len = s.length();
        int[] DP = new int[len + 1];
        DP[0] = 1;
        for (int i = 1; i <= len; i++) {
            if (s.charAt(i - 1) != '0') {
                DP[i] += DP[i - 1];
            }   
            if (i > 1 && s.charAt(i - 2) > '0' && (s.charAt(i - 2) - 48) * 10 + (s.charAt(i - 1) - 48) <= 26) {
                DP[i] += DP[i - 2];
            }
        }
        return DP[len];
    }

2.3 总结

通过上述两道题我们可以发现一个规律就是在此类题型中是不需要进行数值比较的,即当前的结果一定是在满足约束条件的前提下通过前者的结果进行数学运算得到的,其中的难点在于发现他们的规律,以及满足约束条件,我们可以通过列出一部分数据来找到规律进而推导出状态转移方程。

3. 二维连续动态规划

这种类型的题目与上述类型题类似,但是从一维的维度转为了二维的维度,其整体思路也类似,下面以迷宫题举例。

3.1 LeetCode62 不同路径

原题地址

image-20220325104355295

这种类型的迷宫题如果之前有过一定算法基础的话一定会想到DFSBFS但是当题目满足一定条件时也是可以用动态规划来解决的,其中很重要的一个条件就是只能往下和往右走,这个是很重要的一个特点,这样才能保证我们能够进行状态转移。我们(0, 0)为起点,那么走到该点的方法只有一种,那么走到(0, j)(i, 0)呢,是不是都只有一种走法,因为只能从上面或左边走过来,那么靠边的这两条路径肯定都只能有一种走法,那么(1, 1)这个点呢,通过观察也能发现可以从上面走来也可以从左面走来,那他的走法就有两种了,所以状态转移方程也就有了

DP[i][j] = DP[i - 1][j] + DP[i][j - 1]

正如刚刚所说的第 0 行的所有点,和第 0 列的所有点都只有一种走法,那么初始化自然也就有了,AC 代码如下:

public int uniquePaths(int m, int n) {
        int[][] DP = new int[101][101];
        for (int i = 0; i < m; i++) DP[i][0] = 1;
        for (int i = 0; i < n; i++) DP[0][i] = 1;
        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];
    }

3.2 LeetCode63 不同路径II

原题地址

image-20220325105508068

这一题和上一题的不同在于这一题引入了障碍物,与解码方法这一题和爬楼梯之间的区别一样,都是添加了一定的约束条件,那么有了这个障碍物就有了比较大的不同,尤其是在初始化方面,首先我们来进行分析,当第(i, j)的位置有障碍物时,那么走到该点的方案有几种呢?那自然是 0 种了,因为这里已经有障碍物了,那么肯定是不能走到这个点的了。所以对于针对这个约束条件的方案自然就有了,可是在初始化方面呢?我们知道在没有约束条件的情况下,边界的方案都是1,但是在有约束条件的情况下,如果中间出现了一个障碍物,那他之后的点还能过去吗?自然是不行的,所以在初始化时如果有障碍物,那么障碍物之后的点都是 0。有了这一个想法那就好编码了,AC代码:

public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int n = obstacleGrid.length;
        int m = obstacleGrid[0].length;
        int[][] DP = new int[n][m];
        DP[0][0] = obstacleGrid[0][0] == 1 ? 0 : 1;
        for (int i = 1; i < n; i++) {
            if (obstacleGrid[i][0] == 1) DP[i][0] = 0;
            else DP[i][0] = DP[i - 1][0];
        }
        for (int i = 1; i < m; i++) {
            if (obstacleGrid[0][i] == 1) DP[0][i] = 0;
            else DP[0][i] = DP[0][i -  1];
        }
        for (int i = 1; i < n; i++) {
            for (int j = 1; j < m; j++) {
                if (obstacleGrid[i][j] == 1) DP[i][j] = 0;
                else DP[i][j] = DP[i - 1][j] + DP[i][j - 1];
            }
        }
        return DP[n - 1][m - 1];
    }

3.3 总结

通过上述两道题可以发现他们的解题思路其实和连续线性动态规划的解题方法类似,都是通过i与前者的关系通过数学运算得到当前的值,但是类似这类迷宫题一定要注意一点,那就是一定要只能往下或往右走,还有一种就是能往上下左右走的迷宫题就需要使用DFSBFS了。关于此类题目的讲解可以看我的DFS算法专题BFS算法专题

4. 线性比较动态规划

讲完了上述的两种类型的动态规划之后要开始进入比较难的动态规划专题了,而且此类型的动态规划也是目前面试算法题中区分中层和下层面试人员的分水岭,这种类型的动态规划题型一定会存在一个特征那就是要进行比较,比如要选择最省力气的方案、获得最多的钱数这类问题是比较常见的,下面将用两个例子进行讲解。

4.1 LeetCode746 使用最小力气爬楼梯

原题地址

image-20220325145741731

这个题目看着是不是很熟悉,就是从爬楼梯这道题衍生过来的,虽然属于魔改题,但是他的思路就完全不一样了,我们首先需要理解题目的意思,通过实例可以分析出选择第一级,和第二级台阶是不需要花费力气的,而且我们最终的目标并不是跳到例子中20的位置,而是要在20之后,只有在弄清楚了题目的意思之后我们才可以进行分析。首先第一步我们需要确定DP数组的作用,那么根据我们前面几道题的经验可以想到DP[i]就用来表示到达点i时所消耗的费用,那么我们刚刚说过第一级和第二级台阶是不需要花费费用的,那我们就可以把DP[0] = 0,DP[1] = 0,这样一来我们就将初始化也做好了,接下来就要考虑状态转移方程的问题了,通过题目可以知道当我站在点i的时候开始起跳此时所消耗的费用就是点i的值,而我们在来到点i之前已经消耗了DP[i]的费用,那从点i起跳所需要消耗的费用自然就是DP[i] + nums[i]了,这难道就是我们的状态转移方程了吗?当然不是,记住我们这个专题是线性比较动态规划,那么我们都还没进行比较当然不可能是这个状态转移方程了,再次阅读题目我们可以知道题目的要求是使用最小花费,那么这个最小是不是就是一个比较了呢。既然要最小这里我们就需要借助一下贪心的思维了,贪心算法和动态规划其实是有点像的,而且很多的初学者分不清二者之间的区别,我会在贪心算法中介绍二者的区别,在此不过多赘述,为什么说需要借助贪心的思维呢,我们知道贪心就是要在局部取得最优,当我们在每一个局部都取得最优后那最终的结果是不是就一定是最优的呢?

回到题目,题目中要求一次可以选择爬一个台阶或者两个台阶,那我在 i台阶需要怎么比较呢?能爬到i台阶的只能是第i - 1,i - 2这两个台阶,那我们取二者之间较小花费的不就是i台阶时的最优方案了吗,所以它的状态转移方程就是

DP[i] = Math.max(DP[i - 1] + nums[i - 1], DP[i - 2] + nums[i - 2])

目前DP数组、初始化、状态转移方程三要素都解决了自然就能写出代码了。AC代码:

public int minCostClimbingStairs(int[] cost) {
        int[] DP = new int[3];
        DP[0] = 0;
        DP[1] = 0;
        for (int i = 2; i <= cost.length; i++) {
            DP[2] = Math.min(DP[2 - 2] + cost[i - 2], DP[2 - 1] + cost[i - 1]);
            DP[0] = DP[1];
            DP[1] = DP[2];
        }
        return DP[2];
    }

4.2 LeetCode198 打家劫舍

原题地址

image-20220325151604537

打家劫舍是在力扣上比较经典的动态规划题型了,这题我们读一遍题目会发现好像和上一题很像,都是有进行比较而且i都与i - 1i - 2有一定的关系,至于关系是什么就需要一步一步推进了。我们仿照之前的步骤先来确定DP数组的作用,首先我们知道题目要求是偷窃到最高金额,那么DP数组就可以做为偷窃到i时所偷到金额,那么如何进行初始化呢?我们先考虑当题目只给出一户房屋的时候那么此时DP[0]是不是就等于它本身呢,那么如果给出两户房屋呢?我们就只能选择两户中的一户进行偷窃,那自然会选择两户中价值高的那一户了,所以DP[1] = Math.max(nums[0], nums[1]),至此我们就完成了初始化,接下来就是思考状态转移方程了,假设此时我偷到了房屋i那如果我选择偷这户房屋,此时我的DP[i]会是多少呢,如果偷了这户房屋自然第i - 1是不能偷窃的,但是i - 2是可以偷窃的,那么我此时的DP[i] = nums[i] + DP[i -2]那根据题目要求需要偷窃的金额最高,如何才能最高呢,就需要判断这户房屋是否能偷了,什么情况下不能偷?当偷到前一户房屋的时候偷窃的金额比偷窃了当前这一户的时候的金额高,那就不会选择偷窃这一户了,通过公式表达就是DP[i - 1] > DP[i]DP[i] = nums[i] + DP[i - 2]带入将就是DP[i - 1] > DP[i - 2] + nums[i]这种情况下就不会选择偷窃第i户房屋了,那么整理一下就可以推导出状态转移方程

DP[i] = Math.max(DP[i - 1], DP[i - 2] + nums[i])

至此解决动态规划问题的三要素就有了那就可以开始写代码了。AC代码:

 public int rob(int[] nums) {
        if (nums.length == 1) {
            return nums[0];
        }
        int[] DP = new int[3];
        DP[0] = nums[0];
        DP[1] = Math.max(nums[0], nums[1]);
        if (nums.length < 3) return DP[1];
        int n = nums.length;
        for (int i = 2; i < n; i++) {
            DP[2] = Math.max(DP[0] + nums[i], DP[1]);
            DP[0] = DP[1];
            DP[1] = DP[2];
        }
        return DP[2];
    }

4.3 总结

通过上述两道题目就基本上明确了此类动态规划问题的解题方法,分析题目的要求来确定DP数组的意义,通过分析前两个甚至前三个来获得特殊情况进而初始化,在初始化后在通过题目中给出的关系一步一步推导出状态转移方程,当三要素集齐后要继续进行解答自然会容易的多了。

5. 二维比较动态规划

这种类型的动态规划算是比较经典的一种了,比如数字三角形、各种LCS问题我都归类到这一部分了,因为他们的思考方式是在二维层面的,所以都归到这一类来了。

5.1 LeetCode120 三角形最小路径和

原题地址

image-20220325153732089

这道题针对给出的示例1我们换一种摆放方式,这种摆放方式容易看走眼

2
3	4
6	5	7
4	1	8	3

这么看自然就顺眼的多了,那我们开始进行分析,首先知道可以往下和右下走那么我们可以设一个数组DP用于计算走到点i, j时所走的路径和,对于初始化的话,自然最上面(0, 0)时就会等于它本身,但是还需要考虑其他问题,我们从上往下看,那么能走到点(i, j)的只能是(i - 1, j)(i - 1, j - 1)那么如果在第0列和第0行的一级第m - 1列的元素都会触碰到边界,所以这一些都是是属于特殊情况不能进入状态转移方程,由于数量很多所以我们可以边执行边初始化,接下来就剩下状态转移方程了,我们知道能走到点(i, j)的只能是(i - 1, j)(i - 1, j - 1)所以只需要比较(i - 1, j)(i - 1, j - 1)的大小即可,状态转移方程

DP[i][j] = Math.min(DP[i - 1][j - 1], DP[i - 1][j]) + triangle[i][j]

凑齐三要素,开始完成代码。AC代码:

public int minimumTotal(List<List<Integer>> triangle) {
          int[][] DP = new int[201][201];
        int n = triangle.size();
        for (int i = 0; i < n; i++) {
            int m = triangle.get(i).size();
            for (int j = 0; j < m; j++) {
                if (i == 0) {
                    DP[i][j] = triangle.get(i).get(j);
                } else if (j == 0) {
                    DP[i][j] = DP[i - 1][j] + triangle.get(i).get(j);
                } else if (j == m - 1) {
                    DP[i][j] = DP[i - 1][j - 1] + triangle.get(i).get(j);
                } else {
                    DP[i][j] = Math.min(DP[i - 1][j - 1], DP[i - 1][j]) + triangle.get(i).get(j);
                }
            }
        }
        int len = triangle.get(n - 1).size();
        int min = Integer.MAX_VALUE;
        for (int i = 0; i < len; i++) {
            min = Math.min(min, DP[n - 1][i]);
        }
        return min;
    }

5.2 剑指 Offer II 095. 最长公共子序列

原题地址

image-20220325160922874

子序列问题就是常说的LCS问题,这是比较常见的经典动态规划问题,首先我们得先知道什么是子序列,子序列的意思是字符与字符之间的相对位置相同的序列,只需要相对位置相同即可,并不要要求每个位置的字符相同,如果每个位置的字符都相同那么它就是子串了。对于LCS问题我们就需要放入二维表中分析了。以示例1为例子

abcde
a11111
c11222
e11223

如上表所示,(i, j)表示在这个点时的最长子序列,我们可以发现就在第(1, 1),(2, 3),(3, 5)的时候是相同的,所以在这三个位置时都会加1,但是每一个相同点之前的位置都是上一个相同点位置的值,就是在该点时的最长子序列,那么接下来就需要找到他们之间的关系了简单来说当处于点(i, j)时如果两个序列此时的值不相同,那么就是(i - 1, j)和(i, j - 1)中的较大者,若两点相同那就是它的上一级加1,他的上一级就是(i - 1, j - 1)至此我们就可以知道状态转移方程了,由于状态转移方程的情况比较多,所以用数学公式来表示

img

至于初始化就是最特殊的情况也就是当ij为0时都为0,AC代码:

 public int longestCommonSubsequence(String text1, String text2) {
         int[][] DP = new int[text1.length() + 1][text2.length() + 1];
        for (int i = 0; i <= text1.length(); i++) {
            DP[i][0] = 0;
        }
        for (int i = 0; i <= text2.length(); i++) {
            DP[0][i] = 0;
        }
   // 从i + 1和j + 1开始是为了防止出现负数越界
        for (int i = 0; i < text1.length(); i++) {
            for (int j = 0; j < text2.length(); j++) {
                if (text1.charAt(i) == text2.charAt(j)) {
                    DP[i + 1][j + 1] = DP[i][j] + 1;
                } else {
                    DP[i + 1][j + 1] = Math.max(DP[i + 1][j], DP[i][j + 1]);
                }
            }
        }
        return DP[text1.length()][text2.length()];
    }

5.3 总结

通过上面两题可以发现在二维层面的难度会比一维的要难的多,但是最重要的就是通过列出二维表的方式挨个填数,通过填数的方式发现他们之间的规律,利用这个规律就可以得到状态转移方程了,其次在初始化方面一定要主要考虑上下左右边界问题。

6. 结束

至此动态规划比较基本的几种类型就在这了,在这些题型中基本只要考虑DP数组、初始化和状态转移方程,而且可以比较容易想到使用动态规划的解法,在接下来还有股票DP、树形DP、几何DP和比较经典的背包DP,尤其是背包DP是面试的重点,为了减轻阅读压力,我也将这些比较难的DP分为多个文章来写

背包DP

股票DP

树形DP

几何DP

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值