觀李永樂老師《雙蛋問題》解題後感
題目開始前,隨便說幾句。
- 隨便說幾句,就是隨隨便便說的,看不懂沒關係。隨便說,可能會表達得不好,當作閱讀前的熱身 。
李永樂老師雙蛋問題,大概就是講,給你兩個鈦合金雞蛋,在100層樓中去測試雞蛋的耐摔度,就是摔碎雞蛋的臨界點。問你最少要試扔多少次(不是多少個,是扔多少次),這兩個雞蛋是你的科研經費能買到的唯一物質。
之後, 李永樂老師,把問題難度提高,給你K個雞蛋,在N層測耐摔度,最少要扔多少次。
在看本題之前,最好看看李永樂老師的解題思想。
本題用到的算法是動態規劃 + 二分查找。
其實這道題目,只懂得計算機的動態規劃和二分查找是不夠的解答的。
因爲,看完視頻還不滿足,所以就專門跑去 leetcode找一下同款題目來做。
力扣原題如下:
- 雞蛋掉落
你將獲得 K 個雞蛋,並可以使用一棟從 1 到 N 共有 N 層樓的建築。
每個蛋的功能都是一樣的,如果一個蛋碎了,你就不能再把它掉下去。
你知道存在樓層 F ,滿足 0 <= F <= N 任何從高於 F 的樓層落下的雞蛋都會碎,從 F 樓層或比它低的樓層落下的雞蛋都不會破。
每次移動,你可以取一個雞蛋(如果你有完整的雞蛋)並把它從任一樓層 X 扔下(滿足 1 <= X <= N)。
你的目標是確切地知道 F 的值是多少。
無論 F 的初始值如何,你確定 F 的值的最小移動次數是多少?
示例 1:
輸入:K = 1, N = 2
輸出:2解釋: 雞蛋從 1 樓掉落。如果它碎了,我們肯定知道 F = 0 。 否則,雞蛋從 2
樓掉落。如果它碎了,我們肯定知道 F = 1 。 如果它沒碎,那麼我們肯定知道 F = 2 。 因此,在最壞的情況下我們需要移動 2
次以確定 F 是多少。
- 示例 2:
輸入:K = 2, N = 6
輸出:3
- 示例 3:
輸入:K = 3, N = 14 輸出:4
- 提示:
1 <= K <= 100
1 <= N <= 10000
力扣原題
鏈接如下:https://leetcode-cn.com/problems/super-egg-drop/
隨後經過細品,才知道這道題目單單靠算法思維:動態規劃 + 二分查找仍然無法解答(僅僅靠動態規劃,可以解答,但是算法時間複雜度爲O(KNN) ,太高了).
所以爲了優化動態規劃的選擇策略,你採用二分查找,從選項中快速的找到最優解。
但是你採用二分查找得先證明離散數組具備單調性,要麼單調性遞增(/上坡形),要麼單調遞減(\下坡形),要麼分成2段後具備單調性(V字形)
。
你要證明單調性就得運用數學證明,證明雞蛋個數一定的情況下,樓層數越高,最少需要扔蛋次數就越大。
本題框架:
利用遞歸代代替迭代法實效動態規劃解空間dp的生成。
我們大部分都是利用for循環(forfor,或者forforfor)和動態轉移方程,自底向上的運算,生產解空間。
本題利用遞歸法中回溯的過程,實現自底向上,生產解空間,本質上並沒什麼高明之處。對於習慣看for循環的人來說,可讀性還變差了。(其實按照這個思維,動態規劃有點像,帶memo的遞歸回溯算法)
其次就是關於複雜度的運算,對很多人習慣通過for循環的層數來估算時間複雜度(正確的做法應該是看狀態的笛卡爾積大小。比如本題是N,K兩個狀態,那麼他們兩個維度的笛卡爾積大小爲NK),也變得不那麼明朗。(這套框架,時間複雜度依然是O(NK),不是這套算法。因爲我們的策略採用了二分查找,所以複雜度是lgn,本題的複雜度是O(NKLgn)).
而本題狀態轉移方程中涉及對多個選項擇優(從競爭者中擇優,競爭者由候選者dp(n,k)通過業務模型生成)的策略算法在遞歸函數中實現。
注意對遞歸方式起步進行邊界判斷。
本題解法比較精彩的操作:
- 利用哈希表來代替二維數組,提高了dp表下標可讀性。
- 利用帶memo備忘錄的遞歸回溯算法實現動態規劃。
- 利用二分查找策略在衆多選項中找到最優解。
- 利用函數單調性,證明可以採用二分查找策略,並設計出了二分查找分界點的判定函數(用來判定怎麼縮小搜索範圍)。
- 二分查找幾個要素處理的很漂亮:
- 搜索範圍的定義
- 分界點的定義
- 縮小搜索範圍用到的判定函數(判定函數的參數就是分界點了)。
- 終止搜索的區間定義。
- 因爲我們二分查找的變量是區間,所以終止條件也是區間的座標值。
- 本題完美考慮到了離散型區間的元素個數的奇偶性問題。
- 遞歸的邊界判斷條件 if (!memo.containsKey(N * 100 + K))寫在前頭也很漂亮
- 充分的考慮到了動態規劃的 Base Case,即雞蛋數爲0和樓層數爲1的情況。
class Solution {
public int superEggDrop(int K, int N) {
return dp(K, N);
}
/* 用哈希表模擬二維數組,減少下標堆運算的干擾。提高代碼的可讀性。哈希表也具備下標訪問的能力。而哈希函數可以解決存儲的問題。所以多維數組,如果知道下標的上下限可以採用哈希表加座標編碼的方式提高可讀性。
如本題,
雞蛋最多100個,
樓層最多10000個。
我們採用編碼爲
NK,比如8889層樓,89個雞蛋。那麼key爲888989
(或者同理,898889,也可以)
我們可以用字符串運算,“8889” + “89”
其實沒必要,因爲最大數是10000100.這個值在int範圍內,所以我們採用int即可,比如,N = 8889,K = 89
我們只需要通過,位運算的思想,N*100 + K,即可得到888989,其中100就是偏移。
*/
Map<Integer, Integer> memo = new HashMap();
public int dp(int K, int N) {
// 遞歸函數的邊界判斷
if (!memo.containsKey(N * 100 + K)) {
// 截至目前解空間的最優解
int ans;
// 初始化 base case
if (N == 0)
ans = 0;
else if (K == 1)
ans = N;
else {
// 二分查找begin
/**
在選項中i是變量,選項值是結果,而且選項i的最小值位於區間中部,滿足二分查找的應用條件。所以我們可以採用二分查找使其複雜度從O(n)變差O(lgn)
二分查找,
就是定義搜索域的訪問
然後定義一個分界點,對分界點做布爾函數運算,得出boolean結果,利用boolean結果縮小搜索範圍。
直到搜索範圍縮小爲約定大小。
本題,範圍 1 ~ N,表示樓層範圍。
lo爲搜索範圍起點,hi爲搜索範圍的終點。
初始化爲[1,N]
定義一個分界點,分界點就是:
搜索範圍是離散型區間,所以我們可以取區間中間值,如果是偶數個,中間值有兩個,取第1個。如果中間值只有一個,那就直接取。所以我們的分界點方程爲: middle = (ol + hi)/2
分界點的布爾函數爲:算出選項值,由於選項是離散的。
如果當前選項比(middle + 1)的選項大,且比(middle-1)選項小,那麼就說明當前選項處於遞減趨勢,不是最小值(類似U形拋物線的左半段)。所以最小值在middle的右側,因此調整左側起點座標lo爲middle,縮小搜索範圍,繼續迭代搜索。
如果當前選項比(middle + 1)的選項小,且比(middle-1)選項大,那麼就說明當前選項處於遞增趨勢,不是最小值。所以最小值在middle的左側,因此調整右側終點座標hi的爲middle,縮小搜索範圍,繼續迭代搜索。
約定搜索終止的搜索區間,二分查找的變量是區間,所以我們要約定終止區間(不能籠統的說是終止條件)。
約定終止區間爲 如果當前區間大小是奇數,則ol == hi ,如果當前區間大小是偶數,則 ol+1 == hi(因爲我們定義了偶數個的起點ol是兩個中間值的第一個,所以終止條件纔會是ol + 1 == hi, 這就叫百因必有果,很佛系)。
*/
int lo = 1, hi = N;
while (lo + 1 < hi) {
int x = (lo + hi) / 2;
/*
這裏採用分段函數的思想來判斷趨勢。
不過怎麼都是在判斷趨勢,無差。
因爲我們從狀態轉移方程中生成選項的算法中找到分段函數的規律。
動態轉移方程中可以知道,
一個雞蛋從第i層落下,會有碎和不碎兩種情況。 i越高,t1的樓層數越高,t2的樓層數越少。
碎的情況t1,從公式來看,K,N不變的情況下,x-1是遞增函數,所以從i=1開始時,t1肯定比較小。而大概率不會被作爲選項的最終值,因爲選項的最終值會從t1和t2選擇最大的值。
而不碎的情況則相反,t2是遞減函數
從i=1開始時,t2的值比較大,所以大概率會被選中。我們我們可以從t1和t2的大小來判斷,當前的趨勢,將趨勢作爲分界點的布爾函數。
思路是對的,不過還有個問題,我們的思路是基於函數遞增遞減趨勢來實現的,我們知道遞增遞減趨勢需要考慮單調性,而不具備單調性,那麼二分查找的分界點的布爾函數將會不穩定,從而出現誤判情況,最後搜索範圍的選擇會出現錯誤。
所以我們需要證明一個命題:
雞蛋個數一定的情況下,樓層數越高,確定F的最少次數就會越高。
我們如果能從動態規劃方程中證明這一點,那麼整個思路就通順的。
*/
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;
}
// 二分查找end
ans = 1 + Math.min(Math.max(dp(K-1, lo-1), dp(K, N-lo)),
Math.max(dp(K-1, hi-1), dp(K, N-hi)));
}
memo.put(N * 100 + K, ans);
}
return memo.get(N * 100 + K);
}
}
總結
-
總結都寫在前頭了,所以沒什麼總結的。
-
說白點,這道題就是一個2維狀態的動態規劃題,只是擇優策略比較複雜,用到了二分查找。
-
二分查找本來就是需要考慮單調性問題,才能用的數學算法。
-
學會這題等於掌握了動態規劃和二分查找。
-
以及怎麼使用哈希表代替多維數組的方法。
-
說說動態規劃蛋疼的缺點,就是一旦變動的狀態有多個的話,並且還要求所有狀態都會被用到一次,就會出現時間複雜度冪函數級別的增長了。比如本題,有2個狀態,N,K,所以時間複雜度是O(NK)起步,如果有3個狀態就是o(n^3)次方了。所以有時候爲了減少時間複雜度,得考慮怎麼減少dp解空間的計算量。最理想的情況就是假如有3個狀態,N1,N2,N3 找到一個算法時間複雜度爲:O(N1 * logN2 * logN3)。這種題目通常都要求用到數學方法。