面试算法准备:动态规划

1 理论

1.1 dp基本理论

动态规划问题的一般形式就是求最值
求解动态规划的核心问题是穷举
首先,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在**「重叠子问题」**,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素
那么按照上面的理解,模板就是这样的:

# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
    for 选择 in 所有可能的选择:
        # 此时的状态已经因为做了选择而改变
        result = 求最值(result, dp(状态1, 状态2, ...))
    return result

# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

1.2 dp问题归纳

在这一节,主要讨论以下问题:

1、到底什么才叫「最优子结构」,和动态规划什么关系。

2、如何判断一个问题是动态规划问题,即如何看出是否存在重叠子问题。

3、为什么经常看到将 dp 数组的大小设置为 n + 1 而不是 n。

4、为什么动态规划遍历 dp 数组的方式五花八门,有的正着遍历,有的倒着遍历,有的斜着遍历。

1.2.1 最优子结构详解

「最优子结构」是某些问题的一种特定性质,并不是动态规划问题专有的。也就是说,很多问题其实都具有最优子结构,只是其中大部分不具有重叠子问题,所以我们不把它们归为动态规划系列问题而已。符合最优子结构的问题可以从子问题的最优结果推出更大规模问题的最优结果。

那么有哪些问题没有明显的重叠子问题呢?
举个例子:假设你们学校有 10 个班,你已知每个班的最大分数差(最高分和最低分的差值)。那么现在我让你计算全校学生中的最大分数差,你会不会算?可以想办法算,但是肯定不能通过已知的这 10 个班的最大分数差推到出来。因为这 10 个班的最大分数差不一定就包含全校学生的最大分数差,比如全校的最大分数差可能是 3 班的最高分和 6 班的最低分之差。
这个问题就不符合最优子结构,因为你没办通过每个班的最优值推出全校的最优值,没办法通过子问题的最优值推出规模更大的问题的最优值。想满足最优子结,子问题之间必须互相独立。全校的最大分数差可能出现在两个班之间,显然子问题不独立,所以这个问题本身不符合最优子结构。

那么遇到这种最优子结构失效情况,怎么办?策略是:改造问题。
改造问题,也就是把问题等价转化:最大分数差,不就等价于最高分数和最低分数的差么,那不就是要求最高和最低分数么,不就是我们讨论的第一个问题么,不就具有最优子结构了么?那现在改变思路,借助最优子结构解决最值问题,再回过头解决最大分数差问题,是不是就高效多了?

最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的,以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。

1.2.2 如何一眼看出重叠子结构

首先,最简单粗暴的方式就是画图,把递归树画出来,看看有没有重复的节点。
例如,斐波那契数列那个例子:
在这里插入图片描述
这棵递归树很明显存在重复的节点,所以我们可以通过备忘录避免冗余计算。
但毕竟斐波那契数列问题太简单了,实际的动态规划问题比较复杂,比如二维甚至三维的动态规划,当然也可以画递归树,但不免有些复杂。

因此,稍加思考就可以知道,其实根本没必要画图,可以通过递归框架直接判断是否存在重叠子问题。
例如对于某些题目,抽象出该解法的递归框架:

int dp(int[][] grid, int i, int j) {
    dp(grid, i - 1, j), // #1
    dp(grid, i, j - 1)  // #2
}

可以看到 i, j 的值在不断减小,那么我问你一个问题:如果我想从状态 (i, j) 转移到 (i-1, j-1),有几种路径?
显然有两种路径,可以是 (i, j) -> #1 -> #2 或者 (i, j) -> #2 -> #1,不止一种,说明 (i-1, j-1) 会被多次计算,所以一定存在重叠子问题。

所以,不用画图就知道这个解法也存在重叠子问题,需要用备忘录技巧去优化。

1.2.3 dp 数组的大小设置

理论上,你怎么定义都可以,只要根据定义处理好 base case 就可以。

1.2.4 dp 数组的遍历方向

我相信读者做动态规问题时,肯定会对 dp 数组的遍历顺序有些头疼。我们拿二维 dp 数组来举例,有时候我们是正向遍历:

