高樓扔雞蛋問題進階解法

上篇文章聊了高樓扔雞蛋問題,講了一種效率不是很高,但是較爲容易理解的動態規劃解法。後臺很多讀者問如何更高效地解決這個問題,今天就談兩種思路,來優化一下這個問題,分別是二分查找優化和重新定義狀態轉移。

如果還不知道高樓扔雞蛋問題的讀者可以看下「經典動態規劃:高樓扔雞蛋」,那篇文章詳解了題目的含義和基本的動態規劃解題思路,請確保理解前文,因爲今天的優化都是基於這個基本解法的。

二分搜索的優化思路也許是我們可以盡力嘗試寫出的,而修改狀態轉移的解法可能是不容易想到的,可以藉此見識一下動態規劃算法設計的玄妙,當做思維拓展。

二分搜索優化

之前提到過這個解法,核心是因爲狀態轉移方程的單調性,這裏可以具體展開看看。

首先簡述一下原始動態規劃的思路:

1、暴力窮舉嘗試在所有樓層 1 <= i <= N 扔雞蛋,每次選擇嘗試次數最少的那一層;

2、每次扔雞蛋有兩種可能,要麼碎,要麼沒碎;

3、如果雞蛋碎了,F 應該在第 i 層下面,否則,F 應該在第 i 層上面;

4、雞蛋是碎了還是沒碎,取決於哪種情況下嘗試次數更多,因爲我們想求的是最壞情況下的結果。

核心的狀態轉移代碼是這段:

# 當前狀態爲 K 個雞蛋,面對 N 層樓
# 返回這個狀態下的最優結果
def dp(K, N):
    for 1 <= i <= N:
        # 最壞情況下的最少扔雞蛋次數
        res = min(res, 
                  max( 
                        dp(K - 1, i - 1), # 碎
                        dp(K, N - i)      # 沒碎
                     ) + 1 # 在第 i 樓扔了一次
                 )
    return res

這個 for 循環就是下面這個狀態轉移方程的具體代碼實現:

如果能夠理解這個狀態轉移方程,那麼就很容易理解二分查找的優化思路。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

首先我們根據 dp(K, N) 數組的定義(有 K 個雞蛋麪對 N 層樓,最少需要扔幾次),很容易知道 K 固定時,這個函數隨着 N 的增加一定是單調遞增的,無論你策略多聰明,樓層增加測試次數一定要增加。

那麼注意 dp(K - 1, i - 1) 和 dp(K, N - i) 這兩個函數,其中 i 是從 1 到 N 單增的,如果我們固定 K 和 N把這兩個函數看做關於 i 的函數,前者隨着 i 的增加應該也是單調遞增的,而後者隨着 i 的增加應該是單調遞減的

這時候求二者的較大值,再求這些最大值之中的最小值,其實就是求這兩條直線交點,也就是紅色折線的最低點嘛。

我們前文「二分查找只能用來查找元素嗎」講過,二分查找的運用很廣泛,形如下面這種形式的 for 循環代碼:

for (int i = 0; i < n; i++) {
    if (isOK(i))
        return i;
}

都很有可能可以運用二分查找來優化線性搜索的複雜度,回顧這兩個 dp 函數的曲線,我們要找的最低點其實就是這種情況:

for (int i = 1; i <= N; i++) {
    if (dp(K - 1, i - 1) == dp(K, N - i))
        return dp(K, N - i);
}

熟悉二分搜索的同學肯定敏感地想到了,這不就是相當於求 Valley(山谷)值嘛,可以用二分查找來快速尋找這個點的,直接看代碼吧,整體的思路還是一樣,只是加快了搜索速度:

