|
| 1 | +# [0887. 鸡蛋掉落](https://leetcode.cn/problems/super-egg-drop/) |
| 2 | + |
| 3 | +- 标签:数学、二分查找、动态规划 |
| 4 | +- 难度:困难 |
| 5 | + |
| 6 | +## 题目大意 |
| 7 | + |
| 8 | +**描述**:给定一个整数 `k` 和整数 `n`,分别代表 `k` 枚鸡蛋和可以使用的一栋从第 `1` 层到第 `n` 层楼的建筑。 |
| 9 | + |
| 10 | +已知存在楼层 `f`,满足 `0 <= f <= n`,任何从高于 `f` 的楼层落下的鸡蛋都会碎,从 `f` 楼层或比它低的楼层落下的鸡蛋都不会碎。 |
| 11 | + |
| 12 | +每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 `x` 扔下(满足 `1 <= x <= n`),如果鸡蛋碎了,就不能再次使用它。如果 |
| 13 | + |
| 14 | +**要求**:计算并返回要确定 `f` 确切值的最小操作次数是多少。 |
| 15 | + |
| 16 | +**说明**: |
| 17 | + |
| 18 | +- 1ドル \le k \le 100$。 |
| 19 | +- 1ドル \le n \le 10^4$。 |
| 20 | + |
| 21 | +**示例**: |
| 22 | + |
| 23 | +```Python |
| 24 | +输入:k = 1, n = 2 |
| 25 | +输入:2 |
| 26 | +解释:鸡蛋从 1 楼掉落。如果它碎了,肯定能得出 f = 0。否则,鸡蛋从 2 楼掉落。如果它碎了,肯定能得出 f = 1。如果它没碎,那么肯定能得出 f = 2。因此,在最坏的情况下我们需要移动 2 次以确定 f 是多少。 |
| 27 | +``` |
| 28 | + |
| 29 | +## 解题思路 |
| 30 | + |
| 31 | +### 思路 1:动态规划(超时) |
| 32 | + |
| 33 | +这道题目的题意不是很容易理解,我们先把题目简化一下,忽略一些限制条件,理解简单情况下的题意。然后再一步步增加限制条件,从而先弄明白这道题目的意思。 |
| 34 | + |
| 35 | +我们先忽略 `k` 个鸡蛋这个条件,假设有无限个鸡蛋。 |
| 36 | + |
| 37 | +现在有 `1` ~ `n` 一共 `n` 层楼。已知存在楼层 `f`,低于等于 `f` 层的楼层扔下去的鸡蛋都不会碎,高于 `f` 的楼层扔下去的鸡蛋都会碎。 |
| 38 | + |
| 39 | +当然这个楼层 `f` 的确切值题目没有给出,需要我们一次次去测试鸡蛋最高会在哪一层不会摔碎。 |
| 40 | + |
| 41 | +在每次操作中,我们可以选定一个楼层,将鸡蛋扔下去: |
| 42 | + |
| 43 | +- 如果鸡蛋没摔碎,则可以继续选择其他楼层进行测试。 |
| 44 | +- 如果鸡蛋摔碎了,则该鸡蛋无法继续测试。 |
| 45 | + |
| 46 | +现在题目要求:一共有 `n` 层楼,无限个鸡蛋,求出至少需要扔几次鸡蛋,才能保证无论 `f` 是多少层,都能将 `f` 找出来? |
| 47 | + |
| 48 | +最简单且直观的想法: |
| 49 | + |
| 50 | +1. 从第 `1` 楼开始扔鸡蛋。`1` 楼不碎,再去 `2` 楼扔。 |
| 51 | +2. `2` 楼还不碎,就去 `3` 楼扔。 |
| 52 | +3. ...... |
| 53 | +4. 直到鸡蛋碎了,也就找到了鸡蛋不会摔碎的最高层 `f`。 |
| 54 | + |
| 55 | +用这种方法,最坏情况下,鸡蛋在第 `n` 层也没摔碎。这种情况下我们总共试了 `n` 次才确定鸡蛋不会摔碎的最高楼层 `f`。 |
| 56 | + |
| 57 | +下面再来说一下比 `n` 次要少的情况。 |
| 58 | + |
| 59 | +如果我们可以通过二分查找的方法,先从 `1` ~ `n` 层的中间层开始扔鸡蛋。 |
| 60 | + |
| 61 | +- 如果鸡蛋碎了,则从第 `1` 层到中间层这个区间中去扔鸡蛋。 |
| 62 | +- 如果鸡蛋没碎,则从中间层到第 `n` 层这个区间中去扔鸡蛋。 |
| 63 | + |
| 64 | +每次扔鸡蛋都从区间的中间层去扔,这样每次都能排除当前区间一半的答案,从而最终确定鸡蛋不会摔碎的最高楼层 `f`。。 |
| 65 | + |
| 66 | +通过这种二分查找的方法,可以优化到 $\log n$ 次就能确定鸡蛋不贵摔碎的最高楼层 `f`。 |
| 67 | + |
| 68 | +因为 $\log n \le n,ドル所以通过二分查找的方式,「至少」比线性查找的次数要少。 |
| 69 | + |
| 70 | +同样,我们还可以通过三分查找、五分查找等等方式减少次数。 |
| 71 | + |
| 72 | +这是不限制鸡蛋个数的情况下,现在在给定 `n` 层楼的基础上,再限制一下鸡蛋个数为 `k`。 |
| 73 | + |
| 74 | +现在题目要求:一共有 `n` 层楼,`k` 个鸡蛋,求出至少需要扔几次鸡蛋,才能保证无论 `f` 是多少层,都能将 `f` 找出来? |
| 75 | + |
| 76 | +如果鸡蛋足够多(大于等于 $\log_2 n$ 个),可以通过二分查找的方法来测试。如果鸡蛋不够多,可能二分查找过程中,鸡蛋就用没了,则不能通过二分查找的方法来测试。 |
| 77 | + |
| 78 | +那么这时候为了找出 `f` ,我们应该如何求出最少的扔鸡蛋次数。 |
| 79 | + |
| 80 | +可以这样考虑。题目限定了 `n` 层楼,`k` 个鸡蛋。 |
| 81 | + |
| 82 | +如果我们尝试在 `1` ~ `n` 层中的任意一层 `x` 扔鸡蛋: |
| 83 | + |
| 84 | +1. 如果鸡蛋没碎,则说明 `1` ~ `x` 层都不用再考虑了,我们需要用 `k` 个鸡蛋去考虑剩下的 `n - x` 层,问题就从 `(n, k)` 转变为了 `(n - x, k)`。 |
| 85 | +2. 如果鸡蛋碎了,则说明 `x + 1` ~ `n` 层都不用再考虑了,我们需要去剩下的 `k - 1` 个鸡蛋考虑剩下的 `x - 1` 层,问题就从 `(n, k)` 转变为了 `(x - 1, k - 1)`。 |
| 86 | + |
| 87 | +这样一来,我们就可以根据上述关系使用动态规划方法来解决这道题目了。具体步骤如下: |
| 88 | + |
| 89 | +###### 1. 划分阶段 |
| 90 | + |
| 91 | +按照楼层数量、剩余鸡蛋个数进行阶段划分。 |
| 92 | + |
| 93 | +###### 2. 定义状态 |
| 94 | + |
| 95 | +定义状态 `dp[i][j]` 表示为:一共有 `i` 层楼,`j` 个鸡蛋的条件下,为了找出 `f` ,最坏情况下的最少扔鸡蛋次数。 |
| 96 | + |
| 97 | +###### 3. 状态转移方程 |
| 98 | + |
| 99 | +根据之前的描述,`dp[i][j]` 有两个来源,其状态转移方程为: |
| 100 | + |
| 101 | +$dp[i][j] = min_{1 \le x \le n} (max(dp[i - x][j], dp[x - 1][j - 1])) + 1$ |
| 102 | + |
| 103 | +###### 4. 初始条件 |
| 104 | + |
| 105 | +给定鸡蛋 `k` 的取值范围为 `[1, 100]`,`f` 值取值范围为 `[0, n]`,初始化时,可以考虑将所有值设置为当前拥有的楼层数。 |
| 106 | + |
| 107 | +- 当鸡蛋数为 `1` 时,`dp[i][1] = i`。这是如果唯一的蛋碎了,则无法测试了。只能从低到高,一步步进行测试,最终最少测试数为当前拥有的楼层数。 |
| 108 | + - 如果刚开始初始化时已经将所有值设置为当前拥有的楼层数,则这一步可省略。 |
| 109 | +- 当楼层为 `1` 时,在 `1` 层扔鸡蛋,`dp[1][j] = 1`。这是因为: |
| 110 | + - 如果在 `1` 层扔鸡蛋碎了,则 `f < 1`。同时因为 `f` 的取值范围为 `[0, n]`。所以能确定 `f = 0`。 |
| 111 | + - 如果在 `1` 层扔鸡蛋没碎,则 `f >= 1`。同时因为 `f` 的取值范围为 `[0, n]`。所以能确定 `f = 0`。 |
| 112 | + |
| 113 | +###### 5. 最终结果 |
| 114 | + |
| 115 | +根据我们之前定义的状态,`dp[i][j]` 表示为:一共有 `i` 层楼,`j` 个鸡蛋的条件下,为了找出 `f` ,最坏情况下的最少扔鸡蛋次数。则最终结果为 `dp[n][k]`。 |
| 116 | + |
| 117 | +### 思路 1:代码 |
| 118 | + |
| 119 | +```Python |
| 120 | +class Solution: |
| 121 | + def superEggDrop(self, k: int, n: int) -> int: |
| 122 | + dp = [[0 for _ in range(k + 1)] for i in range(n + 1)] |
| 123 | + |
| 124 | + for i in range(1, n + 1): |
| 125 | + for j in range(1, k + 1): |
| 126 | + dp[i][j] = i |
| 127 | + |
| 128 | + # for i in range(1, n + 1): |
| 129 | + # dp[i][1] = i |
| 130 | + |
| 131 | + for j in range(1, k + 1): |
| 132 | + dp[1][j] = 1 |
| 133 | + |
| 134 | + for i in range(2, n + 1): |
| 135 | + for j in range(2, k + 1): |
| 136 | + for x in range(1, i + 1): |
| 137 | + dp[i][j] = min(dp[i][j], max(dp[i - x][j], dp[x - 1][j - 1]) + 1) |
| 138 | + |
| 139 | + return dp[n][k] |
| 140 | +``` |
| 141 | + |
| 142 | +### 思路 1:复杂度分析 |
| 143 | + |
| 144 | +- **时间复杂度**:$O(n^2 \times k)$。三重循环的时间复杂度为 $O(n^2 \times k)$。 |
| 145 | +- **空间复杂度**:$O(n \times k)$。 |
| 146 | + |
| 147 | +### 思路 2:动态规划优化 |
| 148 | + |
| 149 | +上一步中时间复杂度为 $O(n^2 \times k)$。根据 $n$ 的规模,提交上去补出意外的超时了。 |
| 150 | + |
| 151 | +我们可以观察一下上面的状态转移方程 $dp[i][j] = min_{1 \le x \le n} (max(dp[i - x][j], dp[x - 1][j - 1])) + 1$ 。 |
| 152 | + |
| 153 | +这里最外两层循环的 `i`、`j` 分别为状态的阶段,可以先将 `i`、`j` 看作固定值。最里层循环的 `x` 代表选择的任意一层 `x` ,值从 `1` 遍历到 `i`。 |
| 154 | + |
| 155 | +此时我们把 `dp[i - x][j]` 和 `dp[x - 1][j - 1]` 分别单独来看。可以看出: |
| 156 | + |
| 157 | +- 对于 `dp[i - x][j]`:当 `x` 增加时,`i - x` 的值减少,`dp[i - x][j]` 的值跟着减小。自变量 `x` 与函数 `dp[i - x][j]` 是一条单调非递增函数。 |
| 158 | +- 对于 `dp[x - 1][j - 1]`:当 `x` 增加时, `x - 1` 的值增加,`dp[x - 1][j - 1]` 的值跟着增加。自变量 `x` 与函数 `dp[x - 1][j - 1]` 是一条单调非递减函数。 |
| 159 | + |
| 160 | +两条函数的交点处就是两个函数较大值的最小值位置。即 `dp[i][j]` 所取位置。而这个位置可以通过二分查找满足 `dp[x - 1][j - 1] >= dp[i - x][j]` 最大的那个 `x`。这样时间复杂度就从 $O(n^2 \times k)$ 优化到了 $O(n \log n \times k)$。 |
| 161 | + |
| 162 | +### 思路 2:代码 |
| 163 | + |
| 164 | +```Python |
| 165 | +class Solution: |
| 166 | + def superEggDrop(self, k: int, n: int) -> int: |
| 167 | + dp = [[0 for _ in range(k + 1)] for i in range(n + 1)] |
| 168 | + |
| 169 | + for i in range(1, n + 1): |
| 170 | + for j in range(1, k + 1): |
| 171 | + dp[i][j] = i |
| 172 | + |
| 173 | + # for i in range(1, n + 1): |
| 174 | + # dp[i][1] = i |
| 175 | + |
| 176 | + for j in range(1, k + 1): |
| 177 | + dp[1][j] = 1 |
| 178 | + |
| 179 | + for i in range(2, n + 1): |
| 180 | + for j in range(2, k + 1): |
| 181 | + left, right = 1, i |
| 182 | + while left < right: |
| 183 | + mid = left + (right - left) // 2 |
| 184 | + if dp[mid - 1][j - 1] < dp[i - mid][j]: |
| 185 | + left = mid + 1 |
| 186 | + else: |
| 187 | + right = mid |
| 188 | + dp[i][j] = max(dp[left - 1][j - 1], dp[i - left][j]) + 1 |
| 189 | + |
| 190 | + return dp[n][k] |
| 191 | +``` |
| 192 | + |
| 193 | +### 思路 2:复杂度分析 |
| 194 | + |
| 195 | +- **时间复杂度**:$O(n \log n \times k)$。两重循环的时间复杂度为 $O(n \times k),ドル二分查找的时间复杂度为 $O(\log n)$。 |
| 196 | +- **空间复杂度**:$O(n \times k)$。 |
| 197 | + |
| 198 | +### 思路 3:动态规划 + 逆向思维 |
| 199 | + |
| 200 | + |
| 201 | + |
| 202 | +### 思路 3:代码 |
| 203 | + |
| 204 | +```Python |
| 205 | +class Solution: |
| 206 | + def superEggDrop(self, k: int, n: int) -> int: |
| 207 | + dp = [[i for _ in range(k + 1)] for i in range(n + 1)] |
| 208 | + dp[1][1] = 1 |
| 209 | + |
| 210 | + for i in range(1, n + 1): |
| 211 | + for j in range(1, k + 1): |
| 212 | + dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1] + 1 |
| 213 | + if j == k and dp[i][j] >= n: |
| 214 | + return i |
| 215 | + return n |
| 216 | +``` |
| 217 | + |
| 218 | +### 思路 3:复杂度分析 |
| 219 | + |
| 220 | +- **时间复杂度**:$O(n \times k)$。两重循环的时间复杂度为 $O(n \times k)$。 |
| 221 | +- **空间复杂度**:$O(n \times k)$。 |
| 222 | + |
| 223 | +## 参考资料 |
| 224 | + |
| 225 | +- 【题解】[题目理解 + 基本解法 + 进阶解法 - 鸡蛋掉落 - 力扣](https://leetcode.cn/problems/super-egg-drop/solution/ji-ben-dong-tai-gui-hua-jie-fa-by-labuladong/) |
| 226 | +- 【题解】[动态规划(只解释官方题解方法一)(Java) - 鸡蛋掉落 - 力扣](https://leetcode.cn/problems/super-egg-drop/solution/dong-tai-gui-hua-zhi-jie-shi-guan-fang-ti-jie-fang/) |
0 commit comments