给你 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 <= 100
1 <= 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-鸡蛋掉落
如有任何问题或建议,欢迎留言讨论!