上篇文章聊了高樓扔雞蛋問題,講了一種效率不是很高,但是較為容易理解的動態規划解法。后台很多讀者問如何更高效地解決這個問題,今天就談兩種思路,來優化一下這個問題,分別是二分查找優化和重新定義狀態轉移。
如果還不知道高樓扔雞蛋問題的讀者可以看下「經典動態規划:高樓扔雞蛋」,那篇文章詳解了題目的含義和基本的動態規划解題思路,請確保理解前文,因為今天的優化都是基於這個基本解法的。
二分搜索的優化思路也許是我們可以盡力嘗試寫出的,而修改狀態轉移的解法可能是不容易想到的,可以借此見識一下動態規划算法設計的玄妙,當做思維拓展。
二分搜索優化
之前提到過這個解法,核心是因為狀態轉移方程的單調性,這里可以具體展開看看。
首先簡述一下原始動態規划的思路:
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
單增的,如果我們固定 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) 要高效一些。
重新定義狀態轉移
前文「不同定義有不同解法」就提過,找動態規划的狀態轉移本就是見仁見智,比較玄學的事情,不同的狀態定義可以衍生出不同的解法,其解法和復雜程度都可能有巨大差異。這里就是一個很好的例子。
再回顧一下我們之前定義的 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
數組,這里就不寫了。
還可以再優化
再往下就要用一些數學方法了,不具體展開,就簡單提一下思路吧。
在剛才的思路之上,注意函數 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
函數的單調性,用二分查找技巧快速搜索答案;第二種優化是巧妙地修改了狀態轉移方程,簡化了求解了流程,但相應的,解題邏輯比較難以想到;后續還可以用一些數學方法和二分搜索進一步優化第二種解法,不過看了看鏡子中的發量,算了。
本文終,希望對你有一點啟發。
我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!
歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!