一、前言
分类:动态规划。
问题来源LeetCode 887 难度:困难。
问题链接:https://leetcode-cn.com/problems/super-egg-drop/
二、题目
你将获得 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
三、题意分析
题目看了半天没整明白T_T。最后一句是关键,你的目标是确切地知道 F 的值是多少。无论 F 的初始值如何,你确定 F的值的最小移动次数是多少?
可以这样理解:无论F不管是多少,至少要丢多少次鸡蛋?
也可以这样理解:最坏情况下,你至少要扔几次鸡蛋?
四、思路分析
思路来源网上,按照自己的理解进行整理一下。
参考:
- https://leetcode-cn.com/problems/super-egg-drop/solution/ji-dan-diao-luo-by-leetcode-solution-2/
- https://leetcode-cn.com/problems/super-egg-drop/solution/887-by-ikaruga/
4.1 第一类解题思路:动态规划 + 二分搜索
我们可以考虑使用动态规划来做这道题,状态可以表示成 (K,N),其中 K 为鸡蛋数,N 为楼层数。当我们从第 X 楼扔鸡蛋的时候:
- 如果鸡蛋不碎,那么状态变成 (K, N-X),即我们鸡蛋的数目不变,但答案只可能在上方的 N−X 层楼了。也就是说,我们把原问题缩小成了一个规模为 (K,N−X) 的子问题;
- 如果鸡蛋碎了,那么状态变成 (K−1,X−1),即我们少了一个鸡蛋,但我们知道答案只可能在第 XX 楼下方的 X−1 层楼中了。也就是说,我们把原问题缩小成了一个规模为(K−1,X−1) 的子问题。
这样一来,我们定义 dp(K, N) 为在状态 (K,N) 下最少需要的步数。根据以上分析我们可以列出状态转移方程:
这个状态转移方程是如何得来的呢?对于 dp(K, N) 而言,我们像上面分析的那样,枚举第一个鸡蛋扔在的楼层数 X。由于我们并不知道真正的 F 值,因此我们必须保证 鸡蛋碎了之后接下来需要的步数 和 鸡蛋没碎之后接下来需要的步数 二者的 最大值 最小,这样就保证了在 最坏情况下(也就是无论 F 的值如何) dp(K, N) 的值最小。如果能理解这一点,也就能理解上面的状态转移方程,即最小化max(dp(K-1, X-1), dp(K, N-X))。
复杂度分析
时间复杂度:O(K∗NlogN)。我们需要计算 O(K∗N) 个状态,每个状态计算时需要 O(logN) 的时间进行二分搜索。
空间复杂度:O(K∗N)。我们需要 O(K∗N) 的空间存储每个状态的解。
4.2 第二类解题思路:动态规划,问题转换+逆向推到
N 和 F 的关系
- N 的定义:使用一栋从 1 到 N 共有 N 层楼的建筑
- F 的定义:满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破
- 因此得知,F 比 N 多一个 0 层
问题转换
- 将问题从: N 个楼层,有 K 个蛋,求最少要扔 T 次,才能保证当 F 无论是 0 <= F <= N 中哪个值,都能测试出来
- 转变为:有 K 个蛋,扔 T 次,就可以确定 F 的个数,然后得出 N 个楼层
递推公式
- F(K,T) 记为有K个鸡蛋,T次扔鸡蛋的机会,可以测试的最大楼层数;
- 当扔一个鸡蛋,产生的效果是:【蛋碎了减 1 个,机会减 1 次】 + 【蛋没碎,机会减 1 次】
- 通过步骤2,可以获取到递推公式: F(K, T) = F(K - 1, T - 1) + F(K, T - 1)
临界条件
- 当只有一个鸡蛋,只能从1楼开始往上扔直到鸡蛋破碎,其中还有一个0楼,也就是一个鸡蛋可以测试最大楼层=T+1,F(1, T) = T + 1。
- 只有一次扔鸡蛋的机会,等价于只有一个鸡蛋且只能扔一次 F(K+1) = 2。也可以这样理解,只有能扔一次那就从1楼扔下去,破了可以确定0层不会破,1层会破F=2;不破,可以确定0层和1层都不会破F=2,也就是 F(K+1) = 2。
图:F(K, T),F(K, T)为可以确定的楼层数,包含0层,也就是题意中 N-1 = F(K, T)。
五、解决方法
提供了四种解决方法。
方法一:第一类,动态规划 + 二分搜索。时间复杂度:O(K∗NlogN),空间复杂度:O(K∗N)。
方法二: 第二类,自顶向下通过递归解决。时间复杂度:O(K∗N),空间复杂度:O(1)。
class Solution_2
{
public:
int superEggDrop(int K, int N)
{
int T = 1;
while (calcF(K, T) < N + 1) T++;
return T;
}
int calcF(int K, int T)
{
if (T < 1 && K < 1) return 0; // T 和 K 不能都小于1
if (K == 1) return T + 1; // 只有一个鸡蛋,从一楼开始往扔,最多确定楼层为T+1
if (T == 1) return 2; // 只扔一次那就从1楼扔下去,可以确定0层和1层
//if (T == 1 || K == 1) return T + 1;
return calcF(K - 1, T - 1) + calcF(K, T - 1);
}
};
代码解析:
- calcF(K, T),计算f(K,T)值也就是可以确定的楼层数,由于K是固定的,结果随T增加而增加,当可以确定的楼层数大于或等N+1,T即为所求,N+1是因为包含了0层。
方法三:将方法二转为自底向上,非递归解决。时间复杂度:O(K∗N),空间复杂度:O(2N)。
class Solution_3
{
public:
int superEggDrop(int K, int N)
{
if (K <= 0 || N <= 0)
return 0;
vector<vector<int>> dp(2, vector<int>(K+1, 2));
vector<int>* pre = &dp[0];
vector<int>* cur = &dp[1];
for (int i = 2; i <= N; ++i)
{
(*cur)[1] = i + 1;
for (int j = 2; j <= K; ++j)
(*cur)[j] = (*pre)[j - 1] + (*pre)[j];
if ((*cur)[K] > N)
return i;
swap(cur, pre);
}
return 1;
}
};
辅助空间说明,图形观察如下, F(K, T) = F(K - 1, T - 1) + F(K, T - 1),上一层结果觉得下一次值,只需要用2*(K+1)个辅助空间即可。
方法四:对方法三空间进行优化。时间复杂度:O(K∗N),空间复杂度:O(N)。
class Solution_4
{
public:
int superEggDrop(int K, int N)
{
if (K <= 0 || N <= 0)
return 0;
vector<int> dp(K+1,2);
for (int i = 2; i <= N; ++i)
{
for (int j = K; j >= 2; --j)
dp[j] += dp[j - 1];
dp[1] = i + 1;
if (dp[K] > N)
return i;
}
return 1;
}
};
继续观察这个图发现,从后完前计算辅助空间中数据,只需要一行辅助空间就可以了。
六、总结
- 正确理解题意;
- 两类解题思路都是讲大问题拆分成小问题;
- 尤其是第二类解题思路,当扔一下鸡蛋会产生两个结果F(K-1,T-1)和F(K,T-1),这个和《跳台阶》问题很类似;
- 动态规划问题最最最重要的是问题拆分(状态转换)找到递推公式。
七、完整代码
//==========================================================================
/*
* @file : 887_SuperEggDrop.h
* @blogs :
* @author : niebingyu
* @date : 2020/07/08
* @title : 887.鸡蛋掉落
* @purpose : 你将获得 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
*
* 来源:力扣(LeetCode)
* 难度:困难
* 链接:https://leetcode-cn.com/problems/super-egg-drop
*/
//==========================================================================
#pragma once
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;
#define NAMESPACE_SUPEREGGDROP namespace NAME_SUPEREGGDROP {
#define NAMESPACE_SUPEREGGDROPEND }
NAMESPACE_SUPEREGGDROP
// 方法一
// 动态规划 + 二分搜索
// 递推公式
// dp(K, N) = 1 + min(max(dp(K−1, X−1), dp(K, N−X)))
// 1<=x<=N
class Solution_1
{
public:
int superEggDrop(int K, int N)
{
return dp(K, N);
}
private:
unordered_map<int, int> memo;
int dp(int K, int N)
{
if (memo.find(N * 100 + K) == memo.end())
{
int ans;
if (N == 0) ans = 0;
else if (K == 1) ans = N;
else
{
int lo = 1, hi = N;
while (lo + 1 < hi)
{
int x = (lo + hi) / 2;
int t1 = dp(K-1, x-1);
int t2 = dp(K, N-x);
if (t1 < t2) lo = x;
else if (t1 > t2) hi = x;
else lo = hi = x;
}
ans = 1 + min(max(dp(K-1, lo-1), dp(K, N-lo)),
max(dp(K-1, hi-1), dp(K, N-hi)));
}
memo[N * 100 + K] = ans;
}
return memo[N * 100 + K];
}
};
// 方法二:动态规划,问题转换+逆向推到
// 自顶向下,递归解决
// F(K, T) = F(K - 1, T - 1) + F(K, T - 1)
// F(1, T) = T + 1
// F(K, 1) = 2
/*
* N 和 F 的关系
* N 的定义:使用一栋从 1 到 N 共有 N 层楼的建筑
* F 的定义:满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破
* 因此得知,F 比 N 多一个 0 层
*
* 问题转换
* 将问题从: N 个楼层,有 K 个蛋,求最少要扔 T 次,才能保证当 F 无论是 0 <= F <= N 中哪个值,都能测试出来
* 转变为:有 K 个蛋,扔 T 次,求可以确定 F 的个数,然后得出 N 个楼层
*/
class Solution_2
{
public:
int superEggDrop(int K, int N)
{
int T = 1;
while (calcF(K, T) < N + 1) T++;
return T;
}
int calcF(int K, int T)
{
if (T < 1 && K < 1) return 0; // T 和 K 不能都小于1
if (K == 1) return T + 1; // 只有一个鸡蛋,从一楼开始往扔,最多确定楼层为T+1
if (T == 1) return 2; // 只扔一次那就从1楼扔下去,可以确定0层和1层
//if (T == 1 || K == 1) return T + 1;
return calcF(K - 1, T - 1) + calcF(K, T - 1);
}
};
// 方法三:动态规划,问题转换+逆向推到
// 自底向上,非递归解决
//F(K, T) = F(K - 1, T - 1) + F(K, T - 1)
//F(1, T) = T + 1
//F(K, 1) = 2
class Solution_3
{
public:
int superEggDrop(int K, int N)
{
if (K <= 0 || N <= 0)
return 0;
vector<vector<int>> dp(2, vector<int>(K+1, 2));
vector<int>* pre = &dp[0];
vector<int>* cur = &dp[1];
for (int i = 2; i <= N; ++i)
{
(*cur)[1] = i + 1;
for (int j = 2; j <= K; ++j)
(*cur)[j] = (*pre)[j - 1] + (*pre)[j];
if ((*cur)[K] > N)
return i;
swap(cur, pre);
}
return 1;
}
};
// 方法四:动态规划,问题转换+逆向推到
// 自底向上,非递归解决,对方法三空间优化
// F(K, T) = F(K - 1, T - 1) + F(K, T - 1)
// F(1, T) = T + 1
// F(K, 1) = 2
class Solution_4
{
public:
int superEggDrop(int K, int N)
{
if (K <= 0 || N <= 0)
return 0;
vector<int> dp(K+1,2);
for (int i = 2; i <= N; ++i)
{
for (int j = K; j >= 2; --j)
dp[j] += dp[j - 1];
dp[1] = i + 1;
if (dp[K] > N)
return i;
}
return 1;
}
};
以下为测试代码//
// 测试 用例 START
void test(const char* testName, int K, int N, int expect)
{
Solution_1 s1;
int result1 = s1.superEggDrop(K, N);
Solution_2 s2;
int result2 = s2.superEggDrop(K, N);
Solution_3 s3;
int result3 = s3.superEggDrop(K, N);
Solution_4 s4;
int result4 = s4.superEggDrop(K, N);
if (expect == result1 && expect == result2 && expect == result3 && expect == result4)
cout << testName << ", solution passed." << endl;
else
cout << testName << ", solution failed. expect: "<< expect
<< ", result1: "<< result1 << ", result2: " << result2
<< ", result3: "<< result3 << ", result4: " << result4 << endl;
}
// 测试用例
void Test1()
{
int K = 1;
int N = 1;
int expect = 1;
test("Test1()", K, N, expect);
}
void Test2()
{
int K = 1;
int N = 2;
int expect = 2;
test("Test2()", K, N, expect);
}
void Test3()
{
int K = 1;
int N = 3;
int expect = 3;
test("Test3()", K, N, expect);
}
void Test4()
{
int K = 3;
int N = 2;
int expect = 2;
test("Test4()", K, N, expect);
}
void Test5()
{
int K = 3;
int N = 14;
int expect = 4;
test("Test5()", K, N, expect);
}
void Test6()
{
int K = 3;
int N = 92;
int expect = 8;
test("Test6()", K, N, expect);
}
void Test7()
{
int K = 7;
int N = 501;
int expect = 9;
test("Test7()", K, N, expect);
}
NAMESPACE_SUPEREGGDROPEND
// 测试 用例 END
//
void SuperEggDrop_Test()
{
NAME_SUPEREGGDROP::Solution_2 s2;
for (int t = 10; t <= 20; ++t)
{
for (int k = 1; k <= 9; ++k)
{
cout << s2.calcF(k, t) << " ";
}
cout << endl;
}
cout << endl;
NAME_SUPEREGGDROP::Test1();
NAME_SUPEREGGDROP::Test2();
NAME_SUPEREGGDROP::Test3();
NAME_SUPEREGGDROP::Test4();
NAME_SUPEREGGDROP::Test5();
NAME_SUPEREGGDROP::Test6();
NAME_SUPEREGGDROP::Test7();
}
执行结果:
八、拓展一,加深理解
找出N=110,K=5,最优扔鸡蛋楼层可能的一种情况,并进行分析?
- 先在K=5,中找最小大于110的值,结果是K=5,T=7;
- 最优的结果并不是一种,这里提供其中一种扔鸡蛋的方法,在第N+1 = 57,也就是第56层扔一个鸡蛋;
- 如果鸡蛋没有破,此时K=5,T=6,破在N=57-109层中查找,57-109层和0-42层是等价的,f(K=5,T=6)最大可以查找63层(0-62),查找0-42层是没有问题的;
- 如果鸡蛋破了,此时K=4,T=6,此时刚好可以查找57层(0-56)满足要求;
- 后续重复步骤1直到K=1或者T=1。
这里我们可以进一步理解递推公式: F(K, T) = F(K - 1, T - 1) + F(K, T - 1)
九、拓展二,面试问题
有 2 个蛋,用一座 100 层的楼,要使用最少次数测试出蛋几层会碎(F)。
问第一次应该从几层扔。注意不包含0层。
这个问题曾经也遇到过,那时候一头雾水。T.T
从图可以直观的看到第一次扔是在第14层。(1+1)+2+3+4+...+14 = 106