面試官:你有m個雞蛋,如何用最少的次數測出雞蛋會在哪一層碎?

假設你面前有一棟n層的大樓和m個雞蛋,假設將雞蛋從f層或更高的地方放扔下去,雞蛋纔會碎,不然就不會。你須要設計一種策略來肯定f的值,求最壞狀況下扔雞蛋次數的最小值。git

leetcode原題連接github

乍一看這道題很抽象,可能有的人一看到這個題目歷來沒作過,就懵逼了。其實不用慌張,再花裏胡哨的題目,最後均可以抽象成咱們熟悉的數據結構和算法去解決。算法

不限雞蛋

首先,咱們從一個簡單的版本開始理解,假如不限制雞蛋個數,即把題目改爲n層大樓和無限個雞蛋。那麼這題要怎麼解呢?
第一步就是要充分理解題意,排除題目中的干擾,創建模型:數組

  • 在一棟n層的大樓中尋找目標樓層f --> 其實就是在一個1~n的數組中查找一個目標數字。
  • 雞蛋碎了就表明樓層太高,不然就表明樓層太低 --> 每次嘗試都能知道當前數字是大了仍是小了。

很顯然,這就是一個二分查找能解決的問題。
扔雞蛋的次數就是二分查找的比較次數,即log_2(n+1)bash

限制雞蛋個數

那咱們如今再來看限制雞蛋個數狀況下,確定無法用二分查找,可是因爲求解的是一個最優值,咱們天然而然地想到了動態規劃。數據結構

四步走

動態規劃的題目,這邊提供一個思路,就是四步走數據結構和算法

  1. 問題建模,優化的目標函數是什麼?約束條件是什麼?
  2. 劃分子問題(狀態)
  3. 列出狀態轉移方程及初值
  4. 是否知足最優子結構性質

建模

這一步很是很是重要,它創建在良好地理解題意的基礎上。其實不少動態規劃的題目都有這樣的特色:函數

  1. 目標是求一個最優值
  2. 每一步決策有代價,總代價有一個約束值。

而這道題:優化

  1. 目標函數f(n):表明在1~n的樓層中找到f層的嘗試次數,咱們的目標就是求出f(n)的最優值。
  2. 每一步決策的代價:雞蛋可能會碎;總代價的約束值:雞蛋總個數。

劃分子問題

咱們知道動態規劃就是多階段決策的過程,最後求解組合最優值。 咱們先舉一個簡單例子,來理解劃分子問題的思路,看下面這張圖: ui

最短路徑
問題:求起點集 S1~S5到終點集 T1~T5的最短路徑。

分析這道題:定義子問題dis[i]表明節點i到終點的最短距離,沒有約束條件。 而後問題劃分爲4個階段:

  1. 階段1求出離終點最近的C1~C4節點到終點的最短路徑dis[C1]~dis[C4]
  2. 階段2求出離終點最近的B2~B5節點到終點的最短路徑dis[B1]~dis[B5],須要創建在階段1的結果上計算。例如B2節點到終點有兩條路,B2~C1,B2~C2,dis[C1]=2,B2到C1的長度=3;而dis[C2]=3,B2到C2的長度=6,所以dis[B2]=3+dis[B1]=5
  3. 階段3和階段4也是以此類推,最終就求出獲得dis[S1]~dis[S5],得出最小路徑爲圖中紅色的兩條。

在這道題中,dis[i]就是劃分出來的子問題,每一步決策都是一個子問題,並且每個子問題都依賴於之前子問題的計算結果。

所以,在動態規劃中,定義一個合理的子問題很是重要

而扔雞蛋這道題比上面這道題多了個約束條件,咱們把子問題定義爲:用i個雞蛋,在j層樓上扔,在最壞狀況下肯定目標樓層E的最少次數,記爲狀態f[i,j]

列出狀態轉移方程和初值

樓

