【动态规划】高楼扔鸡蛋问题

高楼扔鸡蛋

这是一个比较经典的动态规划问题,最先来自谷歌的面试题。

题目

887. 鸡蛋掉落

方法一:动态规划

分析

我们假设 \(g(k, n)\) 表示当有 \(k\) 枚鸡蛋,楼层数为 \(n\) 时,找到临界楼层 \(F\) 所需要的最小操作次数。

边界条件

当楼层数为零时,查找次数为 \(0\) ,当鸡蛋数量为 \(1\) 时,我们需要一层一层地查找,因此,最坏的情况下,查找次数等于楼层数,因此:

\[\begin{gather*} g(j, 0) = 0, \quad 0 \le j \le k\\ g(1, i) = i, \quad 1 \le i \le n \end{gather*} \]

状态转移

我们考虑一般情况,对于第 \(i\) 层楼:

\[\underbrace{1,\ 2,\ \cdots, \ i-1}, \ i, \underbrace{\ i+1,\ \cdots,\ n-1, \ n} \]

当我们从第 \(i\) 层楼扔下一枚鸡蛋的时候,这枚鸡蛋有两种状态,鸡蛋碎了或者不碎,那么:

  • 若鸡蛋没有碎

    我们可以继续用 \(k\) 枚鸡蛋,在上方的楼层,即 \(i + 1, \cdots, n\),共 \(n - i\) 层楼中,继续寻找 \(F\),因此,所需要查找的次数为 \(g_1=g(k, n - i)\)

  • 若鸡蛋碎了

    这时,鸡蛋的数量少了一枚,因此,我们就需要用剩余的 \(k - 1\) 枚鸡蛋,在下方的楼层,即 \(1, \cdots, i - 1\),共 \(i - 1\) 层楼中,继续寻找 \(F\),因此所需要查找的次数为 \(g_2=g(k - 1, i - 1)\)

这里,我们可以看到,通过状态转移,明显将问题的规模缩小了,由于题目是要求在最坏的情况下,扔鸡蛋的最少次数,所以,最坏的情况下,在第 \(i\) 层楼的操作次数,取决于 \(g_1\)\(g_2\) 的最大值。

因此,在最坏的情况下,最少的操作次数为:

\[g(k,n) = \min^n_{i=1}(\max(g_1, \ g_2)) + 1 = \min^n_{i=1}(\max(g(k, n - i), \ g(k - 1, i - 1))) + 1 \]

代码实现

  • 自顶向下的递归实现
class Solution:
    def superEggDrop(self, k: int, n: int) -> int:
        memory = dict()
        return self.drop_egg(k, n, memory)

    def drop_egg(self, k: int, n: int, memory) -> int:
        if n == 0:
            return 0

        if k == 1:
            return n

        if (k, n) in memory:
            return memory.get((k, n))

        result = float("INF")
        for i in range(1, n + 1):
            g1 = self.drop_egg(k, n - i, memory)
            g2 = self.drop_egg(k - 1, i - 1, memory)
            result = min(result, max(g1, g2) + 1)

        memory[(k, n)] = result
        return result
  • 自底向上的迭代实现
class Solution:
    def superEggDrop(self, k: int, n: int) -> int:
        return self.drop_egg(k, n)

    def drop_egg(self, eggs: int, floor: int) -> int:
        dp = [[float("INF") for _ in range(floor + 1)] for _ in range(eggs + 1)]

        # 鸡蛋数为1时
        for n in range(1, floor + 1):
            dp[1][n] = n

        # 楼层数为0
        for i in range(0, eggs + 1):
            dp[i][0] = 0

        # 楼层数为1
        dp[0][1] = 0
        for i in range(1, eggs + 1):
            dp[i][1] = 1

        for k in range(2, eggs + 1):
            for n in range(2, floor + 1):
                for i in range(1, n + 1):
                    g1 = dp[k][n - i]
                    g2 = dp[k - 1][i - 1]
                    dp[k][n] = min(dp[k][n], max(g1, g2) + 1)

        return int(dp[eggs][floor])

复杂度

复杂度分析:

  • 时间复杂度:\(O(k \times n^2)\)
  • 空间复杂度:\(O(k \times n)\)

注:这种方法,直接枚举所有的状态,在力扣上提交的时候,会超时,因此,我们还需要对上述算法做进一步优化。

方法二:动态规划 + 二分搜索

分析

这里,我们定义一个函数 \(f(k, i)\),令

