题目来源
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
题解分析
首先看到本题的题眼即“最小操作次数”就能大概猜到本题需要使用动态规划的方法进行求解。仔细看一下题目的描述,其实如果自己手动计算的话会发现没有什么特殊的规律,而且很难列举出每一种情况。
本题的解题关键在于如何在鸡蛋碎了时做出选择,我们知道,如果鸡蛋在某一层碎了,那就说明边界条件必定在该层以下,后续需要往下扔鸡蛋;如果鸡蛋在该层没碎,那说明现在还没达到边界条件,后续需要继续往上走,在更高楼层扔鸡蛋。
解法一
这里还是使用动态规划的思想来设计状态转移,我们定义dp[i][j]表示i个鸡蛋,j层楼,最坏的情况下最少需要扔多少次。状态转移方程如下:
\[d p(i, j)=\min _{0<=i<=N}\{\max \{d p(i-1, j-1), d p(i, N-j)\}+1\}\]
上述dp[i-1][j-1]表示鸡蛋在第j层碎了,此时需要往下走,因为鸡蛋碎了,所以鸡蛋个数减一;dp[i][n-j]表示鸡蛋在第j层没碎,此时需要往上走,而往上可以走的层数只剩n-j层了,鸡蛋个数不变。
基于上述思路,一种基于递归和动态规划的解法如下:
class Solution { int mins = -0x3f3f3f3f; int[][] mem; public int superEggDrop(int k, int n) { // dp[i][j]表示i个鸡蛋,j层楼,最坏的情况下最少需要扔多少次 // dp[i][j] = min(1<=j<=n)(max(dp[i-1][j-1], dp[i][n-j]) + 1); mem = new int[k+1][n+1]; for (int i=0; i<=k; i++) { Arrays.fill(mem[i], mins); } return dp(k, n); } public int dp(int k, int n) { // 只有一个鸡蛋,必然最少要扔n次 if (k == 1) { return n; } // 0层楼,无论多少个鸡蛋,不用扔 if (n == 0) { return 0; } if (mem[k][n] != mins) { return mem[k][n]; } int res = 0x3f3f3f3f; for(int j=1; j<=n; j++) { res = Math.min(res, Math.max(dp(k-1, j-1), dp(k, n-j)) + 1); } mem[k][n] = res; return res; }}
很遗憾的是,上述解法会导致超时,尽管使用了备忘录来进行递归剪枝,然而在每层递归中,还是需要从1到n遍历每层楼来进行状态的转移,这会极大增加算法的时间复杂度。
其实,我们可以仔细观察dp[i-1][j-1]以及dp[i][n-j]这两个表达式,当我们遍历j的时候,我们其实可以发现dp[i-1][j-1]其实是随着j的增大而增大的。这是因为随着楼层的增加,最小的操作次数必然增加了,因为需要尝试更多次才能确定边界。同理,dp[i][n-j]会随着j的增加而减少。
基于上述观察,我们其实可以发现,dp[i-1][j-1]和dp[i][n-j]其实分别单调递增和单调递减,这两者一定会有一个交点,而且这个交点就是边界条件,也就是答案。因此,我们可以使用二分法来加速算法运行,通过dp[i-1][j-1]以及dp[i][n-j]来判断二分的边界。
使用二分法优化的解法如下:
class Solution { int mins = -0x3f3f3f3f; int[][] mem; public int superEggDrop(int k, int n) { // dp[i][j]表示i个鸡蛋,j层楼,最坏的情况下最少需要扔多少次 // dp[i][j] = min(1<=j<=n)(max(dp[i-1][j-1], dp[i][n-j]) + 1); mem = new int[k+1][n+1]; for (int i=0; i<=k; i++) { Arrays.fill(mem[i], mins); } return dp(k, n); } public int dp(int k, int n) { // 只有一个鸡蛋,必然最少要扔n次 if (k == 1) { return n; } // 0层楼,无论多少个鸡蛋,不用扔 if (n == 0) { return 0; } if (mem[k][n] != mins) { return mem[k][n]; } int res = 0x3f3f3f3f; int low = 1, high = n; while (low above) { high = mid - 1; res = Math.min(res, below + 1); } else { low = mid + 1; res = Math.min(res, above + 1); } } mem[k][n] = res; return res; }}
解法二
在第一种解法中,我们定义的状态为dp[i][j],它的含义是当前有i个鸡蛋,j层楼,最坏的情况下最少需要扔多少次。其实,动态规划并不是只有一种确定的解法,随着状态定义的不同会有很多截然不同的解法。
在这里,我们可以重新来定义动态规划的状态,假设dp[i][j]表示i个鸡蛋,扔j次可以确定的最高楼层。也就是持有i个鸡蛋,允许最多扔j次,最高用来确定的楼层高度。
根据上述定义,我们可以列出动态转移方程如下:
\[dp[i][j] = dp[i][j-1] + dp[i-1][j-1] + 1\]
其中,dp[i][j-1]表示在某层扔了一个鸡蛋但没碎,此时需要往上走,这时可以扔的次数-1,鸡蛋总数不变。结果可以表示为该层楼上的层数;dp[i-1][j-1]表示在某层扔了鸡蛋但是碎了,此时需要往下走,这时可以扔的次数-1,鸡蛋总数也-1(因为碎了一个)。结果表示为该层楼下的层数。
class Solution { public int superEggDrop(int k, int n) { // dp[i][j]表示i个鸡蛋,扔j次可以确定的楼层 // dp[i][j] = dp[i][j-1] + dp[i-1][j-1] + 1; // dp[i][j-1]表示在某层扔了一个鸡蛋但没碎,此时需要往上走,这时可以扔的次数-1,鸡蛋总数不变。结果可以表示为该层楼上的层数 // dp[i-1][j-1]表示在某层扔了鸡蛋但是碎了,此时需要往下走,这时可以扔的次数-1,鸡蛋总数也-1(因为碎了一个)。结果表示为该层楼下的层数 int[][] dp = new int[k+1][n+1]; int j=0; while(dp[k][j] < n) { j++; for (int i=1; i <= k; i++) { dp[i][j] = dp[i][j-1] + dp[i-1][j-1] + 1; } } return j; }}
上述解法是一种很规范的二维动态规划的写法,这种二维动态规划通常可以进行空间优化写成一维动态规划。这里要改写成一维动态规划,关键是需要使用一个变量来记录之前的状态避免被后续的遍历所覆盖。具体的实现如下代码所示:
class Solution { public int superEggDrop(int k, int n) { // dp[i][j]表示i个鸡蛋,扔j次可以确定的楼层 // dp[i][j] = dp[i][j-1] + dp[i-1][j-1] + 1; // dp[i][j-1]表示在某层扔了一个鸡蛋但没碎,此时需要往上走,这时可以扔的次数-1,鸡蛋总数不变。结果可以表示为该层楼上的层数 // dp[i-1][j-1]表示在某层扔了鸡蛋但是碎了,此时需要往下走,这时可以扔的次数-1,鸡蛋总数也-1(因为碎了一个)。结果表示为该层楼下的层数 int[] dp = new int[k+1]; int j=0; while(dp[k] < n) { j++; int pre = 0; for (int i=1; i <= k; i++) { int temp = dp[i]; dp[i] = dp[i] + pre + 1; pre = temp; } } return j; }}
参考
https://zhuanlan.zhihu.com/p/92288604
Either Excellent or Rusty