1. 程式人生 > >第四章遞迴和動態規劃(一)

第四章遞迴和動態規劃(一)

1,斐波那契數列問題的遞迴和動態規劃
補充題目1:
給定整數n,代表臺階數,1次可以跨2個或者1個臺階,返回有多少種走法。
舉例:n=3,可以三次都跨一個臺階;也可以先跨2個臺階,再跨一個臺階;還可以先跨1一個臺階,再跨兩個臺階。所以有三種方法。
補充題目2:假設母牛每年生1頭小母牛,並且永遠不會死。第一年有1只成熟的母牛,從第二年開始,母牛開始生小牛。每隻小母牛3年之後成熟又可以生小母牛。給定整數n,求出n年後的數量。
舉例:n=6,第一年1頭母牛記為a;第二年a生了新的小母牛,記為b,總數為2;第三年a生了新的小母牛,記為c,總牛數為3;第4年a生了新的小母牛,記為d,總數為4。第五年b成熟了,a和b分別生了新的小母牛,總數為6;第6年c也成熟了,a、b和c分別生了新的小母牛,總數為9,返回9。
要求:時間複雜度O(logn)。
原問題的解答:
很容易寫出暴力遞迴的解法,時間複雜度為O(2的n次方)。
程式碼如下:

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

O(n)複雜度的方法:

public static int f2(int n) {
        if (n < 1) {
            return 0;
        }
        if
(n == 1 || n == 2) { return 1; } int res = 1; int pre = 1; int tmp = 0; for (int i = 3; i <= n; i++) { tmp = res; res = res + pre; pre = tmp; } return res; }

這沒有用遞迴,斐波那契數列可以根據前兩項求出後一項的值。
方法三:O(logn)時間複雜度的方法。
分析:用矩陣乘法的方式可以將時間複雜度降為O(logn)。f(n) = f(n-1) + f(n-2),是一個二階遞推數列,一定可以用矩陣乘法的形式表示,且狀態矩陣為2*2的矩陣(這個太難,暫時理解不了)。
補充問題1:臺階只有1個,走法只有一種,有兩個方法2種,如果有n級,最後跳上第n級的情況,要麼是從n-2級臺階直接跨2級臺階,要麼是n-1級跨1級臺階,所以臺階有n級的方法數,為跨到n-2級臺階的方法數加上跨到n-1級臺階的方法數,即s(n) = s(n-1) + s(n-2),初始項s(1) = 1,s(2) = 2,所以類似於斐波那契數列,但是不同的是初始項不同,可以很輕易地寫出2的n次方與O(n)的方法,請看下面的s1方法和s2方法。

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

    }
public static int s2(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return n;
        }
        int res = 2;
        int pre = 1;
        int tmp = 0;
        for (int i = 3; i <= n; i++) {
            tmp = res;
            res = res + pre;
            pre = tmp;
        }
        return res;
}

以上是2的n次方和O(n)複雜度的方法。
下面講解O(logn)複雜度的方法,也是求狀態矩陣,用矩陣乘法。
補充問題2:所有的牛都不會死,c(n) = c(n-1) + c(n-3)。與斐波那契數列類似,不過是c(n)項依賴於c(n-1)和c(n-3)項的值,而斐波那契數列依賴於f(n-1)和f(n-2)項的值。
c1和c2方法分別是2的n次方和O(n)時間複雜度的方法。

public static int c1(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2 || n == 3) {
            return n;
        }
        return c1(n - 1) + c1(n - 3);
}
public static int c2(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2 || n == 3) {
            return n;
        }
        int res = 3;
        int pre = 2;
        int prepre = 1;
        int tmp1 = 0;
        int tmp2 = 0;
        for (int i = 4; i <= n; i++) {
            tmp1 = res;
            tmp2 = pre;
            res = res + prepre;
            pre = tmp1;
            prepre = tmp2;
        }
        return res;
}