假如決策是在第k層扔下雞蛋,有兩種結果:

  1. 雞蛋碎了,此時e<k,咱們只能用i-1個蛋在下面的k-1層繼續尋找e。而且要求最壞狀況下的次數最少,這是一個子問題,答案爲f[i-1,k-1],總次數即是f[i-1,k-1]+1
  2. 雞蛋沒碎,此時e>=k,咱們繼續用這i個蛋在上面的j-k層尋找E。注意:在k~j層尋找和在1~(j-k)層尋找沒有區別,由於步驟都是同樣的,只不過這(j-k)層在上面罷了,因此就把它當作是對1~(j-k)層的操做。所以答案爲f[i,j-k],次數爲f[i,j-k]+1

初值: 當層數爲0時,f[i,0]=0,當雞蛋個數爲1時,只能從下往上一層層扔,f[1,j]=j

由於是要最壞狀況,因此這兩種狀況要取大值:max{f[i-1,j-1],f[i,j-k]},又要在全部決策中取最小值,因此動態轉移方程是:

f(i,j)=min\{max\{f(i-1,k-1),f(i,j-k)\}+1|1<=k<=j\}

是否知足最優子結構

獲得了狀態轉移方程後,還須要判斷咱們的思路是否是正確。能用動態規劃解決的問題必需要知足一個特性,叫作最優子結構特性

一個最優決策序列的任何子序列自己必定是相對於子序列的初始和結束狀態的最優決策序列。

這句話是什麼意思呢?舉個例子:f[4,5]表示4個雞蛋、5層樓時的最優解,那它的子問題f[3,4],獲得的解在3個雞蛋、4層樓時也是最優解,它全部的子問題都知足這個特性。那這就知足了最優子結構特性。

一個反例

求 路徑長度模10 結果最小的路徑

最優子結構

仍是像上面那道題同樣,分紅四個階段。
按照動態規劃的解法,階段一CT,上面的路2 % 10 = 2,下面的路5 % 10 = 5,選擇上面那條,階段二BC也選擇上面那條,以此類推,最後得出的結果路徑是藍色的這條。

但實際上,真正最優的是紅色的這條路徑20 % 10 = 0。這就是由於不符合最優子結構,對於紅色路徑的子結構CT階段,最優解並非下面這條邊。

時間複雜度

遞歸樹

假設m=3,n=4,咱們來看一下f[3,4]的遞歸樹。

遞歸樹

圖中顏色相同的就是同樣的狀態,能夠看出,重複的遞歸計算不少,所以咱們開設一個數組result[i,j]用於存放f[i,j]的計算結構,避免重複計算,用空間換時間。

代碼

class Solution {
    private int[][] result;
    
    public int superEggDrop(int K, int N) {
        result = new int[K + 1][N + 1];

        for (int i = 1; i < K + 1; i++) {
            for (int j = 1; j < N + 1; j++) {
                result[i][j] = -1;
            }
        }

        return dp(K, N);
    }
    

    /**
     * @param i 剩餘雞蛋個數
     * @param j 樓層高度
     * @return
     */
   private int dp(int i, int j) {
        if (result[i][j] != -1) {
            return result[i][j];
        }
        if (i == 1) {
            return j;
        }
        if (j <= 1) {
            return j;
        }

        int min = Integer.MAX_VALUE;
        for (int k = 1; k <= j; k++) {
            int left = dp(i - 1, k - 1);
            result[i - 1][k - 1] = left;

            int right = dp(i, j - k);
            result[i][j - k] = right;

            int res = Math.max(left, right) + 1;
            if (res < min) {
                min = res;
            }
        }
        return min;
    }
    
   private static int log(int x) {
        double r = (Math.log(x) / Math.log(2));
        if ((r == Math.floor(r)) && !Double.isInfinite(r)) {
            return (int) r;
        } else {
            return (int) r + 1;
        }
    }
}
複製代碼

時間複雜度

動態規劃求時間複雜度的方法是:

時間複雜度 = 狀態總數 * 狀態轉移方程的時間複雜度
複製代碼

在這道題中,狀態總個數很明顯是m*n,而每一個狀態f[i,j]的時間複雜度爲O(j)1 \leq j \leq n,總時間複雜度爲O(mn^2)

優化

O(mn^2)的時間複雜度仍是過高了。能不能想辦法優化一下?

優化1

決策樹

