[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) 要高效一些。

       

原文地址:https://www.cnblogs.com/wangxf2019/p/13922359.html