def superEggDrop(self, K: int, N: int) -> int:

    memo = dict()
    def dp(K, N):
        if K == 1: return N
        if N == 0: return 0
        if (K, N) in memo:
            return memo[(K, N)]

        # for 1 <= i <= N:
        #     res = min(res, 
        #             max( 
        #                 dp(K - 1, i - 1), 
        #                 dp(K, N - i)      
        #                 ) + 1 
        #             )

        res = float('INF')
        # 用二分搜索代替線性搜索
        lo, hi = 1, N
        while lo <= hi:
            mid = (lo + hi) // 2
            broken = dp(K - 1, mid - 1) # 碎
            not_broken = dp(K, N - mid) # 沒碎
            # res = min(max(碎,沒碎) + 1)
            if broken > not_broken:
                hi = mid - 1
                res = min(res, broken + 1)
            else:
                lo = mid + 1
                res = min(res, not_broken + 1)

        memo[(K, N)] = res
        return res

    return dp(K, N)

這個算法的時間複雜度是多少呢?動態規劃算法的時間複雜度就是子問題個數 × 函數本身的複雜度

函數本身的複雜度就是忽略遞歸部分的複雜度,這裏 dp 函數中用了一個二分搜索,所以函數本身的複雜度是 O(logN)。

子問題個數也就是不同狀態組合的總數,顯然是兩個狀態的乘積,也就是 O(KN)。

所以算法的總時間複雜度是 O(K*N*logN), 空間複雜度 O(KN)。效率上比之前的算法 O(KN^2) 要高效一些。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

重新定義狀態轉移

前文「不同定義有不同解法」就提過,找動態規劃的狀態轉移本就是見仁見智,比較玄學的事情,不同的狀態定義可以衍生出不同的解法,其解法和複雜程度都可能有巨大差異。這裏就是一個很好的例子。

再回顧一下我們之前定義的 dp 數組含義:

def dp(k, n) -> int
# 當前狀態爲 k 個雞蛋,面對 n 層樓
# 返回這個狀態下最少的扔雞蛋次數

用 dp 數組表示的話也是一樣的:

dp[k][n] = m
# 當前狀態爲 k 個雞蛋,面對 n 層樓
# 這個狀態下最少的扔雞蛋次數爲 m

按照這個定義,就是確定當前的雞蛋個數和麪對的樓層數,就知道最小扔雞蛋次數。最終我們想要的答案就是 dp(K, N) 的結果。

這種思路下,肯定要窮舉所有可能的扔法的,用二分搜索優化也只是做了「剪枝」,減小了搜索空間,但本質思路沒有變,還是窮舉。

現在,我們稍微修改 dp 數組的定義,確定當前的雞蛋個數和最多允許的扔雞蛋次數,就知道能夠確定 F 的最高樓層數。具體來說是這個意思:

dp[k][m] = n
# 當前有 k 個雞蛋,可以嘗試扔 m 次雞蛋
# 這個狀態下,最壞情況下最多能確切測試一棟 n 層的樓

# 比如說 dp[1][7] = 7 表示:
# 現在有 1 個雞蛋,允許你扔 7 次;
# 這個狀態下最多給你 7 層樓,
# 使得你可以確定樓層 F 使得雞蛋恰好摔不碎
# (一層一層線性探查嘛)

這其實就是我們原始思路的一個「反向」版本,我們先不管這種思路的狀態轉移怎麼寫,先來思考一下這種定義之下,最終想求的答案是什麼?

我們最終要求的其實是扔雞蛋次數 m,但是這時候 m 在狀態之中而不是 dp 數組的結果,可以這樣處理:

int superEggDrop(int K, int N) {

    int m = 0;
    while (dp[K][m] < N) {
        m++;
        // 狀態轉移...
    }
    return m;
}

題目不是給你 K 雞蛋,N 層樓,讓你求最壞情況下最少的測試次數 m 嗎?while 循環結束的條件是 dp[K][m] == N,也就是給你 K 個雞蛋,測試 m 次,最壞情況下最多能測試 N 層樓

注意看這兩段描述,是完全一樣的!所以說這樣組織代碼是正確的,關鍵就是狀態轉移方程怎麼找呢?還得從我們原始的思路開始講。之前的解法配了這樣圖幫助大家理解狀態轉移思路:

