算法学习笔记——动态规划:高楼扔鸡蛋

LeetCode 887. 鸡蛋掉落
建筑有n层(取值1,2,...n),存在一个楼层F0<=F<=n,注意F取值比n多一个),从高于F的楼层扔鸡蛋,鸡蛋会碎;否则鸡蛋不会碎
给你k枚相同的鸡蛋,每次可以在任意楼层向下扔鸡蛋(如果鸡蛋没碎,可以重复使用)问:在最坏情况下至少要扔几次鸡蛋,才能确定F楼层的位置
如k = 2, n = 6时,返回3(在3层处扔鸡蛋,然后若鸡蛋碎了,只剩下一颗鸡蛋,就只能用线性扫描1~2楼)

理解题意:
最坏情况的含义:就是一定要等到搜索区间穷尽时才找到目标楼层,即每次向下摔鸡蛋,不管碎或没碎,都只能将目标区间缩小一定程度

i.e. 应该排除在1楼做一次实验就刚好碎了,结果得到F=0这种“幸运成分”
而是应当认为,在1楼做一次实验,最坏情况是鸡蛋没碎,那么就只能继续在更高楼层去做实验
——每次扔鸡蛋会将高楼分为上下两部分,应该做最坏的打算,即包含F的目标区间位于[剩余楼层数更多的那一部分]

思路:

  1. 如果不限制鸡蛋个数,显然用二分思路可以得到最少的尝试次数;
    但问题是鸡蛋个数有限,二分不可行(假如只有一颗鸡蛋,碎了就没有机会实验了,于是只能线性扫描,从低楼层向高楼层逐次尝试)

能不能先用二分搜索,等到只剩一颗鸡蛋时,再执行线性扫描呢?
这样也不是最优解,比如有100层楼和2颗鸡蛋,在50楼扔一次,剩下49次线性扫描,这样就不如“十分”:第一个鸡蛋每隔10楼扔一次,碎了后用第二颗鸡蛋线性扫描10层楼,总次数不超过20次。
因此,此题不能轻易看出最佳策略,只能穷举所有可能性并取最值(然后用动态规划进行优化)

  1. 如果在某一层楼扔了一个鸡蛋,结果会将整幢楼划分为下面部分的一号楼和上面部分的二号楼(收缩了F的目标区间)
    如果鸡蛋碎了,应该到一号楼中寻找F
    如果鸡蛋没碎,应该到二号楼中寻找F

    (有种二分搜索的感觉)
    同时,要注意题目求的是最坏情况:每次扔鸡蛋会将高楼分为上下两部分,应该做最坏的打算,即总认为包含F的目标区间位于[剩余楼层数更多的那一部分]

根据上面的分析,我们只需关注目前的搜索区间的长度(区间中剩余的楼层数)
当搜索区间长度为0:找到了目标楼层
当搜索区间长度为1:相当于在n=1的楼中寻找F,此时仍需扔一次鸡蛋,确定F=0/1

  1. 从上面的思路出发,找出状态和选择,然后穷举所有可能,先得到暴力解法,然后优化即可
    状态:目前剩下的搜索区间长度和剩余的鸡蛋数
    选择:在哪一层楼扔鸡蛋

  2. dp数组的定义:dp[k][n]代表有n层楼和k个鸡蛋时,最坏情况下确定F值的最少次数

  3. 状态转移:在n层楼的i层扔鸡蛋,本身扔鸡蛋次数+1
    鸡蛋没碎,dp[k][n]=1+dp[k][n-i],搜索区间为下半部分
    鸡蛋碎了,dp[k][n]=1+dp[k-1][i-1],搜索区间为上半部分
    首先,考虑最坏情况,要求dp[k][n]取两者中的最大值(在低层扔鸡蛋,鸡蛋碎了需要搜索上半部分,这是最坏情况;在高层扔鸡蛋,鸡蛋没碎需要搜索下半部分,这是最坏情况;)
    其次,我们不知道在n层楼的哪一层开始扔鸡蛋能得到最少的尝试次数,因此需要遍历尝试并比较所有可能的层数i1<=i<=n)至于下次怎么做选择不用关心,交给递归完成,最终对各个选择的代价取最小值即为最优解

  4. base case:
    k==1dp[1][n]=n只有一颗鸡蛋,必须线性扫描(k==0无意义)
    n==0dp[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值]
  • 在子问题中,kn可以是为常数,则dp(k-1, i-1)随着i单调递增,而dp(k, n-i)随着i单调递减,画图表示为
    在这里插入图片描述
  • 最终,问题转化为:两个随着i单调变化的函数组合称为另一个函数(上图红色部分),求这个函数的山谷(Valley)值

实现:
对于i,维护其左右边界lr,每次使用二分法取值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)
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值