Leetcode 題解演算法之動態規劃

Remember發表於2019-11-28

開篇

我記得我之前有寫過一點動態規劃的文章,這兩天剛好重新回顧了下 DP ,查閱的一些資料,再結合 Leetcode 的練習題,根據自己的理解,來重新認識一些動態規劃。這篇文章的總體流程就是介紹動態規劃的一些場景,解題思路,以及題目解析,我並不喜歡用一些專業的術語去介紹動態規劃,這不是我的風格,而且我解釋的也沒有 Google 的好。

✏️動態規劃的場景

我覺得動態規劃是挺難理解的,可能就在於它的思想不符合正常人的思維方式。它的解題就像遞迴那樣,已經不能按照人腦去一步步去追蹤結果,然後返回了值,想想自己在學習遞迴的時候,有沒有腦子裡一直在想這個遞迴過程的呼叫,我這麼幹過! 這一步應該交給計算機,而動態規劃需要我們定義狀態,然後推出狀態轉移方程,狀態轉移方程找到之後,其實題目也就解決一半了。

動態規劃的一些場景,典型的比如求一些最大最小值,揹包問題,爬樓梯......,下面從一個例子中慢慢去了解動態規劃吧,從典型的爬樓梯開始吧。這篇文章涉及到的所有題目都是以動態規劃的思想解題,並不是說這道題只能用動態規劃來解,不存在的。

動態規劃

其實我們求 n 階臺階的總走法和 n-1 階臺階的總走法本質上一樣的,也就是說這個問題可以被分解為包含最優子結構的子問題。求這個解是可以從它的子問題的解推匯出來的。

1.當 n =1 時,肯定只有一種走法,也就是向上走一步(dp[1]=1)。當 n=2 時,兩種走法(dp[2]=2),一種每一次走一步,兩次到達,一種一次走兩步一次到頂。

對於 n 層臺階,當前可以從兩個位置上來:
2.從第 n-1 的位置向上邁進一步
3.從第 n-2 的位置向上邁進二步

從上面的第一點我們可以知道初始的狀態,從第二第三點我們可以得出第 n 層臺階的總走法 = 它的下一層臺階的總走法+它的下下一層的臺階的總走法,為什麼,因為到達當前層只有兩種情況,要麼從下一層跳一步,要麼從下下一層跳兩步。好了,這道題到這就已經解完了。你可能會問,那你剛說的動態規劃呢?我說完了啊。上面的分析過程就是動態規劃啊。就像我剛才說的,動態規劃背後的思想簡單概括就是,若要解一個指定的問題,我們需要解它的不同部分問題(子問題),再合併子問題求出最終想要的解。

因此,動態規劃解題最重要的兩個步驟:

1.狀態的定義 dp[1]=1,dp[2]=1
2.狀態轉移的方程 dp[n]=dp[n-1]+dp[n-2]

這裡的 dp[n] 表示的含義是當臺階為 n 時,總共有多少走種走法。就像上面說的, n 層臺階的總走法 = 它的下一層臺階的總走法+它的下下一層的臺階的總走法,我們並不需要關心 dp[n-1]dp[n-2] 這個過程是如何推匯出來的,我們只關心它的狀態值。只要定義好了狀態,找出狀態轉移方程,這個題目其實就已經解了一大半了。接下來實現一下具體的程式碼:

 /**
     * @param Integer $n
     * @return Integer
     */
    function climbStairs($n) {
        if($n==1) return 1;
        $dp[1]=1;
        $dp[2]=2;
        for($i=3;$i<=$n;$i++){
            $dp[$i]=$dp[$i-1]+$dp[$i-2];
        }
        return $dp[$n];
    }

嗯,到這裡。其實這道題也就解出來了,但是,千萬別覺得動態規劃就這點東西。這道題目只是入門級的題目,並不是什麼複雜的場景,狀態的定義以及狀態轉移方程相對來說易於推匯出來。實際情況下,對於這兩步的推導是有點難度的,有時候可能定義一維的狀態還不夠,需要二維(接下來的題目會涉及到多維的)......說到這,其實動態規劃的本質就是一個空間換時間的思想。但是請記住,動態規劃解題思路最重要的兩步就是狀態的定義以及狀態轉移方程。動態規劃區別於貪心演算法的地方在於,它就像是獲取了上帝的視角,每次能獲取到全域性的最優解,而不像貪心,每次得到是隻是區域性最優。單這一題過於無趣,接下來我會帶上 Leecode 不同題型來認識動態規劃,從簡單到複雜題型。

