动态规划

动态规划

题目描述

你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。你的目标是确切地知道 F 的值是多少。无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少?

意思就是K个鸡蛋扔多少次(鸡蛋碎了就不能再仍)可以确定F的大小

示例1:

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

方法:动态规划+二分法
动态规划,可以认为是一种打表格的方法(定义来自《算法导论》)。规划(Programming)本来的意思是表格,在学习的基础算法领域使用这个语义是非常贴切的,因此大家理解这个规划意思的时候,可以暂时不用把它和数学里的线性规划联系起来,我们做基础算法题的动态规划问题还远没有到用数学方法解「约束条件下线性目标函数的极值问题」的高度,即使 Programming 在线性规划里确实是这个意思,但是基础算法领域里的规划强调的是:在求解的过程中,记住结果,以后要用到的时候,直接使用而不必重复计算,即「空间换时间」。「背包问题」、「最长回文子串」、「最长公共子序列」、「编辑距离」这些问题的求解我们都会看到程序其实就是在填写一张表格。

动态规划的两个思考方向:

  • 自顶向下求解,称之为「记忆化递归」:初学的时候,建议先写「记忆化递归」的代码,然后把代码改成「自底向上」的「递推」求解;
  • 自底向上求解,称之为「递推」或者就叫「动态规划」:在基础的「动态规划」问题里,绝大多数都可以从这个角度入手,做多了以后建议先从这个角度先思考,实在难以解决再考虑「记忆化递归」。

说明:《算法导论》上把「记忆化递归」也归为「动态规划」的概念里。不管是「记忆化递归」还是「动态规划」,在这中间很关键的点是:如何拆分问题。这就涉及到「状态」的定义,「状态」我个人的理解是:求解一个问题所处的阶段,这个定义是非常关键的,在解题的时候一定要定义清楚,不能是模糊不清的。

这里的约束只有鸡蛋的个数,因此,为了消除鸡蛋的个数对递推的过程中造成的影响,我们在设置状态的时候要在后面加上一个维度,这种做法叫消除「后效性」,是常见的套路。在「打家劫舍」问题一、问题三还有「股票」的 6 道问题用的就是这个技巧。一般而言,一个约束对应一个维度的状态。约束越多,状态的维数就越多(这里限于我的水平和经验,没有严格论证)。

第 1 步:定义状态

dp[i][j]:一共有 i 层楼梯(注意:这里 i 不表示高度)的情况下,使用 j 个鸡蛋的最少实验的次数。

说明:

i 表示的是楼层的大小,不是高度(第几层)的意思,例如楼层区间 [8, 9, 10] 的大小为 3。 j
表示可以使用的鸡蛋的个数,它是约束条件。
第一个维度最先容易想到的是表示楼层的高度,这个定义的调整是在状态转移的过程中完成的。因为如果通过实验知道了鸡蛋的 F 值在高度区间 [8,
9, 10] 里,这个时候只有 1 枚鸡蛋,显然需要做 3 次实验,和区间的大小是相关的。

第 2 步:推导状态转移方程

推导状态转移方程经常做的事情是「分类讨论」,这里「分类讨论」的依据就是,在指定的层数里扔下鸡蛋,根据这个鸡蛋是否破碎,就把问题拆分成了两个子问题。

设指定的楼层为 k,k >= 1 且 k <= i:

  • 如果鸡蛋破碎,测试 F 值的实验就得在 k 层以下做(不包括 k 层),这里已经使用了一个鸡蛋,因此测出 F 值的最少实验次数是:dp[k- 1][j - 1];
  • 如果鸡蛋完好,测试 F 值的实验就得在 k 层以上做(不包括 k 层),这里这个鸡蛋还能使用,因此测出 F 值的最少实验次数是:dp[i - k][j],例如总共 8 层,在第 5 层扔下去没有破碎,则需要在 [6, 7, 8]层继续做实验,因此区间的大小就是 8 - 5 = 3。

最坏情况下,是这两个子问题的较大者,由于在第 k 层扔下鸡蛋算作一次实验,k 的值在1 <=k <= i,对于每一个 k 都对应了一组值的最大值,取这些 k 下的最小值(最优子结构),因此:

