动态规划,简称DP;在面试中的算法题中也很常见,是求最优化解的一种常用思路,之前也是一直困扰我的一个问题。
通常的使用套路(一步一步优化)
① 暴力递归(自顶向下,出现了重叠子问题)
② 记忆化搜索(自顶向下)
③ 递推(自底向上)
动态规划常用步骤:
动态规划中的“动态”可以理解为是“会变化的状态”
① 定义状态(状态是原问题、子问题的解) 比如定义 dp(i) 的含义
② 设置初始状态(边界) 比如设置 dp(0) 的值
③ 确定状态转移方程 比如确定 dp(i) 和 dp(i – 1) 的关系
含义:
① 将复杂的原问题拆解成若干个简单的子问题
② 每个子问题仅仅解决1次,并保存它们的解
③ 最后推导出原问题的解
可以用动态规划来解决的问题,通常具备2个特点 最优子结构(最优化原理):
通过求解子问题的最优解,可以获得原问题的最优解;无后效性;某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响(未来与过去无关);在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的

无后效性

image.png
从起点(0, 0)走到终点(4, 4)一共有多少种走法?只能向右、向下走
假设 dp(i, j) 是从(0, 0)走到(i, j)的走法
dp(i, 0) = dp(0, j) = 1
dp(i, j) = dp(i, j – 1) + dp(i – 1, j)

无后效性
推导 dp(i, j) 时只需要用到 dp(i, j – 1)、dp(i – 1, j) 的值
不需要关心 dp(i, j – 1)、dp(i – 1, j) 的值是怎么求出来的

如果可以向左、向右、向上、向下走,并且同一个格子不能走 2 次
有后效性
dp(i, j) 下一步要怎么走,还要关心上一步是怎么来的
也就是还要关心 dp(i, j – 1)、dp(i – 1, j) 是怎么来的?

找零钱

在贪心算法那一张中我们也讲到了这一题,但是我们当时只用了贪心算法,在这道题中并不能求出最优解
贪心
今天我们利用动态规划来解决这道问题。
问题:
假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?

假设 dp(n) 是凑到 n 分需要的最少硬币个数
如果第 1 次选择了 25 分的硬币,那么 dp(n) = dp(n – 25) + 1
如果第 1 次选择了 20 分的硬币,那么 dp(n) = dp(n – 20) + 1
如果第 1 次选择了 5 分的硬币,那么 dp(n) = dp(n – 5) + 1
如果第 1 次选择了 1 分的硬币,那么 dp(n) = dp(n – 1) + 1
所以 dp(n) = min { dp(n – 25), dp(n – 20), dp(n – 5), dp(n – 1) } + 1

暴力递归

示例:

  1. static int coins1(int n) {
  2. if (n < 1) return Integer.MAX_VALUE;
  3. if (n == 25 || n == 20 || n == 5 || n == 1) return 1;
  4. int min1 = Math.min(coins1(n - 25), coins1(n - 20));
  5. int min2 = Math.min(coins1(n - 5), coins1(n - 1));
  6. return Math.min(min1, min2) + 1;
  7. }

自顶向下的调用,出现了重叠子问题

记忆化搜索

示例:

  1. static int coins2(int n) {
  2. if (n < 1) return -1;
  3. int[] dp = new int[n + 1];
  4. int[] faces = {1, 5, 20, 25};
  5. for (int face : faces) {
  6. if (n < face) break;
  7. dp[face] = 1;
  8. }
  9. return coins2(n, dp);
  10. }
  11. static int coins2(int n, int[] dp) {
  12. if (n < 1) return Integer.MAX_VALUE;
  13. if (dp[n] == 0) {
  14. int min1 = Math.min(coins2(n - 25, dp), coins2(n - 20, dp));
  15. int min2 = Math.min(coins2(n - 5, dp), coins2(n - 1, dp));
  16. dp[n] = Math.min(min1, min2) + 1;
  17. }
  18. return dp[n];
  19. }

自顶向下的调用

为什么我们要用数组来存储对应的值呢?是不是有人想用哈希才存储值?那么问题来了,如我们去了解一下哈希表的底层,看一下哈希的实现,我们会发现,哈希的底层实现就是用数组来完成的,但是为了有更快的查找速度,在哈希表中,我们开辟的数组空间可能有很多都没有用上。所有我们使用数组会更加的减少内存的浪费。

递推实现

这种方法没有使用递归的方式

示例:

  1. static int coins3(int n) {
  2. if (n < 1) return -1;
  3. int[] dp = new int[n + 1];
  4. for (int i = 1; i <= n; i++) {
  5. int min = Integer.MAX_VALUE;
  6. if (i >= 1)
  7. {min = Math.min(dp[i-1],min);}
  8. if (i >= 5)
  9. {min = Math.min(dp[i - 5], min);}
  10. if (i >= 20)
  11. {min = Math.min(dp[i - 20], min);}
  12. if (i >= 25)
  13. {min = Math.min(dp[i - 25], min);}
  14. dp[i] = min + 1;
  15. }
  16. return dp[n];
  17. }

