动态规划问题(十一)鸡蛋掉落问题
问题描述
假设你现在手上有 n 个鸡蛋,现在有一座 k 层的楼。现在,这座楼层中可能存在这么一个临界楼层,从该层楼层以及下面的楼层扔下去的鸡蛋不会破,而只要从高于该楼层丢下的鸡蛋都会破。现在,你需要做的是计算至少要丢多少次鸡蛋你才能确定这个楼层。(当丢鸡蛋时丢下的鸡蛋没有破的话可以继续使用,但是如果破了就不能再使用了)
例如,对于一座高度为 4 的楼层,现在你手上有 2 个鸡蛋,那么你至少需要丢 3 次鸡蛋才能确定临界楼层。(第一次鸡蛋在 2 层丢,如果破了需要在第一层再丢一次,这需要丢2次能确定结果;如果在第二层没有破,那么需要在第三层和第四层在确认一下,因此需要再丢 2 次。由于要能够得到确切的结果,因此至少要丢 3 次才能得到结果)
解决思路
这个问题比较复杂,本身要理解这个题目以及答案的来由就比较困难。下面介绍几种能够比较友好地说明如何去解决这个问题的方法。
-
递归
- 遍历整个楼层,分析当前的掉落情况,递归处理得到原问题的解
- 如果手上只有一个鸡蛋,那么只能从 1 层开始从上向上丢鸡蛋来确定临界楼层(如果手上没有鸡蛋就无法确定临界楼层)
- 对于每一楼层丢的鸡蛋,都有
eggDrop(n, k) = 1 + min(eggDrop(n - 1, x), eggDrop(n, k - x)),其中 x = {1, 2, 3, 4, .......}, 表示在 x 层丢了鸡蛋
-
动态规划
- 参照递归的解决方案,使用递归将会重复计算之前已经计算过的结果,因此可以使用动态规划的方式来存储中间结果
- 转换方程与递归的类似,使用
dp[i][j]
表示当有 i 个鸡蛋,j 层楼的时候,至少需要丢鸡蛋的次数,因此,dp[i][j] = 1 + max(dp[i - 1][x - 1], dp[i][j - x]) (x={1, 2, 3, ....j}),x 表示丢鸡蛋的楼层
-
二项式系数转换
-
能用数学的方式解决的问题,那么它无疑将会是一种相当优秀的解决方案,特别是能够将具体问题转换为对应的数学问题时。
-
回顾一下二项式系数的计算公式,\(C(n, k) = C(n - 1, k - 1) + C(n - k, k)\) 和 \(C(n, 0)=C(n, n) = 1\)
-
现在,将原问题转换一下:在 n 个鸡蛋,k 层楼的高度下,至少需要丢多少次鸡蛋才能确定临界楼层? ——> 在 n 个鸡蛋的情况下,至少要做多少次实验,才能达到 k 层楼。
-
现在,定义函数 \(f(d, n)\) 表示在有 n 个鸡蛋,做 d 次实验的情况下,可以覆盖到的楼层高度。如果当前做实验时鸡蛋破了,那么能够覆盖的楼层高度为 \(f(d - 1, n - 1)\) 。如果没破的话 能够覆盖的楼层高度 \(f(d - 1, n)\),因此,\(f(d, n) = 1 + f(d - 1, n) + f(d - 1, n - 1)\)
-
引入辅助函数 \(g(d, n) = f(d, n + 1) - f(d, n)\)
\[\begin{aligned} g(d, n) &= f(d, n + 1) - f(d, n)\\ &=f(d-1, n + 1) + f(d - 1, n) + 1 - f(d- 1, n) - f(d - 1, n -1) + 1\\ &=[f(d - 1, n + 1) - f(d - 1, n)] + [f(d - 1, n) + f(d - 1, n - 1)]\\ &=g(d - 1, n) + g(d - 1, n - 1) \end{aligned} \] -
现在再与上文的二项式系数进行比较,你会发现它们的形式是一样的。因此 \(g(d, n) = \dbinom{d}{n}\)。由于 \(f(0, n) = 0\),根据 \(g\) 和 \(f\) 之间的关系,\(g(0, n)\) 应该为 0,但是当 n = 1时,\(g(0, 0) = 1\),与之不符。为此,定义 \(g(d, n) = \dbinom{d}{n+1}\)
-
现在
\[\begin{aligned} f(d, n) &= [f(d, n) - f(d, n - 1)]\\ &+[f(d, n - 1) - f(d, n - 2)]\\ &\dots\dots\\ &+[f(d, 1) - f(d, 0)]\\ &+f(d,0) \end{aligned} \] -
由于 \(f(d, 0) = 0\),因此 \(f(d, n) = g(d, n - 1) + d(d, n - 2) + \dots + g(d, 0)\)。由于\(g(d, n) = \dbinom{d}{n+1}\) ,因此 \(f(d, n) = \sum_{i=1}^n\dbinom{d}{i}\)
-
因此现在的问题是 \(f(d, n) \geq k\) 的 d 的最小值,由于 \(f(d, n) = \sum_{i=1}^n\dbinom{d}{i}\) ,因此该问题转换为了 $ \sum_{i=1}^n\dbinom{d}{i} \geq k $ 的 d 的最小值,然而计算二项式系数我们可以再 \(O(n)\) 的时间复杂度内完成。同时,由于 k 是从下到上依次有序的,因此可以通过二分查找来得到最小的 d,从而整体降低了算法的时间复杂度。
-
实现
-
递归
public class Solution { /** * 通过递归的方式来获取需要丢鸡蛋的此时 * * @param n : 手上可用的鸡蛋个数 * @param k : 当前需要处理的楼层的高度 * @return : 至少需要丢鸡蛋的次数 */ public static int eggDropRecur(int n, int k) { /* 如果当前需要考虑的楼层数量为 1 或 0,如果为 0 的情况,那么就不需要再丢鸡蛋了,因此直接返回 0; 而如果楼层的数量为 1,那么只需要再丢一次即可得到确切的结果。 因此直接返回 k 的楼层数 */ if (1 == k || 0 == k) return k; /* 如果手上只有一枚鸡蛋可以使用了,那么就只能从上往下一层一层地丢了,因此也是返回楼层的数量 k。 */ if (1 == n) return k; int min = Integer.MAX_VALUE; /* * 从第一层开始不断地尝试,考虑出现的所有情况,得到需要的最小尝试次数 * * x 是当前的试验楼层 */ for (int x = 1; x <= k; ++x) { min = Math.min( // 在不同的楼层丢鸡蛋可能会有不同的结果,这里取最小值即可 min, Math.max( // 这里的 max 是因为只有得到最大值才能确保能够得到最终的结果 eggDropRecur(n - 1, x - 1), // 当前楼层丢的鸡蛋破了的情况 eggDropRecur(n, k - x) // 当前楼层丢的鸡蛋没有破的情况 ) ); } return min + 1; // +1 是因为当前在当前楼层丢了一次鸡蛋 } }
复杂度分析:
时间复杂度:每次都需要遍历楼层的层数进行递归操作,因此时间复杂度为 \(O(n^k)\)
空间复杂度:只需要常数级别的变量存储这些结果,因此空间复杂度为 \(O(1)\)
-
动态规划
public class Solution { /** * 使用动态规划的方式解决鸡蛋掉落问题 * * @param n : 给予测试的鸡蛋总数 * @param k : 当前测试的建筑物的高度 */ public static int eggDropDP(int n, int k) { // 存储状态的二维数组 int[][] dp = new int[n + 1][k + 1]; /* * 初始化状态数组的边界值, * * 对于 0 个鸡蛋的情况,无法通过丢鸡蛋得到确切的临界楼层,因此无须考虑这种情况 * * 对于建筑物的楼层为 0 的情况,无须通过丢鸡蛋得到确切的临界楼层,因此实验次数为 0 * * 对于只有一个鸡蛋的情况,要得到确切的临界楼层只能从第一层依次向上不断丢鸡蛋得到临界楼层 */ for (int i = 1; i <= n; ++i) { dp[i][1] = 1; dp[i][0] = 0; } for (int j = 1; j <= k; ++j) { dp[1][j] = j; dp[0][j] = 0; } // 初始化状态二维数组结束 int ans; // 存储计算过程中的最小值,即为最终的结果 /* i 代表鸡蛋的个数,j 代表当前要测试的楼层总高度,x 代表丢鸡蛋时出现结果的测试楼层高度 */ for (int i = 2; i <= n; ++i) { for (int j = 2; j <= k; ++j) { dp[i][j] = Integer.MAX_VALUE; for (int x = 1; x <= j; ++x) { ans = 1 + Math.max(dp[i - 1][x - 1], dp[i][j - x]); if (ans < dp[i][j]) dp[i][j] = ans; } } } // 由于状态转换的关系,最终可以得到有 n 个鸡蛋、k 层楼的情况下需要实验的最下次数 return dp[n][k]; } }
复杂度分析:
时间复杂度:由于需要不断遍历楼层,因此最终的时间复杂度为 \(O(n*k^2)\)
空间复杂度:需要使用一个二维数组来存储之前的状态,因此空间复杂度为 \(O(n*k)\)
-
二项式系数转换
public class Solution { // 计算二项式系数及其总和 private static int binomial(int x, int n, int k) { int val = 1, sum = 0; // 二项式系数总和 /* * 由于在这个问题中只需要判断是否能够覆盖整层楼, * 因此无需计算整个二项式系数总和的具体值 */ for (int i = 1; i <= n && sum < k; ++i) { val *= x - i + 1; val /= i; sum += val; } return sum; } /** * 使用二项式系数和二分查找解决鸡蛋掉落问题 * * 不管手上由多少个鸡蛋,总的实验次数不会超过楼层的总高度 * 同样的,不可能一次实验都不做就直接得到实验总数, * 由于楼层的高度是一个有序的,因此可以采用二分查找的方式解决 * * @param n : 能够实验的鸡蛋的总个数 * @param k : 试验的建筑物的总层数 */ int eggDropBinomial(int n, int k) { int lo = 1, hi = k; int mid = 0; while (lo < hi) { mid = (lo + hi) / 2; if (binomial(mid, n, k) < k) lo = mid + 1; // 当前的试验次数不能覆盖整个楼层,因此必须加一 else hi = mid; // 当前的试验次数可以覆盖整个楼层,因此进一步细化试验次数范围 } return lo; } }
复杂度分析:
时间复杂度:主要时间花费在计算二项式系数上,结合二分查找的时间复杂度,总的时间复杂度为 \(O(n\log_2k)\)
空间复杂度:只需要几个临时变量存储计算的中间值,因此空间复杂度为 \(O(1)\)
参考:
https://www.geeksforgeeks.org/egg-dropping-puzzle-dp-11/
https://www.geeksforgeeks.org/eggs-dropping-puzzle-binomial-coefficient-and-binary-search-solution/
https://brilliant.org/wiki/egg-dropping/
http://www.cs.umd.edu/~gasarch/BLOGPAPERS/eggold.pdf