1. 程式人生 > >從 遞迴 到 動態規劃

從 遞迴 到 動態規劃

暴力遞迴:自頂向下

1,把問題轉化為規模縮小了的同類問題的子問題 ,要求 f(x) 必須求 f(y) ,進而必須求 f(z)…
2,有明確的不需要繼續進行遞迴的條件(base case)
3,有當得到了子問題的結果之後的決策過程
4,不記錄每一個子問題的解

動態規劃:自底向上

1,從暴力遞迴中來
2,將每一個子問題的解記錄下來,避免重複計算
3,把暴力遞迴的過程,抽象成了狀態表達
4,從小到大,求出所有狀態對應的值

動態規劃的本質是遞迴加上快取!

抽象描述:
一個系統,有若干狀態,每個狀態下有若干合法的操作,稱為決策,決策會改變系統狀態決策會帶來收益(或費用)
在初始狀態下,求最終狀態下最大收益
在每個階段

,選擇一些決策,狀態隨之改變
收益只取決與當前狀態和決策(無後效性)——不是馬爾可夫
使得系統達到終止狀態時,總收益最大(或費用最小)

  • 總收益一般指各階段收益的總和

動態規劃是在狀態集合上的遞推:f(new state)=f(old state)+payoff(decision)

是最短路:

  • 圖–廣義有向無環圖

    • 節點:所有狀態
    • 邊:可能的決策在狀態上的轉換
  • 起點:初始狀態

  • 終點:終止狀態

特點:

  • 有 最優子結構

    • 子問題最優決策可匯出原問題最優決策
    • 無後效性
  • 有 重疊子問題

    • 去冗餘
    • 空間換時間(加快取)

問題共性:

  • 套路
    • 題中出現: 最優、最大、最小、最長、計數
  • 是 離散問題
    • 容易設計狀態(如01揹包問題)
  • 最優子結構
    • N-1 可以推匯出 N

複雜度:
時間複雜度:O(狀態數*每個狀態下決策數)
空間複雜度:O(狀態數)

解題步驟:

  • 確定狀態集合和收益
  • 初始狀態、終止狀態
  • 確定決策集合
  • 是否無後效
  • 收益如何表示

LeetCode 198 House Robber(打家劫舍)

你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。

給定一個代表每個房屋存放金額的非負整數陣列,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額

示例 1:
輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,然後偷竊 3 號房屋 (金額 = 3)。偷竊到的最高金額 = 1 + 3 = 4 。

解法一:
分治、遞迴
public int mydfs(int i,int[] nums) 函式表示從第 i 家開始偷,能得到的最大金額;
只考慮當前狀態,
從第 i 家開始偷的最大金額
等於 第 i 家的金額+從第 i+2 家開始偷的最大金額 和
0+從第 i+1 家開始偷的最大金額(第i家不偷)
這兩個數的最大值。

僅僅這樣做會超時,需要將每次計算的狀態加快取,避免重複計算。如圖可以看出會有重複的狀態:

這裡寫圖片描述

class Solution {
    public static Map<Integer,Integer> cache=new HashMap<Integer,Integer>();
    public int rob(int[] nums) {
        if(nums.length<=0){
            return 0;
        }
        cache.clear();
        return mydfs(0,nums);
    }

    public int mydfs(int i,int[] nums){
        if(i>=nums.length){
            return 0;
        }
        if(cache.containsKey(i)){
            return cache.get(i);
        }
        int a=nums[i]+mydfs(i+2,nums);
        int b=0+mydfs(i+1,nums);
        int c=Math.max(a,b);
        cache.put(i,c);
        return c;
    }
}

解法二:
遞迴是自頂向下,將其改為自低向上推,非遞迴

狀態為當前位置最大能偷多少錢,所以最後一個位置,最大能偷的錢數就是最後一家的錢數。