int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
    for (int j = 0; j < n; j++)
        // 计算 dp[i][j]

有时候我们反向遍历:

for (int i = m - 1; i >= 0; i--)
    for (int j = n - 1; j >= 0; j--)
        // 计算 dp[i][j]

有时候可能会斜向遍历:

// 斜着遍历数组
for (int l = 2; l <= n; l++) {
    for (int i = 0; i <= n - l; i++) {
        int j = l + i - 1;
        // 计算 dp[i][j]
    }
}

那么,我们应该怎么选择遍历方向呢?
只要把住两点就行了:
1、遍历的过程中,所需的状态必须是已经计算出来的。
2、遍历结束后,存储结果的那个位置必须已经被计算出来。

因此,遍历方式主要就是看 base case 和最终结果的存储位置,保证遍历过程中使用的数据都是计算完毕的就行,有时候确实存在多种方法可以得到正确答案,可根据个人口味自行选择。

2 例题

2.1 斐波那契数列(什么是重叠子问题)

正常递归解法如下:

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

在这里插入图片描述
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。

2.1.1 带备忘录的递归解法

明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

int fib(int N) {
    // 备忘录全初始化为 0
    int[] memo = new int[N + 1];
    // 进行带备忘录的递归
    return dp(memo, N);
}

// 带着备忘录进行递归
int dp(int[] memo, int n) {
    // base case
    if (n == 0 || n == 1) return n;
    // 已经计算过,不用再计算了
    if (memo[n] != 0) return memo[n];
    memo[n] = dp(memo, n - 1) + dp(memo, n - 2);
    return memo[n];
}

画出递归树,你就知道「备忘录」到底做了什么
在这里插入图片描述
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」?反过来,我们直接从最底下、最简单、问题规模最小、已知结果的 f(1) 和 f(2)(base case)开始往上推,直到推到我们想要的答案 f(20)。这就是「递推」的思路,这也是动态规划一般都脱离了递归,而是由循环迭代完成计算的原因。
自底向上代码如下:

int fib(int N) {
    if (N == 0) return 0;
    int[] dp = new int[N + 1];
    // base case
    dp[0] = 0; dp[1] = 1;
    // 状态转移
    for (int i = 2; i <= N; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[N];
}

这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:
在这里插入图片描述

2.2 零钱兑换(讲解最优子结构)

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。

示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0

思路以及代码:
首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。
比如说,假设你考试,每门科目的成绩都是互相独立的。你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,「每门科目考到最高」这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,不能同时达到满分,数学分数高,语文分数就会降低,反之亦然。
这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为「每门科目考到最高」的子问题并不独立,语文数学成绩户互相影响,无法同时最优,所以最优子结构被破坏。

**回到凑零钱问题,为什么说它符合最优子结构呢?**假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的

那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?

1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。

2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。

3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。

所以我们可以这样定义 dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。

根据上述思路,就可以写出伪代码:

// 伪码框架
int coinChange(int[] coins, int amount) {
    // 题目要求的最终结果是 dp(amount)
    return dp(coins, amount)
}

// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(int[] coins, int n) {
    // 做选择,选择需要硬币最少的那个结果
    for (int coin : coins) {
        res = min(res, 1 + dp(coins, n - coin))
    }
    return res
}

该问题的状态转移方程为:
在这里插入图片描述

代码1 备忘录dp 自顶向下:

class Solution {
    int[] memo;
    public int coinChange(int[] coins, int amount) {
        memo = new int[amount+1];
        Arrays.fill(memo, -666);
        return dp(coins,amount);
    }

    int dp(int[] coins,int n){
        if(n == 0){
            return 0;
        }
        if(n < 0){
            return -1;
        }
        if(memo[n] != -666){
            return memo[n];
        }
        int res = Integer.MAX_VALUE;
        for(int coin:coins){
            int subProblem = dp(coins,n-coin);
            if(subProblem == -1){
                continue;
            }
            res = Math.min(res,subProblem+1);
        }
        memo[n] = (res == Integer.MAX_VALUE) ? -1 : res;
        return memo[n];
    }
}

