動態規劃

動態規劃

前幾天被阿里校招筆試一道裝箱問題的編程題嚇懵逼了,遂決定好好看看動態規劃的東西,結合在牛客網上的課程,總結一下基礎動態規劃的知識。

動態規劃的關鍵點在於解決冗餘和記憶化搜索。當遇到一道需要暴力搜索方法解決的問題時,都可以考慮使用動態規劃的方法解決。


動態規劃的推導過程

動態規劃的大致過程可以表示爲:暴力搜索方法->記憶化搜索方法->動態規劃->狀態繼續化簡後的動態規劃方法

首先,動態規劃方法不是空穴來風,他是程序員在解決實際問題中總結出來的經驗。

舉個例子,我們有一道題目,來自牛客網

有數組penny,penny中所有的值都爲正數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數aim(小於等於1000)代表要找的錢數,求換錢有多少種方法。

給定數組penny及它的大小(小於等於50),同時給定一個整數aim,請返回有多少種方法可以湊成aim。

首先,考慮使用最直觀的暴力搜索方法:

public int coins1(int[] arr, int aim) {
    if(arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    return process1(arr, 0, aim);
}

// arr表示penny數組,index表示剩餘penny數組索引起點,aim表示目標錢數
// 這個函數的意思表示通過arr[index]——arr[arr.length-1]的鈔票來拼湊aim
process1(int[] arr, int index, int aim) {
    int res = 0;
    if(index == arr.length) {
        res = aim == 0 ? 1 : 0;
    } else {
        for(int i = 0; arr[index] * i <= aim; i++) {
            res += process1(arr, index + 1, aim - arr[index] * i);
        }
    }
}

可以看到,暴力搜索使用標準的遞歸方法求解,簡單明瞭。但是,可以發現,在遞歸的時候,許多子集會進行重複的計算,例如,假設arr={5, 10, 25, 1}, aim=1000,在處理已經使用0張5元,1張10元2張5元、0張10元的情況下,會重複計算process1(arr, 2, 990)。因此,暴力搜索還有很大的優化空間。

接下來,優化暴力搜索帶來的重複計算量,使用一個二維數組存儲每次中間計算結果,去處重複計算。

public int coins2(int[] arr, int aim) {
    if(arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    int[][] map = new int[arr.length + 1][aim + 1];
    return process2(arr, 0, aim, map);
}

// map存儲所有狀態結果
public int process2(int[] arr, int index, int aim, int[][] map) {
    int res = 0;
    if(index == arr.length) {
        res = aim == 0 ? 1 : 0;
    } else {
        int mapValue = 0;
        for(int i = 0; arr[index] * i <= aim; i++) {
            mapValue = map[index + 1][aim - arr[index] * i];
            if(mapValue != 0) {
                res += mapValue == -1 ? 0 : mapValue;
            } else {
                res += process2(arr, index + 1, aim - arr[index] * i, map);
            }
        }
    }
    map[index][aim] = res == 0 ? -1 : res;
    return res;
}

這樣,在每次計入遞歸過程的時候,都在表中查詢,如果已經計算過了便不再計算。但是,凡是能夠通過遞歸調用完成的計算,都可以通過嚴格規定計算順序,通過非遞歸的方式替代,並且將節省大量的遞歸計算開銷,這種方法就是動態規劃方法。

還是考慮上面的案例,首先,我們需要建立一張狀態表,如果arr長度爲N,生成行數爲N,列數爲aim+1的矩陣dpdp[i][j]的含義是在使用arr[0...i]貨幣的情況下,組成錢數爲j有多少種方法。如下:

動態規劃1

從上圖dp[i][j]的求解過程來看,它需要枚舉上一行的數據,遍歷dp[i-1][j-k*arr[i],其中0<=k*arr[i]<=aim。 所需的時間複雜度爲O(Naim2)

動態規劃2

顯然,這樣太過複雜,看下圖,會發現上面的枚舉的式子可以進一步改寫爲dp[i][j] = dp[i][j-arr[i]] + dp[i-1][j],這樣,之前的動態規劃的複雜度經過化簡後可以達到O(Naim)

public int coins3(int[] arr, int aim) {
    int[][] dp = new int[n][aim+1];
        for(int i = 0; i < n; i++) {
            dp[i][0] = 1;
        }
        for(int j = 0; j < aim+1; j++) {
            if(j % penny[0] == 0)
                dp[0][j] = 1;
        }
        for(int i = 1; i < n; i++) {
            for(int j = 1; j < aim+1; j++) {
                if(j - penny[i] >= 0)
                    dp[i][j] = dp[i-1][j] + dp[i][j-penny[i]];
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
        return dp[n-1][aim];
}

如上,是在時間複雜度上進行了優化,個狀態之間的關係就非常簡潔明瞭了。但是,我們還可以在空間複雜度上進行進一步優化,將二維狀態矩陣使用一維矩陣替代。可以看到,動態規劃在對求解順序上有着非常嚴格的要求,上述狀態求解就是從矩陣左上角依次從上到下,從左到右求解的過程。後面的狀態依賴於前端的狀態。因此,如果我們不需要存儲中間的結果,只需要得到最終結果,那麼完全可以捨棄對每一行狀態的存儲。但是,仍然需要嚴格遵守求解順序。程序可以優化如下:

public int coins4(int[] penny, int n, int aim) {
        int[] dp = new int[aim+1];
        dp[0] = 1;
        for(int i = 0; i < n; i++) {
            for(int j = penny[i]; j < aim+1; j++) {
                dp[j] += dp[j - penny[i]];
            }
        }
        return dp[aim];
    }

程序的空間複雜度由O(naim) 簡化到了O(aim)

例題

題目全部來自於牛客網課程,代碼爲自己編寫。

臺階問題

有n級臺階,一個人每次上一級或者兩級,問有多少種走完n級臺階的方法。爲了防止溢出,請將結果Mod 1000000007。

測試樣例:
1
返回:1

public int countWays(int n) {
        // write code here
        int[] dp = new int[n+1];
        dp[0] = 0;
        dp[1] = 1;
        dp[2] = 2;
        for(int i = 3; i < n+1; i++) {
            dp[i] = (dp[i-1] + dp[i-2])%1000000007;
        }
        return dp[n];
    }

矩陣最小路徑和

有一個矩陣map,它每個格子有一個權值。從左上角的格子開始每次只能向右或者向下走,最後到達右下角的位置,路徑上所有的數字累加起來就是路徑和,返回所有的路徑中最小的路徑和。

給定一個矩陣map及它的行數n和列數m,請返回最小路徑和。保證行列數均小於等於100.

測試樣例:
[[1,2,3],[1,1,1]],2,3
返回:4

public int getMin(int[][] map, int n, int m) {
        // write code here
        int[][] dp = new int[n][m];
        dp[0][0] = map[0][0];
        for(int i = 1; i < n; i++)
            dp[i][0] += dp[i-1][0] + map[i][0];
        for(int j = 1; j < m; j++)
            dp[0][j] = dp[0][j-1] + map[0][j];
        for(int i = 1; i < n; i++) {
            for(int j = 1; j < m; j++) {
                dp[i][j] = Math.min(dp[i][j-1], dp[i-1][j]) + map[i][j];
            }
        }
        return dp[n-1][m-1];
    }

LIS(最長上升子序列)

這是一個經典的LIS(即最長上升子序列)問題,請設計一個儘量優的解法求出序列的最長上升子序列的長度。

給定一個序列A及它的長度n(長度小於等於500),請返回LIS的長度。

測試樣例:
[1,4,2,5,3],5
返回:3

public int getLIS(int[] A, int n) {
        // write code here
        int[] dp = new int[n];
        int max = 0;
        for(int i = 0; i < n; i++) {
            dp[i] = 1;
        }
        for(int i = 1; i < n; i++) {
            for(int j = 0; j < i; j++) {
                if(A[i] > A[j] && dp[i] < dp[j]+1)
                    dp[i] = dp[j] + 1;
            }
        }
        for(int i = 0; i < n; i++) {
            max = dp[i] > max ? dp[i] : max;
        }
        return max;
    }

LCS(最長公共子序列)

給定兩個字符串A和B,返回兩個字符串的最長公共子序列的長度。例如,A=”1A2C3D4B56”,B=”B1D23CA45B6A”,”123456”或者”12C4B6”都是最長公共子序列。

給定兩個字符串A和B,同時給定兩個串的長度n和m,請返回最長公共子序列的長度。保證兩串長度均小於等於300。

測試樣例:
“1A2C3D4B56”,10,”B1D23CA45B6A”,12
返回:6

char[] a = A.toCharArray();
        char[] b = B.toCharArray();
        int[][] dp = new int[n][m];
        boolean finda = false, findb = false;
        for(int i = 0; i < n; i++) {
            if(a[i] == b[0])
                finda = true;
            if(finda)
                dp[i][0] = 1;
        }
        for(int i = 0; i < m; i++) {
            if(b[i] == a[0])
                findb = true;
            if(findb)
                dp[0][i] = 1;
        }
        for(int i = 1; i < n; i++) {
            for(int j = 1; j < m; j++) {
                if(a[i] == b[j]) {
                    dp[i][j] = dp[i-1][j-1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
                }
            }
        }
        return dp[n-1][m-1];

01揹包問題

一個揹包有一定的承重cap,有N件物品,每件都有自己的價值,記錄在數組v中,也都有自己的重量,記錄在數組w中,每件物品只能選擇要裝入揹包還是不裝入揹包,要求在不超過揹包承重的前提下,選出物品的總價值最大。

給定物品的重量w價值v及物品數n和承重cap。請返回最大總價值。

測試樣例:
[1,2,3],[1,2,3],3,6
返回:6

public int maxValue(int[] w, int[] v, int n, int cap) {
        int[] dp = new int[cap+1];
        for(int i = 0; i < n; i++) {
            for(int j = cap; j >=w[i]; j--) {
                dp[j] = Math.max(dp[j-w[i]] + v[i], dp[j]);
            }
        }
        return dp[cap];
    }

最優編輯問題

對於兩個字符串A和B,我們需要進行插入、刪除和修改操作將A串變爲B串,定義c0,c1,c2分別爲三種操作的代價,請設計一個高效算法,求出將A串變爲B串所需要的最少代價。

給定兩個字符串A和B,及它們的長度和三種操作代價,請返回將A串變爲B串所需要的最小代價。保證兩串長度均小於等於300,且三種代價值均小於等於100。

測試樣例:
“abc”,3,”adc”,3,5,3,100
返回:8

public int findMinCost(String A, int n, String B, int m, int c0, int c1, int c2) {
        // write code here
        int[][] dp = new int[n][m];
        char[] a = A.toCharArray();
        char[] b = B.toCharArray();
        boolean findb = false, finda = false;
        for(int i = 0; i < n; i++) {
            if(a[i] == b[0])
                findb = true;
            if(findb)
                dp[i][0] = c1*i;
            else
                dp[i][0] = Math.min(c1*(i+1)+c0, c1*i+c2);
        }
        for(int j = 0; j < m; j++) {
            if(a[0] == b[j])
                finda = true;
            if(finda)
                dp[0][j] = c0*j;
            else
                dp[0][j] = Math.min(c0*(j+1)+c1, c0*j+c2);
        }
        for(int i = 1; i < n; i++) {
            for(int j = 1; j < m; j++) {
                if(a[i] == b[j])
                    dp[i][j] = dp[i-1][j-1];
                else 
                    dp[i][j] = Math.min(Math.min(dp[i-1][j]+c1, dp[i][j-1]+c0), 
                               Math.min(dp[i-1][j-1]+c2, dp[i-1][j-1]+c0+c1));
            }
        }
        return dp[n-1][m-1];
    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章