887. 鸡蛋掉落

给你 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 是多少。

示例 2:

输入:k = 2, n = 6
输出:3

示例 3:

输入:k = 3, n = 14
输出:4

提示:

    1 <= k <= 100
    1 <= n <= 104

前言

本题是谷歌的一道经典面试题。由于本题过于经典,谷歌公司已经不再将这题作为面试的候选题目了。

本题难度较高,要想通过本题,需要一定的动态规划优化或数学功底。本题的标准解法为动态规划,由于篇幅有限,不会叙述 动态规划的边界条件、自底向上的动态规划和自顶向下的动态规划分别怎么实现 等较为基础的知识,而是把重点放在推导动态规划状态转移方程的过程,以及优化的思路、证明以及方法。

读者应当期望在阅读完本题解后,能够对方法一有一个大致的思路,并且可以在尝试中编写出代码。方法一已经是很优秀的解法,本题解也着重于此。而对于方法二和方法三,已经超过了面试难度,是竞赛中的考点,仅供读者挑战自我的极限。
方法一:动态规划 + 二分查找

思路和算法

我们可以考虑使用动态规划来做这道题,状态可以表示成 (k,n)(k, n)(k,n),其中 kkk 为鸡蛋数,nnn 为楼层数。当我们从第 xxx 楼扔鸡蛋的时候:

    如果鸡蛋不碎,那么状态变成 (k,n−x)(k, n-x)(k,n−x),即我们鸡蛋的数目不变,但答案只可能在上方的 n−xn-xn−x 层楼了。也就是说,我们把原问题缩小成了一个规模为 (k,n−x)(k, n-x)(k,n−x) 的子问题;

    如果鸡蛋碎了,那么状态变成 (k−1,x−1)(k-1, x-1)(k−1,x−1),即我们少了一个鸡蛋,但我们知道答案只可能在第 xxx 楼下方的 x−1x-1x−1 层楼中了。也就是说,我们把原问题缩小成了一个规模为 (k−1,x−1)(k-1, x-1)(k−1,x−1) 的子问题。

这样一来,我们定义 dp(k,n)\textit{dp}(k, n)dp(k,n) 为在状态 (k,n)(k, n)(k,n) 下最少需要的步数。根据以上分析我们可以列出状态转移方程:

dp(k,n)=1+min⁡1≤x≤n(max⁡(dp(k−1,x−1),dp(k,n−x)))\textit{dp}(k, n) = 1 + \min\limits_{1 \leq x \leq n} \Big( \max(\textit{dp}(k-1, x-1), \textit{dp}(k, n-x)) \Big) dp(k,n)=1+1≤x≤nmin​(max(dp(k−1,x−1),dp(k,n−x)))

这个状态转移方程是如何得来的呢?对于 dp(k,n)\textit{dp}(k, n)dp(k,n) 而言,我们像上面分析的那样,枚举第一个鸡蛋扔在的楼层数 xxx。由于我们并不知道真正的 fff 值,因此我们必须保证 鸡蛋碎了之后接下来需要的步数 和 鸡蛋没碎之后接下来需要的步数 二者的 最大值 最小,这样就保证了在 最坏情况下(也就是无论 fff 的值如何) dp(k,n)\textit{dp}(k, n)dp(k,n) 的值最小。如果能理解这一点,也就能理解上面的状态转移方程,即最小化 max⁡(dp(k−1,x−1),dp(k,n−x))\max(\textit{dp}(k-1, x-1), \textit{dp}(k, n-x))max(dp(k−1,x−1),dp(k,n−x))。

如果我们直接暴力转移求解每个状态的 dp\textit{dp}dp 值,时间复杂度是为 O(kn2)O(kn^2)O(kn2),即一共有 O(kn)O(kn)O(kn) 个状态,对于每个状态枚举扔鸡蛋的楼层 xxx,需要 O(n)O(n)O(n) 的时间。这无疑在当前数据范围下是会超出时间限制的,因此我们需要想办法优化枚举的时间复杂度。

我们观察到 dp(k,n)\textit{dp}(k, n)dp(k,n) 是一个关于 nnn 的单调递增函数,也就是说在鸡蛋数 kkk 固定的情况下,楼层数 nnn 越多,需要的步数一定不会变少。在上述的状态转移方程中,第一项 T1(x)=dp(k−1,x−1)\mathcal{T_1}(x) = \textit{dp}(k-1, x-1)T1​(x)=dp(k−1,x−1) 是一个随 xxx 的增加而单调递增的函数,第二项 T2(x)=dp(k,n−x)\mathcal{T_2}(x) = \textit{dp}(k, n-x)T2​(x)=dp(k,n−x) 是一个随着 xxx 的增加而单调递减的函数。

