1. 程式人生 > 實用技巧 >經典動態規劃:高樓扔雞蛋

經典動態規劃:高樓扔雞蛋

讀完本文,你可以去力扣拿下如下題目:

887.雞蛋掉落

-----------

今天要聊一個很經典的演算法問題,若干層樓,若干個雞蛋,讓你算出最少的嘗試次數,找到雞蛋恰好摔不碎的那層樓。國內大廠以及谷歌臉書面試都經常考察這道題,只不過他們覺得扔雞蛋太浪費,改成扔杯子,扔破碗什麼的。

具體的問題等會再說,但是這道題的解法技巧很多,光動態規劃就好幾種效率不同的思路,最後還有一種極其高效數學解法。秉承咱們號一貫的作風,拒絕奇技淫巧,拒絕過於詭異的技巧,因為這些技巧無法舉一反三,學了也不划算。

下面就來用我們一直強調的動態規劃通用思路來研究一下這道題。

一、解析題目

題目是這樣:你面前有一棟從 1 到 N

N 層的樓,然後給你 K 個雞蛋(K 至少為 1)。現在確定這棟樓存在樓層 0 <= F <= N,在這層樓將雞蛋扔下去,雞蛋恰好沒摔碎(高於 F 的樓層都會碎,低於 F 的樓層都不會碎)。現在問你,最壞情況下,你至少要扔幾次雞蛋,才能確定這個樓層 F 呢?

也就是讓你找摔不碎雞蛋的最高樓層 F,但什麼叫「最壞情況」下「至少」要扔幾次呢?我們分別舉個例子就明白了。

比方說現在先不管雞蛋個數的限制,有 7 層樓,你怎麼去找雞蛋恰好摔碎的那層樓?

最原始的方式就是線性掃描:我先在 1 樓扔一下,沒碎,我再去 2 樓扔一下,沒碎,我再去 3 樓……

以這種策略,最壞情況應該就是我試到第 7 層雞蛋也沒碎(F = 7

),也就是我扔了 7 次雞蛋。

先在你應該理解什麼叫做「最壞情況」下了,雞蛋破碎一定發生在搜尋區間窮盡時,不會說你在第 1 層摔一下雞蛋就碎了,這是你運氣好,不是最壞情況。

現在再來理解一下什麼叫「至少」要扔幾次。依然不考慮雞蛋個數限制,同樣是 7 層樓,我們可以優化策略。

最好的策略是使用二分查詢思路,我先去第 (1 + 7) / 2 = 4 層扔一下:

如果碎了說明 F 小於 4,我就去第 (1 + 3) / 2 = 2 層試……

如果沒碎說明 F 大於等於 4,我就去第 (5 + 7) / 2 = 6 層試……

以這種策略,最壞情況應該是試到第 7 層雞蛋還沒碎(F = 7),或者雞蛋一直碎到第 1 層(F = 0

)。然而無論那種最壞情況,只需要試 log7 向上取整等於 3 次,比剛才嘗試 7 次要少,這就是所謂的至少要扔幾次。

PS:這有點像 Big O 表示法計算​演算法的複雜度。

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

實際上,如果不限制雞蛋個數的話,二分思路顯然可以得到最少嘗試的次數,但問題是,現在給你了雞蛋個數的限制 K,直接使用二分思路就不行了

比如說只給你 1 個雞蛋,7 層樓,你敢用二分嗎?你直接去第 4 層扔一下,如果雞蛋沒碎還好,但如果碎了你就沒有雞蛋繼續測試了,無法確定雞蛋恰好摔不碎的樓層 F 了。這種情況下只能用線性掃描的方法,演算法返回結果應該是 7。

有的讀者也許會有這種想法:二分查詢排除樓層的速度無疑是最快的,那乾脆先用二分查詢,等到只剩 1 個雞蛋的時候再執行線性掃描,這樣得到的結果是不是就是最少的扔雞蛋次數呢?

很遺憾,並不是,比如說把樓層變高一些,100 層,給你 2 個雞蛋,你在 50 層扔一下,碎了,那就只能線性掃描 1~49 層了,最壞情況下要扔 50 次。

如果不要「二分」,變成「五分」「十分」都會大幅減少最壞情況下的嘗試次數。比方說第一個雞蛋每隔十層樓扔,在哪裡碎了第二個雞蛋一個個線性掃描,總共不會超過 20 次​。

最優解其實是 14 次。最優策略非常多,而且並沒有什麼規律可言。

說了這麼多廢話,就是確保大家理解了題目的意思,而且認識到這個題目確實複雜,就連我們手算都不容易,如何用演算法解決呢?

二、思路分析

對動態規劃問題,直接套我們以前多次強調的框架即可:這個問題有什麼「狀態」,有什麼「選擇」,然後窮舉。

「狀態」很明顯,就是當前擁有的雞蛋數 K 和需要測試的樓層數 N。隨著測試的進行,雞蛋個數可能減少,樓層的搜尋範圍會減小,這就是狀態的變化。