它是从较小的值开始计算,如果n为41,那我们就从1开始计算,根据代码所示,在四之前我们都是用价值为1的硬币,当达到五之后我们用价值为5的硬币,……
QQ图片20210821214831.jpg
整个dp数组存储着之前每个值所需的硬币数,每次寻找都是根据之前最少的硬币数,再加上现在所要的那一枚,
根据硬币所给的值,划分成不规则的分组。

通用实现

现在我们解决了找零钱的问题,但是我们做的是零钱地固定值,面试的时候面试官出的题不可能是和现在一的一样吧,就算代码上的数能改,要是每次给你的数都不一样呢?那咋办,哪能每次都改一下吗?那是不可能的,所以我们需要一个通解,但这不是让大家被代码,而是对这种类型题的更深层次理解。

第一件事就是根据上面的代码,我们要怎么才能知道我们每次凑齐的是哪些硬币

  1. static int coins4(int n) {
  2. if (n < 1) return -1;
  3. int[] dp = new int[n + 1];
  4. // faces[i]是凑够i分时最后那枚硬币的面值
  5. int[] faces = new int[dp.length];
  6. for (int i = 1; i <= n; i++) {
  7. int min = dp[i - 1];
  8. faces[i] = 1;
  9. if (i >= 5 && dp[i - 5] < min) {
  10. min = dp[i - 5];
  11. faces[i] = 5;
  12. }
  13. if (i >= 20 && dp[i - 20] < min) {
  14. min = dp[i - 20];
  15. faces[i] = 20;
  16. }
  17. if (i >= 25 && dp[i - 25] < min) {
  18. min = dp[i - 25];
  19. faces[i] = 25;
  20. }
  21. dp[i] = min + 1;
  22. print(faces, i);
  23. }
  24. // print(faces, n);
  25. return dp[n];
  26. }
  27. static void print(int[] faces, int n) {
  28. System.out.print("[" + n + "] = ");
  29. while (n > 0) {
  30. System.out.print(faces[n] + " ");
  31. n -= faces[n];
  32. }
  33. System.out.println();
  34. }

我们利用一个faces数组来记录最后一个添加硬币的值,之后我们不断向上推导,我们就知道了它是由哪些硬币组成的。

     static int coins0(int n, int[] faces) {
        if (n < 1 || faces == null || faces.length == 0) return -1;
        int[] dp = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            int min = Integer.MAX_VALUE;
            for (int face : faces) {
                if (i < face) continue;
                min=Math.min(dp[i-face],min );
            }
            dp[i]=min+1;
        }
        return dp[n];
    }

我们用数组来存贮硬币的面值,利用for循环来进行我们之前的判断

最大连续子序列和

问题:
给定一个长度为 n 的整数序列,求它的最大连续子序列和 比如 –2、1、–3、4、–1、2、1、–5、4 的最大连续子序列和是 4 + (–1) + 2 + 1 = 6

状态定义
假设 dp(i) 是以 nums[i] 结尾的最大连续子序列和(nums是整个序列)
以 nums[0] –2 结尾的最大连续子序列是 –2,所以 dp(0) = –2
以 nums[1] 1 结尾的最大连续子序列是 1,所以 dp(1) = 1
以 nums[2] –3 结尾的最大连续子序列是 1、–3,所以 dp(2) = dp(1) + (–3) = –2
以 nums[3] 4 结尾的最大连续子序列是 4,所以 dp(3) = 4
以 nums[4] –1 结尾的最大连续子序列是 4、–1,所以 dp(4) = dp(3) + (–1) = 3
以 nums[5] 2 结尾的最大连续子序列是 4、–1、2,所以 dp(5) = dp(4) + 2 = 5
以 nums[6] 1 结尾的最大连续子序列是 4、–1、2、1,所以 dp(6) = dp(5) + 1 = 6
以 nums[7] –5 结尾的最大连续子序列是 4、–1、2、1、–5,所以 dp(7) = dp(6) + (–5) = 1
以 nums[8] 4 结尾的最大连续子序列是 4、–1、2、1、–5、4,所以 dp(8) = dp(7) + 4 = 5

状态转移方程和初始状态

动态规划这个问题的精髓;我就觉得就是状态转移方程和初始状态,我们就算理解了DP的思想,知道DP是怎么回事,但是我们如果遇到的这道题,我们不能找出他的状态转移方程;我们对这道题还是束手无策。

状态转移方程
如果 dp(i – 1) ≤ 0,那么 dp(i) = nums[i]
如果 dp(i – 1) > 0,那么 dp(i) = dp(i – 1) + nums[i]

初始状态
dp(0) 的值是 nums[0]

最终的解
最大连续子序列和是所有 dp(i) 中的最大值 max { dp(i) },i ∈ [0, nums.length)

在我看来,状态转移方程,最开始并不是要你直接写出公式,而是明白了他其中是怎么变化的,经过自己慢慢的炼化,最后总结出的公式
示例:

