LeetCode 887. 鸡蛋掉落
建筑有n
层(取值1,2,...n
),存在一个楼层F
(0<=F<=n
,注意F
取值比n
多一个),从高于F
的楼层扔鸡蛋,鸡蛋会碎;否则鸡蛋不会碎
给你k
枚相同的鸡蛋,每次可以在任意楼层向下扔鸡蛋(如果鸡蛋没碎,可以重复使用)问:在最坏情况下,至少要扔几次鸡蛋,才能确定F
楼层的位置
如k = 2, n = 6时,返回3(在3层处扔鸡蛋,然后若鸡蛋碎了,只剩下一颗鸡蛋,就只能用线性扫描1~2楼)
理解题意:
最坏情况的含义:就是一定要等到搜索区间穷尽时才找到目标楼层,即每次向下摔鸡蛋,不管碎或没碎,都只能将目标区间缩小一定程度
i.e. 应该排除在1楼做一次实验就刚好碎了,结果得到
F=0
这种“幸运成分”
而是应当认为,在1楼做一次实验,最坏情况是鸡蛋没碎,那么就只能继续在更高楼层去做实验
——每次扔鸡蛋会将高楼分为上下两部分,应该做最坏的打算,即包含F
的目标区间位于[剩余楼层数更多的那一部分]
思路:
- 如果不限制鸡蛋个数,显然用二分思路可以得到最少的尝试次数;
但问题是鸡蛋个数有限,二分不可行(假如只有一颗鸡蛋,碎了就没有机会实验了,于是只能线性扫描,从低楼层向高楼层逐次尝试)
能不能先用二分搜索,等到只剩一颗鸡蛋时,再执行线性扫描呢?
这样也不是最优解,比如有100层楼和2颗鸡蛋,在50楼扔一次,剩下49次线性扫描,这样就不如“十分”:第一个鸡蛋每隔10楼扔一次,碎了后用第二颗鸡蛋线性扫描10层楼,总次数不超过20次。
因此,此题不能轻易看出最佳策略,只能穷举所有可能性并取最值(然后用动态规划进行优化)
- 如果在某一层楼扔了一个鸡蛋,结果会将整幢楼划分为下面部分的一号楼和上面部分的二号楼(收缩了
F
的目标区间)
如果鸡蛋碎了,应该到一号楼中寻找F
;
如果鸡蛋没碎,应该到二号楼中寻找F
(有种二分搜索的感觉)
同时,要注意题目求的是最坏情况:每次扔鸡蛋会将高楼分为上下两部分,应该做最坏的打算,即总认为包含F
的目标区间位于[剩余楼层数更多的那一部分]
根据上面的分析,我们只需关注目前的搜索区间的长度(区间中剩余的楼层数)
当搜索区间长度为0:找到了目标楼层
当搜索区间长度为1:相当于在n=1
的楼中寻找F
,此时仍需扔一次鸡蛋,确定F=0/1
-
从上面的思路出发,找出状态和选择,然后穷举所有可能,先得到暴力解法,然后优化即可
状态:目前剩下的搜索区间长度和剩余的鸡蛋数
选择:在哪一层楼扔鸡蛋 -
dp数组的定义:
dp[k][n]
代表有n
层楼和k
个鸡蛋时,最坏情况下确定F
值的最少次数 -
状态转移:在
n
层楼的i
层扔鸡蛋,本身扔鸡蛋次数+1
鸡蛋没碎,dp[k][n]=1+dp[k][n-i]
,搜索区间为下半部分
鸡蛋碎了,dp[k][n]=1+dp[k-1][i-1]
,搜索区间为上半部分
首先,考虑最坏情况,要求dp[k][n]
取两者中的最大值(在低层扔鸡蛋,鸡蛋碎了需要搜索上半部分,这是最坏情况;在高层扔鸡蛋,鸡蛋没碎需要搜索下半部分,这是最坏情况;)
其次,我们不知道在n
层楼的哪一层开始扔鸡蛋能得到最少的尝试次数,因此需要遍历尝试并比较所有可能的层数i
(1<=i<=n
)至于下次怎么做选择不用关心,交给递归完成,最终对各个选择的代价取最小值即为最优解 -
base case:
k==1
,dp[1][n]=n
只有一颗鸡蛋,必须线性扫描(k==0
无意义)
n==0
,dp[k][0]=0
,搜索区间长度为0,代表已经找出F
实现:
动态规划不一定非要用dp数组,对于这题用递归更方便
写出递归的暴力解法后,用备忘录优化即可达到dp数组同样的复杂度
class Solution:
def superEggDrop(self, k: int, n: int) -> int:
memo = dict() # 备忘录
def dp(k, n):
# dp[k][n]代表有n层楼和k个鸡蛋时,最坏情况下确定F值的最少次数
# base case
# k==1,dp[1][n]=n,只有一颗鸡蛋,必须线性扫描(k==0无意义)
# n==0,dp[k][0]=0,搜索区间长度为0,代表已经找出F
if k == 1:
return n
if n == 0:
return 0
if (k, n) not in memo:
# 我们不知道在n层楼的哪一层开始扔鸡蛋是最好的选择,因此需要遍历尝试所有可能的层数i(`1<=i<=n`),取最小值min
ans = float('inf')
for i in range(1, n + 1):
ans = min(ans, 1 + max(dp(k - 1, i - 1), dp(k, n - i))) # 在碎了/没碎中,取最坏情况max
memo[(k, n)] = ans
return memo[(k, n)]
return dp(k,n)
子问题数目=状态总数=KN,子问题本身复杂度=N(dp函数中的for循环)
总复杂度O(kN^2)
优化效率:二分搜索优化
注意,二分搜索优化与之前提到的用二分思路扔鸡蛋没有任何关系,能用二分搜索仅仅是因为这个问题的
dp
函数具有单调性,因而可以用二分搜索来快速寻找最值(具有单调性的问题,大多可以通过二分搜索来优化)
回顾dp(k,n)
定义:有n
层楼和k
个鸡蛋时,最坏情况下确定F
值的最少次数
注意到这样一个事实:对于dp(k,n)
,当k
固定时,整个函数的值随着n
的增大而单调递增
- 我们之前所做的,就是对于
i (1<=i<=n)
求函数max(dp(k-1, i-1), dp(k, n-i))+1
的值,然后找到[使函数取最小值的i
值] - 在子问题中,
k
和n
可以是为常数,则dp(k-1, i-1)
随着i
单调递增,而dp(k, n-i)
随着i
单调递减,画图表示为
- 最终,问题转化为:两个随着
i
单调变化的函数组合称为另一个函数(上图红色部分),求这个函数的山谷(Valley)值
实现:
对于i
,维护其左右边界l
和r
,每次使用二分法取值i=mid=(l+r)//2
,比较dp(k-1, i-1)
和dp(k, n-i)
,可以判断:若该处位于山谷值左侧,向右收缩区间;否则向左收缩区间(并不断更新红色部分的函数的最小值)
class Solution:
def superEggDrop(self, k: int, n: int) -> int:
memo = dict() # 备忘录
def dp(k, n):
# dp[k][n]代表有n层楼和k个鸡蛋时,最坏情况下确定F值的最少次数
# base case
# k==1,dp[1][n]=n,只有一颗鸡蛋,必须线性扫描(k==0无意义)
# n==0,dp[k][0]=0,搜索区间长度为0,代表已经找出F
if k == 1:
return n
if n == 0:
return 0
if (k, n) not in memo:
# 我们不知道在n层楼的哪一层开始扔鸡蛋是最好的选择,因此要尝试所有可能的层数i(1<=i<=n),取最小值min
ans = float('inf')
# for i in range(1, n + 1):
# ans = min(ans, 1 + max(dp(k - 1, i - 1), dp(k, n - i))) # 在碎了/没碎中,取最坏情况max
# 二分法求山谷值,代替线性搜索
l, r = 1, n
while l <= r:
mid = (l + r) // 2
i = mid
broken = dp(k - 1, i - 1) # 碎了
notBroken = dp(k, n - i) # 没碎
if broken < notBroken: # 该处位于山谷值左侧
l = mid + 1
ans = min(ans, notBroken + 1)
else:
r = mid - 1
ans = min(ans, broken + 1)
memo[(k, n)] = ans
return memo[(k, n)]
return dp(k, n)