代码2 dp 数组的迭代解法 自底向上:
dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。
代码:

int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    // 数组大小为 amount + 1,初始值也为 amount + 1
    Arrays.fill(dp, amount + 1);

    // base case
    dp[0] = 0;
    // 外层 for 循环在遍历所有状态的所有取值
    for (int i = 0; i < dp.length; i++) {
        // 内层 for 循环在求所有选择的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) {
                continue;
            }
            dp[i] = Math.min(dp[i], 1 + dp[i - coin]);/**<extend up><div class="img-content"><img src="/algo/images/动态规划详解进阶/6.jpg" class="myimage"/></div> */
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

2.3 最长递增子序列(讲解如何求解状态转移方程)

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的
子序列

示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1

思路以及代码:
得出状态转移方程的过程类似于高中的数学归纳法,比如我们想证明一个数学结论,那么我们先假设这个结论在 k < n 时成立,然后根据这个假设,想办法推导证明出 k = n 的时候此结论也成立。如果能够证明出来,那么就说明这个结论对于 k 等于任何数都成立。
类似的,我们设计动态规划算法,不是需要一个 dp 数组吗?我们可以假设 dp[0…i-1] 都已经被算出来了,然后问自己:怎么通过这些结果算出 dp[i]?

根据这个定义,我们就可以推出 base case:dp[i] 初始值为 1(表示到i为止,递增最长子序列长度至少为1),因为以 nums[i] 结尾的最长递增子序列起码要包含它自己。
在这里插入图片描述
这个 GIF 展示了算法演进的过程:
在这里插入图片描述
根据这个定义,我们的最终结果(子序列的最大长度)应该是 dp 数组中的最大值。
那么,算法演进过程中每个 dp[i] 的结果是我们肉眼看出来的,我们应该怎么设计算法逻辑来正确计算每个 dp[i] 呢?
这里需要使用数学归纳的思想:
假设我们已经知道了 dp[0…4] 的所有结果,我们如何通过这些已知结果推出 dp[5] 呢?
在这里插入图片描述
nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到这些子序列末尾,就可以形成一个新的递增子序列,而且这个新的子序列长度加一。

nums[5] 前面有哪些元素小于 nums[5]?这个好算,用 for 循环比较一波就能把这些元素找出来。

以这些元素为结尾的最长递增子序列的长度是多少?回顾一下我们对 dp 数组的定义,它记录的正是以每个元素为末尾的最长递增子序列的长度。

以我们举的例子来说,nums[0] 和 nums[4] 都是小于 nums[5] 的,然后对比 dp[0] 和 dp[4] 的值,我们让 nums[5] 和更长的递增子序列结合,得出 dp[5] = 3:
在这里插入图片描述
结合我们刚才说的 base case,下面我们看一下完整代码:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int[] dp = new int[nums.length+1];
        //dp初始值为1
        Arrays.fill(dp,1);
        //计算dp
        for(int i = 1;i<nums.length;i++){
            for(int j = 0;j<i;j++){
                if(nums[i] > nums[j]){
                    dp[i] = Math.max(dp[i],dp[j] + 1);
                }
            }
        }
        //计算最大值
        int res = 0;
        for(int i = 0;i<nums.length;i++){
            res = Math.max(res,dp[i]);
        }
        return res;
    }
}

总结一下如何找到动态规划的状态转移关系:

1、明确 dp 数组的定义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。

2、根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0…i-1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。

2.4 俄罗斯套娃信封问题(二维dp)

给你一个二维整数数组 envelopes ,其中 envelopes[i] = [wi, hi] ,表示第 i 个信封的宽度和高度。
当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。
请计算 最多能有多少个 信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。
注意:不允许旋转信封。

示例 1:
输入:envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出:3
解释:最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]。
示例 2:
输入:envelopes = [[1,1],[1,1],[1,1]]
输出:1

思路以及代码:
这道题目其实是最长递增子序列的一个变种,因为每次合法的嵌套是大的套小的,相当于在二维平面中找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。