d p ( i , j ) = min ⁡ 1 < = k < = i { max ⁡ { d p ( k − 1 , j − 1 ) , d p ( i − k , j ) } + 1 } dp(i,j) = \min_{1 <=k <= i}\lbrace \max \lbrace dp(k - 1, j - 1), dp(i-k, j)\rbrace+ 1\rbrace dp(i,j)=1<=k<=imin{max{dp(k1,j1),dp(ik,j)}+1}

解释:

由于仍那一个鸡蛋需要记录一次操作,所以末尾要加上 1;
每一个新值的计算,都参考了比它行数少,列数少的值,这些值一定是之前已经计算出来的,这样的过程就叫做「状态转移」。
这个问题只是状态转移方程稍显复杂,但空间换时间,逐层递推填表的思想依然是常见的动态规划的思路。

第 3 步:考虑初始化

一般而言,需要 0 这个状态的值,这里 0 层楼和 0 个鸡蛋是需要考虑进去的,它们的值会被后来的值所参考,并且也比较容易得到。

因此表格需要 N + 1 行,K + 1 列。

由于 F 值不会超过最大楼层的高度,要求的是最小值,因此初始化的时候,可以叫表格的单元格值设置成一个很大的数,但是这个数肯定也不会超过当前考虑的楼层的高度。

第 0 行:楼层为 0 的时候,不管鸡蛋个数多少,都测试不出鸡蛋的 F 值,故全为 0;
第 1 行:楼层为 1 的时候,0 个鸡蛋的时候,扔 0 次,1 个以及 1 个鸡蛋以上只需要扔 1 次;
第 0 列:鸡蛋个数为 0 的时候,不管楼层为多少,也测试不出鸡蛋的 F 值,故全为 0,虽然不符合题意,但是这个值有效,它在后面的计算中会被用到;
第 1 列:鸡蛋个数为 1 的时候,这是一种极端情况,要试出 F 值,最少次数就等于楼层高度;

第 4 步:考虑输出

输出就是表格的最后一个单元格的值 dp[N][K]。

第 5 步:思考状态压缩

看状态转移方程,当前单元格的值只依赖之前的行,当前列和它左边一列的值。可以状态压缩,让「列」滚动起来。但是「状态压缩」的代码增加了理解的难度,我们这里不做。

二分法
首先我们根据 dp(i, j) 数组的定义(有 j 个鸡蛋面对 i 层楼,最少需要扔几次),很容易知道 j 固定时,这个函数随着 i 的增加一定是单调递增的,无论你策略多聪明,楼层增加测试次数一定要增加。

那么注意 dp(k - 1, j - 1) 和 dp(i - k , j) 这两个函数,其中 k是从 1 到 i 单增的,如果我们固定 i 和 j,把这两个函数看做关于 k 的函数,前者随着 k 的增加应该也是单调递增的,而后者随着 i 的增加应该是单调递减的。这时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线交点。

在这里插入图片描述

class Solution {
    public int superEggDrop(int K, int N) {
        int[][] dp=new int[N+1][K+1];
        for(int i=0;i<=N;i++){
            Arrays.fill(dp[i],i);
        }
        //第0行:楼层为0的时候,不管鸡蛋个数多少,都测试不出鸡蛋的F值,故全为0
        for(int j=0;j<=K;j++){
            dp[0][j]=0;
        }
        //第1行:楼层为1 的时候,0个鸡蛋的时候,扔 次,1个以及1个鸡蛋以上只需要扔1次
        dp[1][0]=0;
        for(int j=1;j<=K;j++){
            dp[1][j]=1;
        }
        // 第0列:鸡蛋个数为0的时候,不管楼层为多少,也测试不出鸡蛋的F值,故全为0
        // 第1列:鸡蛋个数为1的时候,这是一种极端情况,要试出F值,最少次数就等于楼层高度(想想复杂度的定义)
        for(int i=0;i<=N;i++){
            dp[i][0]=0;
            dp[i][1]=i;
        }
        for(int i=2;i<=N;i++){
            for(int j=2;j<=K;j++){
                int left = 1, right = i, mid = 1;
                while (left < right) {
                    //mid相当于上文中的k(小写)
                    mid = left + ((right - left) >> 1);
                    if (dp[mid - 1][j - 1] < dp[i - mid][j]) left = mid + 1;
                    else if (dp[mid - 1][j - 1] > dp[i - mid][j]) right = mid;
                    else break;
                }
                dp[i][j] = Math.max(dp[mid - 1][j - 1], dp[i - mid][j]) + 1;
            }
        }
        return dp[N][K];    
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值