- 程式範例: C++
- 難度: ⭐⭐⭐⭐⭐
- 觀念:思維、二分搜、動態規劃、數學
# 著名的雞蛋掉落問題
我有雞蛋 個,有一棟樓共有 層,現在我從任意一層開始扔雞蛋,雞蛋可能會碎,可能不會;如果沒有碎,我可以撿起來繼續扔,現在想找到一個臨界樓層,從這個樓層以及該樓層以下往下扔,雞蛋都不會碎,從這個樓層以上往下扔,雞蛋都會碎。
# 基本思考
一顆雞蛋
如果只有一顆雞蛋就要測量出結果,就只能從第一層樓開始丟,每次增加一樓,直到雞蛋破裂為止。
因此,最少只需測量一次,最多則要測量到 次才碎。
無限多顆雞蛋
如果現在有無限顆雞蛋,我們讓每個雞蛋都從中間樓層丟下,實踐二分搜尋法。
假設此時 的最差狀況,測量的次數只需要 次即可。
兩顆雞蛋 - 基礎解
如果我們現在只有兩顆蛋,我們可以把它們想像成粗調節輪與細調節輪,
一顆雞蛋以 樓為單位搜尋,當該雞蛋破裂,就代表臨界樓層在該單位以下,
那我麼就從上一個單位開始一樓一樓的測試,測到雞蛋破裂為止。
假設此時的,其最差狀況測量次數需要 次。透過基礎的微分運算,我們知道 時會有最小值。因此,在 的時候,最差狀況也還是需要測量 次才能知道答案。
兩顆雞蛋 - 最優解
如果想讓最大次數最小化,也就要讓最小次數最大化,也就是說最大和最小之間的差最小。
假設第一枚雞蛋每多扔一次,第二枚雞蛋就少扔一次。假設扔第一個雞蛋從第 x 層開始扔,(最壞情況) 第二次間隔 x-1 層開始扔(也就是 x+x-1 層),第三次間隔 x-2 層開始扔,直到間隔 1 層開始扔。因此 , 解得 , 因此從 層開始扔,保證能找到臨界樓層,所需的次數最少。
# 解決思路
# 動態規劃 + 二分搜
定義 為有 個雞蛋, 層樓情況下,找到臨界樓層至少要扔雞蛋的次數。
考慮狀態轉移方程,假設從 x 層開始扔第一個雞蛋,有兩種情況:
- 雞蛋碎了,說明臨界樓層在~ 之間,且雞蛋碎了一個,因此情況轉變為
- 雞蛋沒有碎,說明臨界樓層在~ 之間,且雞蛋可以撿起來繼續扔,因此情況變為
需要考慮最壞情況,因此得取這兩個情況的的最大值,再加上當前從 x 扔的這次,狀態轉移方程如下:
但是第一枚雞蛋從 層開始扔是我們假設的,第一枚雞蛋從哪一層開始扔會使得所需的次數最小呢?
這裡就需要我們進行枚舉了,, 因此對狀態轉移方程進行改進:
此時,直接撰寫程式的話, 的複雜度是,共有一個 個狀態,對於每個狀態枚舉雞蛋的樓層 需要 的時間。因此我們需要想辦法優化枚舉的時間複雜度。
我們觀察到 是一個對於 的單調函數,也就是說在雞蛋數 固定的情況下,樓層數 越多,需要的測量次數一定不會變少。在上述的轉移方程中,第一項 是一個隨 增加的單調遞增函數,第二項 是一個隨 增加而單調遞減的函數。
如果這兩個函數都是連續函數,那麼我們只需要找出這兩個函數的交點,在交點處就能保證這兩個函數的最大值最小。但在本題中, 和 都是離散函數。在這種情況下,我們需要找到最大的滿足 的,以及最小的滿足 的。
我們只需要比較在 和 處兩個函數的最大值,取一個最小的作為 即可。在數學上,我們可以證明出 和 相差 1,這也是比較顯然的,因為它們正好夾住了那個想像中的交點,並且相距盡可能地近。因此我們就可以使用二分搜的方法找出,再得到:
我們在所有滿足條件的 上進行二分搜。對於狀態 而言, 即為 中的任一整數;
在二分搜的過程中,假設當前這一步我們查找到了,如果,那麼真正的 一定在 的左側,否則真正的 就會在 的右側。
在得到了 後,我們可以知道即為 ,此時我們只需要比較 和,取較小的那個對應的位置作為 即可。
這樣一來,對於給定的狀態,我們只需要 的時間,就可以找到最優的,因此,總時間複雜度從 就能降低至 了。
class Solution { | |
unordered_map<int, int> memo; | |
int dp(int k, int n) { | |
if (memo.find(n * 100 + k) == memo.end()) { | |
int ans; | |
if (n == 0) { | |
ans = 0; | |
} else if (k == 1) { | |
ans = n; | |
} else { | |
int lo = 1, hi = n; | |
while (lo + 1 < hi) { | |
int x = (lo + hi) / 2; | |
int t1 = dp(k - 1, x - 1); | |
int t2 = dp(k, n - x); | |
if (t1 < t2) { | |
lo = x; | |
} else if (t1 > t2) { | |
hi = x; | |
} else { | |
lo = hi = x; | |
} | |
} | |
ans = 1 + min(max(dp(k - 1, lo - 1), dp(k, n - lo)), | |
max(dp(k - 1, hi - 1), dp(k, n - hi))); | |
} | |
memo[n * 100 + k] = ans; | |
} | |
return memo[n * 100 + k]; | |
} | |
public: | |
int superEggDrop(int k, int n) { | |
return dp(k, n); | |
} | |
}; |
# 單調性的處理
我們固定 ,隨著 的增加,對於狀態轉移方程中 這一項,它的值是不變的,因為它和 無關。
而對於狀態轉移方程中 這一項,隨著 的增加,它的值也會增加。從上面的方法中,我們知道 隨著 單調遞增,而 隨著 單調遞減,那麼當 增加時, 對應的函數折線圖在每個整數點上都是增加的,因此在 不變的情況下, 是單調遞增的。
我們可以想像一條斜率為負的直線和一條斜率為正的直線,當斜率為負的直線(模擬 )向上平移(模擬 的增加)時,它和斜率為正的直線(模擬 )的交點會一直向右移動,這個交點就確定了,這與方法一也是一致的。
因此當我們固定 時,隨著 的增加, 對應的最優解的坐標 單調遞增,這樣一來每個 的均攤時間複雜度為 。
class Solution { | |
public: | |
int superEggDrop(int k, int n) { | |
int dp[n + 1]; | |
for (int i = 0; i <= n; ++i) { | |
dp[i] = i; | |
} | |
for (int j = 2; j <= k; ++j) { | |
int dp2[n + 1]; | |
int x = 1; | |
dp2[0] = 0; | |
for (int m = 1; m <= n; ++m) { | |
while (x < m && max(dp[x - 1], dp2[m - x]) >= max(dp[x], dp2[m - x - 1])) { | |
x++; | |
} | |
dp2[m] = 1 + max(dp[x - 1], dp2[m - x]); | |
} | |
for (int m = 1; m <= n; ++m) { | |
dp[m] = dp2[m]; | |
} | |
} | |
return dp[n]; | |
} | |
}; |
# 數學的方法
反過來想這個問題:如果我們可以做 次操作,而且有 個雞蛋,那麼我們能找到答案的最高的 n 是多少?我們設 為在上述條件下的 。如果我們求出了所有的,那麼只需要找出最小的滿足 的 。
我們需要找出最高的 ,因此我們不必思考到底在哪裡扔這個雞蛋,我們只需要扔出一個雞蛋,看看到底發生了什麼:
- 如果雞蛋沒有碎,那麼對應的是,也就是說在這一層的上方可以有 層;
- 如果雞蛋碎了,那麼對應的是,也就是說在這一層的下方可以有 層。
因此我們就可以寫出狀態轉移方程:
邊界條件為:
- 當 的時候 。
- 當 時,。
class Solution { | |
public: | |
int superEggDrop(int k, int n) { | |
if (n == 1) { | |
return 1; | |
} | |
vector<vector<int>> f(n + 1, vector<int>(k + 1)); | |
for (int i = 1; i <= k; ++i) { | |
f[1][i] = 1; | |
} | |
int ans = -1; | |
for (int i = 2; i <= n; ++i) { | |
for (int j = 1; j <= k; ++j) { | |
f[i][j] = 1 + f[i - 1][j - 1] + f[i - 1][j]; | |
} | |
if (f[i][k] >= n) { | |
ans = i; | |
break; | |
} | |
} | |
return ans; | |
} | |
}; |