動態規劃簡單介紹及Swift代碼實現

本文一共分三部分:算法

  • 什麼是動態規劃?
  • 平時編程過程當中,哪些場景適用於適用動態規劃?
  • 動態規劃代碼怎麼寫?

什麼是動態規劃?

動態規劃(dynamic programming,簡稱DP), 是求解決策過程最優化的數學方法,經過把原問題分解爲相對簡單的子問題的方式求解複雜問題的方法。動態規劃經常適用於有重疊子問題和最優子結構性質的問題。動態規劃是一種利用空間換時間來求解最優解的的方法,通常在編程中的時間複雜度會少於常規解法(如暴力解法,回溯算法)。編程

適用狀況

動態規劃只能應用於有最優子結構的問題。最優子結構的意思是局部最優解能解決全局最優解。數組

  1. 最優子結構性質。若是問題的最優解所包含的子問題的解也是最優的,咱們就稱該問題具備最優子機構性質(即知足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。
  2. 無後效性。即子問題的解一旦肯定,就再也不改變,不受在這以後、包含它的更大問題的求解決策影響。
  3. 子問題重疊性質。子問題重疊性質是指在用遞歸算法自頂向下對問題進行求解時,每次產生的子問題並不老是新問題,有些子問題會被重複計算屢次,動態規劃算法正是利用了這些子問題的重疊性質,對每個子問題只計算一次,而後將其計算結果保存在一個表格中,當再次須要計算已經計算過的子問題時,只是在表格中簡單的查看一下結果,從而得到較高的效率。

動態規劃代碼怎麼寫?

下面看幾個例子>bash

爬樓梯問題

假設你正在爬樓梯。須要 n 階你才能到達樓頂。 每次你能夠爬 1 或 2 個臺階。你有多少種不一樣的方法能夠爬到樓頂呢?app

注意:給定 n 是一個正整數。函數

解法一:暴力解法優化

在暴力解法中,咱們將會把全部可能爬的樓梯階數進行組合,也就是1和2. 而在每一步中咱們都會遞歸調用原函數模擬爬1階和2階的情形,並返回兩個函數的返回值之和。ui

// 其中i定義了當前階數,n定義的是目標階數
climbStairs(i, n) = climbStairs(i+1, n) + climbStairs(i+2, n)
複製代碼
// Swift實現算法
func climbStairs(_ n: Int) -> Int {
    
    return climbStairs(0, n: n)
}

func climbStairs(_ i: Int, n: Int) -> Int {
    if i > n {
        return 0
    }
    if i == n {
        return 1
    }
    return climbStairs(i + 1, n: n) + climbStairs(i + 2, n: n)
}
複製代碼

複雜度分析:spa

  • 時間複雜度:O(2^n)。樹形遞歸的大小爲2^ncode

    n = 5時,遞歸樹是這樣的

  • 空間複雜度:O(n)。遞歸樹的深度能夠達到n。

解法二:記憶化遞歸

使用暴力法求解,每一步計算結果都出現了冗餘。另外一種思路是,咱們能夠把每一步的結果存儲在memo數組之中,每當函數再次調用,咱們就直接從memo數組返回結果。 在memo數組的幫助下,咱們獲得一個修復的遞歸樹,其大小減少到n。

func climbStairs(_ n: Int) -> Int {
    var memo = Array(repeating: 0, count: n)
    return climbStairs(0, n: n, memo: &memo)
}

func climbStairs(_ i: Int, n: Int, memo: inout Array<Int>) -> Int {
    if i > n {
        return 0
    }
    if i == n {
        return 1
    }
    if memo[i] > 0 {
        return memo[i]
    }
    memo[i] = climbStairs(i + 1, n: n, memo: &memo) + climbStairs(i + 2, n: n, memo: &memo);
    return memo[i]
}
複製代碼

複雜度分析:

  • 時間複雜度:O(n)。樹形遞歸的大小減少到n
  • 空間複雜度:O(n)。遞歸樹的深度達到n

解法三:動態規劃

不難發現,這問題能夠被分解成一些包含最優子結構的子問題,即它的最優解能夠從其子問題的最優解來有效的構建,咱們可使用動態規劃來解決這一問題。

第i階能夠由如下兩種方法獲得:

  1. 在第(i-1)階向後爬一階。
  2. 在第(i-2)階向後爬二階。 因此,到達第i階的方法總數就是(i-1)階和(i-2)階方法數之和。

令dp[i]表示能到達第i階的方法總數: dp[i] = dp[i-1] + dp[i-2]

func climbStairs(_ n: Int) -> Int {
    if n == 1 {
        return 1
    }
    var dp = Array(repeating: 0, count: n+1)
    dp[1] = 1
    dp[2] = 2
    for i in 3...n {
        dp[i] = dp[i-1] + dp[i-2]
    }
    return dp[n]
}
複製代碼

經過這個例子,咱們能夠總結如下,動態規劃的思考過程: 動態規劃的思考過程能夠總結爲:大事化小,小事化了

大事化小

一個較大的問題,經過找到與子問題的重疊,把複雜的問題劃分爲多個小問題,也成爲狀態轉移。

小事化了

小問題的解決一般是經過初始化,直接計算結果獲得;

具體的步驟:

  1. 將大問題分解爲子問題
  2. 肯定狀態表示
  3. 肯定狀態轉移
  4. 考慮初始狀態和邊界狀況

一些動態規劃的例子

零錢湊整

若是咱們有面值爲1元、3元和5元的硬幣若干枚,如何用最少的硬幣湊夠11元?

  1. 將大問題分解爲子問題:由於由一、三、5元硬幣組成11元狀況比較多,用暴力法窮舉出全部狀況比較複雜,咱們能夠把這個問題分解成若干個子問題,用dp[i]表示湊夠i元錢所需的硬幣數量,dp[i] = min{dp[i-1]+1, dp[i-3]+1, dp[1-5]+1}
  2. 狀態表示:dp[i]
  3. 狀態轉移方程:僞代碼表示
if i < 3 dp[i] = dp[i-1] + 1
else
if i >= 5 dp[i] = min{dp[i-1]+1, dp[i-3]+1, dp[1-5]+1}
else dp[i] = min{dp[i-1]+1, dp[i-3]+1}
複製代碼
  1. 考慮初始狀態和邊界狀況 當i = 0時,0個硬幣便可 當i = 1, i = 3, i = 5時,只須要1個硬幣便是最優解

代碼以下:

func countMoney(_ n: Int) -> Int {
    if n == 0 {
        return 0
    }
    var dp = Array(repeating: 0, count: n+1)
    dp[1] = 1
    dp[3] = 1
    dp[5] = 1
    for i in 1...n {
        if i < 3 {
            dp[i] = dp[i-1] + 1
        } else
        if i >= 5 {
            dp[i] = min(dp[i-1] + 1, dp[i-3]+1, dp[i-5]+1)
        } else
        {
            dp[i] = min(dp[i-1] + 1, dp[i-3] + 1)
        }
    }
    return dp[n]
}
複製代碼

接下來,咱們再看一到題

揹包問題

話說有一哥們去森林裏玩發現了一堆寶石,他數了數,一共有n個。 但他身上能裝寶石的就只有一個揹包,揹包的容量爲C。這哥們把n個寶石排成一排並編上號: 0,1,2,…,n-1。第i個寶石對應的體積和價值分別爲V[i](V表明volume)和W[i] (W表明worth)。排好後這哥們開始思考: 揹包總共也就只能裝下體積爲C的東西,那我要裝下哪些寶石才能讓我得到最大的利益呢?

按照上面的步驟對問題進行分析: 咱們定義一個函數F(i, j),表示可以裝的寶石的最大價值,i表示有的寶石的,是一個數組(0, 1, 2,..., i)j表示揹包的容量,假設,咱們這裏一共有6和寶石,體積分別爲 V = [2, 2, 6, 5, 4, 3],對應的價值分別是W = [6, 3, 5, 4, 6, 2]

當揹包容量C = 1時,則F(0, 1) = 0; F(1, 1) = 0; ... ; F(5, 1) = 0;

當揹包容量C = 2時,則F(0, 2) = 6; F(1, 2) = 6; ...; F(5, 2) = 6;

當揹包容量C = 3時,則F(0, 3) = 6; F(1, 3) = 6; ...; F(5, 3) = 6;

由此,咱們能夠得出結論,揹包能裝的寶石的最大價值和寶石數量及揹包容量有關。

咱們目的是求怎麼樣把n個寶石,最大價值的裝到揹包裏。

咱們作個假設: 先把下標從1開始計算。n個寶石的下標分別是1,2,3,...,n

  1. 第n個寶石,不是咱們想要的寶石,那麼1,2,3,...,n-1,就能求出咱們所需的最大價值,用上面的函數表達式表示爲:F(n-1, C);
  2. 假設第n個寶石是咱們想要的寶石,那麼揹包要把第n個寶石的空間減出來,剩餘空間來裝其餘寶石,那麼能裝的最大價值的寶石函數表達式爲:F(n-1, C-V[n]); 則空間爲C的揹包能裝的最大價值是F(n-1, C-V[n])+W[n]

這裏只有這兩種狀況,因此,咱們把這兩種狀況綜合一下,最大值就是咱們要求解的函數的最終值。 F(n, C) = MAX(F(n-1, C), F(n-1, C-V[n])+W[n])

那麼,就是說n個寶石的最大價值和n-1個寶石的最大價值有關。

按照咱們上面所說的步驟來求解:

  1. 將大問題分解爲子問題:咱們已經把一個大問題,化爲一個小問題了。
  2. 狀態表示:F(n, C)
  3. 肯定狀態轉移方程: F(n, C) = MAX(F(n-1, C), F(n-1, C-V[n])+W[n])
  4. 考慮初始狀態和邊界狀況, F(0, 0) = 0;

代碼實現以下:

struct Diamond {
    var id: String
    var volume: Int
    var value: Int
    var isSelected = false
    
    
    init(_ volume: Int, value: Int, id: String) {
        self.volume = volume
        self.value = value
        self.id = id
    }
}

class Knapsack {
    var number: Int     // 物品數量
    var C: Int          // 揹包最大致積或最大重量
    
    var diamonds = Array<Diamond>()
    var V = Array<Array<Int>>()
    
    init(number: Int, C: Int) {
        self.C = C
        self.number = number
        // 初始化一個二維數組
        self.V = initializeArray()
        
        // 初始化寶石對象
        setDiamonds()
        // 打印現有的寶石
        printDiamonds()
    }
    // 初始寶石數組
    func setDiamonds() {
        for i in 0...number {
            if i == 0 {
                diamonds.append(Diamond(0, value: 0, id: "0"))
            } else {
                diamonds.append(Diamond(i + 1, value: i + 2, id: String(i)))
            }
        }
    }
    // 打印全部寶石
    func printDiamonds() {
        for diamond in diamonds {
            print("id:\(diamond.id), value:\(diamond.value), volume:\(diamond.volume)")
        }
    }
    // 打印選中/未選中寶石
    func printDiamonds(_ selected: Bool) {
        if selected {
            print("被選中的寶石:")
        } else {
            print("未被選中的寶石:")
        }
        for diamond in diamonds {
            if diamond.isSelected == selected && diamond.volume != 0 {
                print("id:\(diamond.id), value:\(diamond.value), volume:\(diamond.volume)")
            }
        }
    }
    // 初始化dp數組
    func initializeArray() -> [[Int]] {
        var myArr = Array<Array<Int>>()
        for _ in 0...number {
            myArr.append(Array(repeating: 0, count: C+1))
        }
        return myArr
    }
    
    func findOptimalSolution() -> Int {
        // i = 0, j = 0爲邊界條件,初始化的時候,初始化爲0
        // 填充二維數組
        for i in 1...number {
            for j in 1...C {
                if j < diamonds[i].volume {
                    // 當剩餘的空間不夠裝這個寶石的時候,當前數組元素值與上個元素值相同
                    V[i][j] = V[i-1][j]
                } else {
                    // 當剩餘空間夠裝的下該寶石的時候,則動態規劃該寶石是否要選中該寶石
                    V[i][j] = max(V[i-1][j], V[i-1][j-diamonds[i].volume] + diamonds[i].value)
                }
            }
        }
        // 二維數組最後一個元素就是最大價值
        return V[number][C]
    }
    
    // 查找哪些寶石被選中
    func findSelectedDiamonds(i: Int, j: Int) {
        if i > 0 {
            if V[i][j] == V[i-1][j] {
                diamonds[i].isSelected = false
                findSelectedDiamonds(i: i-1, j: j)
            } else {
                if j - diamonds[i].volume >= 0 && V[i][j] == V[i-1][j-diamonds[i].volume] + diamonds[i].value {
                    diamonds[i].isSelected = true
                    findSelectedDiamonds(i: i - 1, j: j - diamonds[i].volume)
                }
            }
        }
    }
}
複製代碼

用表格表示以下:

揹包體積:1 揹包體積:2 揹包體積:3 揹包體積:4 揹包體積:5 揹包體積:6 揹包體積:7
寶石1(volume = 2, worth = 6) 0 6 6 6 6 6 6
寶石2(volume = 2, worth = 3) 0 6 6 9 9 9 9
寶石3(volume = 6, worth = 5) 0 6 6 9 9 9 9
寶石4(volume = 5, worth = 4) 0 6 6 9 9 9 10
寶石5(volume = 4, worth = 6) 0 6 6 9 9 12 12

最長上升子序列(longest increasing subsequence)

一個序列有N個數:A[1],A[2],…,A[N],求出最長非降子序列的長度。

分析這個問題: 咱們定義一個數組dpdp[i]表示0...i之間序列的最大上升子序列,其中i<N。 拿個簡單的輸入舉個例子:假設輸入是:[10, 9, 2, 5, 3, 7, 101]

dp[0] = 1, ([10])
dp[1] = 1, ([10, 9])
dp[2] = 2, ([10, 9, 2])
dp[3] = 2, ([10, 9, 2, 5])
dp[4] = 2, ([10, 9, 2, 5, 3])
dp[5] = 3, ([10, 9, 2, 5, 3, 7])
dp[6] = 4, ([10, 9, 2, 5, 3, 7, 101])
複製代碼

想要求dp(i),就把i前面的各個子序列中,最後一個數不大於A[i]的序列長度加1,而後取出最大的長度即爲dp(i)

按照上面的步驟求解:

  1. 將大問題分解爲子問題:
  2. 肯定狀態表示: dp[i]
  3. 肯定狀態轉移: dp[i] = max(dp[j])+1, ∀0≤j<i
  4. 考慮初始狀態和邊界狀況: dp[0] = 1

代碼以下:

/* * 這種方法依賴於這樣一個事實,即給定數組中索引i以前的最長遞增子序列與數組中稍後出現的元素無關。所以,若是咱們知道LIS到i索引的長度,咱們就能夠根據索引j爲0≤j≤(i+1)的元素包含(i+1)元素,從而計算出LIS可能的長度。 * 咱們使用一個dp數組來存儲所需的數據。dp[i]表示僅考慮到i索引的數組元素,且必須包含i元素的狀況下,可能的最長遞增子序列的長度。爲了找出dp[i],咱們須要嘗試在每一個可能的遞增子序列中追加當前元素(nums[i])到(i-1)索引(包括(i-1)索引),這樣經過添加當前元素造成的新序列也是遞增子序列。所以,咱們能夠很容易地肯定dp[i]使用: * dp[i] = max(dp[j])+1, ∀0≤j<i * 最終,全部dp[i]的最大值來肯定最終的結果。 * LIS.length = max(dp[i]),∀0≤j<i * 時間複雜度:兩層循環 O(n^2) * 空間複雜度:O(n) */


func lengthOfLIS(_ nums: [Int]) -> Int {
    if nums.count == 0 {
        return 0
    }
    var dp = Array(repeating: 0, count: nums.count)
    dp[0] = 1
    var maxAns = 1
    for i in 1..<dp.count {
        var maxVal = 0
        for j in 0..<i {
            if nums[i] > nums[j] {
                maxVal = max(maxVal, dp[j])
            }
        }
        dp[i] = maxVal + 1
        maxAns = max(maxAns, dp[i])
    }
    return maxAns
}
複製代碼

二維動態規劃問題

平面上有N*M個格子,每一個格子中放着必定數量的蘋果。你從左上角的格子開始,每一步只能向下走或是向右走,每次走到一個格子上就把格子裏的蘋果收集起來, 這樣下去,你最多能收集到多少個蘋果。

i表示行,j表示列 按照上面的步驟求解:

  1. 將大問題分解爲子問題:dp[i][j]則和dp[i-1][j]dp[i][j-1]有關,
  2. 肯定狀態表示: dp[i][j]
  3. 肯定狀態轉移: dp[i][j] = A[i][j] + max(dp[i-1][j], if i > 0; dp[i][j-1], if j > 0)
  4. 考慮初始狀態和邊界狀況: dp[0][0] = A[0][0]

僞代碼實現:

int[][] dp
for i = 0; i < N - 1; i++
    for j = 0; j < M - 1; j++
        if i == 0 dp[i][j] = dp[i][j-1] + A[i][j]
        if j == 0 dp[i][j] = dp[i-1][j] + A[i][j]
        else dp[i][j] = max(dp[i-1][j], dp[i][j-1])
 return dp[N-1][M-1]
複製代碼

參考連接:

  1. 漫畫:什麼是動態規劃?
  2. 動態規劃:重新手到專家
  3. 動態規劃之揹包問題(一)
相關文章
相關標籤/搜索