这如何帮助我们来优化这个问题呢?当 xxx 增加时,T1(x)\mathcal{T_1}(x)T1​(x) 单调递增而 T2(x)\mathcal{T_2}(x)T2​(x) 单调递减,我们可以想象在一个直角坐标系中,横坐标为 xxx,纵坐标为 T1(x)\mathcal{T_1}(x)T1​(x) 和 T2(x)\mathcal{T_2}(x)T2​(x)。当一个函数单调递增而另一个函数单调递减时,我们如何找到一个位置使得它们的最大值最小呢?

fig1

如上图所示,如果这两个函数都是连续函数,那么我们只需要找出这两个函数的交点,在交点处就能保证这两个函数的最大值最小。但在本题中,T1(x)\mathcal{T_1}(x)T1​(x) 和 T2(x)\mathcal{T_2}(x)T2​(x) 都是离散函数,也就是说,xxx 的值只能取 1,2,31, 2, 31,2,3 等等。在这种情况下,我们需要找到最大的满足 T1(x)<T2(x)\mathcal{T_1}(x) < \mathcal{T_2}(x)T1​(x)<T2​(x) 的 x0x_0x0​,以及最小的满足 T1(x)≥T2(x)\mathcal{T_1}(x) \geq \mathcal{T_2}(x)T1​(x)≥T2​(x) 的 x1x_1x1​,对应到上图中,就是离这两个函数(想象中的)交点左右两侧最近的整数。

我们只需要比较在 x0x_0x0​ 和 x1x_1x1​ 处两个函数的最大值,取一个最小的作为 xxx 即可。在数学上,我们可以证明出 x0x_0x0​ 和 x1x_1x1​ 相差 111,这也是比较显然的,因为它们正好夹住了那个想象中的交点,并且相距尽可能地近。因此我们就可以使用二分查找的方法找出 x0x_0x0​,再得到 x1x_1x1​:

    我们在所有满足条件的 xxx 上进行二分查找。对于状态 (k,n)(k, n)(k,n) 而言,xxx 即为 [1,n][1, n][1,n] 中的任一整数;

    在二分查找的过程中,假设当前这一步我们查找到了 xmidx_\textit{mid}xmid​,如果 T1(xmid)>T2(xmid)\mathcal{T_1}(x_\textit{mid}) > \mathcal{T_2}(x_\textit{mid})T1​(xmid​)>T2​(xmid​),那么真正的 x0x_0x0​ 一定在 xmidx_\textit{mid}xmid​ 的左侧,否则真正的 x0x_0x0​ 在 xmidx_\textit{mid}xmid​ 的右侧。

二分查找的写法因人而异,本质上我们就是需要找到最大的满足 T1(x)<T2(x)\mathcal{T_1}(x) < \mathcal{T_2}(x)T1​(x)<T2​(x) 的 x0x_0x0​,根据 xmidx_\textit{mid}xmid​ 进行二分边界的调整。在得到了 x0x_0x0​ 后,我们可以知道 x1x_1x1​ 即为 x0+1x_0 + 1x0​+1,此时我们只需要比较 max⁡(T1(x0),T2(x0))\max(\mathcal{T_1}(x_0), \mathcal{T_2}(x_0))max(T1​(x0​),T2​(x0​)) 和 max⁡(T1(x1),T2(x1))\max(\mathcal{T_1}(x_1), \mathcal{T_2}(x_1))max(T1​(x1​),T2​(x1​)),取较小的那个对应的位置作为 xxx 即可。

这样一来,对于给定的状态 (k,n)(k, n)(k,n),我们只需要 O(log⁡n)O(\log n)O(logn) 的时间,通过二分查找就能得到最优的那个 xxx,因此时间复杂度从 O(kn2)O(kn^2)O(kn2) 降低至 O(knlog⁡n)O(kn \log n)O(knlogn),可以通过本题。

class Solution {
    Map<Integer, Integer> memo = new HashMap<Integer, Integer>();

    public int superEggDrop(int k, int n) {
        return dp(k, n);
    }

    public int dp(int k, int n) {
        if (!memo.containsKey(n * 100 + k)) {
            int ans;
            if (n == 0) {
                ans = 0;
            } else if (k == 1) {
                ans = n;
            } else {
                int lo = 1, hi = n;
                while (lo + 1 < hi) {
                    int x = (lo + hi) / 2;
                    int t1 = dp(k - 1, x - 1);
                    int t2 = dp(k, n - x);

                    if (t1 < t2) {
                        lo = x;
                    } else if (t1 > t2) {
                        hi = x;
                    } else {
                        lo = hi = x;
                    }
                }

                ans = 1 + Math.min(Math.max(dp(k - 1, lo - 1), dp(k, n - lo)), Math.max(dp(k - 1, hi - 1), dp(k, n - hi)));
            }

            memo.put(n * 100 + k, ans);
        }

        return memo.get(n * 100 + k);
    }
}