这道题的解法比较巧妙:
先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序;之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案。
在这里插入图片描述
后在 h 上寻找最长递增子序列,这个子序列就是最优的嵌套方案:
在这里插入图片描述

那么为什么这样就可以找到可以互相嵌套的信封序列呢?稍微思考一下就明白了:

首先,对宽度 w 从小到大排序,确保了 w 这个维度可以互相嵌套,所以我们只需要专注高度 h 这个维度能够互相嵌套即可。
其次,两个 w 相同的信封不能相互包含,所以对于宽度 w 相同的信封,对高度 h 进行降序排序,保证二维 LIS 中不存在多个 w 相同的信封(因为题目说了长宽相同也无法嵌套)。

代码如下:

class Solution {
    public int maxEnvelopes(int[][] envelopes) {
        int n = envelopes.length;
        //第一维进行升序,如果第一维度的值相同,就按照第二维进行降序
        Arrays.sort(envelopes,(int[] a,int [] b)->{
            return a[0]==b[0]?b[1]-a[1]:a[0]-b[0];
        });
        //对高度的数组求一个最长递增子序列
        int[] height = new int[n];
        for (int i = 0; i < n; i++)
            height[i] = envelopes[i][1];

        return lengthOfLIS(height);
    }

    public int lengthOfLIS(int[] nums) {
        int[] dp = new int[nums.length+1];
        //dp初始值为1
        Arrays.fill(dp,1);
        //计算dp
        for(int i = 1;i<nums.length;i++){
            for(int j = 0;j<i;j++){
                if(nums[i] > nums[j]){
                    dp[i] = Math.max(dp[i],dp[j] + 1);
                }
            }
        }
        //计算最大值
        int res = 0;
        for(int i = 0;i<nums.length;i++){
            res = Math.max(res,dp[i]);
        }
        return res;
    }
}

2.5 鸡蛋掉落 (考察改造问题,符合最优子结构)

给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。
已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。
每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。
请你计算并返回要确定 f 确切的值 的 最小操作次数 是多少?

示例 1:
输入:k = 1, n = 2
输出:2
解释:
鸡蛋从 1 楼掉落。如果它碎了,肯定能得出 f = 0 。
否则,鸡蛋从 2 楼掉落。如果它碎了,肯定能得出 f = 1 。
如果它没碎,那么肯定能得出 f = 2 。
因此,在最坏的情况下我们需要移动 2 次以确定 f 是多少。

思路以及代码:
最笨的方法:从一楼开始一点一点试,假如七层楼,最坏情况应该就是我试到第 7 层鸡蛋也没碎(F = 7),也就是我扔了 7 次鸡蛋。

现在再来理解一下什么叫「至少」要扔几次。依然不考虑鸡蛋个数限制,同样是 7 层楼,我们可以优化策略。
最好的策略是使用二分查找思路,我先去第 (1 + 7) / 2 = 4 层扔一下:
如果碎了说明 F 小于 4,我就去第 (1 + 3) / 2 = 2 层试……
如果没碎说明 F 大于等于 4,我就去第 (5 + 7) / 2 = 6 层试……
以这种策略,最坏情况应该是试到第 7 层鸡蛋还没碎(F = 7),或者鸡蛋一直碎到第 1 层(F = 0)。然而无论那种最坏情况,只需要试 log7 向上取整等于 3 次,比刚才尝试 7 次要少,这就是所谓的至少要扔几次。

实际上,如果不限制鸡蛋个数的话,二分思路显然可以得到最少尝试的次数,但问题是,现在给你了鸡蛋个数的限制 K,直接使用二分思路就不行了。
比如说只给你 1 个鸡蛋,7 层楼,你敢用二分吗?你直接去第 4 层扔一下,如果鸡蛋没碎还好,你可以把鸡蛋捡起来再去更高的楼层尝试;但如果碎了,你就没有鸡蛋继续测试了,无法确定鸡蛋恰好摔不碎的楼层 F 了。

