解题思路参考链接:https://github.com/Shellbye/Shellbye.github.io/issues/42
你将获得 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
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
暴力解法
超时
首先,我们来看扔鸡蛋这个事儿本身,假设在N
层的高楼中有K
个鸡蛋,这个时候我们在n
层扔了一个鸡蛋,那么这一次动作,把整个高楼其实就分成了两部分,一部分是1楼到n
楼,这是一个层高为n
的新楼,我们暂时叫它一号楼;另一部分是n+1
到N
楼,这是一栋新产生的层高为N-(n+1)+1=N-n
的新楼,我们叫他二号楼。
然后,我们来看刚扔下去的鸡蛋,如果它碎了,说明楼层太高(起码高于F
),那么F
应该是在一号楼,那么我们就带着剩下的K-1
个鸡蛋去一号楼继续,当我们站在一号楼的某一层的时候,其实和最开始是一样的(递归的信号)。如果鸡蛋没碎,说明楼层不够高(低于F
),此时我们要去二号楼,但是这里有一点点需要注意的,就是我们在二号楼的某一层的时候,其实该层在原始的楼里是要比当前楼层高n
层的,其他同理。
最后,因为我们是要找无论 F 的初始值如何
的条件下的查找次数,所以我们要在一号楼和二号楼各自的查找次数中选择那个最大的值,用计算机语言描述整个过程,就是
searchTime(K, N) = max( searchTime(K-1, X-1), searchTime(K, N-X) )
其中的X
就是我们刚才扔鸡蛋做在的楼层,它具体是哪一层并不重要。对于从1到N
的每一个X
,都可以计算出一个对应的searchTime
的值,在这N
个值中,最小的那个就是本题的答案了!
class Solution(object):
def superEggDrop(self, K, N):
"""
:type K: int
:type N: int
:rtype: int
"""
# 法一:递归
return self.help(K,N)
def help(self,K,N):
# 递归结束条件
if K==1 or N==1 or N==0:return N
min_res=N
for i in range(1,N+1):
# 加1表示移动一次,把当前楼层分为上下两部分,根据鸡蛋是否破损,分别递归
temp=max(self.help(K-1,i-1),self.help(K,N-i))+1
min_res=min(min_res,temp)
return min_res
上面的这个方法有多暴力呢?通过推导式我们发现它和斐波那契数列第N项的计算方法(Fib(n) = Fib(n-1) + Fib(n-2)
)类似,那么,这个东西的时间复杂度又怎么计算呢?对于第N项 Fib(n) = Fib(n-1) + Fib(n-2)
,这个计算本身就是一个加法,其时间复杂度是O(1)
,但是其中包含了第N-1和第N-2两项,他们又同理本身需要一个O(1)
,且也分别包含了第N-1-1、第N-1-2和第N-2-1、第N-2-2四项,以此类推,这就是一个满二叉树的节点总数求和(如图),即O(2^n)
,同样的,其空间复杂度很低,仅仅是O(1)
。
动态规划(实质将递归的中间结果保存下来)
上面的计算之所以时间复杂度高,与递归版本的斐波那契数列一样,就是因为重复计算了很多遍底部节点的值,为了加快这个计算过程,一个简单的提升方法就是拿空间换时间,把计算的中间结果都存储起来,后面直接查表即可。
class Solution(object):
def superEggDrop(self, K, N):
"""
:type K: int
:type N: int
:rtype: int
"""
# 法二:动态规划 dp[k][n]=max(dp[k-1][i-1]+dp[k][n-i])+1
dp=[[0]*(N+1) for i in range(K+1)]
# dp初始化
for i in range(1,N+1):
# 0个鸡蛋
dp[0][i]=0
# 1个鸡蛋
dp[1][i]=i
for i in range(1,K+1):
# 0层
dp[i][0]=0
# 从第2个鸡蛋开始dp
for i in range(2,K+1):
for j in range(1,N+1):
min_res=N
for x in range(1,j+1):
min_res=min(min_res,max(dp[i-1][x-1],dp[i][j-x])+1)
dp[i][j]=min_res
return dp[K][N]
这个解法利用了一个二维数组存储了部分计算结果(空间复杂度O(KN)),使得时间复杂度降低到了O(KN^2)
。但是依然是一个平方级别的时间复杂度,不够快,还能优化吗?
基于二分查找的动态规划法
最开始的时候,我们就想到了二分查找,但是因为发现不对,就果断抛弃了,事实上它还是有利用价值的。在上一个O(KN^2)
的算法中,我们拿着K
个鸡蛋检查了每一个楼层来寻找F
,但是事实上这并不是必须的,为什么呢?我们来看我们上面总结的这个递归的等式
searchTime(K, N) = max( searchTime(K-1, X-1), searchTime(K, N-X) )
现在我们令T1 = searchTime(K-1, X-1)
,T2 = searchTime(K, N-X)
,其中,T1
是随着X
的增长而增长的,T2
是随着X
的增长而降低的,如下图
其中蓝色描出来的部分,就是searchTime(K, N)
了,我们可以看出来它是局部有序的,所以可以考虑二分查找之!
交点就是我们要求解的点,显然可用二分查找(比较T1与T2的大小来移动X)!
这里有一些与简单二分查找不一样的地方,就是在简单二分查找中,我们拿到输入数组A
和它的下标low
,high
,mid
之后,比较大小直接就是用下标读取数组的值A[low] < A[mid]
,但是在我们这个二分查找中,这个值是需要依赖与本题逻辑相关的一些计算的,具体看代码
class Solution(object):
def superEggDrop(self, K, N):
"""
:type K: int
:type N: int
:rtype: int
"""
# 法三:二分搜索+动态规划 dp[k][n]=max(dp[k-1][i-1]+dp[k][n-i])+1
# 定义一个成员变量充当全局变量,保存递归中间值
self.memo={}
return self.help(K,N)
def help(self,K,N):
if N==0:return 0
if K==1:return N
key=N*1000+K
if key in self.memo.keys():
return self.memo[key]
left,right=1,N
# 二分
while left<right-1:
middle=(left+right)//2
low_val=self.help(K-1,middle-1)
heigh_val=self.help(K,N-middle)
if low_val<heigh_val:
left=middle
elif low_val>heigh_val:
right=middle
else:left=right=middle
# 比较left和right对应处的值
temp=min(max(self.help(K-1,left-1),self.help(K,N-left)),max(self.help(K-1,right-1),self.help(K,N-right)))+1
self.memo[key]=temp
return temp
更快的方法
这个方法是上一个方法的延续,并把时间复杂度从O(KNlogN)
降到了O(KN)
,下面我们看一下它的思路。
首先令Xa = opt(K,N)
是能够找最小移动次数的最小的X
,根据上一个方法中我们分析T1
和T2
的单调性的方法,我们可以再次分析,并可以最终得出opt(K,N)
是随着N
的增长而增长的,这个我们也可以从下图中看到
随着N
的增长,T2
在向上移动,他们的交叉点Xa
也在向上移动,那么在上一个方法中需要遍历从1
到X
的循环就可以改成从Xa
到X
了,因为小于Xa
的都找不到最小移动次数。
与上一个方法自顶向下的方法不同,这次的解法是自底向上的,它每次的计算都是会先找到Xa
,然后就可以直接计算出结果。
class Solution(object):
def superEggDrop(self, K, N):
"""
:type K: int
:type N: int
:rtype: int
"""
# 法四:最优化策略动态规划
dp=[0]*(N+1)
# 初始化k=1情况
for i in range(N+1):
dp[i]=i
for k in range(2,K+1):
dp2=[0]*(N+1)
x=1
for n in range(1,N+1):
# 寻找最优的x
# max(dp[x-1],dp2[n-x])>max(dp[x],dp[n-x-1])表示上一次较低层比当前层的最优值大
while x<n and max(dp[x-1],dp2[n-x])>max(dp[x],dp2[n-x-1]):
x+=1
dp2[n]=1+max(dp[x-1],dp2[n-x])
dp=dp2
return dp[N]
这里需要特别注意一下dp
,在for
循环中,它代表是上一次循环解出来的最小值,也就是比当前楼层低一层的情况下的最优解。所以while
条件中的max(dp[x - 1], dp2[n - x]) > max(dp[x], dp2[n - x - 1])
,
带入T1 =dp(K−1,X−1)
,T2 =dp(K,N−X)
,就会发现其实是max(T1(x-1), T2(x-1)) > max(T1(x), T2(x))
换个思路
上面的方法的思路,都还是顺着题目的思路的进行的,其实我们可以换一个思路来想:“求k个鸡蛋在m步内可以测出多少层”。我们令dp[k][m]
表示k个鸡蛋在m步内可以测出的最多的层数,那么当我们在第X层扔鸡蛋的时候,就有两种情况:
- 鸡蛋碎了,我们少了一颗鸡蛋,也用掉了一步,此时测出
N - X + dp[k-1][m-1]
层,X
和它上面的N-X
层已经通过这次扔鸡蛋确定大于F
; - 鸡蛋没碎,鸡蛋的数量没有变,但是用掉了一步,剩余
X + dp[k][m-1]
,X
层及其以下已经通过这次扔鸡蛋确定不会大于F
;
也就是说,我们每一次扔鸡蛋,不仅仅确定了下一次扔鸡蛋的楼层的方向,也确定了另一半楼层与F
的大小关系,所以在下面的关键代码中,使用的不再是max
,而是加法(这里是重点)。评论里有人问到为什么是相加,其实这里有一个惯性思维的误区,上面的诸多解法中,往往求max
的思路是“两种方式中较大的那一个结果”,其实这里的相加,不是鸡蛋碎了和没碎两种情况的相加,而是“本次扔之后可能测出来的层数 + 本次扔之前已经测出来的层数”。
dp[k][m]表示k个鸡蛋在m步内可以测出来的的最大层数
dp[k][m]=dp[k-1][m-1]+dp[k][m-1]+1
class Solution(object):
def superEggDrop(self, K, N):
"""
:type K: int
:type N: int
:rtype: int
"""
# 法五:换个思路 二维动态规划 dp[k][m]表示k个鸡蛋在m步内可以测出来的的最大层数 dp[k][m]=dp[k-1][m-1]+dp[k][m-1]+1
dp=[[0]*(N+1) for i in range(K+1)]
# 主要:必须先遍历m,因为要寻找当前m步下,花费一定数量鸡蛋能测到的最大层数
for m in range(1,N+1):
for k in range(1,K+1):
dp[k][m]=dp[k-1][m-1]+dp[k][m-1]+1
if dp[k][m]>=N:
return m
# 最多需要N步
return N