首先咱們知道,在一個1~n的數組中,查找目標數字,最少須要比較log_2n次,也就是二分查找。這個理論能夠經過決策樹來證實:
咱們使用二叉樹來表示全部的決策,內部節點表示一次扔雞蛋的決策,左子樹表示碎了,右子樹表示沒碎,葉子節點表明E的全部結果。每一條從根節點到葉子節點的路徑對應算法求出E以前的全部決策

內部節點(i,j),i表示雞蛋個數,j表示在j層樓扔下。

決策樹.png

當樓層高度n=5時,E總共有6種狀況(n=0表明沒找到),因此葉子節點的個數是n+1個。 而咱們關心的是樹的高度,即決策的次數。根據二叉樹理論:當樹有n個葉子節點,數的高度至少爲log_2n,即比較次數在最壞狀況下至少須要log_2n次,也就是當這顆樹儘可能平衡的時候。

換句話說,在給定樓層n的狀況下,決策次數的下限是log_2(n+1),這個下限能夠經過二分查找達到,只要雞蛋的數量足夠(就是咱們剛纔討論的不限雞蛋的狀況)。

所以,一旦狀態f[i,j]的雞蛋個數i>log_2(j+1),就不用計算了,直接輸出二分查找的比較次數log_2(j+1)便可。

這樣咱們的狀態總數就降爲n*log_2(k+1),時間複雜度降爲O(n^2 log_2n)

代碼

/**
 * @param i 剩餘雞蛋個數
 * @param j 樓層高度
 * @return
 */
private int dp(int i, int j) {
    if (result[i][j] != -1) {
        return result[i][j];
    }
    if (i == 1) {
        return j;
    }
    if (j <= 1) {
        return j;
    }
    
    //此處剪枝優化
    int lowest = log(j + 1);
    if (i > lowest) {
        return lowest;
    }

    int min = Integer.MAX_VALUE;
    for (int k = 1; k <= j; k++) {
        int left = dp(i - 1, k - 1);
        result[i - 1][k - 1] = left;

        int right = dp(i, j - k);
        result[i][j - k] = right;

        int res = Math.max(left, right) + 1;
        if (res < min) {
            min = res;
        }
    }
    return min;
}
複製代碼

優化2

優化還未結束,咱們嘗試從動態轉移方程的函數性質入手,觀察函數f(i,j),以下圖:

函數表格
咱們能夠發現一個規律,f(i,j)是根據j遞增的單調函數,即 f(i,j)>=f(i,j-1),這個性質是能夠用數學概括法證實的,在這裏不作證實,有興趣的查看文末參考文獻。

再來看動態轉移方程:

f(i,j)=min\{max\{f(i-1,k-1),f(i,j-k)\}+1|1<=k<=j\}

因爲f(i,j)具備單調性,所以f(i-1,k-1)是根據k遞增的函數,f(i,j-k)是根據k遞減的函數。

分別畫出這兩個函數的圖像:

函數圖像

圖像1:f(i-1,k-1)
圖像2:f(i,j-k)
圖像3:max\{f(i-1,k-1),f(i,j-k)\}+1當k=kbest時,f達到最小值,咱們的目標就是找到kbest的值

對於這個函數,可使用二分查找來找到kbest:
若是f(i-1,k-1)<f(i,j-k),則k<kbest,即k在圖中kbest的左邊;
若是f(i-1,k-1)>f(i,j-k),則k>kbest,即k在圖中kbest的右邊。

代碼

class EggDrop {
    private int[][] result;
    
    public int superEggDrop(int K, int N) {
        result = new int[K + 1][N + 1];

        for (int i = 1; i < K + 1; i++) {
            for (int j = 1; j < N + 1; j++) {
                result[i][j] = -1;
            }
        }

        return dp(K, N);
    }
    

