动态规划

动态规划

前几天被阿里校招笔试一道装箱问题的编程题吓懵逼了,遂决定好好看看动态规划的东西,结合在牛客网上的课程,总结一下基础动态规划的知识。

动态规划的关键点在于解决冗余和记忆化搜索。当遇到一道需要暴力搜索方法解决的问题时,都可以考虑使用动态规划的方法解决。


动态规划的推导过程

动态规划的大致过程可以表示为:暴力搜索方法->记忆化搜索方法->动态规划->状态继续化简后的动态规划方法

首先,动态规划方法不是空穴来风,他是程序员在解决实际问题中总结出来的经验。

举个例子,我们有一道题目,来自牛客网

有数组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];
    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章