\[f(k, i) = \max^n_{i=1}(g_1, \ g_2) = \max^n_{i=1}(g(k, n - i), \ g(k - 1, i - 1)) \]

结合前面的分析,我们可以看出,当 \(k\) 不变时,即鸡蛋的个数不变时,函数 \(g_1\) 和 函数 \(g_2\) 只受楼层高度 \(i\) 影响,即:

  • \(g_1=g(k, \ n - i), \ 1 \in [i, n]\) 是一个随 \(i\) 增加的单调递减函数;
  • \(g_2=g(k - 1, \ i - 1),\ 1 \in [i, n]\) 是一个随 \(i\) 增加的单调递增函数;

对于函数 \(g_1\) 和 函数 \(g_2\) 在区间 \([i, n]\) 内,因为变量 \(i\) 只能取区间 \([i, n]\)离散的整数,因此,在区间 \([i, n]\) 内必然存在一个点 \((k_0, i_0)\) 使得单调函数 \(g_1\) 和单调函数 \(g_2\) 的值相等,这里,我们假设函数\(f(k, i)\)的最小值是 \(f(k_0,n_0)\)

因此,我们可以将函数 \(f(k, i)\)表示为:

\[f(k, i) = \begin{cases} g(k, \ n - i), & 1 \le i \le i_0\\ g(k - 1, \ i - 1), & i_0 \le i \le n \end{cases} \]

那么,状态转移方程,就可以写成:

\[g(k,n) = \min^n_{i=1}(f(k, i)) = f(k_0,n_0) \]

那么,我们只需要将优化的重点,放在求解 \(f(k_0,n_0)\) 上即可。对于单调函数,在区间 \([i, n]\) 内我们可以通过二分法的方式缩小查找区间,求解 \(f(k_0,n_0)\)

注意:

  • 函数 \(g_1\) 和 函数 \(g_2\) 并非严格递增或者递减;
  • 我们可以通过数学方式证明,使得函数 \(g_1\) 和 函数 \(g_2\) 在区间 \([i, n]\) 相等的点有两个,分别为 \((k_0, i_0)\)\((k_1, i_1)\),且 \(i_0\)\(i_1\) 相差 \(1\),我们并不需要关心具体值是多少,我们只需要记录它们所对应的函数值即可。

代码实现

  • 自顶向下的递归实现
from typing import Dict

class Solution:
    def superEggDrop(self, k: int, n: int) -> int:
        memory = dict()
        return self.drop_egg(k, n, memory)

    def drop_egg(self, k: int, n: int, memory: Dict) -> int:
        if n == 0:
            return 0

        if k == 1:
            return n

        if (k, n) in memory:
            return memory.get((k, n))

        result = float("INF")
        left, right = 1, n
        while left <= right:
            mid = left + (right - left) // 2
            g1 = self.drop_egg(k, n - mid, memory)
            g2 = self.drop_egg(k - 1, mid - 1, memory)
            result = min(result, max(g1, g2) + 1)
            if g1 < g2:
                right = mid - 1
            elif g1 > g2:
                left = mid + 1
            else:
                break

        memory[(k, n)] = result
        return result
  • 自底向上的迭代实现
class Solution:
    def superEggDrop(self, k: int, n: int) -> int:
        return self.drop_egg(k, n)

    def drop_egg(self, eggs: int, floor: int) -> int:
        dp = [[float("INF") for _ in range(floor + 1)] for _ in range(eggs + 1)]

        # 鸡蛋数为1时
        for n in range(1, floor + 1):
            dp[1][n] = n

        # 楼层数为0
        for i in range(0, eggs + 1):
            dp[i][0] = 0

        # 楼层数为1
        dp[0][1] = 0
        for i in range(1, eggs + 1):
            dp[i][1] = 1

        for k in range(2, eggs + 1):
            for n in range(2, floor + 1):
                # 使用二分搜索代替迭代搜索
                result = float("INF")
                left, right = 1, n
                while left <= right:
                    mid = left + (right - left) // 2
                    g1 = dp[k][n - mid]
                    g2 = dp[k - 1][mid - 1]
                    result = min(result, max(g1, g2) + 1)
                    if g1 < g2:
                        right = mid - 1
                    elif g1 > g2:
                        left = mid + 1
                    else:
                        break

                dp[k][n] = result

        return int(dp[eggs][floor])

复杂度

复杂度分析:

  • 时间复杂度:\(O(k \times n \times logn)\)
  • 空间复杂度:\(O(k \times n)\)

posted @ 2023-01-17 21:55  LARRY1024  阅读(243)  评论(0编辑  收藏  举报