2,矩陣的最小路徑和
題目:給定一個矩陣m,從左上角開始每次只能往下或者往右走,最後到達右下角的位置,路徑上的所有數字累加起來就是路徑和,返回所有的路徑和中最小的路徑和。
舉例:
矩陣如下:
1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
這個矩陣,路徑1,3,1,0,6,1,0。和為12,這是最小的路徑和。
思路:這是經典的動態規劃問題,假設矩陣的大小為m*n,m行n列。先生成大小和m一樣的矩陣dp,dp[i][j]的值表示從左上角(即(0,0))位置走到(i,j)位置的最小路徑和。對m的第一行所有位置來說,從(0,0)位置開始走到(0,j)位置只能往右走,所以從(0,0)位置到(0,j)位置的路徑和就是m[0][0..j]這些值的累加結果。同理,對m的第一列的所有位置來說,即(i,0)(0<=i<=m),從(0,0)位置走到(i,0)位置只能向下走,所以從(0,0)位置到(i,0)位置的路徑和就是m[0..i][0]這些值累計起來的結果。就以題目例子來說,dp第一行和第一列的值如下:
1 4 9 18
9
14
22
生成的dp如下:
1 4 9 18
9 5 8 12
14 5 11 12
22 13 15 12
思路:除了第一行和第一列之外,每一個位置都考慮從左邊到達自己的路徑和更小還是從上邊達到自己的路徑和更小,最右下角的值就是整個問題的答案,具體過程請參考如下程式碼中的minPathSun1方法。

public static int minPathSum1(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        int[][] dp = new int[row][col];
        dp[0][0] = m[0][0];
        for (int i = 1; i < row; i++) {
            dp[i][0] = dp[i - 1][0] + m[i][0];
        }
        for (int j = 1; j < col; j++) {
            dp[0][j] = dp[0][j - 1] + m[0][j];
        }
        for (int i = 1; i < row; i++) {
            for (int j = 1; j < col; j++) {
                dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + m[i][j];
            }
        }
        return dp[row - 1][col - 1];
}

矩陣中有m*n個位置,每個位置都計算一次從(0,0)位置達到自己的最小路徑和,計算的時候只是比較上邊位置的最小路徑和與左邊位置的最小路徑和哪個更小,所以時間複雜度為O(m*n),dp矩陣的大小為m*n,所以額外空間複雜度為O(m*n)。
動態規劃經過空間壓縮後的方法,這道題的經典動態規劃方法在經過空間壓縮後,時間複雜度依然為O(m*n),但是額外空間複雜度可以從O(m*n)降為O(min{m,n}),也就是不使用大小為m*n的dp矩陣,而僅僅使用大小為min{m,n}的arr陣列。具體過程如下:
1),生成長度為4的陣列arr,初始時arr={0,0,0,0},我們知道從(0,0)位置到達m中第一行的每個位置,最小路徑和就是從(0,0)位置的值開始依次累加的結果,所以依次把arr設定為arr={1,4,9,18},此時arr[j]的值代表從(0,0)位置達到(0,j)位置的最小路徑和。
2),步驟1中arr[j]

public static int minPathSum2(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int more = Math.max(m.length, m[0].length);
        int less = Math.min(m.length, m[0].length); // 
        boolean rowmore = more == m.length; // 
        int[] arr = new int[less]; 
        arr[0] = m[0][0];
        for (int i = 1; i < less; i++) {
            arr[i] = arr[i - 1] + (rowmore ? m[0][i] : m[i][0]);
        }
        for (int i = 1; i < more; i++) {
            arr[0] = arr[0] + (rowmore ? m[i][0] : m[0][i]);
            for (int j = 1; j < less; j++) {
                arr[j] = Math.min(arr[j - 1], arr[j])
                        + (rowmore ? m[i][j] : m[j][i]);
            }
        }

        return arr[less - 1];

}