class Solution {
    public static Map<Integer,Integer> cache=new HashMap<Integer,Integer>();
    public int rob(int[] nums) {
        if(nums.length<=0){
            return 0;
        }
        cache.clear();
        int n=nums.length;
        cache.put(n-1,nums[n-1]);
        for(int i=n-2;i>=0;i--){
            int a=nums[i]+(cache.containsKey(i+2)?cache.get(i+2):0);
            int b=0+(cache.containsKey(i+1)?cache.get(i+1):0);
            cache.put(i,Math.max(a,b));
        }
        return cache.get(0);
    }
}

狀態標記 i 永遠是[0,n-1) 所以可以用資料存,用陣列存比Map省空間:

class Solution {
    public static int[] cache=new int[10000];
    public int rob(int[] nums) {
        if(nums.length<=0){
            return 0;
        }
        int n=nums.length;
        cache[n-1]=nums[n-1];
        for(int i=n-2;i>=0;i--){
            int a=nums[i]+(i+2<n ? cache[i+2]:0);
            int b=0+( i+1<n ? cache[i+1]:0);
            cache[i]=Math.max(a,b);
        }
        return cache[0];
    }
}

繼續修改上面的程式碼:
1:增加特判->可能減少耗時;
2:精簡程式碼。發現只有i=n-2是,i+2才不會小於n ,三元表示式後面都是多餘的。增加cache[n-2] 的計算,後面就不用判斷邊界了,三行程式碼可以減為一行。

class Solution {
    public static int[] cache=new int[10000];
    public int rob(int[] nums) {
        if(nums.length<=0){
            return 0;
        }
        if(nums.length==1){
            return nums[0];
        }

        int n=nums.length;
        cache[n-1]=nums[n-1];
        cache[n-2]=Math.max(nums[n-2],nums[n-1]);
        for(int i=n-3;i>=0;i--){
            cache[i]=Math.max(0+cache[i+1],nums[i]+cache[i+2]);
        }
        return cache[0];
    }
}

小兵向前衝

N*M的棋盤上,小兵要從左下角走到右上角,只能向上或者向右走,問有多少種走法?

注意:N*M 的棋盤,N和M為格子數,座標一共有 (N+1)*(M+1) 個,小兵起始座標為(0,0)終點座標為(N,M)。

套路:是計數問題
這道題的狀態是二維的。
遞迴:自頂向下

其實這是一個數學的組合問題:
從左下角走到右上角總共只需要走8步(要麼向右走4步,要麼向上走4步),這樣只需要C(8,4)就可以了。

遞迴程式碼:
從終點往起點推。

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 4;
    private static final int M = 4;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                result[i][j] = -1;
            }
        }
        System.out.println(search(N, M));
    }

    public static int search(int xi, int yi) {
        if (xi == 0 || yi == 0) {
            return 1;
        }

        // 快取
        if (result[xi][yi] >= 0) {
            return result[xi][yi];
        }

        result[xi][yi] = search(xi - 1, yi) + search(xi, yi - 1);
        return result[xi][yi];
    }
}

遞迴實現:
自低向上
從起點往終點推。
狀態 result[i][j] 為到達座標 (i,j) ,有幾種走法。


public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 4;
    private static final int M = 4;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for(int i=0;i<=N;i++){
            result[i][0]=1;//到達座標(x,0)只有一種走法,即向上
        }
        for(int j=0;j<=M;j++){
            result[0][j]=1;//到達座標(0,x) 只有一種走法,即向左
        }
        //遞推
        for(int i=1;i<=N;i++){
            for(int j=1;j<=M;j++){
                result[i][j]=result[i-1][j]+result[i][j-1];//到達座標(i,j)的走法=到達(i-1,j)的走法+到達(i,j-1)的走法
            }
        }
        System.out.println(result[N][M]);
    }
}

小兵先前衝,某點不能走