有的读者也许会有这种想法:二分查找排除楼层的速度无疑是最快的,那干脆先用二分查找,等到只剩 1 个鸡蛋的时候再执行线性扫描,这样得到的结果是不是就是最少的扔鸡蛋次数呢?
很遗憾,并不是,比如说把楼层变高一些,100 层,给你 2 个鸡蛋,你在 50 层扔一下,碎了,那就只能线性扫描 1~49 层了,最坏情况下要扔 50 次。

那么怎么解决这个问题呢?
这道题的状态选择要先进行考虑,其中状态很简单,也就是楼层N和鸡蛋数量K,而选择就是选择在哪层扔鸡蛋
先考虑只有两个鸡蛋的情况,一共100层,在第i层扔第一个鸡蛋
1)如果碎了,那么就要从第一层开始线性搜索,直到鸡蛋破碎,最坏情况下,扔i-1次。为什么碎了,就要线性搜索,因为只有两个鸡蛋,如果这个碎了,那么只剩下一个鸡蛋了,必须保证在找到临街楼层之前不能碎,所以只能线性搜索了
(2)如果鸡蛋没碎,那么可以对(i+1,顶层)进行二分搜索,直到鸡蛋碎了,再进行线性搜索
因此,我们试着推导一下这种思路的状态转移方程:
f(x):x层楼,最坏情况下,至少扔几次

在第i层扔,最坏情况下要扔的总次数 =在i层扔的这一次+max( 碎了的情况,没碎的情况)=1+max(i-1,f(100-i))

那么我们就可以构建状态转移方程了:
状态转移方程
状态:n层楼,k个鸡蛋
选择:从i层扔

状态的转移
碎了:1+dp(i-1,k-1)
没碎:1+dp(n-i,k)
因此,我们可以推出状态转移方程的最终版:
在这里插入图片描述
其中,max是求碎了和没碎两种情况中扔鸡蛋总次数最大的那种需要的次数,而min就是求在最坏情况下的最少的次数。

那么我们再考虑下算法的base case:
对于n,n=0的时候,扔0次
对于n,k=1的时候,只能线性扫描,扔n次

因此我们可以得到代码:

class Solution {
    int[][] memo;

    public int superEggDrop(int k, int n) {
        memo = new int[k + 1][n + 1];
        return dp(k, n);
    }

    public int dp(int k,int n){
        if(k == 1){
            return n;
        }
        if(n == 0){
            return 0;
        }
        if(memo[k][n] != 0){
            return memo[k][n];
        }
        int res = Integer.MAX_VALUE;
        for(int i = 1;i<=n;i++){
            res = Math.min(res,Math.max(dp(k,n-i),dp(k-1,i-1))+1);
        }
        memo[k][n] = res;
        return res;
    }
}

但是吧,在letcode超时了,还是需要用二分优化:
令i=mid=low+(high-low)/2,
分别计算碎了和没碎 broken=dp(k-1,mid-1)+,unbroken=dp(k,n-mid)
如果碎了>没碎
更新res,此时至少扔几次的结果是 res=min(res,broken+1) 同时说明交点在mid的左边区间:high=mid-1
如果碎了>没碎
更新res,此时至少扔几次的结果是 res=min(res,broken+1) 同时说明交点在mid的右边区间:low=mid+1

class Solution {
public:
    int memo[101][10001]; // 备忘录

    int superEggDrop(int k, int n) {
        memset(memo, 0, sizeof(memo));
        return dp(k, n);
    }

    int dp(int k, int n) {
        if (k == 1) return n;
        if (n == 0) return 0;
        if (memo[k][n] != 0) return memo[k][n]; // 已经计算过的情况

        int res = 1000000;
        int low = 1, high = n;

        while (low <= high) {
            int mid = low + (high - low) / 2;
            int broken = dp(k - 1, mid - 1);
            int unbroken = dp(k, n - mid);
            
            if (broken > unbroken) {
                res = min(res, broken + 1);
                high = mid - 1;
            } else {
                res = min(res, unbroken + 1);
                low = mid + 1;
            }
        }

        memo[k][n] = res; // 更新备忘录
        return res;
    }
};


2.6 最小路径和 (如何一眼看出重叠子问题)

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。

