由Leetcode詳解算法 之 動態規劃(DP)

由於最近一段時間接觸了一些Leetcode上的題目,發現許多題目的解題思路類似,從中其實能夠了解某類算法的一些應用場景。
這個隨筆系列就是我嘗試的分析總結,但願也能給你們一些啓發。java

動態規劃的基本概念

一言以蔽之,動態規劃就是將大問題分紅小問題,以迭代的方式求解。算法

可使用動態規劃求解的問題通常有以下的兩個特徵:
一、有最優子結構(optimal substructure)
即待解決問題的最優解可以經過求解子問題的最優解獲得。數組

二、子問題間有重疊(overlapping subproplems)
即一樣的子問題在求解過程當中會被屢次調用,而不是在求解過程當中不斷產生新的子問題。動態規劃通常會將子問題的解暫時存放在一個表中,以方便調用。(這也是動態規劃與分治法之間的區別)
下圖是斐波那契數列求解的結構圖,它並不是是「樹狀」,也就是說明其子問題有重疊。
app

動態規劃的通常過程

一、分析獲得結果的過程,發現子問題(子狀態)ide

二、肯定狀態轉移方程,即小的子問題與稍大一些的子問題間是如何轉化的。函數

以斐波那契爲例(兩種方式:自頂向下自底向上

以求解斐波那契數列爲例,咱們很容易獲得求解第N項的值的子問題是第i項(i<N)的值。
而狀態轉移方程也顯而易見:f(n) = f(n-1) + f(n-2)code

由此咱們能夠獲得相應迭代算法表達:blog

function fib()
    if n <= 1 return n
   return fib(n - 1) + fib(n - 2)

不過,如以前所說,動態規劃一個特色就是會存儲子問題的結果以免重複計算,(咱們將這種方式稱做memoization)經過這種方式,可使時間複雜度減少爲O(N),不過空間複雜度所以也爲O(N)。咱們可使用一個映射表(map)存儲子問題的解:排序

var m := map(0 -> 0, 1 -> 1)
function fib(n)
    if key n is not in map m
        m[n] := fib(n - 1) + fib(n - 2)
   return m[n]

上面的方式是自頂向下(Top-down)方式的,由於咱們先將大問題「分爲」子問題,再求解/存值;
而在自底向上(Bottom-up)方式中,咱們先求解子問題,再在子問題的基礎上搭建出較大的問題。(或者,能夠視爲「迭代」(iterative)求解)經過這種方法的空間複雜度爲O(1),而並不是自頂向下方式的O(N),由於採用這種方式不須要額外的存值。遞歸

function fib(n)
    if n = 0
        return 0
   else
        var previousFib := 0, currentFib := 1
         repeat n - 1 times
            var newFib := previousFib + currentFib
            previousFib := currentFib
            currentFib  := newFib
    return currentFib

動態規劃與其餘算法的比較

動態規劃與分治法

分治法(Divide and Conquer)的思想是:將大問題分紅若干小問題,每一個小問題之間沒有關係,再遞歸的求解每一個小問題,好比排序算法中的「歸併排序」和「快速排序」;

動態規劃中的不一樣子問題存在必定聯繫,會有重疊的子問題。所以動態規劃中已求解的子問題會被保存起來,避免重複求解。

動態規劃與貪心算法

貪心算法(greedy algorithm)無需求解全部的子問題,其目標是尋找到局部的最優解,並但願能夠經過「每一步的最優」獲得總體的最優解。

若是把問題的求解看做一個樹狀結構,動態規劃會考慮到樹中的每個節點,是可回溯的;而貪心算法只能在每一步的層面上作出最優判斷,「一條路走到黑」,是「一維」的。所以貪心算法能夠看做是動態規劃的一個特例。

那麼有沒有「一條路走到黑」,最後的結果也是最優解的呢?
固然有,好比求解圖的單源最短路徑用到的Dijkstra算法就是「貪心」的:每一次都選擇最短的路徑加入集合。而最後獲得的結果也是最優的。(這和路徑問題的特殊性質也有關係,由於若是路徑的權值非零,很容易就能獲得路徑遞歸的結果「單增」)

Leetcode例題分析

Unique Binary Search Trees (Bottom-up)

96. Unique Binary Search Trees
Given n, how many structurally unique BST's (binary search trees) that store values 1 ... n?
給定n,求節點數爲n的排序二叉樹(BST)共有幾種(無重複節點)。

思路

能夠令根節點依次爲節點1~n,比根節點小的組成左枝,比根節點大的組成右枝。
子樹亦可根據此方法向下分枝。遞歸求解。

算法

令G(n)爲長度爲n的不一樣排序樹的數目(即目標函數);
令F(i,n)爲當根節點爲節點i時,長度n的不一樣排序樹的數目。

對於每個以節點i爲根節點的樹,F(i,n)實際上等於其左子樹的G(nr)乘以其右子樹的G(nl);
由於這至關於在兩個獨立集合中各取一個進行排列組合,其結果爲兩個集合的笛卡爾乘積

咱們由此能夠獲得公式F(i,n) = G(i-1)*G(n-i)
從而獲得G(n)的遞歸公式:
G(n) = ΣG(i-1)G(n-i)

算法實現

class Solution {
    public int numTrees(int n) {
        int[] G = new int[n+1];
        G[0] = 1;
        G[1] = 1;

        for(int i = 2; i <= n; ++i){
            for(int j = 1; j <= i; ++j){
                G[i] += G[j - 1] * G[i - j];
            }
        }
        return G[n];
    }
}

一個典型的「自底向上」的動態規劃問題。
固然,因爲經過遞推公式能夠由數學方法獲得G(n)的計算公式,直接使用公式求解也不失爲一種方法。

Coin Change (Top-down)

322. Coin Change
You are given coins of different denominations and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.
coins數組表示每種硬幣的面值,amount表示錢的總數,若能夠用這些硬幣能夠組合出給定的錢數,則返回須要的最少硬幣數。沒法組合出給定錢數則返回-1。

算法思路

一、首先定義一個函數F(S) 對於amount S 所須要的最小coin數
二、將問題分解爲子問題:假設最後一個coin面值爲C 則F(S) = F(S - C) + 1
S - ci >= 0 時,設F(S) = min[F(S - ci)] + 1 (選擇子函數值最小的子函數,回溯可獲得整體coin最少)
S == 0 時,F(S) = 0;
n == 0 時,F(S) = -1

算法實現

class Solution {
    public int coinChange(int[] coins, int amount) {
        if(amount < 1) return 0;
        return coinChange(coins, amount, new int[amount]);
    }

    private int coinChange(int[] coins, int rem, int[] count)
    {
        if(rem < 0) return -1; 
        if(rem == 0) return 0;
        if(count[rem - 1]!=0) return count[rem - 1]; //這裏的rem-1 其實就至關於 rem 從 0 開始計數(不浪費數組空間)
        int min = Integer.MAX_VALUE; //每次遞歸都初始化min
        for(int coin : coins){
            int res = coinChange(coins, rem - coin, count); //計算子樹值
            if(res >= 0 && res < min) 
                min = 1 + res; //父節點值 = 子節點值+1 (這裏遍歷每一種coin以後獲得的最小的子樹值)
        }
        count[rem - 1] = (min == Integer.MAX_VALUE) ? -1:min; //最小值存在count[rem-1]裏,即這個數值(rem)的最小錢幣數肯定了
        return count[rem-1];
    }
}

算法採用了動態規劃的「自頂向下」的方式,使用了回溯法(backtracking),而且對於回溯樹進行剪枝(coin面值大於amount時)。
同時,爲了下降時間複雜度,將已計算的結果(必定面值所須要的最少coin數)存儲在映射表中。

雖然動態規劃是錢幣問題的通常認爲的解決方案,然而實際上,大部分的貨幣體系(好比美圓/歐元)都是能夠經過「貪心算法」就能獲得最優解的。

最後,若是你們對於文章有任何意見/建議/想法,歡迎留言討論!

相關文章
相關標籤/搜索