今天要聊一個很經典的算法問題,若干層樓,若干個雞蛋,讓你算出最少的嘗試次數,找到雞蛋恰好摔不碎的那層樓。國內大廠以及谷歌臉書面試都經常考察這道題,只不過他們覺得扔雞蛋太浪費,改成扔杯子,扔破碗什么的。
具體的問題等會再說,但是這道題的解法技巧很多,光動態規划就好幾種效率不同的思路,最后還有一種極其高效數學解法。秉承咱們號一貫的作風,拒絕奇技淫巧,拒絕過於詭異的技巧,因為這些技巧無法舉一反三,學了也不划算。
下面就來用我們一直強調的動態規划通用思路來研究一下這道題。
一、解析題目
題目是這樣:你面前有一棟從 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 表示法計算算法的復雜度。
實際上,如果不限制雞蛋個數的話,二分思路顯然可以得到最少嘗試的次數,但問題是,現在給你了雞蛋個數的限制 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)。
三、疑難解答
這個問題很復雜,但是算法代碼卻十分簡潔,這就是動態規划的特性,窮舉加備忘錄/DP table 優化,真的沒啥新意。
首先,有讀者可能不理解代碼中為什么用一個 for 循環遍歷樓層 [1..N],也許會把這個邏輯和之前探討的線性掃描混為一談。其實不是的,這只是在做一次「選擇」。
比方說你有 2 個雞蛋,面對 10 層樓,你這次選擇去哪一層樓扔呢?不知道,那就把這 10 層樓全試一遍。至於下次怎么選擇不用你操心,有正確的狀態轉移,遞歸會算出每個選擇的代價,我們取最優的那個就是最優解。
另外,這個問題還有更好的解法,比如修改代碼中的 for 循環為二分搜索,可以將時間復雜度降為 O(K*N*logN);再改進動態規划解法可以進一步降為 O(KN);使用數學方法解決,時間復雜度達到最優 O(K*logN),空間復雜度達到 O(1)。
二分的解法也有點誤導性,你很可能以為它跟我們之前討論的二分思路扔雞蛋有關系,實際上沒有半毛錢關系。能用二分搜索是因為狀態轉移方程的函數圖像具有單調性,可以快速找到最值。
簡單介紹一下二分查找的優化吧,其實只是在優化這段代碼:
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 固定時,這個函數一定是單調遞增的,無論你策略多聰明,樓層增加測試次數一定要增加。
那么注意 dp(K - 1, i - 1) 和 dp(K, N - i) 這兩個函數,其中 i 是從 1 到 N 單增的,如果我們固定 K 和 N,把這兩個函數看做關於 i 的函數,前者隨着 i 的增加應該也是單調遞增的,而后者隨着 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)
這里就不展開其他解法了,留在下一篇文章 高樓扔雞蛋進階
我覺得吧,我們這種解法就夠了:找狀態,做選擇,足夠清晰易懂,可流程化,可舉一反三。掌握這套框架學有余力的話,再去考慮那些奇技淫巧也不遲。
最后預告一下,《動態規划詳解(修訂版)》和《回溯算法詳解(修訂版)》已經動筆了,教大家用模板的力量來對抗變化無窮的算法題,敬請期待。
我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!

歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!