示例 1:
在这里插入图片描述
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
示例 2:
输入:grid = [[1,2,3],[4,5,6]]
输出:12

思路以及代码:
在这里插入图片描述
我们想计算从起点 D 到达 B 的最小路径和,那你说怎么才能到达 B 呢?
题目说了只能向右或者向下走,所以只有从 A 或者 C 走到 B。
我们把「从 D 走到 B 的最小路径和」这个问题转化成了「从 D 走到 A 的最小路径和」和 「从 D 走到 C 的最小路径和」这两个问题。
理解了上面的分析,这不就是状态转移方程吗?所以这个问题肯定会用到动态规划技巧来解决。

class Solution {
    int[][] memo;
    public int minPathSum(int[][] grid) {
        int n = grid.length;
        int m = grid[0].length;
        memo = new int[n][m];
        for(int[] row:memo){
            Arrays.fill(row,-1);
        }
        return dp(grid,n-1,m-1);
    }
    public int dp(int[][] grid,int i,int j){
        if(i == 0 && j == 0){
            return grid[0][0];
        }
        if(i<0 || j<0){
            return Integer.MAX_VALUE;
        }
        if(memo[i][j] != -1){
            return memo[i][j];
        }
        memo[i][j] = Math.min(dp(grid,i-1,j),dp(grid,i,j-1))+grid[i][j];
        return memo[i][j];
    }
}

2.7 编辑距离(dp 数组的大小设置/解决字符串的dp问题)

给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符

示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
示例 2:
输入:word1 = “intention”, word2 = “execution”
输出:5
解释:
intention -> inention (删除 ‘t’)
inention -> enention (将 ‘i’ 替换为 ‘e’)
enention -> exention (将 ‘n’ 替换为 ‘x’)
exention -> exection (将 ‘n’ 替换为 ‘c’)
exection -> execution (插入 ‘u’)

思路以及代码:

解决两个字符串的动态规划问题,一般都是用两个指针 i, j 分别指向两个字符串的最后,然后一步步往前移动,缩小问题的规模。
设两个字符串分别为 “rad” 和 “apple”,为了把 s1 变成 s2,算法会这样进行:
在这里插入图片描述
在这里插入图片描述
还有一个很容易处理的情况,就是 j 走完 s2 时,如果 i 还没走完 s1,那么只能用删除操作把 s1 缩短为 s2
类似的,如果 i 走完 s1 时 j 还没走完了 s2,那就只能用插入操作把 s2 剩下的字符全部插入 s1。等会会看到,这两种情况就是算法的 base case
因此最终得到的base case就是base case 是 i 走完 s1 或 j 走完 s2,可以直接返回另一个字符串剩下的长度。
伪代码如下:

if s1[i] == s2[j]:
    啥都别做(skip)
    i, j 同时向前移动
else:
    三选一:
        插入(insert)
        删除(delete)
        替换(replace)

那么如何选择这三个操作呢?暴力解法就是都试一次,之后选步数最小的。

代码一 递归暴力:

class Solution {
    public int minDistance(String word1, String word2) {
        int n = word1.length();
        int m = word2.length();
        //从后向前比
        return dp(word1,n-1,word2,m-1);
    }
    public int dp(String s1,int i,String s2,int j){
        // base case
        if(i == -1){
            return j+1;
        }
        if(j == -1){
            return i+1;
        }
        if(s1.charAt(i) == s2.charAt(j)){
            return dp(s1,i-1,s2,j-1);
        }else{
            return min_three(
            	// 插入 直接在 s1[i+1] 插入一个和 s2[j] 一样的字符 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
                dp(s1,i,s2,j-1)+1, 
                // 我直接把 s[i] 这个字符删掉 前移 i,继续跟 j 对比
                dp(s1,i-1,s2,j)+1, 
                // 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了 同时前移 i,j 继续对比
                dp(s1,i-1,s2,j-1)+1 
            );
        }
        
    }

    public int min_three(int a,int b,int c){
        return Math.min(a,Math.min(b,c));
    }
}

还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
本题的递归框架为:

int dp(i, j) {
    dp(i - 1, j - 1); // #1
    dp(i, j - 1);     // #2
    dp(i - 1, j);     // #3
}

对于子问题 dp(i-1, j-1),如何通过原问题 dp(i, j) 得到呢?有不止一条路径,比如 dp(i, j) -> #1 和 dp(i, j) -> #2 -> #3。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。

dp优化后的代码:

class Solution {
    // 备忘录
    int[][] memo;
        
    public int minDistance(String s1, String s2) {
        int m = s1.length(), n = s2.length();
        // 备忘录初始化为特殊值,代表还未计算
        memo = new int[m][n];
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }
        return dp(s1, m - 1, s2, n - 1);
    }

    int dp(String s1, int i, String s2, int j) {
        if (i == -1) return j + 1;
        if (j == -1) return i + 1;
        // 查备忘录,避免重叠子问题
        if (memo[i][j] != -1) {
            return memo[i][j];
        }
        // 状态转移,结果存入备忘录
        if (s1.charAt(i) == s2.charAt(j)) {
            memo[i][j] = dp(s1, i - 1, s2, j - 1);
        } else {
            memo[i][j] =  min(
                dp(s1, i, s2, j - 1) + 1,
                dp(s1, i - 1, s2, j) + 1,
                dp(s1, i - 1, s2, j - 1) + 1
            );
        }
        return memo[i][j];
    }

    int min(int a, int b, int c) {
        return Math.min(a, Math.min(b, c));
    }
}

2.8 不同的子序列 (排列问题)

给你两个字符串 s 和 t ,统计并返回在 s 的 子序列 中 t 出现的个数,结果需要对 109 + 7 取模。

示例 1:
输入:s = “rabbbit”, t = “rabbit”
输出:3
解释:
如下所示, 有 3 种可以从 s 中得到 “rabbit” 的方案。
rabbbit
rabbbit
rabbbit
示例 2:
输入:s = “babgbag”, t = “bag”
输出:5
解释:
如下所示, 有 5 种可以从 s 中得到 “bag” 的方案。
babgbag
babgbag
babgbag
babgbag
babgbag

思路以及代码:
这么恶心的题,一看就是递归、dp,那么可以先试着找一下子问题:
原问题是求 s[0…] 的所有子序列中 t[0…] 出现的次数,并且t的出现顺序不能变,那么可以先看 t[0] 在 s 中的什么位置,假设 s[2], s[6] 是字符 t[0],那么原问题转化成了在 s[3…] 和 s[7…] 的所有子序列中计算 t[1…] 出现的次数。
那么根据上面的推断,我们dp函数需要的变量有两个字符串和两个指针,定义如下:

// 定义:s[i..] 的子序列中 t[j..] 出现的次数为 dp(s, i, t, j)
int dp(String s, int i, String t, int j)

再根据这个思路,试着写一下状态转移方程:

dp(s,i,t,j) = SUM(dp(s,k+1,t,j+1) where k>=i && s[k]==t[j])

那么就可以写一下代码了(递归):

class Solution {
    public int numDistinct(String s, String t) {
        if(s.length() < t.length()){
            return 0;
        }
        return dp(s,0,t,0);
    }
    public int dp(String s,int i,String t,int j){
        if(j == t.length()){
            return 1;
        }
        int res = 0;
        for(int k = i;k<s.length();k++){
            if(s.charAt(k) == t.charAt(j)){
                res += dp(s,k+1,t,j+1);
            }
        }
        return res;
    }

}

dp优化:(备忘录防止重复计算+增加退出判断减少无用计算)

class Solution {
    int memo[][];
    public int numDistinct(String s, String t) {
        memo = new int[s.length()+1][t.length()+1];
        for(int[] mo:memo){
            Arrays.fill(mo,-1);
        }
        if(s.length() < t.length()){
            return 0;
        }
        return dp(s,0,t,0);
    }
    public int dp(String s,int i,String t,int j){
        //basecase2 : 另一个剪枝
        if(s.length() - i < t.length() - j){
            return 0;
        }
        if(j == t.length()){
            return 1;
        }
        if(memo[i][j] != -1){
            return memo[i][j];
        }
        int res = 0;
        for(int k = i;k<s.length();k++){
            if(s.charAt(k) == t.charAt(j)){
                res += dp(s,k+1,t,j+1);
            }
        }
        memo[i][j] = res;
        return res;
    }

}