先用數學分析下:
記(0,0)點為A點,(7,5)點為B點,(3,3)點為P點。
從A->B點總共C(12,5)種走法。
從A->P點總共C(6,3)種走法。
從P->B點總共C(6,2)種走法。
由於P點不能走,那麼經過P點,從A點走到B點有幾種走法呢?
C(6,3)*C(6,2)種走法。
因此,不經過P點的走法:C(12,5)-C(6,3)*C(6,2)=492種走法。
注意,這個結果跟不經過哪個點有直接關係。
比如現在改為(2,2)點不能走,則:
C(12,5)-C(4,2)*C(8,3)=456種。
這裡寫圖片描述
要到達R點需要經過P點和Q點,這時P不能走,則只需要置為0即可。即:search(4,3) = 0 + search(4,2);
也就是說,遇到(3,3)這個點就返回0。

在上題基礎上增加判斷條件即可。

遞迴:

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 7;
    private static final int M = 5;
    //(XNO,YNO)這個點表示不能走
    private static final int XNO = 3;
    private static final int YNO = 3;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                result[i][j] = -1;
            }
        }
        System.out.println(search(N, M));
    }

    public static int search(int xi, int yi) {
        if (xi == 0 || yi == 0) {
            return 1;
        }
        //(XNO,YNO)這個點表示不能走
        if (xi == XNO && yi == YNO) {
            return 0;
        }

        // 快取
        if (result[xi][yi] >= 0) {
            return result[xi][yi];
        }

        result[xi][yi] = search(xi - 1, yi) + search(xi, yi - 1);
        return result[xi][yi];
    }
}

遞推:

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 7;
    private static final int M = 5;
     //(XNO,YNO)這個點表示不能走
    private static final int XNO = 3;
    private static final int YNO = 3;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for(int i=0;i<=N;i++){
            result[i][0]=1;//到達座標(x,0)只有一種走法,即向上
        }
        for(int j=0;j<=M;j++){
            result[0][j]=1;//到達座標(0,x) 只有一種走法,即向左
        }
        //遞推
        for(int i=1;i<=N;i++){
            for(int j=1;j<=M;j++){
                //(XNO,YNO)這個點表示不能走
                if (i == XNO && j == YNO) {
                    result[XNO][YNO] = 0;
                } else {
                    result[i][j]=result[i-1][j]+result[i][j-1];//到達座標(i,j)的走法=到達(i-1,j)的走法+到達(i,j-1)的走法
                }
            }
        }
        System.out.println(result[N][M]);
    }
}

小兵向前衝,往上、右可以走1步或兩步

只要後面增加兩項即可:
到達座標 (i,j) 的走法數,result[i][j] ,等於到達左邊一格的走法數 result[i-1][j] + 到達下面一格的走法數 result[i][j-1]+達到左邊二格的走法數 result[i-2][j] + 到達下面二格的走法數 result[i][j-2]。

這次由於-2,遞迴時,xi,yi 可能跳過0,直接為負數,所以要增加遞迴退出條件。

遞迴:

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 2;
    private static final int M = 2;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                result[i][j] = -1;
            }
        }
        System.out.println(search(N, M));
    }

    public static int search(int xi, int yi) {
        if (xi == 0 || yi == 0) {
            return 1;
        }
        if (xi < 0 || yi < 0) {
            return 0;
        }

        // 快取
        if (result[xi][yi] >= 0) {
            return result[xi][yi];
        }

        result[xi][yi] = search(xi - 1, yi) + search(xi, yi - 1)+ search(xi-2, yi) + search(xi, yi-2);
        return result[xi][yi];
    }
}

遞推:
需要先遞推出,第一行、第二行、第一列,第二列,防止推中間座標時,越界。
遞推要比遞迴難寫。因為要考慮很多特殊情況。最常見的就是,陣列下標不要出現負數以及不要越界。這樣,對於使得陣列下標出現負數的情況,需要特殊賦初值。

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 2;
    private static final int M = 2;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        //推出第一列
        for(int i=0;i<=N;i++){
            result[i][0]=1;//到達座標(x,0)只有一種走法,即向上
        }
        //推出第一行
        for(int j=0;j<=M;j++){
            result[0][j]=1;//到達座標(0,x) 只有一種走法,即向左
        }
        result[1][1] = result[0][1] + result[1][0];
        //推出第二列
        for (int i = 2; i <= N; i++) {
            result[i][1] = result[i-1][1] + result[i][0] + result[i-2][1];
        }
        //推出第二行
        for (int j = 2; j <= M; j++) {
            result[1][j] = result[1][j-1] + result[0][j] + result[1][j-2];
        }
        //遞推
        for(int i=2;i<=N;i++){
            for(int j=2;j<=M;j++){
                result[i][j]=result[i-1][j]+result[i][j-1]+ result[i-2][j] + result[i][j-2];//到達座標(i,j)的走法=到達(i-1,j)的走法+到達(i,j-1)的走法+到達(i-2,j)的走法+到達(i,j-2)的走法
            }
        }
        System.out.println(result[N][M]);
    }
}