    /**
     * @param i 剩餘雞蛋個數
     * @param j 樓層高度
     * @return
     */
   private int dp(int i, int j) {
        if (result[i][j] != -1) {
            return result[i][j];
        }
        if (i == 1) {
            return j;
        }
        if (j <= 1) {
            return j;
        }
        int lowest = log(j + 1);
        if (i >= lowest) {
            result[i][j] = lowest;
            return lowest;
        }

        int left = 1, right = j;
        while (left <= right) {
            int k = (left + right) / 2;
            int broken = dp(i - 1, k - 1);
            result[i - 1][k - 1] = broken;
            int notBroken = dp(i, j - k);
            result[i][j - k] = notBroken;

            if (broken < notBroken) {
                left = k + 1;
            } else if (broken > notBroken) {
                right = k - 1;
            } else {
                return notBroken + 1;
            }
        }
        //沒找到,最小值就在left或者right中
        return Math.min(Math.max(dp(i - 1, left - 1), dp(i, j - left)),
            Math.max(dp(i - 1, right - 1), dp(i, j - right))) + 1;
    }
    
   private static int log(int x) {
        double r = (Math.log(x) / Math.log(2));
        if ((r == Math.floor(r)) && !Double.isInfinite(r)) {
            return (int) r;
        } else {
            return (int) r + 1;
        }
    }
}
複製代碼

時間複雜度

如今狀態轉移方程的時間複雜度降爲了O(log_2N),算法的時間複雜度降爲O(Nlog_2^2 N)

優化3

如今不管是狀態總數仍是狀態轉移方程都很難優化了,但還有一種算法有更低的時間複雜度。
咱們定義一個新的狀態g(i,j),它表示用j個蛋嘗試i次在最壞狀況下能肯定E的最高樓層數

動態轉移方程

假設在k層扔下一隻雞蛋:

若是碎了,則在後面的(i-1)次裏,咱們要用(j-1)個蛋在下面的樓層中肯定E。爲了使 g(i,j)達到最大,咱們固然但願下面的樓層數達到最多,這是一個子問題,答案爲 g(i-1,j-1)。

若是沒碎,則在後面(i-1)次裏,咱們要用j個蛋在上面的樓層中肯定E,這一樣須要樓層數達到最多,便爲g(i-1,j) 。

所以動態轉移方程爲:

g(i,j)=g(i-1,j-1)+g(i-1,j)+1
複製代碼

邊界值

當i=1時,表示只嘗試一次,那最多隻能肯定一層樓,即g(1,j)=1 (j>=1)

當j=1是,表示只有一個蛋,那隻能第一層一層層往上扔,最壞狀況下一直扔到頂層,即g(i,1)=i (i>=1)

而後咱們的目標就是找到一個嘗試次數x,使x知足g(x-1,m)<ng(x,m)>=n

代碼

public class EggDrop {

    private int dp(int iTime, int j) {
        if (iTime == 1) {
            return 1;
        }
        if (j == 1) {
            return iTime;
        }
        return dp(iTime - 1, j - 1) + dp(iTime - 1, j) + 1;
    }

    public int superEggDrop(int i, int j) {
        int ans = 1;
        while (dp(ans, i) < j) {
            ans++;
        }
        return ans;
    }
}
複製代碼

這個算法的時間複雜度是O(\sqrt{N}),證實比較複雜,這裏就不展開了,能夠參考文末文獻。

小結

最後咱們總結一下動態規劃算法的解題方法:

  • 四步走:問題建模、定義子問題、動態轉移方程、最優子結構。
  • 時間複雜度 = 狀態總數 * 狀態轉移方程的時間複雜度。
  • 考慮是否須要設置標記,例若有的題目還要求打印出最小路徑。
  • 寫代碼,遞歸和循環選擇你熟悉的來寫。
  • 若是時間複雜度不能接受,考慮能不能優化算法。

優化思路

  • 是否可以剪枝優化(優化1)
  • 從函數自己的數學性質入手(優化2)
  • 轉換思路,嘗試一下別的狀態轉移方程(優化3)
  • ……

動態規劃在算法中屬於較難的題型,難點就在定義子問題和寫出動態轉移方程。因此須要勤加練習,訓練本身的思惟。 這裏給出幾道動態規劃的經典題目,這幾道題都須要吃透,能夠用本文中提到的四步走的方式來思考和解題。

Maximum Length of Repeated Subarray
Coin Change
Partition Equal Subset Sum

最後:

求贊

參考文獻

國家集訓隊朱晨光論文
知乎-如何用最少的次數測出雞蛋會在哪一層摔碎?

相關文章
相關標籤/搜索