给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。
已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。
每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。
请你计算并返回要确定 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 <= 1001 <= n <= 104
前置信息
为了方便小伙伴们理解鸡蛋如何扔,我们先看一下固定楼层和鸡蛋数量的时候,如何求 f ?
可以看到,只有一枚鸡蛋的时候,为了稳妥的找到 f,我们只能从第一层一层一层向上尝试,而有多枚鸡蛋的时候,我们就可以拆分区间,大大提升查找的效率。
初版动态规划
解题思路
首先这种给定动态条件,求最值的问题,肯定可以通过动态规划来解题。
本题中的动态条件有两个,k 枚鸡蛋,n 层楼。
所以我们可以定义一个二维 dp => dp[i][j] ,表示 i 枚鸡蛋和 j 层楼所需要的最少操作次数。
接下来我们思考一下如何推导 dp[i][j] 呢?
其实扔鸡蛋的结果无非只有两种,鸡蛋碎了以及鸡蛋没碎。
所以我们假设当前在第 x (1<=x<=j) 层扔鸡蛋,
如果鸡蛋碎了,那么 dp[i][j] = dp[i-1][x-1]+1,也就是鸡蛋还有 i-1 枚,要查找的楼层还有 x-1 层。
如果鸡蛋没碎,那么 dp[i][j] = dp[i][j-x]+1,也就是鸡蛋还有 i 枚,要查找的楼层还有 j-x 层。
因为我们要考虑最坏的情况,所以可以得到状态转移方程:
dp[i][j] = min(dp[i][j],max(dp[i-1][x-1],dp[i][j-x])+1) (1<=x<=j)
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// 初始化 dp 数组
dp = Array(k+1);
for(let i = 0;i<=k;i++){
dp[i] = Array(n+1).fill(0)
}
// 将鸡蛋为 1 的情况赋值对应楼层
for(let j = 1;j<=n;j++){
dp[1][j] = j
}
// 利用状态转移方程,推导出 dp[k][n]
for(let i = 2;i<=k;i++){
for(let j = 1;j<=n;j++){
dp[i][j] = j;
for(let x = 1;x<=j;x++){
dp[i][j] = Math.min(dp[i][j],Math.max(dp[i-1][x-1],dp[i][j-x])+1)
}
}
}
// 返回结果
return dp[k][n];
};
这样的一版代码虽然逻辑没有问题,基础的测试用例也都可以通过,但是提交会超时,是因为我们求所有的二维 dp 的过程中,利用了双层循环获取所有的鸡蛋和楼层的组合,又进行了楼层次数的遍历获取当前的 dp 值,所以时间复杂度为 O(kn²),空间复杂度为 O(kn)。
二分优化
解题思路
接下来,我们思考针对上一版代码,如何进行优化,提高推导 dp 的效率呢?
我们再来重新看一下状态转移方程:
dp[i][j] = min(dp[i][j],max(dp[i-1][x-1],dp[i][j-x])+1)
我们观察 dp[i-1][x-1] 和 dp[i][j-x],前者会根据 x 值增大而增大,后者会根据 x 值增大而减小,同理,前者根据 x 值减小而减小,后者根据 x 值减小而增大。
又因为我们需要取两者的最大值来更新 dp,所以想要取得最优的 dp,就需要让 x 接近于一个中间值,让两个值尽可能的相等,这样它们中的最大值才会尽可能的小。
所以这里我们可以通过二分查找目标 x 值,从而将最内层查找 x 值的时间复杂度从 O(n) 降低到 O(logn),整体时间复杂度为 O(knlogn),空间复杂度为 O(kn)。
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// 初始化 dp 数组
dp = Array(k+1);
for(let i = 0;i<=k;i++){
dp[i] = Array(n+1).fill(0)
}
// 将鸡蛋为 1 的情况赋值对应楼层
for(let j = 1;j<=n;j++){
dp[1][j] = j
}
// 利用状态转移方程,推导出 dp[k][n]
for(let i = 2;i<=k;i++){
for(let j = 1;j<=n;j++){
dp[i][j] = j;
// 利用二分查找最优 x 值
let l = 1,r = j;
while(l<r){
const mid = (l+r)>>1;
if(dp[i-1][mid-1]<dp[i][j-mid]) l = mid+1
else r = mid
}
dp[i][j] = Math.min(dp[i][j],Math.max(dp[i-1][l-1],dp[i][j-l])+1)
}
}
// 返回结果
return dp[k][n];
};
这一次提交是可以通过的,但是结果依然不理想,执行用时在 300ms 左右。
记忆 x 值
解题思路
接下来我们思考针对上一版代码,还能如何进行优化呢?
我们思考这样一个问题,在我们的内层循环中(1~n),j 是单调递增的,那么此时最优的 x 值必然也是单调递增的。所以每一次 j 增大后,我们只需要在上次的 x 值基础上向后查找最优的 x 值即可。这样就把时间复杂度优化到了 O(kn)。
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// 初始化 dp 数组
dp = Array(k+1);
for(let i = 0;i<=k;i++){
dp[i] = Array(n+1).fill(0)
}
// 将鸡蛋为 1 的情况赋值对应楼层
for(let j = 1;j<=n;j++){
dp[1][j] = j
}
// 利用状态转移方程,推导出 dp[k][n]
for(let i = 2;i<=k;i++){
// 通过记忆 x 值优化推导过程
let x = 1;
for(let j = 1;j<=n;j++){
dp[i][j] = j;
while(dp[i-1][x-1]<dp[i][j-x]) x++
dp[i][j] = Math.min(dp[i][j],Math.max(dp[i-1][x-1],dp[i][j-x])+1)
}
}
// 返回结果
return dp[k][n];
};
这一版代码提交,效率比之前有明显提升,执行用时在 150ms 左右。但是整体结果依然不理想,只击败了 30% 左右的用户。
记忆 x 值 + 滚动数组
解题思路
接下来我们还可以优化一下我们的代码。
我们观察状态转移方程:
dp[i][j] = min(dp[i][j],max(dp[i-1][x-1],dp[i][j-x])+1)
可以看到,每次的 dp[i][j] 只依赖于当前所处数组以及 i-1 数组,所以我们可以通过滚动数组来优化空间复杂度,从之前的 O(kn) 优化到 O(n)。
滚动数组
这里我们说一下滚动数组技巧。
因为数组下标是奇偶变化的,所以这里我们对当前下标 i%2,只会得到 0,1 两种结果,而 i-1 对应的结果则刚好是当前下标对应的 0,1 中的另一个值,这样就形成了 i-1 存储在 0,i 存储在 1,i+1 存储在 0 ...
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// 初始化 dp 数组
dp = Array(2);
for(let i = 0;i<2;i++){
dp[i] = Array(n+1).fill(0)
}
// 将鸡蛋为 1 的情况赋值对应楼层
for(let j = 1;j<=n;j++){
dp[1][j] = j
}
// 利用状态转移方程,推导出 dp[k][n]
for(let i = 2;i<=k;i++){
// 通过滚动数组优化空间复杂度
let cur = i%2,pre = !cur*1;
// 通过记忆 x 值优化推导过程
let x = 1;
for(let j = 1;j<=n;j++){
dp[cur][j] = j;
while(dp[pre][x-1]<dp[cur][j-x]) x++
dp[cur][j] = Math.min(dp[cur][j],Math.max(dp[pre][x-1],dp[cur][j-x])+1)
}
}
// 返回结果
return dp[k%2][n];
};
代码提交后,在内存消耗方面击败了 80+% 的用户。
倒推版动态规划
解题思路
针对上述的状态定义,第四版代码基本已经把能优化的点都做了,但是依然无法达到最优解。
所以这里我们尝试另一种状态定义解决本题。
这里我们定义二维 dp => dp[i][j],其中 i 表示操作次数,j 表示鸡蛋数量,dp[i][j] 表示操作 i 次,拥有 j 枚鸡蛋,所能测出的最高楼层。
这一种状态定义之所以更高效是因为之前的 dp 是 [鸡蛋数量][楼层高度],而现在是 [操作次数][鸡蛋数量],而通过之前的解题过程我们可以知道,操作次数和楼层高度之间是指数级关系的,所以我们这里推导二维 dp 的时候,要比之前循环的次数少很多。
推导转移方程和之前类似,同样只有两种情况,鸡蛋碎以及鸡蛋没碎。
假设当前在第 x 层扔鸡蛋,
如果鸡蛋碎了,说明 x 层到第 n 层都是大于 f 的,所以可以确定 dp[i-1][j-1]<=f<x,
可以发现,这种情况是比较好的,不管楼层有多高,通过这一次,我们都把范围确定在了 dp[i-1][j-1]~x。
如果没碎,那么这个鸡蛋我们还可以继续使用。
此时有 i-1 次操作机会,j 个鸡蛋,所有还可以查找 dp[i-1][j] 个楼层,
所以这个时候我们 dp[i][j] 可以求解 dp[i-1][j-1]+1+dp[i-1][j] 个楼层。
这里的 1 是我们当前扔鸡蛋的这一层楼。
因为第一种情况虽好,但是是可遇不可求的,如果没有发生第一种情况,则第二种情况是必然的。
所以这里我们最差的情况就是每次都是第二种情况,为了一定能求得结果,所以这里采用第二种情况的转移方程。
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// 初始化 dp 数组
dp = [Array(k+1).fill(0)];
// 初始化操作次数
let cnt = 0;
// 当 dp[cnt][k] 小于目标楼层的时候,递增操作次数,直到可以测出目标楼层
while(dp[cnt][k]<n){
// 递增操作次数
cnt++
// 初始化 dp[cnt]
dp[cnt] = [0]
// 推导 dp
for(let j = 1;j<=k;j++){
dp[cnt][j] = dp[cnt-1][j-1]+1+dp[cnt-1][j]
}
}
// 返回操作次数
return cnt;
};
以 num 表示最终操作次数,此时时间复杂度为 O(numK),空间复杂度为 O(numK)。
又因为 num 和 N 之间是指数级的关系,所以本题解的时间复杂度为 O(KlogN),空间复杂度为 O(KlogN)。
提交后执行用时和内存消耗都击败了 80+% 的用户,已经非常接近最优解了。
一维 DP 优化
解题思路
因为推导 dp 的过程中,本次的值只依赖于上一次操作次数,所以可以将 dp 数组优化为一维 dp。
dp[i] 表示当前次数下 i 个鸡蛋可以测出的最高楼层。
因为下一次依赖上一次的值,所以应该是从后向前推每一层的值,如此一层一层的更新 dp。
代码实现
var superEggDrop = (k, n)=> {
// 特判,如果只有一枚鸡蛋,返回楼层数
if(k===1) return n;
// / 初始化 dp 数组
const dp = Array(k+1).fill(0);
// 初始化操作次数
let cnt = 0;
// 当 dp[cnt][k] 小于目标楼层的时候,递增操作次数,直到可以测出目标楼层
// 要注意的是这里的 cnt 没没有表现在 dp 数组中,但是是切实存在的
while(dp[k]<n){
// 递增操作次数
cnt++;
// 推导 dp
for(let j = k;j>0;j--){
dp[j] = dp[j-1]+1+dp[j]
}
}
// 返回操作次数
return cnt;
};
本题解的时间复杂度和上一版相同,为 O(KlogN),空间复杂度为 O(K)。
提交后执行用时和内存消耗都击败了 90+% 的用户,达到了最优解。
至此我们就完成了 leetcode-887-鸡蛋掉落
如有任何问题或建议,欢迎留言讨论!