組合 個數問題

從n個東西里去m個:
C(n,m) = C(n-1, m-1) + C(n-1, m)
C(n-1, m-1)表示第n個東西被選了
C(n-1, m)表示第n個東西沒有被選

遞迴:

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 20;
    private static final int M = 10;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N + 1][M + 1];
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                result[i][j] = -1;
            }
        }

        System.out.println(search(N, M));
    }

    public static int search(int n, int m) {
        if (n<m) {
            return 0;
        }
        if (m==0) {
            return 1;
        }

        // 快取
        if (result[n][m] >= 0) {
            return result[n][m];
        }

        result[n][m] = search(n - 1, m-1) + search(n-1, m);
        return result[n][m];
    }
}

遞推:

public class Main {
    // 定義x軸有N個格子,y軸有M個格子
    private static final int N = 20;
    private static final int M = 10;
    private static int[][] result;

    public static void main(String[] args) {
        result = new int[N+1][M+1];
        for(int i=0;i<=N;i++){
            for(int j=0;j<=M;j++){
                if(i==j||j==0){
                    result[i][j]=1;
                }
                if(i<j){
                    result[i][j]=0;
                }
            }
        }

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

        System.out.println(result[N][M]);
    }
}

01揹包問題

小偷有一個容量為W的揹包,有n件物品,第i個物品價值vi,且重wi
目標: 找到xi使得對於所有的xi = {0, 1}
sum(wi*xi) <= W, 並且 sum(xi*vi)最大

套路:最大

遞迴:

public class Main2 {
    static int n;
    static int c;
    static int[] weight;
    static int[] price;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.println("輸入物品數量n:");
        n = sc.nextInt();
        System.out.println("輸入揹包容量c:");
        c = sc.nextInt();
        System.out.println("輸入" + n + "個物品的重量:");
        weight = new int[n];
        for (int i = 0; i < n; i++) {
            weight[i] = sc.nextInt();
        }
        System.out.println("輸入" + n + "個物品的價值:");
        price = new int[n];
        for (int i = 0; i < n; i++) {
            price[i] = sc.nextInt();
        }
        System.out.println(robot(0, c));
    }

    static Map<Pair, Integer> cache = new HashMap<Pair, Integer>();
    private static int robot(int idx, int w) {// idx表示從第i個物品開始取,w為揹包剩餘容量,這兩個可以構成一個狀態
        if (idx >= n || w- weight[idx] <= 0) {//揹包裝不下第idx個物品了直接返回
            return 0;
        }
        if (cache.containsKey(new Pair(idx, w))) {
            return cache.get(new Pair(idx, w));
        }
        int a = robot(idx + 1, w - weight[idx]) + price[idx];
        int b = robot(idx + 1, w);
        int c = Math.max(a, b);
        cache.put(new Pair(idx, w), c);
        return c;
    }

}

class Pair {
    int idx;
    int w;

    Pair(int i, int ww) {
        this.idx = i;
        this.w = ww;
    }
}

時間空間複雜度都是 O(n*w)

遞推:

public class Main2 {
    static int n;
    static int c;
    static int[] weight;
    static int[] price;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.println("輸入物品數量n:");
        n = sc.nextInt();
        System.out.println("輸入揹包容量c:");
        c = sc.nextInt();
        System.out.println("輸入" + n + "個物品的重量:");
        weight = new int[n];
        for (int i = 0; i < n; i++) {
            weight[i] = sc.nextInt();
        }
        System.out.println("輸入" + n + "個物品的價值:");
        price = new int[n];
        for (int i = 0; i < n; i++) {
            price[i] = sc.nextInt();
        }