Leetcode 120. 三角形最小路徑和

動態規劃

這道題本身就是一個二維陣列,所以我們再定義狀態時候也定義一個二維陣列(先不考慮壓縮空間)。$dp[$i][$j] 表示從底部到 (i,j) 位置最短路徑和,初始的 dp 值就等於最後一排的值,同樣的道理,這裡定義的 dp[i][j] 的意思是從底部到達二維陣列 (i,j) 的最小和。那麼狀態轉移方程呢?從題目中可以看到這麼一句話 每一步只能移動到下一行中相鄰的結點上,對於我們這裡,是從下往上計算,那麼對應的狀態轉移方程:

當前(i,j) 位置和的最小值: $dp[$i][$j]=$triangle[$i][$j]+min($dp[$i+1][$j],$dp[$i+1][$j+1])
比如圖中例子5那麼它的 dp 轉移方程即:$dp[2][1]=它可以由下層的$dp[3][1]+它自身 或者 下層的$dp[3][2]+它自身值 哪個路徑短,就是哪個。

請注意上面出現兩個變數 $triangle[$i][$j] 以及 $dp[$i][$j] ,前者表示的是表中二維陣列第 i 行 j 列上的值,而後者是我們定義的狀態值表示在(i,j)這個位置上總和的最小值,它是由之前的一步步推匯出來的。而這個推導的過程我們是不關心的,我們只關心它的結果。

為什麼反著推呢,原因很簡單,因為最後的值必然出現在$dp[0][0]的位置,頂層只有一個元素。

 /**
     * @param Integer[][] $triangle
     * @return Integer
     */
    function minimumTotal($triangle)
    {
        if (empty(count($triangle))) {
            return 0;
        }
        for ($i = count($triangle) - 1; $i >= 0; $i--) {
            for ($j = 0; $j < count($triangle[$i]); ++$j) {
                $triangle[$i][$j] += min($triangle[$i + 1][$j], $triangle[$i + 1][$j + 1]);
            }
        }
        return $triangle[0][0];
    }

這裡程式碼做了一點細微的處理,不需要額外定義 dp[],因為我們在進入當前層計算最小和值的時候,只需要下一層最小和的狀態值,而不是陣列具體位置的值本身,所以每次算完可以覆蓋原先的值,給上一層使用。

Leetcode 152. 乘積最大子序列

動態規劃

我們還是先按照之前說的先定義狀態,然後求出狀態轉移方式。這道題用一個一維陣列儲存dp 即可。

DP[i] 代表從下標0到下標i這個範圍內的連續子陣列最大的乘積

狀態轉移方程:

DP[i+1] = DP[i] * 當前下標的值

這裡有個關鍵點在於 DP 儲存的是當前位置連續子陣列的最大的乘積,但是我們並不知道當前下標的值是負數還是正數,如果是負數,那麼 DP[i+1] 將會是一個最小值,所以只是單純的這樣定義狀態是不行的。如果是負數的話,我們就應該選取前面推出的負數最大值 即最小值。如果是正數的話我們才應該把之前的最大 DP 拿過來直接相乘,所以這裡需要定義兩個狀態。最大值的狀態 $max[$i] 和最小值的狀態 $min[$i]

    /**
     * @param Integer[] $nums
     * @return Integer
     */
    function maxProduct($nums)
    {
        $max[0] = $min[0] = $res = $nums[0];
        for ($i = 1; $i < count($nums); $i++) {
            $max[$i] = max($max[$i - 1] * $nums[$i], $min[$i - 1] * $nums[$i], $nums[$i]);
            $min[$i] = min($max[$i - 1] * $nums[$i], $min[$i - 1] * $nums[$i], $nums[$i]);
            $res     = max($res, $max[$i]);
        }
        return $res;
    }

