问题描述
这是LeetCode的第887题。题目如下:
你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。
每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。
你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。
每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。
你的目标是确切地知道 F 的值是多少。
无论 F 的初始值如何,你确定 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 <= K <= 100
1 <= N <= 10000
思路1
这道题说是DP中的高难度题不为过吧…首先要注意一点,鸡蛋不碎是可以再用的。
首先想到的思路(二分法、十分法、其他方法···)
乍一看,要找到扔鸡蛋不碎的临界楼层(此楼层及上面的楼层扔鸡蛋都会碎,下面的都不碎),大多数人(我就是这样想的)想到的应该是二分查找吧。但仔细想想,二分查找只能适用于鸡蛋无限的情况下:假设为100层楼,鸡蛋无限,可以从第50层扔一下试试,碎了就去25层再扔一个,没碎就去75层再扔一个试试。这样只需要尝试logN次一定能找到临界楼层。
但鸡蛋数量有限的情况下就不能这样扔了:还是100层楼,有2个鸡蛋,当从50层扔鸡蛋碎了,手里只剩1个鸡蛋了,这时候只能确定临界楼层在[1,50)之间,只能从第1层开始往第49层一层一层试了。鸡蛋可能在第1层就碎了,但这不是最坏情况,最坏情况就是要保证不管临界楼层在哪一层,所求出的最少尝试次数都能找到这个楼层。这里的最坏情况是临界楼层是49层,需要再尝试49次,加上一开始在50层扔碎的一个鸡蛋,一共是50次。所以,二分法是不合适的。
既然二分法每次折半查找不合适,那如果减小查找的间距呢?每次不再是二分,而是十分法,即每次跨越10层楼层查找:先在10层扔一下,没碎就去20层再扔,没碎再去30层扔…当只剩一个鸡蛋时就开始一层一层试,这样的最坏情况就是第100层都不会碎,一共尝试了以下楼层:10,20,30,40,50,60,70,80,90,91,92,93,94,95,96,97,98,99,100,共19次。但这是最优解吗?不是的,还有其他的方案。
由上面的十分法想一下,当扔到第90层鸡蛋还没碎的情况下,我现在还有两个鸡蛋,第91-100层一定要一层一层试吗?当然是不需要的,因为我只需要在只剩一个鸡蛋的情况下才需要一层层尝试。所以,最后的91-100层我可以再二分查找啊:先在95层扔一下,没碎,那再去98层扔一下,也没碎,那再去99层扔一下,也没碎,再去100层,还是没碎,这样一共尝试了前面的10、20、30、40、50、60、70、80、90加上95、98、99、100,一共尝试了13次,发现这个解比起上次的19次更优了。但注意!!如果某一层鸡蛋碎了呢?那么只剩一个鸡蛋了,就只能一层一层试了。所以,最坏情况下的临界楼层是根据查找方式而变化的,也就是不管临界楼层在哪,不管用什么查找方法,这个求得的最小次数最终一定能找到它。
那么问题又来了,既然最后91-100层可以改变查找方式,那我在前面的部分也可以改变查找方式啊。比如使用动态查找方式,我每次查找都改变间距,不再是每次跳跃十层,而是第一次跳k层,第二次跳k-1层,第三次跳k-2层…最后一次跳1层。这个k不是固定的,使用不同的k获得的查找次数也不一样。只要满足k + (k-1) + (k - 2) + (k - 3) + ··· + 1 >=楼层数N即可。如下图:
这样的话,第一次我需要在15楼扔个鸡蛋试试,没碎去28楼再扔一个······一共尝试15、28、40、51、61、70、78、85、91、96、100,共11次,这种方案也比最开始的19次要更优。
再比如,在前50层中进行十分查找,后五十层进行二分查找;或者前20层中进行二分查找,20-40层进行四分查找,40-60层进行八分查找…或者每次跳跃的k不同······方案有无数种,我们要求的就是不管使用什么查找方案,最终找到这个最少次数,对于在任何位置的临界楼层都能找到它。
动态规划三要素
1.dp数组的含义:现在需要考虑的状态,一个是手里还有几个鸡蛋,一个是当前楼有多少层。使用dp[n][k]
表示n层楼,手里还有k个鸡蛋时,至少需要试多少次才能找到临界楼层。
2.初始值:当楼层数为0的时候,就是没有任何楼层的时候,不管手里有几个鸡蛋,都不需要尝试,即dp[0][*] = 1
;当手里鸡蛋只剩一个时,不管剩下多少层,只能一层一层试,那么有多少层楼就需要试多少次,即dp[k][1] = k
。列出如下表格,左边一列是楼有多少层,上边一行是手里有多少个鸡蛋:(这里其实可以不考虑n = 0的情况,直接从n = 1开始,初始值dp[1][k] = 1,因为楼只有一层,不管有多少个鸡蛋,都只需要试一次)
3.状态转移方程:假设我站在第n层扔鸡蛋,手里有k个鸡蛋,那么这个鸡蛋扔下去,就会有两种结果:碎或者不碎。如果碎了,我就需要去第n层下面的1~n-1层中再试,楼层数剩下n-1层,此时手里的鸡蛋数为k-1;如果没碎,我就需要去第n层上面的n+1~N层中再试,楼层数剩下N-(n+1)+1共N-n层,此时手中的鸡蛋数为k。这里可以把上下两部分想像成两栋楼,一栋楼共是n-1层,另一栋楼共N-n层。本来需要做的选择是在第n层楼的上下两段中选择次数较多的一段,现在看是在两栋楼中选择次数较多的一次,这个选择等同于人为地去设置鸡蛋碎还是不碎,因为题目要求的是最差的情况下,所以,去上面楼层(第二栋楼)继续试还是去下面楼层(第一栋楼)继续试,哪栋楼需要试的次数多就选哪种,i.e.在上面楼层(第二栋楼)需要尝试的次数多,那就让鸡蛋不碎;在下面楼层(第一栋楼)尝试的次数多,那就让鸡蛋碎。此时的状态转移方程表示楼高n层,手里有k个鸡蛋时找到临界楼层的最少次数:dp[n][k] = max{dp[n - 1][k - 1], dp[N - n][k]} + 1
(+1是加上在第n层扔的这一次),dp[n - 1][k - 1]
,dp[N - n][k]
的值都是已知的了。
这里需要注意一点:不要关注第n层楼上面下面的第几层楼,而是第n层楼上面下面还剩下多少层楼需要尝试!假设一共10层,第一次在6层扔了一次,那么需要考虑的就是下面有5层需要尝试、上面还有4层需要尝试,在当前手里有k个鸡蛋的情况下,去哪边尝试扔的次数更多一些。这里下面的5层和上面的4层可以想象成是两栋楼,一栋有5层,一栋有4层,手里还有k个鸡蛋,去哪栋楼扔会扔的次数更多。那么从哪层楼开始扔呢?从第1层楼开始,一直扔到第N层挨层试,每次都取当前楼层的上下两部分中需要尝试次数较多的那部分的次数,这样才能保证在当前楼层扔完后,所选的这个次数能适用于最坏的情况,即适用于所有情况。现在得到:第一次在第1层扔、第一次在第2层扔、第一次在第3层扔······第一次在第N层扔的N个次数值,它们都能保证第一次在当前楼层扔时,最坏情况下都能找到临界楼层,但是第一次在哪里扔我是可以选的,当然是第一次在哪层楼扔需要尝试的次数更少我就在哪扔第一次,即从这N个值中选最小的那个。
解答1:
下面着手写代码:首先,遍历楼层数N,从一层高的楼开始,一直到N层高的楼;其次,从手里有2个鸡蛋开始,遍历到手里有K个鸡蛋;每次需要做什么呢?即求出dp[n][k]
。要求出dp[n][k]
,就需要挨着尝试第一次把鸡蛋扔在第几层更好,从第1层开始,一直试到当前的楼层高n。
C++代码如下:
int superEggDrop(int K, int N) {
vector<vector<int>> dp(N + 1, vector<int>(K + 1, 0));
// 初始化dp数组
for (int n = 0; n <= N; n++) dp[n][1] = n;
//状态转移方程
for (int n = 1; n <= N; n++)
for (int k = 2; k <= K; k++) {
int res = INT_MAX;
for (int j = 1; j <= n; j++)//第一次从哪扔?从第1层开始试
res = min(res, max(dp[j-1][k-1], dp[n-j][k]) + 1);
dp[n][k] = res;
}
return dp[N][K];
}
但是,这种方法的时间复杂度很高。由于枚举N层高的楼、手里K个鸡蛋的情况共有KN种状态,每种状态都需要从第1层楼开始挨层尝试一遍,所以时间复杂度为O(KN*N),即O(KN^2)。这个时间复杂度是无法通过LeetCode这道题的提交的。所以,需要着手优化。
思路1的改进:使用二分法
这里的二分法并不是前面所提到的那种二分法。待改进的部分是最内层的循环,即第一次从哪层扔时,不再是从第1层开始挨着试,而是使用二分法试。
为什么可以这样做呢?观察dp[n][k] =min{ max{dp[n - 1][k - 1], dp[N - n][k]} + 1}
这个方程,在鸡蛋数k不变、总楼层数N不变的情况下,自变量n在[1, N]变化时,第一个函数dp[n - 1][k - 1]
是单调递增的,因为手里鸡蛋数量固定为k-1时,当前楼层数越多,我需要试的次数就越多,函数最小值为:n = 1时,最小值为dp[0][k - 1] = 0
;最大值为:n = N时,此时最大值为dp[N - 1][k - 1]
。第二个函数dp[N - n][k]
在手里鸡蛋数量固定为k时,自变量n从[1, N]变化时是单调递减的,因为当前楼层数n越大,剩下的楼层数N - n就越小,需要试的次数就越少,最小值为:n = N时,dp[0][k] = 0
;最大值为:n = 1时,dp[N - 1][k]
。这两个函数是离散函数,为了方便在图中表示,这里把它想像成连续函数。如下图:
那么,两个函数的较大值max{dp[n - 1][k - 1], dp[N - n][k]}
便是图中蓝色标注的部分,我把它定义为Z(x),即Z(x) = max{dp[n - 1][k - 1], dp[N - n][k]}
,那么,Z(x)的自变量x的取值便是[1, n],同样把这个离散函数Z(x)当作连续函数看待,那么min{Z(x)}的最小值就是前面两个函数相交的点。现在,就需要用二分法来找这个最小值点。
首先要明确的是,不管n取[1, N]中的任何值,两个函数的交点一定存在,现在把之前的从n = 1开始试的代码改为在楼高为n时,在[1, n]范围内找交点。由于真实函数是离散的,所以两个函数的交点不一定重合,可能出现连续函数交点的附近,要么左边要么右边。由于我们已知一开始dp[n - 1][k - 1]
是小于dp[N - n][k]
的,所以只要找到dp[n - 1][k - 1]
大于等于dp[N - n][k]
的那个点即可定位交点的范围。初始时,low为1,high为n,若在n = mid点的函数值还是dp[mid - 1][k - 1]
<dp[n - mid][k]
,则说明交点出现在mid的右边,这时就让low指向mid + 1的位置,并记录此时更小的Z(x)函数值,然后继续查找;直到找到有一个点dp[n - 1][k - 1]
≥dp[N - n][k]
,此时说明交点在mid的左边,则让high指向mid - 1的位置,记录此时更小的Z(x)函数值。特殊的情况是两个离散的函数刚好有交点,此时这个交点对应的函数值一定是Z(x)的最小值,就不用再查找了。最后将最小值加上在交点这一层扔的一次鸡蛋即可。
C++代码如下:
class Solution {
public:
int superEggDrop(int K, int N) {
if (K == 1) return N;
if (N == 1) return 1;
vector<vector<int>> dp(N + 1, vector<int>(K + 1, 0));
// 初始化dp数组
for (int n = 1; n <= N; n++) dp[n][1] = n;
// for (int k = 1; k <= K; k++) dp[1][k] = 1;
for (int n = 1; n <= N; n++) {
for (int k = 2; k <= K; k++) {
int low = 1, high = n, mid;
int res = dp[n-1][k];//初始值设置为dp[N-n][k]函数的最大值
//下面使用二分查找
while (low <= high) {
mid = low + (high - low) / 2;
// mid = low + ((high - low) >> 1);
if (dp[mid-1][k-1] == dp [n-mid][k]){//刚好找到交点
res = min(res, dp[mid-1][k-1]);
break;
} else if (dp[mid-1][k-1] > dp[n-mid][k]){//确定交点在[low, mid]之间
high = mid - 1;
res = min(res, dp[mid-1][k-1]);
} else if (dp[mid-1][k-1] < dp[n-mid][k]){//确定交点在[mid, high之间
low = mid + 1;
res = min(res, dp[n-mid][k]);
}
}
dp[n][k] = res + 1;//最后加上在交点这一层扔的一次
}
}
return dp[N][K];
}
};
参考:
1.李永乐B站 双蛋问题
2.labuladong的算法小抄:《经典动态规划:高楼扔鸡蛋》
3.LeetCode评论区:@Zack的评论
4.BRILLIANT