這個圖描述的僅僅是某一個樓層 i,原始解法還得線性或者二分掃描所有樓層,要求最大值、最小值。但是現在這種 dp 定義根本不需要這些了,基於下面兩個事實:

1、無論你在哪層樓扔雞蛋,雞蛋只可能摔碎或者沒摔碎,碎了的話就測樓下,沒碎的話就測樓上

2、無論你上樓還是下樓,總的樓層數 = 樓上的樓層數 + 樓下的樓層數 + 1(當前這層樓)

根據這個特點,可以寫出下面的狀態轉移方程:

dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1

dp[k][m - 1] 就是樓上的樓層數,因爲雞蛋個數 k 不變,也就是雞蛋沒碎,扔雞蛋次數 m 減一;

dp[k - 1][m - 1] 就是樓下的樓層數,因爲雞蛋個數 k 減一,也就是雞蛋碎了,同時扔雞蛋次數 m 減一。

PS:這個 m 爲什麼要減一而不是加一?之前定義得很清楚,這個 m 是一個允許的次數上界,而不是扔了幾次。

至此,整個思路就完成了,只要把狀態轉移方程填進框架即可:

int superEggDrop(int K, int N) {
    // m 最多不會超過 N 次(線性掃描)
    int[][] dp = new int[K + 1][N + 1];
    // base case:
    // dp[0][..] = 0
    // dp[..][0] = 0
    // Java 默認初始化數組都爲 0
    int m = 0;
    while (dp[K][m] < N) {
        m++;
        for (int k = 1; k <= K; k++)
            dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1;
    }
    return m;
}

如果你還覺得這段代碼有點難以理解,其實它就等同於這樣寫:

for (int m = 1; dp[K][m] < N; m++)
    for (int k = 1; k <= K; k++)
        dp[k][m] = dp[k][m - 1] + dp[k - 1][m - 1] + 1;

看到這種代碼形式就熟悉多了吧,因爲我們要求的不是 dp 數組裏的值,而是某個符合條件的索引 m,所以用 while 循環來找到這個 m 而已。

這個算法的時間複雜度是多少?很明顯就是兩個嵌套循環的複雜度 O(KN)。

另外注意到 dp[m][k] 轉移只和左邊和左上的兩個狀態有關,所以很容易優化成一維 dp 數組,這裏就不寫了。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

還可以再優化

再往下就要用一些數學方法了,不具體展開,就簡單提一下思路吧。

在剛纔的思路之上,注意函數 dp(m, k) 是隨着 m 單增的,因爲雞蛋個數 k 不變時,允許的測試次數越多,可測試的樓層就越高

這裏又可以藉助二分搜索算法快速逼近 dp[K][m] == N 這個終止條件,時間複雜度進一步下降爲 O(KlogN),我們可以設 g(k, m) =……

算了算了,打住吧。我覺得我們能夠寫出 O(K*N*logN) 的二分優化算法就行了,後面的這些解法呢,聽個響鼓個掌就行了,把慾望限制在能力的範圍之內才能擁有快樂!

不過可以肯定的是,根據二分搜索代替線性掃描 m 的取值,代碼的大致框架肯定是修改窮舉 m的 for 循環:

// 把線性搜索改成二分搜索
// for (int m = 1; dp[K][m] < N; m++)
int lo = 1, hi = N;
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (... < N) {
        lo = ...
    } else {
        hi = ...
    }

    for (int k = 1; k <= K; k++)
        // 狀態轉移方程
}

簡單總結一下吧,第一個二分優化是利用了 dp 函數的單調性,用二分查找技巧快速搜索答案;第二種優化是巧妙地修改了狀態轉移方程,簡化了求解了流程,但相應的,解題邏輯比較難以想到;後續還可以用一些數學方法和二分搜索進一步優化第二種解法,不過看了看鏡子中的髮量,算了。

本文終,希望對你有一點啓發。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章