目录
简介
动态规划是分治思想的延伸,通常来说就是大事化小,小事化了的艺术。在将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,并供后面处理更大规模的问题时直接
使用这些结果。说人话就是利用历史记录来避免我们重复计算。
动态规划具备了以下三个特点
1. 把原来的问题分解成了几个相似的子问题。
2. 所有的子问题都只需要解决一次。
3. 储存子问题的解。
动态规划的本质,是对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)
动态规划问题一般从以下四个角度考虑:
1. 状态定义因为要利用历史记录来计算,我们就需要使用一个容器来保存,大部分我们使用的是数组,因为数组方便嘛,也有一些使用二维数组。因此我们需要知道每个数组元素代表的含义,这个数组名就命为dp[]吧,也就是说要定义dp[i]的意义。
2. 状态间的转移方程定义状态转移方程,我的理解是有点类似于高中时学过的归纳法(再次回到被数学支配的恐惧),当我们要求dp[n] 时,是可以利用 dp[n-1]、dp[n-2]……dp[1]来求出dp[n]的,也就是说他们必然满足一种关系,比如:dp[n] = dp[n-1] + dp[n-2].这一步可以说是最难得一步,但也是最关键的一步。
3. 状态的初始化学过数学归纳法的都应该知道,我们能利用关系求出第n个值,但总得有个初值,不然你就算知道了dp[n] = dp[n-1] + dp[n-2],要求d[n]能求出来吗?因此这一步我们需要定义初值,也就是dp[1] = ? 或者 dp[0] = ?
4. 返回结果要求的的是第n个,直接返回数组中dp[n]即可(假设从1开始)。
适用场景:最大值/最小值, 可不可行, 是不是,方案个数
案例分析
斐波那契数列
举个简单的例子,都知道斐波那契数列吧,斐波那契数列每一项都是前两项的和,利用这个特点我们就能使用递归,例如下面这种:
但是有个问题,那就是我们每次计算第n个时,都要把原来的重新计算一遍,这个时间复杂度达到了O(N^2),效率太低了,而且当n达到一定值时还会造成栈溢出。
高级一点的可能会使用迭代:
定义一个first = 1,second = 1,third = 0,然后循环遍历从3开始到n结束,把first + second的值赋给third,second的值赋给first,trird的值再赋给second,就这样一直循环。
public int Fibonacci(int n) {
int first = 1;
int second = 1;
int third = 1;
for(int i = 3 ; i <= n ; i++){
third = first + second;
first = second;
second = third;
}
return third;
}
但其实这个迭代的过程其实就相当于动态规划:
一:状态定义
创建一个数组dp,每个dp[i]的意义就是第i个斐波那契数列的值
二:状态间的转移方程
由题目就可以得出,第n个的值为前两项的和,也就是说 dp[n] = dp[n-1] + dp[n-2].
三:状态的初始化
也可以由已知的出,dp[1] = 1,dp[2] = 1
四:返回结果
返回dp[n]
迭代只是每次循环都把值更新了,而动态规划是把每个数据都保存了,本质是类似的。
public int Fibonacci(int n) {
int dp[] = new int[n+1];
dp[1] = 1;
dp[2] = 1;
for(int i = 3 ; i <= n ; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
在线OJ链接:斐波那契数列
分割字符串
先看题目:
这个题目我们第一眼看去最简单想到的就是哈希表存储string,boolean,判断并返回,这种方法虽然可行,但不太好,如果字符串很长,哈希表就要存储许多数据。因此我们使用动态规划:
一:状态定义
定义数组dp[],一个字符串能否被分割也就意味着整个数组是否能被分割,所以,我们可以将dp[i]定义为前 i个元素能否被分割,能就将其置为true,否则置为false。
二:状态间的转移方程
因为字符串可能不止被分割为两个,因此我们在求dp[i]时,要判断前面是否已经有单词在词典中找到,如果前面已经有找到的将其下标元素置为true,于是我们可以设置求dp[ i ]的进入条件为前面的dp[ j ] == true ,而如果 contains( substring( j , i ) ),则可将其设置为true
因此通过归纳法,我们可以得出要求dp [n] 需要判断两层条件,一层是通过保存的历史记录得出,而另一层则是需要求的时候顺带判断。即dp[ i ] = dp[ j ] && contains(substring( j , i )) 而且这里有个小细节,当字符串是"nowcodewe",词典不变时,当下标指向 w 时,dp[3] 结束 没有找到 "codew" 不应该结束而是应该调到dp[7]并继续判断"w",所以这里还需要设计一层循环。
三:状态的初始化
因为数组下标包含0,而前0个字符就是"",我们认为词典是包含的,否则循环进不去,因此状态的初始化有时还是需要根据代码和思路一起得出,否则容易出问题
四:返回结果
返回dp[s.length()]
public boolean wordBreak(String s, Set<String> dict) {
boolean dp[] = new boolean[s.length()+1];
dp[0] = true;
//dp[i] 代表前i个字符是否能被分割,因此i要可以等于字符串的长度
for(int i = 1 ; i <= s.length() ; i++ ){
//进入条件首先是 j < i,不能等于,等于意味着你已经知道了dp[i]的结果
for(int j = 0 ; j < i ; j++){
//要求的是dp[j] && contains([j+1,i])
//因为substring函数是前闭后开[j,i),所以第 j 个下标对应的是 j+1 , i-1下标对应 i
if(dp[j] && dict.contains(s.substring(j,i))){
dp[i] = true;
//只要满足即为true,不关心它是怎么分割的
break;
}
}
}
return dp[s.length()];
}
在线OJ链接:拆分字符串
三角矩阵
例图如下:
由题目可知,每一步只能移到下面一行相邻的数字,就是说50只能往下走10或者80,而不能到40或30,并且最左和最右只有一条路径可以走:
话不多说,直接上动态规划:
一:状态定义
函数给的是一个集合嵌套集合,因此我们也可以构建一个二维数组dp[][],dp[ i ][ j ]代表到达第 i 行 第 j 列的最小路径和。
但是如果行数很多的话有点浪费空间了,因此其实这道题并不推荐重新构建一个二维数组,而是用它所给的那种集合套集合的形式,但写这道题主要是为了熟悉动态规划所以还是按步骤来吧。
二:状态间的转移方程
由题目我们可以推断出到达dp[ i ] [ j ] 只有从 dp[ i - 1 ][ j - 1 ] 和 dp[ i - 1 ][ j ]
因为要求最小,所以要返回两个点中数值小的并且加上自己本身的数值。
即dp[ n ]= min(dp [ i - 1 ][ j - 1 ] ,dp[i - 1] [ j ]) + array[ i ] [ j ]
三:状态的初始化
题目给出的集合中的第一个元素就是dp[ 0 ][ 0 ].
四:返回结果
返回 最后一行中最小的元素
dp数组代码:
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
int size = triangle.size(); //定义行
if(triangle.isEmpty() || size == 0){
return 0;
}
int [][] dp = new int[size][size];
dp[0][0] = triangle.get(0).get(0);
for(int i = 1 ; i < size ; i++){
int sum = 0;
for(int j = 0 ; j <= i ; j++){
//累计往下加
if(j == 0){
//最左边一行的情况
sum = dp[i-1][j];
}else if(j == i){
//最右边一行的情况
sum = dp[i-1][j-1];
}else{
//非边界,即0<j<i
sum = Math.min(dp[i-1][j],dp[i-1][j-1]);
}
dp[i][j] = triangle.get(i).get(j) + sum;
}
}
//到这一步已经累计到最后一行了,最后一行所有元素中找最小的即可
int minsum = dp[size-1][0];
for(int j = 1 ; j < size ; j++){
minsum = Math.min(minsum,dp[size-1][j]);
}
return minsum;
}
ArrayList代码:
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
if (triangle.isEmpty())
return 0;
ArrayList<ArrayList<Integer>> minPathSum = new ArrayList<>();
//每一行重新new一个集合
for (int i = 0; i < triangle.size(); ++i) {
minPathSum.add(new ArrayList<>());
}
minPathSum.get(0).add(triangle.get(0).get(0));
for (int i = 1; i < triangle.size(); ++i) {
int curSum = 0;
for (int j = 0; j <= i; ++j) {
// 处理左边界和右边界
if (j == 0) {
curSum = minPathSum.get(i - 1).get(0);
} else if (j == i) {
curSum = minPathSum.get(i - 1).get(j - 1);
} else {
curSum = Math.min(minPathSum.get(i - 1).get(j),
minPathSum.get(i - 1).get(j - 1));
}
minPathSum.get(i).add(triangle.get(i).get(j) + curSum);
}
}
int size = triangle.size();
int min = minPathSum.get(size-1).get(0);
for(int i = 1 ; i < size ; i++){
min = Math.min(min,minPathSum.get(size-1).get(i));
}
return min;
}
其实如果这道题我们也可以逆向思维,从底向上看,那么代码其实比从顶向下看代码更简洁,因为自顶向下要判断边界的条件,而自底向上每个点都有两条到达路径,适应所有点,不用判断边界。
代码:
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
if (triangle.isEmpty())
return 0;
ArrayList<ArrayList<Integer>> minPathSum = new ArrayList<>(triangle);
int size = minPathSum.size();
for(int i = size - 2 ; i >= 0 ; i--){
for(int j = 0 ; j <= i ; j++){
int curSum = Math.min(triangle.get(i + 1).get(j),triangle.get(i + 1).get(j + 1))
+ triangle.get(i).get(j);
minPathSum.get(i).set(j, curSum);
}
}
return minPathSum.get(0).get(0);
}
在线OJ链接: 三角矩阵
本文收录专栏《数据结构与算法》