如果覺得這樣看著變扭,多定義兩個變數作為中轉。

/**
     * @param Integer[] $nums
     * @return Integer
     */
    function maxProduct($nums)
    {
        $max = $min = $res = $nums[0];
        for ($i = 1; $i < count($nums); $i++) {
            $mx  = $max;
            $mn  = $min;
            $max = max(max($nums[$i], $nums[$i] * $mx), $nums[$i] * $mn);
            $min = min(min($nums[$i], $nums[$i] * $mx), $nums[$i] * $mn);
            $res = max($res, $max);
        }
        return $res;
    }

Leetcode 300. 最長上升子序列

動態規劃

這道題並不是要求連續的,只要後面的數比前面的大,那麼就是可以被新增到連續上升子序列中的,只是說每次應該加入較小的數字,才能給後面騰出更多的位置,道理是這麼說的。

預設開始的狀態一定是1 因為就算是全部遞減的值,它的最長上升子序列至少還是等於1,也就是它本身。

DP[i]=Max(DP[i],DP[j-1]+1) 如果$nums[i-1]<$nums[$j],說明此時$nums[i]也能加入到子序列中

這個狀態轉移方程是什麼意思呢?可以先看程式碼在進行解釋。

    /**
     * @param Integer[] $nums
     * @return Integer
     */
    function lengthOfLIS($nums)
    {
        $size = count($nums);
        if ($size < 2) return $size;
        $dp[0] = $res = 1;
        for ($i = 1; $i < count($nums); $i++) {
            $dp[$i] = 1;
            for ($j = 0; $j < $i; $j++) {
                if ($nums[$j] < $nums[$i]) {
                    $dp[$i] = max($dp[$i], $dp[$j] + 1);
                }
            }
          $res    = max($res, $dp[$i]);
        }
        return $res;
    }

兩層遍歷,第一層表示的到第 i 個元素,整體的意思是,每次到第 i 個元素的時候,就去看 i 之前的已經推匯出來的狀態。j 的值就是0到 i,只要 $nums[$j] < $nums[$i] 說明 i 的位置可以利用之前推導的結果組成一個最長上升的子序列。所以就把之前推匯出的 $dp[$j] +1,和已經推匯出的 $dp[$i] 進行比較,獲取最大值,重新儲存在 $dp[i] 中。然後每次全域性更新最大上升子序列值,最後返回即可。

Leetcode 121. 買賣股票的最佳時機

動態規劃

這系列的題目很有意思,這道題只能有一次交易,就是求利益最大化的買賣。對於這裡的狀態定義,可能一開始難以想到,我們可以這樣想,每一天我們的狀態只會有三種,未持有股票(沒購買過),持有股票以及未持有股票(拋售了):

初始狀態 $dp[0][0]=$dp[0][2]=0,$dp[0][1]= -第一天的價格。什麼意思呢,其實就是初始狀態的定義表示第一天沒買股票,那麼收益的值0,即 $dp[0][0]=0$dp[0][2] 表示第一天賣出股票的收益,因為第一天不存在賣,所以初始值也是0。最後看 $dp[0][1]= -第一天的價格,這裡表示如果第一天買了股票那麼收益當然是負數的,即購買的價格。

狀態定義完了,接下來就是狀態轉移方程了。也就是 $dp[i][0]$dp[i][1] 以及 $dp[i][2] 咋麼轉移,我們可以這麼想第 i 天持有未購買的情況只能是前面的也沒購買,第 i 天持有的情況分為兩種情況,一種是前一天已經持有了或者是前一天也是未持有狀態然後今天購買持有了。剩下的第 i 天已出售情況只能是前一天持有的狀態然後今天拋售了。這個理清楚了,程式碼也就好寫了。

/**
     * @param Integer[] $prices
     * @return Integer
     */
    function maxProfit($prices)
    {
        $dp[0][0] = $dp[0][2] = 0;
        $dp[0][1] = -$prices[0];
        $res      = 0;
        for ($i = 1; $i < count($prices); $i++) {
            $dp[$i][0] = $dp[$i - 1][0];
            $dp[$i][1] = max($dp[$i - 1][1], $dp[$i - 1][0] - $prices[$i]);
            $dp[$i][2] = $dp[$i - 1][1] + $prices[$i];

            $res = max($res, $dp[$i][0], $dp[$i][1], $dp[$i][2]);
        }

        return $res;
    }

