雙蛋問題的 Python 遞歸解決
今天看了 李永樂老師關於雙蛋問題的講解視頻,受用很大。本着好記性不如爛筆頭的精神,把這個問題記錄在此。html
據傳某大廠有這樣一個面試題:手裏有 2 個雞蛋,另外有 100 層樓。有一未知的臨界樓層,雞蛋從臨界樓層如下扔下去,必定不會碎;從臨界樓層以上丟下去,必定會碎。沒有摔碎的雞蛋能夠反覆使用,碎了的雞蛋就不能再往下扔了。問,在最糟糕的狀況下,至少須要多少次可以找到臨界樓層?python
吐槽一句,這個雞蛋可能比較特殊,由於普通雞蛋別說 100 層樓,從桌子上掉下去基本就碎了。不過問題自己是頗有價值的,咱們能夠把雞蛋改爲玻璃球之類的,低樓層摔不碎,高樓層受不了就成了。面試
另外,要想讀懂本文,恐怕須要一點遞歸和算法基礎,不然不必定能看懂。這不是由於我水平高,寫的東西高深莫測。而是由於個人水平過低,道行太淺,目前還沒辦法作到深刻淺出,十分抱歉。算法
好了,閒話很少扯,來談談解決問題的思路。app
二分查找解決無限多雞蛋的狀況
直接看問題,彷佛沒什麼思路。咱們不妨稍微簡化一下問題。好比,若是咱們有無數多個雞蛋,最壞的狀況下至少須要幾個雞蛋能找到臨界樓層?ide
這就很簡單了,使用二分法便可。先從 50 層試,若是雞蛋碎了,說明臨界樓層在下面,就去 25 層再試;若是雞蛋沒碎,說明臨界樓層在上面,到 75 層去試。以此類推,每次排除一半的可能,很快就能找到答案。性能
二分法須要的次數的公式是 \(log_2(100)\) 向下取整再加 1,計算結果應該是 7。測試
遞歸法解決雙蛋問題
不過二分查找彷佛並無對咱們解決問題有什麼特別好的啓發,咱們只好另闢蹊徑。咱們可不能夠經過 分而治之 的思想來解決這個問題呢?優化
首先,基線條件很好肯定:url
- 在有 2 個雞蛋的狀況下,若是隻有一層樓,只須要試一次;若是有兩層樓,只須要試兩次;若是沒有樓,那就乾脆不用試了(看似是廢話,可是是很重要的邊界條件)。
- 若是隻有 1 個雞蛋,只能老老實實從下往上嘗試,也就是在最壞的狀況下,有幾層樓就要試幾回。
接下來,咱們就要思考遞歸條件了。如何能將問題簡化。
令在有 2 個雞蛋時,最壞的狀況下,N 層樓所須要嘗試的最少次數爲 \(T_N\)。
假設總共有 N 層樓,咱們在第 K 層樓進行一次嘗試。那麼此時,就會分紅兩種狀況:
- 雞蛋在 K 層碎掉了,也就說明臨界樓層在 K 層如下。可是此時,咱們只剩下 1 個雞蛋,最壞的狀況下還要檢測 \(K - 1\) 次才能找到臨界樓層
- 雞蛋在 K 層沒有碎,臨界樓層在 K 層以上。此時咱們仍是有 2 個雞蛋,還剩下 \(N-K\) 層樓須要檢測,那麼最壞的狀況下,還須要檢測 \(T_{N-K}\) 次。很顯然 \(N-K\) 要比 N 少,咱們順利實現對問題的簡化。
最壞的狀況顯然是 \(K - 1\) 和 \(T_{N-K}\) 兩個數的最大的那一個再加上 1,由於咱們先試了一次。這個最大的數,就是 \(T_N\)。
不過這裏面有一個 K 是不能肯定的。爲了找到合適的 K,咱們須要把 K 從 1 到 N 的狀況所有計算出來,找到使得 \(T_N\) 最小的狀況便可。
用代碼來解決這個問題就是:
def two_egg(n: int) -> int: """ 雙蛋問題的遞歸求解 :param n: 樓層數 :return: 最壞狀況下,找到臨界樓層所需最少嘗試次數 """ if n == 0: # 沒有樓就不須要試 return 0 elif n == 1: # 有一層樓,試一次 return 1 result_list = [] for k in range(1, n + 1): # 在每一層都試一下 result_list.append(max(k - 1, two_egg(n - k)) + 1) # 把每一層的狀況都記錄下來 return min(result_list) # 最好的結果就是咱們想要的 # 用 1 到 11 的數字測試,不用 100 是由於電腦性能不夠,測到 11 是由於 10 和 11 的結果不一樣 for f in range(1, 12): print(f'{f} -------> {two_egg(f)}')
上面的代碼用到了遞歸。隨着遞歸層數的增長,會佔用不少資源,計算時間也會特別長。能夠經過記錄低樓層的結果,優化上面的代碼:
def two_egg_opt(n: int, result_dict: dict) -> int: if n in result_dict: return result_dict[n] else: result_list = [] for k in range(1, n + 1): # 在每一層都試一下 result_list.append(max(k - 1, two_egg_opt(n - k, result_dict)) + 1) # 把每一層的狀況都記錄下來 result_dict[n] = min(result_list) # 最好的結果就是咱們想要的 return min(result_list) # 從前計算的結果記錄在result_dict中,下次使用能夠直接拿,極大減小了遞歸層數 result_dict = {0: 0, 1: 1} for i in range(1, 101): result_dict[i] = two_egg_opt(i, result_dict) print(result_dict)
優化前的代碼用個人小電腦根本沒法求出 100 層樓的雙蛋問題的解。而使用這個優化後的代碼,1 到 100 層樓雙蛋問題的解幾乎馬上就出來了。
遞歸法解決廣泛雙蛋問題
用二分查找,能夠解決雞蛋數目不限的狀況,遞歸查找能夠解決只有 2 個雞蛋的狀況。如今,咱們把問題進一步擴展:若是咱們有 M 個雞蛋,N 層樓,在最壞的狀況下,至少須要測試多少次可以找到臨界樓層?
基線條件根上面的差很少同樣:
- 無論有多少個雞蛋,若是隻有一層樓,只須要試一次;若是沒有樓,那就乾脆不用試了。
- 若是隻有 1 個雞蛋,只能老老實實從下往上嘗試,也就是在最壞的狀況下,有幾層樓就要試幾回。
遞歸條件其實也很相似,只是由於雞蛋數目的引入,會稍微複雜一丁丁點點。
令在有 M 個雞蛋時,最壞的狀況下,N 層樓所須要嘗試的最少次數爲 \(T_{M,\space N}\)。
依舊假設總共有 N 層樓,咱們在第 K 層樓進行一次嘗試。那麼此時,仍是會分紅兩種狀況:
- 雞蛋在 K 層碎掉了,也就說明臨界樓層在 K 層如下。可是此時,咱們只剩下 \(M-1\) 個雞蛋,最壞的狀況下還要檢測 \(T_{M-1,\space K - 1}\) 次才能找到臨界樓層
- 雞蛋在 K 層沒有碎,臨界樓層在 K 層以上。此時咱們仍是有 M 個雞蛋,還剩下 \(N-K\) 層樓須要檢測,那麼最壞的狀況下,還須要檢測 \(T_{M,\space N-K}\) 次
上面的兩種狀況,要麼簡化了雞蛋數量,要麼簡化了樓層數量,最終均可以經過遞歸來找到答案。最終的結果須要是 \(T_{M-1,\space K - 1}\) 和 \(T_{M,\space N-K}\) 這兩個數中最大的那一個加上 1,由於咱們最開始的時候在 K 層測試了一下。
一樣地,咱們須要遍歷測試當 K 爲 1 到 N 時的各類狀況,取其中所需步驟最少的,就是咱們要的結果。
用代碼表示就是:
def two_egg_general(m: int, n: int) -> int: """ 廣泛雙蛋問題的解決 :param m: 雞蛋數量 :param n: 樓層總層數 :return: 最糟糕的狀況下,找到臨界樓層所需最少嘗試數目 """ if n == 0: # 若是沒有樓,不須要試 return 0 elif n == 1: # 只有 1 層樓,試一次就足夠 return 1 if m == 1: # 只有 1 個蛋,有幾層樓就要使幾回 return n result_list = [] for k in range(1, n + 1): result_list.append(max(two_egg_general(m - 1, k - 1), two_egg_general(m, n - k)) + 1) return min(result_list) for i in range(1, 12): for j in range(1, 12): print(f'({i}, {j}) --> {two_egg_general(i, j)}', end=' | ') print()
測試結果以下:
附上雙蛋問題的參照表,都是吻合的。只不過我是以樓層數爲橫軸,雞蛋數爲縱軸了而已。
一樣地,也能夠對這個代碼進行優化:
def two_egg_gen_opt(m: int, n: int, result_dict: dict) -> int: """ 廣泛雙蛋問題遞歸解決的優化 :param m: 雞蛋數量 :param n: 樓層總層數 :param result_dict: 儲存結果的字典 :return: 最糟糕的狀況下,找到臨界樓層所需最少嘗試數目 """ if (m, n) in result_dict: return result_dict[(m, n)] if n == 0: # 若是沒有樓,不須要試 result_dict[(m, n)] = 0 return 0 elif n == 1: # 只有 1 層樓,試一次就足夠 result_dict[(m, n)] = 1 return 1 if m == 1: # 只有 1 個蛋,有幾層樓就要使幾回 result_dict[(m, n)] = n return n result_list = [] for k in range(1, n + 1): result_list.append(max(two_egg_gen_opt(m - 1, k - 1, result_dict), two_egg_gen_opt(m, n - k, result_dict)) + 1) result_dict[(m, n)] = min(result_list) return min(result_list) result_dict = {} for i in range(1, 20): for j in range(1, 1002): print(f'({i}, {j}) --> {two_egg_gen_opt(i, j, result_dict)}', end=' | ') print()