[LeetCode] 887. Super Egg Drop 超级鸡蛋掉落
题目是这样:你面前有一栋从 1 到 N
共 N
层的楼,然后给你 K
个鸡蛋(K
至少为 1)。
现在确定这栋楼存在楼层 0 <= F <= N
,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎
(高于 F
的楼层都会碎,低于 F
的楼层都不会碎)。现在问你,最坏情况下,你至少
要扔几次鸡蛋,才能确定这个楼层 F
呢?
注:这里的楼层数和我们日常生活中理解的有差异,楼层数0表示地面,从地面扔鸡蛋
一定不碎,楼层数1,即表示我们日常认知里的2楼。
题目中 求 最坏情况下,为确定楼层 F,扔鸡蛋最少次数。题目给我们的直观感觉是 使用
二分法求解,思想类似于 “小老鼠喝毒药”,使用最少的老鼠,找出哪一瓶是毒药。得到的最少
次数为 logN,这样,题目给的 参数 K就没用到,那这种解法就一定有问题。
问题在哪呢?假设,现在 k=1,N=100,按照上面二分法的思路,在50层扔鸡蛋,如果鸡蛋没碎,
F 在区间 [51,100 ],此时鸡蛋还能继续用,但是如果鸡蛋碎了,即使我们已经知道 F 在区间 [1,49 ],
但是我们仍然无法找到 F 。这时此时唯一的办法是,从第一楼开始,往上一直到100层,一层一层地
扔鸡蛋,直到鸡蛋碎在 m 层,此时 扔了m次鸡蛋 , 得到 F = m-1。最坏情况下,一直到N=100层鸡
蛋才碎了,扔了100次,得到 F = 99。这里的最坏情况是指鸡蛋破碎一定发生在搜索区间穷尽时,
和求算法时间复杂度的最坏情况概念很相似。
题目的含义中有 “最坏情况下最小的扔鸡蛋次数” ,可以尝试使用动态规划的方法求解;
1.定义状态:「状态」很明显,就是当前拥有的鸡蛋数 K
和需要测试的楼层数 N。随着测试
的进行,鸡蛋个数可能减少,楼层的搜索范围会减小,这就是状态的变化。
2.状态转移:「选择」其实就是去选择哪层楼扔鸡蛋。对 1到N之间的所有楼层,我们可以
计算在最坏情况下找到 F 需要扔鸡蛋的次数n(i)。然后取最小的n(i),即得到我们想要的结果。
总结如下:
1、暴力穷举尝试在所有楼层 1 <= i <= N
扔鸡蛋,每次选择最坏情况尝试次数最少的那一层;
2、每次扔鸡蛋有两种可能,要么碎,要么没碎;
3、如果鸡蛋碎了,F
应该在第 i
层下面,否则,F
应该在第 i
层上面;
4、鸡蛋是碎了还是没碎,取决于哪种情况下尝试次数更多,因为我们想求的是最坏情况下的结果
状态转移的伪代码如下:
1 def dp(K, N): 2 for 1 <= i <= N: 3 # 最坏情况下的最少扔鸡蛋次数 4 res = min(res, 5 max( 6 dp(K - 1, i - 1), # 碎 7 dp(K, N - i) # 没碎 8 ) + 1 # 在第 i 楼扔了一次 9 ) 10 return res
上面的状态转移是 使用线性的方式,使用一个 for loop,算出所有楼层的n(i),最后取最小的。
这种方法在LeetCode上会有超时。
可以使用二分搜索的方法优化。这里的二分搜索和上面提到的不是一回事。伪代码如下:
1 lo, hi = 1, N 2 while lo <= hi: 3 mid = (lo + hi) // 2 4 broken = dp(K - 1, mid - 1) # 碎 5 not_broken = dp(K, N - mid) # 没碎 6 # res = min(max(碎,没碎) + 1) 7 if broken > not_broken: 8 hi = mid - 1 9 res = min(res, broken + 1) 10 else: 11 lo = mid + 1 12 res = min(res, not_broken + 1) 13 return res
因为递归中存在大量的重复子问题,所以我们可以使用备忘录的方法,避免子问题的重复计算,
提高效率。最终的代码如下:
1 //N层楼中扔鸡蛋,找到最坏情况下,鸡蛋恰好不碎的楼层,所需的最少实验次数 2 class Solution { 3 public: 4 int superEggDrop(int K, int N) 5 { 6 memo.clear(); 7 return dp(K,N); 8 } 9 private: 10 int dp(int K, int N) 11 { 12 //base case 13 if(K==1) return N; 14 if(N==0) return 0; 15 //检索备忘录,若备忘录中有相应的状态结果,直接返回 16 if(memo.find(N*100+K)!=memo.end()) return memo[N*100+K]; 17 //结果初始化 18 int res = INT_MAX; 19 //线性搜索 20 // for(int i=1;i<=N;++i) 21 // { 22 // res = min(res,max(dp(K,N-i),dp(K-1,i-1))+1); 23 // } 24 //二分搜索 25 int low = 1,high = N; 26 while(low<=high) 27 { 28 int mid = (low+high)/2; 29 int broken = dp(K-1,mid-1);//在mid层扔鸡蛋,碎 30 int not_broken = dp(K,N-mid);//在midc层人鸡蛋,不碎 31 if(broken>not_broken)//打碎了是最坏情况 32 { 33 high = mid-1;//缩小搜索区间到[low,mid-1] 34 res = min(res,broken+1); 35 } 36 else //没打碎是最坏情况 37 { 38 low = mid +1;//缩小搜索区间 [mid+1,high] 39 res = min(res,not_broken + 1); 40 } 41 } 42 //计算的结果记录到备忘录中 43 memo[N*100+K] = res; 44 return res; 45 } 46 unordered_map<int,int> memo;//备忘录,记录计算过的状态 47 };
算法复杂度分析:
动态规划算法的时间复杂度就是 子问题个数 × 函数本身的复杂度。
子问题个数:也就是不同状态组合的总数,显然是两个状态的乘积,也就是 O(KN)。
函数本身的复杂度:就是忽略递归部分的复杂度,这里 dp
函数中用了一个二分搜索,
所以函数本身的复杂度是 O(logN)。
所以使用了二分搜索优化之后的算法的总时间复杂度是 O(K*N*logN), 空间复杂度 O(KN)。
效率上比未优化的算法 O(KN^2) 要高效一些。