        cache=new int[n][c+1];
        for(int i=n-1;i>=0;--i){
            for(int w=0;w<=c;++w){
                cache[i][w]=Math.max( check(i+1,w-weight[i])+price[i] , check(i+1,w) );
            }
        }
        System.out.println(cache[0][c]);
    }
    static int[][] cache;
    private static int check(int idx,int w){
        if (idx >= n || w- weight[idx] <= 0) {//揹包裝不下第idx個物品了直接返回
            return 0;
        }
        return cache[idx][w];
    }
}

如果 W很多怎麼辦,cache消耗的空間太大了
空間和時間的區別:時間消耗就消耗 了,空間可以重複利用
發現 迴圈中,每次cache陣列,i 只和 i+1 有關係,所以可以cache陣列橫座標只用申請前2個空間,每次取和放的時候,橫座標都模2,使得空間可以重複利用,空間複雜度變為 O(W)
這個技巧叫滾動陣列

public class Main2 {
    static int n;
    static int c;
    static int[] weight;
    static int[] price;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.println("輸入物品數量n:");
        n = sc.nextInt();
        System.out.println("輸入揹包容量c:");
        c = sc.nextInt();
        System.out.println("輸入" + n + "個物品的重量:");
        weight = new int[n];
        for (int i = 0; i < n; i++) {
            weight[i] = sc.nextInt();
        }
        System.out.println("輸入" + n + "個物品的價值:");
        price = new int[n];
        for (int i = 0; i < n; i++) {
            price[i] = sc.nextInt();
        }

        cache=new int[2][c+1];
        for(int i=n-1;i>=0;--i){
            for(int w=0;w<=c;++w){
                cache[i%2][w]=Math.max( check(i+1,w-weight[i])+price[i] , check(i+1,w) );
            }
        }
        System.out.println(cache[0][c]);
    }
    static int[][] cache;
    private static int check(int idx,int w){
        if (idx >= n || w- weight[idx] <= 0) {//揹包裝不下第idx個物品了直接返回
            return 0;
        }
        return cache[idx%2][w];
    }
}

最大公共子序列

旅行商問題

確定狀態:f(s,x)表示經過s集合裡那些城市,最終在城市x時所經過的最小距離。s表示經歷過的城市集合;X表示最後一個城市,在集合s裡

初始狀態: f({1},1) = 0,假設從城市1開始

終止狀態:f({1,2,3,..n},1)=?

決策: 在狀態(s, x)找到一個不在集合s裡的城市y,若從x走到y,則f(s,x) + distance(x,y)是一個經過集合sUy城市的路徑。

無後效性:收益只取決於狀態(s,x)和決策y

費用表示: f(s,x) = min {f(s – x, y) + distance(y,x) }其中s-x表示從集合s中去掉城市x之後的集合, y在集合s-x中

注意點: 最後回到起點的狀態有點特殊,因為起點已經在集合裡

複雜度:集合數 2n, 狀態數 2nn
時間複雜度 :列舉s, x, y,所以總複雜度O(2nn2)
空間複雜度:O(2nn)

用set 表示集合效率較低。如果要儲存的數都是不重複的比較小的整數,可以用二進位制串表示。
如集合{1,3,5,6,7} 表示成二進位制串用 1110101 ,其中集合裡面有的數對應的位數寫成1,沒有的寫成0。
要判斷第3位是不是1,就把 1110101 右移(3-1)位,得到11101,然後和 00001 &,如果結果為1 表示集合中有3,否則表示集合中沒有3。

推廣一下,對於數字x,要看它的第i位是不是1,那麼可以通過判斷布林表示式 (((x >> (i - 1) ) & 1) == 1的真值來實現。

總結

套路:滾動陣列、狀態壓縮、升維、單調性、四邊形不等式(高階套路)
本質:先暴力,找冗餘,去冗餘