Leetcode 122. 買賣股票的最佳時機 II

動態規劃

這是上一題的擴充套件,上一題只能交易一次,這一題可以多次交易。這道題我不用三個維護狀態維護了,只有兩種狀態,第 i 天 未持有股票 和第 i 天持有股票。轉移方程嘛就很好理解了,持有股票最大值,之前持有的最大值和之前不持有股票今天買了,取最大值。至於不持有股票最大值也分為兩種,之前就一直不持有股票值和之前持有股票值今天賣了,取最大值,最後取 dp 陣列中不持有股票最後的值。因為每一次更新的都是到 i 天的交易最大值,所以最後得到的必然是全域性最大值。

    /**
     * @param Integer[] $prices
     * @return Integer
     */
    function maxProfit($prices)
    {
        if (count($prices) == 0) return 0;
        $dp[0][1] = -$prices[0];
        $dp[0][0] = 0;
        for ($i = 1; $i < count($prices); $i++) {
            $dp[$i][1] = max($dp[$i - 1][1], $dp[$i - 1][0] - $prices[$i]);
            $dp[$i][0] = max($dp[$i - 1][1] + $prices[$i], $dp[$i - 1][0]);
        }
        return $dp[count($prices) - 1][0];
    }

Leetcode 123. 買賣股票的最佳時機 III 終極難題

動態規劃

這道題又是上一版的擴充套件,因為規定了具體的交易次數,所以這一題單單是二維資料也必然不夠了,因為我們還需要一個維度用來維護第 k 次交易的狀態。這道題難度確實挺大的。

DP[i][k][j] 定義了一個三維陣列,i 表示第幾天 k 表示第幾次交易 j 表示是否持有股票

然後分情況,第 i 天第 k 次都用兩種情況持有股票和沒有持有股票這兩種狀態。自己看程式碼理解一下吧。

/**
     * @param Integer[] $prices
     * @return Integer
     */
    function maxProfit($prices)
    {
        $res         = 0;
        $dp[0][0][0] = 0;
        $dp[0][0][1] = -$prices[0];
        $dp[0][1][0] = -$prices[0];
        $dp[0][1][1] = -$prices[0];
        $dp[0][2][0] = 0;

        for ($i = 1; $i < count($prices); $i++) {
            $dp[$i][0][0] = $dp[$i - 1][0][0];
            $dp[$i][0][1] = max($dp[$i - 1][0][1], $dp[$i - 1][0][0] - $prices[$i]);

            $dp[$i][1][0] = max($dp[$i - 1][1][0], $dp[$i - 1][0][1] + $prices[$i]);
            $dp[$i][1][1] = max($dp[$i - 1][1][0] - $prices[$i], $dp[$i - 1][1][1]);

            $dp[$i][2][0] = max($dp[$i - 1][2][0], $dp[$i - 1][1][1] + $prices[$i]);
        }

        $length = count($prices) - 1;

        return max($res, $dp[$length][0][0], $dp[$length][1][0], $dp[$length][2][0]);
    }

另外,結合自己做題之路,分享一個好方法,千萬千萬別一題題做下來,既然刷題當然需要針對性,一定要專門針對性的練習,哪一塊資料結構或者演算法短板,沒關係,先補下知識,然後想刷二分?可以,Leecode(如果你看的是英文版的話,Leetcode-cn 的話更省事了,直接看中文) 首頁點選 tags,選擇 Binary Serach,這樣針對性的學習,我想成長只是時間的問題吧。因為你帥我才告訴你。

Leetcode 題解演算法之動態規劃

動態規劃的題型很多,這裡到此為止了。剩下的可以自己去 Leetcode 專門查詢對應的題型刷題。

公眾號記錄刷題 Leetcode 以及學習之路,:目前粉絲量驚人,達到了 100000/1000 人。?歡迎加入。

動態規劃

吳親庫裡

相關文章