很早之前就看过算法导论这本书,当时看到动态规划算法的时候就觉得很厉害,然而一直是半懂不懂的状态。一看就会,一做就废。
最近又研究了下,通过本文做一个整理和总结。
先不讲任何理论,直接从问题出发。
斐波拉契数列
斐波拉契数列是数学里非常经典的问题,用算法实现的话,最容易想到的方式是递归。但递归过程中做了很多重复计算。
比如要计算fib(5)的值,则要分别计算fib(4)和fib(3)。而计算fib(4)时又要计算fib(3)和fib(2)的值。其中fib(3)就要重复计算。
如果把每个计算结果保留好,就不用重复计算了,直接从保留的结果中拿对应值就可以,极大节省了效率。
代码如下:
public int getFibNum(int n) {
// 初始化一个n+1长度的数组,用于存放每一项的结算结果。其中,a[0]使用默认值0
int[] a = new int[n+1];
for (int i = 1; i <= n; i++) {
if (i <= 2) {
a[i] = i;
} else {
a[i] = a[i-1] + a[i-2];
}
}
return a[n];
}
注意到,这里使用了一个推导公式:fib[i] = fib[i-1] + fib[i-2]
上楼梯
假设有一个n个台阶的楼梯,每次只能走一个台阶或两个台阶。问走到台阶顶部一共有多少种走法?
分析:设想上楼梯的最后一步,可以从倒数第一个台阶上,也可以从倒数第二个台阶上。
设从倒数第一个台阶上的顶部的走法是f(1),倒数第二个台阶上到顶部的走法是f(2), 显然f(1)=1,f(2)=2。
从倒数第三个台阶上到顶部的方式:
先上1步到倒数第二阶,然后上到顶部,有f(2)种上楼方式;或者先上2步到达倒数第一阶,然后上到顶部,有f(1)中上楼方式。
因此从倒数第3阶上到顶部的方式f(3) = f(2) + f(1)。
同理可推论得:f(n) = f(n-1) + f(n-2)。
实际上这还是一个斐波拉契数列。
找零钱
假设有1元,5元,11元的纸币,数量不限。现要通过这些纸币找零n元,最少需要多少张纸币?
找零钱一般通过贪心算法实现,在可选择的范围内始终选择最大面值的零钱,比如人民币找零。
但实际上并不是所有的找零钱都能通过贪心算法实现,这种方式对币值是有要求的,这里不讨论具体条件。
例如题目所述,当找零钱15元时,如果用贪心算法,则是11+1+1+1+1,需要5张。实际上5+5+5是更优解。
对于这种没办法用贪心算法来找零的问题怎么处理呢?
分析:设找零n元的最优解为f(n),假设最后一步挑选一张纸币即可完成找零,最后一张纸币可能为1元,5元,11元。因此有:
f(n) = max(1+f(n-1), 1+f(n-5), 1+f(n-11))。
算法如下:
public static int getNum(int n) {
// 存储找零钱数量
int[] m = new int[n+1];
for (int i = 1; i < n + 1; i++) {
int a, b = Integer.MAX_VALUE, c = Integer.MAX_VALUE;
// 最后一步取1元的张数
a = 1 + m[i - 1];
// 最后一步取5元的张数
if (i >= 5) {
b = 1 + m[i - 5];
}
// 最后一步取11元的张数
if (i >= 11) {
c = 1 + m[i - 11];
}
int min = a;
if (b < min) {
min = b;
}
if (c < min) {
min = c;
}
m[i] = min;
}
return m[n];
}
棋子移动
一个a行b列的棋盘,现有一棋子位于棋盘左上角第一位置,每次只能向右或向下移动一格,则棋子移动到右下角最后一个位置一共有多少种路径?
分析:设棋盘第i行j列的坐标为(i,j),棋子从初始位置移动到坐标(i,j)的路径为f(i,j)。
棋子移动到(i,j)的最后一步有两种方式,即从(i-1,j)移动到(i,j),或者从(i,j-1)移动到(i,j)。
因此可推论得:f(i,j) = f(i-1,j) + f(i,j-1)。
用一个二维数据存储棋子移动到各个位置点的路径数,代码如下:
public static int getPathNum(int a, int b) {
int[][] m = new int[a][b];
for (int i = 0; i <= a - 1; i++) {
for (int j = 0; j <= b - 1; j++) {
if (i == 0) {
// 在第一行移动,只有横移这一种方式
m[0][j] = 1;
} else if (j == 0) {
// 在第一列移动,只有竖移这一种方式
m[i][0] = 1;
} else {
m[i][j] = m[i-1][j] + m[i][j-1];
}
}
}
return m[a-1][b-1];
}
0-1背包
相比于上面的问题,0-1背包稍微复杂一点。问题描述如下:
给定n个重量为W1,W2,…,Wn,价值为V1,V2,…,Vn的物品和容量为C的背包,求这个物品中一个最有价值的子集,使得在满足背包的容量的前提下,包内的总价值最大
分析:设放入第i个物品后,包内的总价值为f(i,C)。
第i个物品可以装入背包,也可以不装入背包。不装情况下价值为f(i-1,C),装入的情况下价值为装入第i个物品之前的最大价值加上第i个物品的价值,也就是f(i-1, C-Wi) + Vi。
从这两个值中取较大的值就是真正的最大价值。
最终递推公式为:f(i,C) = max(f(i-1,C), f(i-1, C-Wi) + Vi)。这个递推公式用离散数学的思想很容易推导。
事实上可以通过逐步推导的方式来证明这个公式。设已有物品:
下面通过递推的方式来计算,将结果填入表格中:
- 当背包容量为0时,价值全部为0,即f(i, 0) = 0。
- 当背包容量为1,允许放入的物品编号为1时,物品1超出容量,因此f(1,1) = 0。
- 当背包容量为1,允许放入的物品编号为1、2时,物品1超重,放入第二个物品,f(2,1) = 10。
- 当背包容量为1,允许放入的物品编号为1、2、3时,物品1和3均超重,只能放入2,f(3,1) = 10。
- 同理f(4,1) = 10。
- 当背包容量为2,允许放入的物品编号为1时,可以放入,f(1,2) = 12。
- 当背包容量为2,允许放入的物品编号为1、2时,放入1的价值是12,放入2的价值是10,均放入则超重,所以f(2,2) = 12。
- 同理f(3,2) = 12, f(4,2) = 15。
- …
结果如下所示:
这个计算结果是通过枚举计算来获得的,规律完全符合上述推导公式。当物品数量或者背包容量继续增大时,枚举计算已经不现实了,而通过推导公式则毫无问题。
算法实现如下:
public static int getMaxValue(int[] w, int[] v, int c) {
int size = w.length;
int max = 0;
int[][] dp = new int[size][c+1];
for (int i = 0; i < size; i++) {
for (int j = 1; j <= c; j++) {
if (i == 0) {
if (w[0] <= j) {
// 一个物品,可以放就放,不能放就是默认值0
max = v[0];
}
} else {
int a = dp[i-1][j]; // 已放入的i-1个物品的价值
int b = 0;
if (j >= w[i]) {
b = dp[i-1][j - w[i]] + v[i];
}
max = Math.max(a, b);
}
dp[i][j] = max;
}
}
return dp[size-1][c];
}
public static void main(String[] args) {
int[] w = {2, 1, 3, 2};
int[] v = {12, 10, 8, 15};
System.out.println(getMaxValue(w, v, 5));
}
连续子数组的最大和
给定一个int数组,数组中的数可以为正或负,输出和最大的子数组的和。
例如[-1, 2, -3, 5, -1, 2],连续子数组[5, -1, 2]和最大,为6。
分析:设以第i个元素结尾的连续子数组,和最大为f(i)。对于每个i(0<=i<n),将f(i)用一个数组存起来。然后取最大的f(i),就是以第i个元素结尾的连续子数组最大的和。
f(i)怎么计算呢?假设数组为[-1, 2],则显然f(0) = -1, f(1) = max(f(0) + 2, 2) = 2。
继续扩展数组为[-1, 2, -3],则f(2) = max(f(1) - 3, -3) = -1。
依次类推,很容易得到f(i) = max(f(i-1) + array[i], array[i])。实现代码如下:
public static int maxSubArray(int[] nums) {
// m[i]表示第i个元素结果的最大子数组的和
int[] m = new int[nums.length];
m[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
if (m[i-1] + nums[i] >= nums[i]) {
m[i] = m[i-1] + nums[i];
} else {
m[i] = nums[i];
}
}
int max = m[0];
for (int i = 1; i < m.length; i++) {
if (m[i] > max) {
max = m[i];
}
}
return max;
}
动态规划
分析了以上几个问题之后再来说动态规划的理论。
基本思想
问题的最优解如果可以由子问题的最优解推导得到,则可以先求子问题的最优解,再构造原问题的最优解;若子问题有较多的重复出现,则可以自底向上从最终子问题向原问题逐步求解。
使用条件
使用动态规划的两个条件:存在一个优化子结构;存在重叠子问题。
从上文的几个问题可以看出,每个问题都存在一个推导公式,也就是优化子结构。计算问题的最优解时需要用到子问题的解,存在重叠子问题。符合动态规划的使用条件。
最长回文子串
给定一个字符串,输出其最长回文子串
分析:设f(i,j)=1表示从i到j位置的子串为回文串,则当i-1和j+1位置的字符相同时,则f(i-1,j+1)=1。
状态转移方程为:
当s[i]=s[j],f(i,j)=f(i+1,j-1)。
实现如下:
public static String getLongestSubStr(String s) {
if (s.length() == 0 || s.length() == 1) {
return s;
}
if (s.length() == 2) {
return s.charAt(0) == s.charAt(1) ? s : s.substring(0,1);
}
int size = s.length();
int max = 1; // 最长回文子串长度
int start = 0;
char[] chars = s.toCharArray();
int[][] dp = new int[size][size]; // dp[i][j]表示位置i到j上的子串是否为回文串
for (int i = 0; i < size; i++) {
dp[i][i] = 1; // 单字符必为回文串
}
// 当s[i] == s[j]时,i到j之间是回文串则必是回文串,即dp[i][j]=dp[i+1][j-1]。
// 当s[i] != s[j]时,dp[i][j]=0
// 举例,当s[1]=s[5]时,要判断dp[1][5],只需要知道dp[2][4]的值。而dp[2][4]需要知道dp[1][3]的值。
for (int j = 1; j < size; j++) {
for (int i = 0; i < j; i++) {
if (j - i == 1) {
if (chars[i] == chars[j]) {
dp[i][j] = 1;
if (max == 1) {
max = 2;
start = i;
}
}
} else {
if (chars[i] == chars[j]) {
dp[i][j] = dp[i+1][j-1];
if (dp[i][j] == 1 && j - i + 1 > max) {
max = j - i + 1;
start = i;
}
}
}
}
}
return s.substring(start, start+max);
}
最大子序列之和
不选取相邻元素的基础上,选取一个子序列,使其和最大,输出最大的和。
例如大小为3的数组[1,2,3], 不相邻且和最大的子序列为[1,3],和为4。再比如大小为4的数组[3, 4, 6, 4], 不相邻且和最大的子序列为[3,6],和为10。
分析:
设前i个元素组成的最大子序列的和为dp[i],则前i+1个元素组成的最大子序列的和可能为dp[i], 也可能为dp[i-2] + array[i]。
算法实现如下:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
* 计算
* @param n int整型 数组的长度
* @param array int整型一维数组 长度为n的数组
* @return long长整型
*/
public long subsequence (int n, int[] array) {
// write code here
// 前n+1项最大和可能为前n项最大和,也可能为前n-1项中的最大数加上第n+1项数
int[] dp = new int[n];
if (n == 1) {
return array[0];
} else if (n == 2) {
return Math.max(array[0], array[1]);
} else {
dp[0] = array[0];
dp[1] = Math.max(array[0], array[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + array[i]);
}
int max = dp[0];
for (int i = 1; i < n; i++) {
if (dp[i] > max) {
max = dp[i];
}
}
return max;
}
}
两个数组的最长公共子序列
有两个int数组,长度分别为len1,len2,且 0 < len2 <= len1 <= 10000, 输出两个数组的最长公共子数组的长度。
例如[1,2,3,4,5]和[2,3,4,5,6],最大公共子数组为[2,3,4,5], 长度为4。
分析:
设dp[i][j]表示两个数组中分别以第i个元素和第j个元素结尾的数组的最大公共子数组的长度,如dp[2][1]表示第一个数组的前三个元素数组[1,2,3]和第二个数组的前两个元素数组[2,3]的最大公共子序列的长度,dp[2][1]=2。
则当arr1[i+1] = arr2[j+1]时,dp[i+1][j+1] = 1 + dp[i][j]。状态转移方程找出来之后实现就简单了。
算法实现如下:
public int findLongestCommonStr(String str1,String str2) {
int len1 = str1.length();
int len2 = str2.length();
int max = 0;
int[][] dp = new int[len1][len2];
for (int i = 0; i < len1; i++) {
for (int j = 0; j < len2; j++) {
if (str1.charAt(i) == str1.charAt(j)) {
if (i == 0 || j == 0) {
dp[i][j] = 1;
max = 1;
} else {
dp[i][j] = 1 + dp[i-1][j-1];
if (dp[i][j] > max) {
max = dp[i][j];
}
}
}
}
}
return max;
}