2.9 打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

思路以及代码:
从左到右走过这一排房子,在每间房子前都有两种选择:抢或者不抢。
当你走过了最后一间房子后,你就没得抢了,能抢到的钱显然是 0(base case)。

以上已经明确了「状态」和「选择」:你面前房子的索引就是状态,抢和不抢就是选择。
在这里插入图片描述
因此,状态转移方程:

int res = Math.max(
    // 不抢,去下家
    dp(nums, start + 1),
    // 抢,去下下家
    nums[start] + dp(nums, start + 2)
);

总代码:

class Solution {
    int[] memo;
    public int rob(int[] nums) {
        memo = new int[nums.length];
        Arrays.fill(memo,-1);
        return dp(nums,0);
    }
    //start表示去哪一家
    public int dp(int[] nums,int start){
        if(start >= nums.length){
            return 0;
        }
        if(memo[start] != -1){
            return memo[start];
        }
        //1.抢 去下下家
        //2.不抢 去下家
        int res = Math.max(dp(nums,start+2)+nums[start],dp(nums,start+1));
        memo[start] = res;
        return res; 
    }
}

2.10 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢

示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1 阶 + 1 阶
2 阶

示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1 阶 + 1 阶 + 1 阶
1 阶 + 2 阶
2 阶 + 1 阶

思路以及代码:
这题很像斐波那契数:爬到第 n 级台阶的方法个数等于爬到 n - 1 的方法个数和爬到 n - 2 的方法个数之和。
之后base case就是到达最后一节台阶或者最后两节台阶
状态是位于哪个台阶,选择就是一次走一节还是两节

代码:

class Solution {
    int memo[];
    public int climbStairs(int n) {
        memo = new int[n+1];
        Arrays.fill(memo,-1);
        return dp(n);
    }

    public int dp(int n){
        if(n <= 2){
            return n;
        }
        if(memo[n] != -1){
            return memo[n];
        }
        memo[n] = dp(n-1) + dp(n-2);
        return memo[n];
    }
}

2.11 最大子数组和

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。

示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:
输入:nums = [1]
输出:1

示例 3:
输入:nums = [5,4,-1,7,8]
输出:23

思路以及代码:
为了方便,dp 数组的含义设置为以 nums[i] 为结尾的「最大子数组和」为 dp[i]
所以,对于第i个位置,有两种选择:

  • 不和前面的相邻子数组相连接,dp[i] = nums[i]
  • 和前面的相邻子数组相连接,dp[i] = nums[i]+dp[i-1]
class Solution {
    public int maxSubArray(int[] nums) {
        int len = nums.length;
        if(len == 0){
            return 0;
        }
        //dp[i]表示以nums[i]结尾的最大连续子数组的值
        int[] dp = new int[len];
        dp[0] = nums[0];
        for(int i = 1;i<len;i++){
            dp[i] = Math.max(nums[i],nums[i]+dp[i-1]);
        }
        int res = Integer.MIN_VALUE;
        for(int i = 0;i<len;i++){
            res = Math.max(res,dp[i]);
        }
        return res;
    }
}

2.12 完全平方数

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

示例 1:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4

示例 2:
输入:n = 13
输出:2
解释:13 = 4 + 9

思路以及代码:
题目问和为 n 的平方数的最小数量,那么我可以根据和为 n-1x1, n-2x2, n-3x3… 的平方数的最小数量推导出来。

class Solution {
    public int numSquares(int n) {
        int[] dp = new int[n+1];
        Arrays.fill(dp,Integer.MAX_VALUE);
        dp[0] = 0;
        for(int i = 1;i<=n;i++){
            for(int j = 1;j*j<=i;j++){
                dp[i] = Math.min(dp[i],dp[i - j*j]+1);
            }
        }
        return dp[n];
    }
}
  • 22
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值