擴充套件:本體壓縮空間的方法幾乎可以應用到所有需要二維動態規劃表的題目中,通過一個數組滾動更新的方式無疑節省了大量的空間。在優化之前,取得某個位置動態規劃值得過程是在矩陣中進行兩次定址,優化後,這一過程只需要一次定址,程式的常數時間也得到了一定程度的加速。但是空間壓縮的方法是有侷限性的,本體如果改成”列印具有最小路徑和的路徑”,那麼就不能使用空間壓縮的方法。如果類似本題這種需要二維表的動態規劃題目,最終目的是想求最優解的具體路徑,往往需要完整的動態規劃表,但如果只是想求最優解的值,則可以使用空間壓縮的方法。因為空間壓縮的方法是滾動更新的,會覆蓋之前求解的值,讓求解軌跡變得不可回溯。
3,換錢的最少貨幣數
題目:給定陣列arr,arr中所有的值都為整數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數aim代表要找的錢數,求組成aim的最少貨幣數。
舉例:
arr={5,2,3},aim=20。
4張5元可以組成20元,其他的找錢方案要使用更多張的貨幣,所以返回4。
arr={5,2,3},aim=0。
不用任何貨幣就可以組成0元,所以返回0。
arr={3,5},aim=2。
根本無法組成2元,錢不能找開的情況下預設返回-1。
補充題目:給定陣列arr,arr中所有的值都為正數。每個值僅代表一張錢的面值,再給定一個整數aim代表要找的錢數,求組成aim的最少貨幣數。
舉例:
arr={5,2,3},aim=20。
5、2、3元的錢各有一張,所以無法組成20元,返回-1。
arr={5,2,5,3},aim=10。
5元的貨幣兩張,可以組成10元,所以返回2。
arr={5,2,5,3},aim=15。
所有的貨幣加起來組成15元,返回4。
arr={5,2,5,3},aim=0。
不用任何貨幣就可以組成0元,返回0。
解答:原問題的經典動態規劃方法。如果arr的長度為n,生成長度為n、列數為aim+1的動態規劃表的dp。dp[i][j]的含義是,在可以任意使用arr[0..i]貨幣的情況下,組成j所需要的最小張數。根據這個定義,dp[i][j]的值按如下方式計算:
1),dp[0..1][0]的值(即dp矩陣中第一列的值)表示找的錢數為0時需要的最少張數,錢數為0時,完全不需要任何貨幣,所以全設為0。
2),dp[0][0..aim]的值(dp矩陣第一行的值)表示只能使用arr[0]貨幣的情況下,找某個錢數的最小張數。比如,arr[0]=2,那麼只能找開的錢數為2、4、6、8…所以令dp[0][2]=1,dp[0][4]=2,dp[0][6]=3,…第一行其他位置所代表的錢數一律找不開,所以一律設為32位整數的最大值,我們把這個值記為max。
3),剩下的位置依次從左到右,再從上到下計算。假設計算到位置(i,j),dp[i][j]的值可能來自下面的情況:
a,完全不使用當前貨幣arr[i]情況下的最少張數,即dp[i-1][j]的值。
b,只使用一張當前貨幣arr[i]情況下的最少張數,即dp[i-1][j-arr[i]]+1。
c,只使用兩張當前貨幣arr[i]情況下的最少張數,即dp[i-1][j-2*arr[i]]+2。
d,只使用三張當前貨幣arr[i]情況下的最少張數,即dp[i-1][j-3*arr[i]]+3。
所有的情況中,最終取張數最小的。所以:
dp[i][j]=min{dp[i - 1][j - k*arr[i]] + k(0 <= k)}
=>dp[i][j]=min{dp[i - 1][j],min{dp[i - 1][j -x * arr[i]] + x(1<=x)}}
=>dp[i][j]=min{dp[i - 1][j],min{dp[i - 1][j - arr[i]-y * arr[i] + y + 1(0<=y)}}
又有 min{dp[i-1][j - arr[i] - y * arr[i] + y(0<=y)} => dp[i][j-arr[i]],所以最終有:dp[i][j]=min{dp[i - 1][j],dp[i][j-arr[i] + 1}。如果j-arr[i] < 0,即發生越界了,說明arr[i]太小,用一張都會超過錢數j,令dp[i][j]=dp[i-1][j]即可。具體過程參看如下程式碼中的minCoins方法,整個過程的時間複雜度與額外空間複雜度都為O(n*aim),n為arr的長度。

public static int minCoins1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        int n = arr.length;
        int max = Integer.MAX_VALUE;
        int[][] dp = new int[n][aim + 1];
        for (int j = 1; j <= aim; j++) {
            dp[0][j] = max;
            if (j - arr[0] >= 0 && dp[0][j - arr[0]] != max) {
                dp[0][j] = dp[0][j - arr[0]] + 1;
            }
        }
        int left = 0;
        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= aim; j++) {
                left = max;
                if (j - arr[i] >= 0 && dp[i][j - arr[i]] != max) {
                    left = dp[i][j - arr[i]] + 1;
                }
                dp[i][j] = Math.min(left, dp[i - 1][j]);
            }
        }
        return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}

原問題在動態規劃基礎上的空間壓縮方法。參考”矩陣的最小路徑和”問題,也就是上題。