动态规划DP

608 阅读6分钟

这是我参与8月更文挑战的第N天,活动详情查看:8月更文挑战

动态规划,简称DP;在面试中的算法题中也很常见,是求最优化解的一种常用思路,之前也是一直困扰我的一个问题。

通常的使用套路(一步一步优化)

① 暴力递归(自顶向下,出现了重叠子问题)

② 记忆化搜索(自顶向下)

③ 递推(自底向上)

动态规划常用步骤:

动态规划中的“动态”可以理解为是“会变化的状态”

① 定义状态(状态是原问题、子问题的解) 比如定义 dp(i) 的含义

② 设置初始状态(边界) 比如设置 dp(0) 的值

③ 确定状态转移方程 比如确定 dp(i) 和 dp(i – 1) 的关系

含义:

① 将复杂的原问题拆解成若干个简单的子问题

② 每个子问题仅仅解决1次,并保存它们的解

③ 最后推导出原问题的解

可以用动态规划来解决的问题,通常具备2个特点 最优子结构(最优化原理):

通过求解子问题的最优解,可以获得原问题的最优解;无后效性;某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响(未来与过去无关);在推导后面阶段的状态时,只关心前面阶段的具体状态值,不关心这个状态是怎么一步步推导出来的

无后效性

从起点(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

暴力递归

示例:

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

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

记忆化搜索

示例:

  static int coins2(int n) {
        if (n < 1) return -1;
        int[] dp = new int[n + 1];
        int[] faces = {1, 5, 20, 25};
        for (int face : faces) {
            if (n < face) break;
            dp[face] = 1;
        }
        return coins2(n, dp);
    }
    
    static int coins2(int n, int[] dp) {
        if (n < 1) return Integer.MAX_VALUE;
        if (dp[n] == 0) {
            int min1 = Math.min(coins2(n - 25, dp), coins2(n - 20, dp));
            int min2 = Math.min(coins2(n - 5, dp), coins2(n - 1, dp));
            dp[n] = Math.min(min1, min2) + 1;
        }
        return dp[n];
    }

自顶向下的调用

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

递推实现

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

示例:

static int coins3(int n) {
        if (n < 1) return -1;
        int[] dp = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            int min = Integer.MAX_VALUE;
            if (i >= 1)
            {min = Math.min(dp[i-1],min);}
            if (i >= 5)
            {min = Math.min(dp[i - 5], min);}
            if (i >= 20)
            {min = Math.min(dp[i - 20], min);}
            if (i >= 25)
            {min = Math.min(dp[i - 25], min);}
            dp[i] = min + 1;
        }
        return dp[n];
    }

它是从较小的值开始计算,如果n为41,那我们就从1开始计算,根据代码所示,在四之前我们都是用价值为1的硬币,当达到五之后我们用价值为5的硬币,……

整个dp数组存储着之前每个值所需的硬币数,每次寻找都是根据之前最少的硬币数,再加上现在所要的那一枚,

根据硬币所给的值,划分成不规则的分组。

通用实现

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

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

     static int coins4(int n) {
        if (n < 1) return -1;
        int[] dp = new int[n + 1];
        // faces[i]是凑够i分时最后那枚硬币的面值
        int[] faces = new int[dp.length];
        for (int i = 1; i <= n; i++) {
            int min = dp[i - 1];
            faces[i] = 1;

            if (i >= 5 && dp[i - 5] < min) {
                min = dp[i - 5];
                faces[i] = 5;
            }
            if (i >= 20 && dp[i - 20] < min) {
                min = dp[i - 20];
                faces[i] = 20;
            }
            if (i >= 25 && dp[i - 25] < min) {
                min = dp[i - 25];
                faces[i] = 25;
            }
            dp[i] = min + 1;
            print(faces, i);
        }
//      print(faces, n);
        return dp[n];
    }
    
    static void print(int[] faces, int n) {
        System.out.print("[" + n + "] = ");
        while (n > 0) {
            System.out.print(faces[n] + " ");
            n -= faces[n];
        }
        System.out.println();
    }

我们利用一个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)

优化

我们为什么要存储那些较小的值呢?我们不断遍历,寻找最大值,当前项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)

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;
    }

二分搜索 – 实现

最长公共子序列(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

思路和我们刚刚的思路是一样的,原本我们对应的是一个值,现在是多个值

上面的解题思路和之前的十分相似,大家应该都看的明白,下面才是重点想说的

由图可知,我们知道这其中每个数规律之后,我们再一次优化我们的代码

这一次我们开辟了一个二维数组,来维护我们表中的数,算是比较浪费空间的一种做法,但其实我们可以用一维数组来维护表中的一行,再到下一行的时候,我们对一维数组进行更新,这时候就会有神奇的事情发生,我在我们更新的过程中就会出现上下两行

而此时,我们拐角处的值我们就可以进行计算,我们在定义一个变量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()函数