Leetcode 887. 鸡蛋掉落 动态规划
题目是这样:你面前有一栋从 1 到N
共N
层的楼,然后给你K
个鸡蛋(K
至少为 1)。现在确定这栋楼存在楼层0 <= F <= N
,在这层楼将鸡蛋扔下去,鸡蛋恰好没摔碎(高于F
的楼层都会碎,低于F
的楼层都不会碎)。现在问你,最坏情况下,你至少要扔几次鸡蛋,才能确定这个楼层F
呢?
PS:F 可以为 0,比如说鸡蛋在 1 层都能摔碎,那么 F = 0。
也就是让你找摔不碎鸡蛋的最高楼层F
,但什么叫「最坏情况」下「至少」要扔几次呢?我们分别举个例子就明白了。
比方说现在先不管鸡蛋个数的限制,有 7 层楼,你怎么去找鸡蛋恰好摔碎的那层楼?
最原始的方式就是线性扫描:我先在 1 楼扔一下,没碎,我再去 2 楼扔一下,没碎,我再去 3 楼……
以这种策略,最坏情况应该就是我试到第 7 层鸡蛋也没碎(F = 7
),也就是我扔了 7 次鸡蛋。
现在你应该理解什么叫做「最坏情况」下了,鸡蛋破碎一定发生在搜索区间穷尽时,不会说你在第 1 层摔一下鸡蛋就碎了,这是你运气好,不是最坏情况。
现在再来理解一下什么叫「至少」要扔几次。依然不考虑鸡蛋个数限制,同样是 7 层楼,我们可以优化策略。
最好的策略是使用二分查找思路,我先去第(1 + 7) / 2 = 4
层扔一下:
如果碎了说明F
小于 4,我就去第(1 + 3) / 2 = 2
层试……
如果没碎说明F
大于等于 4,我就去第(5 + 7) / 2 = 6
层试……
以这种策略,最坏情况应该是试到第 7 层鸡蛋还没碎(F = 7
),或者鸡蛋一直碎到第 1 层(F = 0
)。然而无论那种最坏情况,只需要试log7
向上取整等于 3 次,比刚才的 7 次要少,这就是所谓的至少要扔几次。
PS:这有点像 Big O 表示法计算算法的复杂度。
实际上,如果不限制鸡蛋个数的话,二分思路显然可以得到最少尝试的次数,但问题是,现在给你了鸡蛋个数的限制K
,直接使用二分思路就不行了。
比如说只给你 1 个鸡蛋,7 层楼,你敢用二分吗?你直接去第 4 层扔一下,如果鸡蛋没碎还好,但如果碎了你就没有鸡蛋继续测试了,无法确定鸡蛋恰好摔不碎的楼层F
了。这种情况下只能用线性扫描的方法,算法返回结果应该是 7。
有的读者也许会有这种想法:二分查找排除楼层的速度无疑是最快的,那干脆先用二分查找,等到只剩 1 个鸡蛋的时候再执行线性扫描,这样得到的结果是不是就是最少的扔鸡蛋次数呢?
很遗憾,并不是,比如说把楼层变高一些,100 层,给你 2 个鸡蛋,你在 50 层扔一下,碎了,那就只能线性扫描 1~49 层了,最坏情况下要扔 50 次。
如果不要「二分」,变成「五分」「十分」都会大幅减少最坏情况下的尝试次数。比方说第一个鸡蛋每隔十层楼扔,在哪里碎了第二个鸡蛋一个个线性扫描,总共不会超过 20 次。
最优解其实是 14 次。最优策略非常多,而且并没有什么规律可言。
/* * @lc app=leetcode.cn id=887 lang=cpp * * [887] 鸡蛋掉落 * * https://leetcode-cn.com/problems/super-egg-drop/description/ * * algorithms * Hard (28.88%) * Likes: 622 * Dislikes: 0 * Total Accepted: 43K * Total Submissions: 148.9K * Testcase Example: '1\n2' * * 给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。 * * 已知存在楼层 f ,满足 0 ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。 * * 每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 * )。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。 * * 请你计算并返回要确定 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 * 1 * * */
1、基础思路
我们在第i
层楼扔了鸡蛋之后,可能出现两种情况:鸡蛋碎了,鸡蛋没碎。注意,这时候状态转移就来了:
如果鸡蛋碎了,那么鸡蛋的个数K
应该减一,搜索的楼层区间应该从[1..N]
变为[1..i-1]
共i-1
层楼;
如果鸡蛋没碎,那么鸡蛋的个数K
不变,搜索的楼层区间应该从 [1..N]
变为[i+1..N]
共N-i
层楼。
递归的 base case 很容易理解:当楼层数N
等于 0 时,显然不需要扔鸡蛋;当鸡蛋数K
为 1 时,显然只能线性扫描所有楼层:
n=0,return0;k=1,return n;
可以正常解决,但是会超时。也是所有优化思路的基础
class Solution { public: int superEggDrop(int k, int n) { vector<vector<int>> dp(k+1,vector<int>(n+1,-1)); return find(k,n,dp); } int find(int k,int n, vector<vector<int>> &dp){ if(k==1) return n; if(n==0) return 0; if(dp[k][n]!=-1) return dp[k][n]; int res=INT_MAX; for(int i=1;i<=n;++i){ res=min(res,max(find(k-1,i-1,dp),find(k,n-i,dp))+1); } dp[k][n]=res; return res; } };
2、优化思路-二分查找
注意dp(K - 1, i - 1)
和dp(K, N - i)
这两个函数,其中i
是从 1 到N
单增的,如果我们固定K
和N
,把这两个函数看做关于i
的函数,前者随着i
的增加应该也是单调递增的,而后者随着i
的增加应该是单调递减的:
注意dp(K - 1, i - 1)
和dp(K, N - i)
这两个函数,其中i
是从 1 到N
单增的,如果我们固定K
和N
,把这两个函数看做关于i
的函数,前者随着i
的增加应该也是单调递增的,而后者随着i
的增加应该是单调递减的:
这时候求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线交点,也就是红色折线的最低点嘛
也就是每次不遍历,而是使用二分查找来找到交点值
这个思路可以通过case
class Solution { public: int superEggDrop(int k, int n) { vector<vector<int>> dp(k+1,vector<int>(n+1,-1)); return find(k,n,dp); } int find(int k,int n, vector<vector<int>> &dp){ if(k==1) return n; if(n==0) return 0; if(dp[k][n]!=-1) return dp[k][n]; int res=INT_MAX; int lo=1,hi=n; while(lo<=hi){ int mid=(lo+hi)/2; int broken=find(k-1,mid-1,dp); int no_broken=find(k,n-mid,dp); if(broken<no_broken){ lo=mid+1; res=min(res,no_broken+1); } else{ hi=mid-1; res=min(res,broken+1); } } dp[k][n]=res; return res; } };