「選擇」其實就是去選擇哪層樓扔雞蛋。回顧剛才的線性掃描和二分思路,二分查詢每次選擇到樓層區間的中間去扔雞蛋,而線性掃描選擇一層層向上測試。不同的選擇會造成狀態的轉移。

現在明確了「狀態」和「選擇」,動態規劃的基本思路就形成了:肯定是個二維的 dp 陣列或者帶有兩個狀態引數的 dp 函式來表示狀態轉移;外加一個 for 迴圈來遍歷所有選擇,擇最優的選擇更新狀態:

# 當前狀態為 K 個雞蛋,面對 N 層樓
# 返回這個狀態下的最優結果
def dp(K, N):
    int res
    for 1 <= i <= N:
        res = min(res, 這次在第 i 層樓扔雞蛋)
    return res

這段偽碼還沒有展示遞迴和狀態轉移,不過大致的演算法框架已經完成了。

我們選擇在第 i 層樓扔了雞蛋之後,可能出現兩種情況:雞蛋碎了,雞蛋沒碎。注意,這時候狀態轉移就來了

如果雞蛋碎了,那麼雞蛋的個數 K 應該減一,搜尋的樓層區間應該從 [1..N] 變為 [1..i-1]i-1 層樓;

如果雞蛋沒碎,那麼雞蛋的個數 K 不變,搜尋的樓層區間應該從 [1..N] 變為 [i+1..N]N-i 層樓。

PS:細心的讀者可能會問,在第i層樓扔雞蛋如果沒碎,樓層的搜尋區間縮小至上面的樓層,是不是應該包含第i層樓呀?不必,因為已經包含了。開頭說了 F 是可以等於 0 的,向上遞迴後,第i層樓其實就相當於第 0 層,可以被取到,所以說並沒有錯誤。

因為我們要求的是最壞情況下扔雞蛋的次數,所以雞蛋在第 i 層樓碎沒碎,取決於那種情況的結果更大

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

遞迴的 base case 很容易理解:當樓層數 N 等於 0 時,顯然不需要扔雞蛋;當雞蛋數 K 為 1 時,顯然只能線性掃描所有樓層:

def dp(K, N):
    if K == 1: return N
    if N == 0: return 0
    ...

至此,其實這道題就解決了!只要新增一個備忘錄消除重疊子問題即可:

def superEggDrop(K: int, N: int):

    memo = dict()
    def dp(K, N) -> int:
        # base case
        if K == 1: return N
        if N == 0: return 0
        # 避免重複計算
        if (K, N) in memo:
            return memo[(K, N)]

        res = float('INF')
        # 窮舉所有可能的選擇
        for i in range(1, N + 1):
            res = min(res, 
                      max(
                            dp(K, N - i), 
                            dp(K - 1, i - 1)
                         ) + 1
                  )
        # 記入備忘錄
        memo[(K, N)] = res
        return res
    
    return dp(K, N)

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

函式本身的複雜度就是忽略遞迴部分的複雜度,這裡 dp 函式中有一個 for 迴圈,所以函式本身的複雜度是 O(N)。

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

所以演算法的總時間複雜度是 O(K*N^2), 空間複雜度 O(KN)。

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

三、疑難解答

這個問題很複雜,但是演算法程式碼卻十分簡潔,這就是動態規劃的特性,窮舉加備忘錄/DP table 優化,真的沒啥新意。

首先,有讀者可能不理解程式碼中為什麼用一個 for 迴圈遍歷樓層 [1..N],也許會把這個邏輯和之前探討的線性掃描混為一談。其實不是的,這只是在做一次「選擇」

比方說你有 2 個雞蛋,面對 10 層樓,你這次選擇去哪一層樓扔呢?不知道,那就把這 10 層樓全試一遍。至於下次怎麼選擇不用你操心,有正確的狀態轉移,遞迴會算出每個選擇的代價,我們取最優的那個就是最優解。

另外,這個問題還有更好的解法,比如修改程式碼中的 for 迴圈為二分搜尋,可以將時間複雜度降為 O(K*N*logN);再改進動態規劃解法可以進一步降為 O(KN);使用數學方法解決,時間複雜度達到最優 O(K*logN),空間複雜度達到 O(1)。

二分的解法也有點誤導性,你很可能以為它跟我們之前討論的二分思路扔雞蛋有關係,實際上沒有半毛錢關係。能用二分搜尋是因為狀態轉移方程的函式影象具有單調性,可以快速找到最值。

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

二分搜尋優化

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

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

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 迴圈就是下面這個狀態轉移方程的具體程式碼實現:

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

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

那麼注意 dp(K - 1, i - 1)dp(K, N - i) 這兩個函式,其中 i 是從 1 到 N 單增的,如果我們固定 KN把這兩個函式看做關於 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 函式的單調性,用二分查詢技巧快速搜尋答案;第二種優化是巧妙地修改了狀態轉移方程,簡化了求解了流程,但相應的,解題邏輯比較難以想到;後續還可以用一些數學方法和二分搜尋進一步優化第二種解法,不過看了看鏡子中的髮量,算了。

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

_____________

我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!