一文帶你入門動態規劃

一隻胡說八道的猴子發表於2021-04-01

動態規劃

寫在前面

沒思路的時候就把樹畫出來,這會事半功倍

概述

我們首先明確一點,動態規劃問題的一般形式就是求最大值或者最小值。
其核心就是窮舉。因為求最值肯定要將其全部的可能都列出來,這才找的出最值。
動態規劃適合的窮舉具有重疊子問題的特徵,如果暴力窮舉,效率回極其低下,所以需要備忘錄或則DB table來優化窮舉過程,避免不必要的計算。
動態規劃問題一定具備最優子結構性質,這樣才可以通過子問題得到原問題的解。
動態規劃問題的核心是就是窮舉出最值,但是問題可以千變萬化,窮舉出所有可行解並不是 容易的事情,只有列出正確的動態轉移方程,才可以正確的窮舉。寫出動態轉移方程也是最難的。
**

寫出動態轉移方程的核心要義

步驟

1.這個問題最簡單的情況(basecase)是什麼
2.這個問題有什麼狀態
3.每個狀態可以做什麼,可以做出什麼選擇使得狀態傳送變化
4.如何定義dp陣列/函式的含義來表現“狀態”和選擇

基本框架

#初始化basecase
db[][]..=base case
#進行狀態轉移
for 狀態1 in 狀態2的所有取值
    for 狀態1 in 狀態2的所有取值

斐波那契數入門動態規劃

Leetcode連結509 斐波那契數https://leetcode-cn.com/problems/fibonacci-number/

題目描述

斐波那契數,通常用 F(n) 表示,形成的序列稱為 斐波那契數列 。該數列由 0 和 1 開始,後面的每一項數字都是前面兩項數字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
給你 n ,請計算 F(n) 。
 
示例 1:
輸入:2
輸出:1
解釋:F(2) = F(1) + F(0) = 1 + 0 = 1


示例 2:
輸入:3
輸出:2
解釋:F(3) = F(2) + F(1) = 1 + 1 = 2


示例 3:
輸入:4
輸出:3
解釋:F(4) = F(3) + F(2) = 2 + 1 = 3

1.解法1,暴力遞迴窮舉

程式碼

public int fib(int n) {
    if (n==0){
        return 0;
    }
    if (n==1||n==2){
        return 1;
    }
    return fib(n-1)+fib(n-2);
}

注意:但凡遇到遞迴的問題都應該畫出遞迴樹,這對分析演算法的複雜度,尋找演算法低效性的原因都有巨大的幫助

遞迴樹圖解

從遞迴樹中我們可以看到這存在大量的重複的運算,這是沒意義的運算而且十分耗時。

要計算fib(5)就必須計算fib(4)和fib(3)
要計算fib(4)就必須計算fib(3)和fib(2)

如果fib(4)中已經計算過fib(3)那麼fib(5)中就不必重複計算fib(3)了,這時候就需要引入DP table 或則備忘錄,通過查表的方式來判斷該值有沒有計算過,有沒有重複計算

在這裡插入圖片描述

時間複雜度分析

二叉樹的節點個數為指數級別,所求子問題的個數為O(2^n)
解決一個子問題的時間為O(1),因為值涉及到一個加法運算
故時間複雜度為 O(2^n)

消耗的記憶體與時間情況

在這裡插入圖片描述

2.解法二,備忘錄解法

在解法1中我們也介紹了暴力解法中存在的問題,及其問題存在的原因,那麼在解法二中我們就通過加上備忘錄的方式,來避免重複計算,這樣可以大大提高解題的效率

程式碼

class Solution {
    int[] DpTable;
       public  int fib(int n){
        DpTable=new int[n+1];
        return fib2(n);
    }

    public  int fib2(int n) {
        /*結束遞迴的條件*/
      if (n==0){
          return 0;
      }
      if (n==1||n==2){
          return 1;
      }
      if (DpTable[n]!=0){
          return DpTable[n];
      }
      DpTable[n]=fib2(n-1)+fib2(n-2);
      return DpTable[n];
    }
}

消耗的記憶體與時間情況

在這裡插入圖片描述

3.解法3 dp陣列的迭代解法

我們可以把備忘錄獨立出來成為一張表,就叫做DB table 在這張表上自底向上推算

程式碼

class Solution {
   public  int fib(int n) {
        if (n==0){
            return 0;
        }
        if (n==1||n==2){
            return 1;
        }
        int[] arr = new int[n+1];
        arr[0]=0;
        arr[1]=1;
        arr[2]=1;
        for (int i = 3; i <=n; i++) {
            arr[i]=arr[i-1]+arr[i-2];
        }
        return arr[n];
    }
}

消耗的記憶體與時間情況

在這裡插入圖片描述

小發現

可以發現時間和空間往往二者不能兼得,要想減少時間就必須花費一定的空間開銷來建立備忘錄來減少時間開銷