static int maxSubArray1(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        int max = dp[0];
        for (int i = 1; i < dp.length; i++) {
            int prev = dp[i - 1];
            if (prev <= 0) {
                dp[i] = nums[i];
            } else {
                dp[i] = prev + nums[i];
            }
            max = Math.max(dp[i], max);
        }
        return max;
    }

我们创建了一个dp数组来存储以每个数结尾的最大连续子序列
空间复杂度O(n)
时间复杂度O(n)

优化

E98ECD35902D4952F21547FD1672947A.jpg
我们为什么要存储那些较小的值呢?我们不断遍历,寻找最大值,当前项sum为负数,我们再相加就是减小自身,所以我们重新的地方开始

static int maxSubArray2(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        int dp = nums[0];
        int max = dp;
        for (int i = 1; i < nums.length; i++) {
            if (dp <= 0) {
                dp = nums[i];
            } else {
                dp = dp + nums[i];
            }
            max = Math.max(dp, max);
        }
        return max;
    }

最长上升子序列(LIS)

问题:
给定一个无序的整数序列,求出它最长上升子序列的长度(要求严格上升)
比如 [10, 2, 2, 5, 1, 7, 101, 18] 的最长上升子序列是 [2, 5, 7, 101]、[2, 5, 7, 18],长度是 4

状态定义
假设数组是 nums, [10, 2, 2, 5, 1, 7, 101, 18]
dp(i) 是以 nums[i] 结尾的最长上升子序列的长度,i ∈ [0, nums.length)
以 nums[0] 10 结尾的最长上升子序列是 10,所以 dp(0) = 1
以 nums[1] 2 结尾的最长上升子序列是 2,所以 dp(1) = 1
以 nums[2] 2 结尾的最长上升子序列是 2,所以 dp(2) = 1
以 nums[3] 5 结尾的最长上升子序列是 2、5,所以 dp(3) = dp(1) + 1 = dp(2) + 1 = 2
以 nums[4] 1 结尾的最长上升子序列是 1,所以 dp(4) = 1
以 nums[5] 7 结尾的最长上升子序列是 2、5、7,所以 dp(5) = dp(3) + 1 = 3
以 nums[6] 101 结尾的最长上升子序列是 2、5、7、101,所以 dp(6) = dp(5) + 1 = 4
以 nums[7] 18 结尾的最长上升子序列是 2、5、7、18,所以 dp(7) = dp(5) + 1 = 4
最长上升子序列的长度是所有 dp(i) 中的最大值 max { dp(i) },i ∈ [0, nums.length)
QQ图片20210823001018.jpg

    static int lengthOfLIS1(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        int[] dp = new int[nums.length];
        int max = dp[0] = 1;
        for (int i = 1; i < dp.length; i++) {
            dp[i] = 1;
            for (int j = 0; j < i; j++) {
                if (nums[i] <= nums[j]) continue;
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
            max = Math.max(dp[i], max);
        }
        return max;
    }

二分搜索 – 实现
image.png

最长公共子序列(LCS)

问题

求两个序列的最长公共子序列长度
[1, 3, 5, 9, 10] 和 [1, 4, 9, 10] 的最长公共子序列是 [1, 9, 10],长度为 3
ABCBDAB 和 BDCABA 的最长公共子序列长度是 4,可能是
ABCBDAB 和 BDCABA > BDAB
ABCBDAB 和 BDCABA > BDAB
ABCBDAB 和 BDCABA > BCAB
ABCBDAB 和 BDCABA > BCBA

思路和我们刚刚的思路是一样的,原本我们对应的是一个值,现在是多个值
image.png
上面的解题思路和之前的十分相似,大家应该都看的明白,下面才是重点想说的
QQ图片20210823102805.jpg
由图可知,我们知道这其中每个数规律之后,我们再一次优化我们的代码
image.png
这一次我们开辟了一个二维数组,来维护我们表中的数,算是比较浪费空间的一种做法,但其实我们可以用一维数组来维护表中的一行,再到下一行的时候,我们对一维数组进行更新,这时候就会有神奇的事情发生,我在我们更新的过程中就会出现上下两行
QQ图片20210823103738.jpg
而此时,我们拐角处的值我们就可以进行计算,我们在定义一个变量lefttop来记录左上角的值,用来计算相等时加1

 static int lcs(int[] num1,int[] num2){
        if(num1==null||num1.length==0)return 0;
        if(num2==null||num2.length==0)return 0;
        int[] rowsnum=num1,closnum=num2;
        if(num2.length>num1.length){
            rowsnum=num2;
            closnum=num1;
        }
        int[] dp=new int[closnum.length+1];
        for(int i=1;i<=rowsnum.length;i++){
            int cur=0;
            for (int j=1;j<=closnum.length;j++){
                int lefttop=cur;
                cur=dp[j];
                if(rowsnum[i-1]==closnum[j-1]){
                    dp[j]=lefttop+1;
                }else {
                    dp[j]=Math.max(dp[j],dp[j-1]);
                }
            }
        }
        return dp[closnum.length];
    }

我们现在只是用整型数组作为例子,我们应该用字符串的形式来做这道题,原理一样,Java中的字符串也可以变成数组的模样用到toCharArray()函数