通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
相同点:其基本思想都是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解(合并)得到原问题的解。
不同点:
分治法将分解后的子问题看成相互独立的,通过用递归来做。
动态规划将分解后的子问题理解为相互间有联系,有重叠部分,需要记忆,通常用迭代来做。
1.最优化原理(最优子结构性质):一个最优化策略具有这样的性质,不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略。简而言之,一个最优化策略的子策略总是最优的。一个问题满足最优化原理又称其具有最优子结构性质。
2.无后效性:将各阶段按照一定的次序排列好之后,对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的决策,而只能通过当前的这个状态。换句话说,每个状态都是过去历史的一个完整总结。这就是无后向性,又称为无后效性。
3.子问题的重叠性:动态规划将原来具有指数级时间复杂度的搜索算法改进成了具有多项式时间复杂度的算法。其中的关键在于解决冗余,这是动态规划算法的根本目的。在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,在以后尽可能多地利用这些子问题的解。动态规划实质上是一种以空间换时间的技术,它在实现的过程中,不得不存储产生过程中的各种状态,所以它的空间复杂度要大于其它的算法。
有面值1,3,5分的三种硬币,现在给出一个价值C,问组成价值C最少需要几枚硬币?
分析:
动态规划解决问题,主要找出两样东西: 1.“状态” dp[i] : 组成价值i所需要的最少的硬币的数量 2.状态转移方程 * dp[i] : 组成价值i所需要的最少的硬币的数量 —> 状态 * dp[0] = 0 * dp[1] = 1 + dp[1-1] = 1 + dp[0] = 1 * dp[2] = 1 + dp[2-1] = 1 + dp[1] = 2 * dp[3]: * 1 + dp[3-1] = 1 + 2 = 3 * 1 + dp[3-3] = 1 + 0 = 1 * ....... * dp[i] = min{1+dp[i-1], 1+dp[i-3v], 1+dp[i-5]} * dp[i] = min{1 + dp[i-v[j]]} i >= v[j] i表示要组成的价值,vj表示第j个硬币的面额
递归实现
我们先看一个传统的递归代码:
public class Corn {
public static void main(String[] args) {
int[] arr = {1, 3, 5};
int c = 16;
int num = func (arr, c);
System.out.println ("num:" + num);
System.out.println ("number: " + number);
}
static int number = 0;//记录递归的次数
private static int func(int[] arr, int c) {
if (c == 0) {
return 0;
}
number++;
if (c == 1 || c == 3 || c == 5) {
return 1;
} else {
if (c < 3) {
return 1 + func (arr, c - 1);
} else if (c < 5) {
int n1 = 1 + func (arr, c - 1);
int n2 = 1 + func (arr, c - 3);
return Math.min (n1, n2);
} else {
int n1 = 1 + func (arr, c - 1);
int n2 = 1 + func (arr, c - 3);
int n3 = 1 + func (arr, c - 5);
return Math.min (Math.min (n1, n2), n3);
}
}
}
}
我们定义了一个number,代码每递归一次(重复求解一些子问题)number++,因此来记录组成价值16,代码需要运行的次数:
运行结果:
num:4
number: 502
我们可以看到,得到价格16最少需要4个硬币,代码需要运行502次(无论代码运行多少次,number不变)。由于是递归,每次求解都要回到最开始的子问题,因此使得一些子问题被反复的重复,极大的浪费了时间。对此,这里将求解的子问题记忆化存储,以便下次需要同一个子问题解时直接查表,代码如下:
使用存储思想后的递归实现
import java.util.Arrays;
public class Corn2 {
public static void main(String[] args) {
int[] arr = {1,3,5};
int c = 15;
int[] dp = new int[c+1];//记录组成价值i所需要的最少的硬币的数量
Arrays.fill(dp, -1);
int num = func(arr,c,dp);
System.out.println ("num:" + num);
System.out.println ("number" + number);
}
/**
* arr数组放的是硬币,c是指定的价值,返回价值c最少需要的硬币的数量
* @param arr
* @param c
*/
static int number = 0;//计算递归的次数
private static int func(int[] arr, int c,int[] dp) {
if(dp[c] != -1){
return dp[c];
}
number++;
if(c == 0){
dp[c] = 0;
return 0;
}
if(c == 1 || c == 3 || c == 5){
dp[c] = 1;
return 1;
}else {
if(c < 3){
dp[c] = 1 + func (arr,c-1,dp);
}else if(c < 5){
int n1 = 1 + func (arr,c-1,dp);
int n2 = 1 + func (arr,c-3,dp);
dp[c] = Math.min (n1,n2);
}else{
int n1 = 1 + func (arr,c-1,dp);
int n2 = 1 + func (arr,c-3,dp);
int n3 = 1 + func (arr,c-5,dp);
dp[c] = Math.min (Math.min (n1,n2),n3);
}
return dp[c];
}
}
}
这里用dp数组来存储以求解的子问题,当代码运行一次后所有代码中的子问题都以被存储记载,当代码第二次运行时直接调用存储的结果就行,运行结果:
num:4
number16
当我们把价值C改为21时,运行结果:
num:5
number21
动态规划代码实现
我们可以看到代码的运行次数是极大的减少,这就是动态规划的思想,将求解的子问题记忆花存储,极大的节省了时间,然而上面的代码只是用了存储思想的递归代码,并不是一个真正的动态规划代码(未使用状态转移方程)
import java.util.Arrays;
public class Corn2 {
public static void main(String[] args) {
int[] v = {1, 3, 5};
int c = 16;
int number = 0;
int[] dp = new int[c + 1];
for (int i = 1; i <= c; i++) {
dp[i] = i;
for (int j = 0; j < v.length; j++) {
number++;
// i 1 + dp[1-1] 1 + dp[i-3] 1 + dp[i-5]
if (i >= v[j] && 1 + dp[i - v[j]] < dp[i]) {
dp[i] = 1 + dp[i - v[j]];
}
}
}
System.out.println (Arrays.toString (dp));//打印dp数组(即:组成价格C前所有整数价格的硬币个数)
System.out.println ("num:" + dp[c]);
System.out.println ("number:" + number);
运行结果:
[0, 1, 2, 1, 2, 1, 2, 3, 2, 3, 2, 3, 4, 3, 4, 3, 4]
num:4
number:48