湊零錢問題進階動態規劃

題目描述

Leetcode連結 322 零錢兌換https://leetcode-cn.com/problems/coin-change/
**

給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函式來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。
你可以認為每種硬幣的數量是無限的。

示例 1:
輸入:coins = [1, 2, 5], amount = 11
輸出:3 
解釋:11 = 5 + 5 + 1


示例 2:
輸入:coins = [2], amount = 3
輸出:-1



示例 3:
輸入:coins = [1], amount = 0
輸出:0

示例 4:
輸入:coins = [1], amount = 1
輸出:1

示例 5:
輸入:coins = [1], amount = 2
輸出:2

1.暴力遞迴解法

程式碼

class Solution {
    int res = Integer.MAX_VALUE;
    public int coinChange(int[] coins, int amount) {
        if(coins.length == 0){
            return -1;
        }
        findWay(coins,amount,0);
        // 如果沒有任何一種硬幣組合能組成總金額,返回 -1。
        if(res == Integer.MAX_VALUE){
            return -1;
        }
        return res;
    }

    public void findWay(int[] coins,int amount,int count){
        if(amount < 0){
            return;
        }
        if(amount == 0){
            res = Math.min(res,count);
        }

        for(int i = 0;i < coins.length;i++){
            findWay(coins,amount-coins[i],count+1);
        }
    }
}

消耗的記憶體與時間情況

超時,超時了,說明時間複雜度過高,需要通過加入備忘錄的反式來減少時間複雜度,一空間換時間

![image.png](https://img-blog.csdnimg.cn/img_convert/b1da5a6646a60c146bdd16e237332c69.png#align=left&display=inline&height=126&margin=[object Object]&name=image.png&originHeight=252&originWidth=847&size=11567&status=done&style=none&width=423.5)

2.新增了備忘錄的解法

程式碼

package com.pjh;
import com.sun.xml.internal.ws.api.model.MEP;
public class Leetcode322Solution11 {
    int[] memory;
    public int coinChange(int[] coins, int amount) {
        /*coins硬幣的陣列為空返回-1*/
        if (coins.length==0){
            return -1;
        }
        memory=new int[amount+1];
        return findMin(coins,amount);
    }
    /*coins為儲存硬幣的陣列,amount為當前還剩的錢的數量,account為所用硬幣的數量*/
    public int findMin(int[] coins,int amount){
        /*結束遞迴的條件*/
        if (amount==0){
           return 0;
        }
        if (amount<0){
          return -1;
        }
        /*判斷備忘錄中有沒有該值,有該值則直接返回*/
        if (memory[amount]!=0){
            return memory[amount];
        }
        int min1=Integer.MAX_VALUE;
        for (int coin : coins) {
            /*減去該硬幣的值進行下一次遞迴*/
            int temp= findMin(coins,amount-coin);
            if (temp>=0&&temp+1<min1){
                // 加1,是為了加上得到res結果的那個步驟中,兌換的一個硬幣
                min1=temp+1;
            }
        }
        /*備忘錄記錄*/
        memory[amount]=min1;
        /*返回值*/
        return memory[amount]==Integer.MAX_VALUE?-1:memory[amount];
    }
}

消耗的記憶體與時間情況

在這裡插入圖片描述

3.按照四個步驟列出動態轉移方程

步驟

1.這個問題最簡單的情況(basecase)是什麼
2.這個問題有什麼狀態
3.每個狀態可以做什麼,可以做出什麼選擇使得狀態傳送變化
4.如何定義dp陣列/函式的含義來表現“狀態”和選擇

分析

1.最基本條件即 錢的金額為0的時候所需硬幣數的0
2.狀態就是錢的總金額,隨著決策樹一層一層決策,金額不斷減少
3.發生狀態變化的條件,每選擇一枚硬幣就減少一定的金額
4.dp陣列的定義,定義陣列儲存金額

狀態轉移方程如下

db(n)=
  0,n==0
  -1,n<0
   min{dp(n-coins)+1} , n>0

4.dp陣列的迭代解法

程式碼

class Solution {
   public int coinChange(int[] coins, int amount) {
        if(coins.length == 0){
            return -1;
        }
        int[] memory = new int[amount + 1];
        /*初始化陣列,陣列值設定為比傳入值大1即可*/
        Arrays.fill(memory,amount+1);
        /*初始化basecase*/
        memory[0]=0;
        /*遍歷ammount*/
        for (int i = 0; i <= amount; i++) {
            /*遍歷coins,狀態遍歷的種類*/
            for (int coin : coins) {
                /*發生狀態變化的條件*/
                if (i-coin<0) continue;
                /*比較當前值與memory的值誰大*/
                memory[i]=Math.min(memory[i], memory[i-coin]+1);
            }
        }
        return memory[amount]==amount+1?-1: memory[amount];
    }
}


消耗的記憶體與時間情況

在這裡插入圖片描述

相關文章