思路

    N 和 F 的关系
        N 的定义:使用一栋从 1 到 N 共有 N 层楼的建筑
        F 的定义:满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破
        因此得知,F 比 N 多一个 0 层

    问题转换
        将问题从: N 个楼层,有 K 个蛋,求最少要扔 T 次,才能保证当 F 无论是 0 <= F <= N 中哪个值,都能测试出来
        转变为:有 K 个蛋,扔 T 次,求可以确定 F 的个数,然后得出 N 个楼层

    通过扔蛋测试,怎样才能确定 F ,怎样才能确定全部的 F

        比如: N = 1 层楼
        在 1 层扔,碎了,因为楼层高于 F 才会碎,所以 F < 1 。又因为 0 <= F <= N ,所以能确定 F = 0
        在 1 层扔,没碎,因为从 F 楼层或比它低的楼层落下的鸡蛋都不会碎,所以 F >= 1 。又因为 0 <= F <= N ,所以能确定 F = 1

        再比如: N = 2 层楼
        在 1 层扔,碎了,F < 1,所以确定 F = 0
        在 1 层扔,没碎,但在 2 层扔,碎了, F >= 1 && F < 2,所以确定 F = 1
        在 2 层扔,没碎,F >= 2,所以确定 F = 2

    如果只有 1 个蛋

        如果唯一的 1 个蛋碎了,就无法继续测试了
        如果从中间开始测,万一蛋碎了,连 1 个 F 都无法确定
        只能从低到高,一层一层的判断
        所以有 T 次机会,只可以确定出 T + 1 个 F

    如果只有 1 次机会

        这个好理解,只有 1 次机会,就算有很多蛋也派不上用场,所以等同于只有 1 个蛋,并且扔一次,根据上边的例子,只能确定 2 个 F
        也就是只能确定 T(1) + 1 个 F

    计算能确定 F 的个数
        如果只有 1 个蛋,或只有 1 次机会时,只可以确定出 T + 1 个 F
        其他情况时,递归。【蛋碎了减 1 个,机会减 1 次】 + 【蛋没碎,机会减 1 次】

    题目给出了 K ,不断增大 T ,计算出来的 F 的个数已经超过了 N + 1 时,就找到了答案

答题

class Solution {
public:
    int calcF(int K, int T)
    {
        if (T == 1 || K == 1) return T + 1;
        return calcF(K - 1, T - 1) + calcF(K, T - 1);
    }

    int superEggDrop(int K, int N)
    {
        int T = 1;
        while (calcF(K, T) < N + 1) T++;
        return T;
    }
};

面试中遇到的子问题

一次面试中,面试官问了这道题的子问题。

有 2 个蛋,用一座 100 层的楼,要使用最少次数测试出蛋几层会碎(F)。
问第一次应该从几层扔。

分析题意,其实本质上是和本题是一样的。
相当于int ans = superEggDrop(2, 100);,得到 14 次。
因为最少需要 14 次,所以第 1 次扔在 14 层,如果蛋碎了,接下来 1~13 这个区间就只能一次一次尝试了。

    接下来第二次扔蛋,因为扔过 1 次了,接下来的区间大小只有 12 ,只能够 15~26 ,所以扔到 27 层。

但是现场没有纸笔,不考代码,脑容量不够递归。
考虑到上面分析,从后往前推,推到最后一次扔蛋,会确定 F = 100 和 F = 99 。
再往前一次,应该是留出了 T(1) + 1 的个数,
再往前一次,留出了 T(2) + 1 的个数,
…………
所以在蛋的数量固定为 2 的情况下,T 和 F(N) 的关系应该是:
扔 1 次,确定 2 个 F,也就是 (2 - 1 = 1) 个 N。
扔 2 次,确定 2 + 2 个 F,也就是 (4 - 1 = 3) 个 N。
扔 3 次,确定 2 + 2 + 3 个 F,也就是 (7 - 1 = 6) 个 N。
即:1 + 2 + 3 + ... + 14 > 100
所以答案是 14 次。

图片.png
其它思考

    这个问题简化后,其实和猜数字(猜一个数字,返回大了小了还是对了)是一个类型的,可以对比着思考。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

毕业_设计

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值