动态规划算法
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
一、前序知识
- 最优化原理:
简单来说就是一个最优策略的子策略也是必须是最优的,而所有子问题的局部最优解将导致整个问题的全局最优。如果一个问题能满足最优化原理,就称其具有最优子结构性质
一、基本思想
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
二、适用情况
- 最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
- 无后效性:即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
- 子问题重叠性质:子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。(重叠子问题)
三、一般解题思路
- 将原问题转化为子问题:
- 子问题与原问题形式相同或类似,只是问题规模变小了,从而变简单了;
- 子问题一旦求出就要保存下来,保证每个子问题只求解一遍
- 确定状态:
- 状态:和子问题相关的各个变量的一组取值 ,称之为一个“状态”。一个“状态”对应于一个或多个子问题,所谓某“状态的值”,就是这个“状态”所对应的子问题的解。
- 状态空间:所有“状态”的集合,构成问题的“状态空间”,“状态空间”的大小,与用动态规划解决问题的时间复杂度直接相关。
- 确定一些初始状态(边界状态)
- 可以理解为递归的终止条件
- 确定状态转移方程:
状态的转移可以用递推公式表示,此递推公式也可以被称作“状态转移方程”。
形式类似如下:
四、经典案例
1、0/1背包问题
0-1 背包问题: 给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 wi,其价值为 vi
问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?。
解题步骤:
- 将原问题转化为子问题:
子问题:
当背包容量为 0 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
当背包容量为 1 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
当背包容量为 2 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
。。。。。。
当背包容量为 8 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
一共有 (c+1) * (n+1) 个子问题。(c:背包容量,n:物品数量) - 确定状态:
定义状态,对于 0/1 背包问题,可以设计一个二维数组dp[n][c]来存放子问题的状态, 而每一个子问题的状态为 d[i][j] 表示 从物品 1 ~ i 物品中选择放入容量为 j 的背包得到的最优解。 - 确定一些初始状态(边界状态):
当不放入物品时,价值为0:dp[0][0~c] = 0
当背包容量为0时,价值为0:dp[0~n][0] = 0 - 确定状态转移方程:
该问题的状态转移方程即:分析得出 dp[i][j] 的计算方法-
j < w[i] 的情况,这时候背包容量不足以放下第 i 件物品,只能选择不拿
dp[ i ][ j ] = dp[ i-1 ][ j ] -
j >= w[i] 的情况,这时背包容量可以放下第 i 件物品,我们就要考虑拿这件物品是否能获取更大的价值。
- 如果拿取:dp[ i ][ j ] = dp[ i-1 ][ j-w[ i ] ] + v[ i ]。 这里的 dp[ i-1 ][ j-w[ i ]]是从 1 ~ i-1 件物品选择,背包容量为 j-w[i] 时的最大价值。
- 如果不拿:dp[ i ][ j ] = dp[ i-1 ][ j ]
通过比较这两种情况哪种价值最大,来判断是否要放入第 i 个物品,即:
-
if(j >= w[i])
dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
else
dp[i][j] = dp[i-1][j];
实战:
假如有1个背包,背包容量是8。有4个物品,重量分别为:{2,3,4,5},价值为:{3,4,5,6}
要求在不超过背包容量的情况下,使背包装载物品的价值最大。
dp[n][c]数组计算结果如下:当背包容量为8,从4个物品中选,最优解为 dp[4][8] = 10
物品编号 i \ 背包容量 j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 0 | 3 | 4 \color{blue}4 4 | 4 | 7 | 7 | 7 | 7 |
3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
4 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 10 \color{red}10 10 |
计算过程:以表格中的蓝色的4,即 dp[2][3] 进行分析:
dp[2][3]: 当不装入2物品时,dp[2][3] = dp[1][3] = 3;当装入2物品时,背包容量为3,去除2物品的重量,背包容量还剩0,即 dp[2][3] = dp[1][0] + w[2] = 0 + 4
选两者的最大值4,即dp[2][3] = 4,填入dp[][]表。
通过这一种方式,我们可以得到背包问题中的最优解,但是并不能知道在最优解的情况下,背包中放入了哪些物品。
为了想要知道在最优解时背包中放了哪些物品,我们可以采取回溯的方法。从表格的右下角(动态规划的终点)开始回溯,利用性质 dp[i][j] == dp[i-1][lj] 表示第 i 个物品没有放入背包,不相等则表示放入背包。
- 回溯
dp[4][8] != dp[3][8]:第 4 个物品放入背包。第4个物品放入背包,背包的容量为 8-5=3,这时候回溯到 dp[3][3]。
dp[3][3] == dp[2][3]:第 3 个物品没有放入背包。这时候可以回溯到dp[2][3]
dp[2][3] != dp[1][3]:第 2 个物品放入背包。第2个物品放入背包,背包的容量为 3-3=0,这时候回溯到 dp[1][0]。
dp[1][0] == dp[0][0]:第 1 个物品没有放入背包
最终可以得出在最优解为10时,此时背包中放入的物品为:物品{2,4}
算法实现
package indi.pentiumcm.thought;
import java.util.ArrayList;
import java.util.List;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.thought
* @className: DpBack
* @author: pentiumCM
* @email: 842679178@qq.com
* @date: 2020/3/25 23:38
* @describe: 动态规划 - 0/1背包问题
*/
public class DpBack {
/**
* 0/1背包算法实现
*
* @param backCap 背包的容量
* @param weights 物品的重量数组
* @param values 物品的价值数组
* @return
*/
public void backPro(int backCap, int[] weights, int[] values) {
// 物品个数
int goodNum = weights.length;
// 1. 确定状态
// dp[i][j] 表示前i件物品放入重量为j的背包时的最大价值
int[][] dp = new int[goodNum + 1][backCap + 1];
// 2. 确定一些初始状态:dp[0][0~backCap]=0,dp[0~goodNum][0]=0
for (int i = 0; i <= backCap; i++) {
dp[0][i] = 0;
}
for (int i = 0; i <= goodNum; i++) {
dp[i][0] = 0;
}
// 3.确定状态转移方程:dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
for (int i = 1; i <= goodNum; i++) {
// j 从 1 遍历到背包容量
for (int j = 1; j <= backCap; j++) {
if (weights[i - 1] <= j) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
// 回溯,看背包里面放了哪些物品
int tempCap = backCap;
List<Integer> backList = new ArrayList<>();
for (int i = goodNum; i > 0; i--) {
if (dp[i][tempCap] != dp[i - 1][tempCap]) {
backList.add(i);
tempCap -= weights[i - 1];
}
}
System.out.print("背包中放了物品:");
for (int i = backList.size() - 1; i >= 0; i--) {
System.out.print(" " + backList.get(i));
}
System.out.print("\n" + "获得的最大价值为: " + dp[goodNum][backCap]);
}
public static void main(String[] args) {
// 背包容量
int backCap = 8;
// 物品的重量
int[] weights = {2, 3, 4, 5};
// 物品的价值
int[] values = {3, 4, 5, 6};
new DpBack().backPro(backCap, weights, values);
}
}
运行结果:
2、旅行商问题
旅行商问题(TSP问题):
是指旅行家要旅行n个城市,要求各个城市经历且仅经历一次然后回到出发城市,并要求所走的路程最短。
示例:
其中0,1,2,3,4代表五个城市,例如从城市0出发,经过城市1,2,3,4,最后回到城市0。
此模型可抽象为图,城市之间的距离可用邻接矩阵 C 表示,如下图所示:
解题步骤:
-
将原问题转化为子问题:
原问题:
从0出发,经过{1,2,3,4}这几个城市,然后回到0,使得路程最短。
子问题:- 从顶点 0 出发,到1,然后再从1出发,经过{2,3,4}这几个城市,然后回到0,使得路程最短。
- 从顶点 0 出发,到1,然后再从1出发到2,经过{3,4}几个城市,最后回到0,使得路程最短。
。。。。。。
- 从顶点 0 出发,到1,然后再从1出发到2,经过{3,4}几个城市,最后回到0,使得路程最短。
- 从顶点 0 出发,到2,然后再从2出发,经过{1,3,4}这几个城市,然后回到0,使得路程最短。
- 。。。。。。
- 从顶点 0 出发,到3,然后再从3出发,经过{1,2,4}这几个城市,然后回到0,使得路程最短。
- 。。。。。。
- 从顶点 0 出发,到4,然后再从4出发,经过{1,2,3}这几个城市,然后回到0,使得路程最短。
- 。。。。。。
- 从顶点 0 出发,到1,然后再从1出发,经过{2,3,4}这几个城市,然后回到0,使得路程最短。
-
确定状态:
假设从顶点s出发,令d(i, V)表示从顶点 i 出发经过V(点的集合)中各个顶点一次且仅一次,最后回到出发点s的最短路径长度。 -
确定一些初始状态(边界状态):
当V为空集,那么d(i, V)为 d(i,{∅}),表示直接从 i 回到 s 了,此时d(i,V) = Cis(Cis表示顶点 i 和 s 之间的距离)
// 初始化dp表的第一列,d(i,{∅})
for(int i =0;i <n;i++){
dp[i][0] = C[i][0];
}
- 确定状态转移方程:
当V不为空,那么就是对子问题的最优求解。须在V这个城市集合中,尝试每一个,并求出最优解。
d(i,V) = min{ Cik + d(i,V-k) } (Cik:相邻城市 i 和 k的距离,d(i,V-k):子问题)
所以状态转移方程为:
过程步骤:
假设从城市 0 出发,分别要经过{1,2,3,4},最终回到城市0。所以原问题为:d(0,{1,2,3,4})。
以上是选择了路径为:0 → 1 → 2 → 3 → 4 → 0
d(0,{1,2,3,4}) = min{ d(1,{2,3,4}) + C01,d(2,{1,3,4}) + C02,d(3,{1,2,4}) + C03,d(4,{1,2,3}) + C04 }
d(1,{2,3,4}) = min{ d(2,{3,4}) + C12,d(3,{2,4}) + C13,d(4,{2,3}) + C14 }
d(2,{3,4}) = min{ d(3,{4}) + C23,d(4,{3}) + C24 }
d(3,{4}) = min{ d(4,{∅}) + C34 }
d(4,{∅}) = C40
dp表结构:
设置一个二维的动态规划表dp:
先确定一下dp表的大小,有n个城市,从0开始编号,那么dp表的行数就是n,列数就是2(n-1),集合{1,2,3,4}的子集个数。在求解的时候,第一列的值对应这从邻接矩阵可以导出,后面的列可以有前面的列和邻接矩阵导出。
其次,为了编程方便,建立{ }二进制编码转换:{1,3,4}表示为二进制的1101,十进制的13,对于规则为:其中集合里面有的数对应的二进制中位数写成1,没有的写成0。
- 对于第y个城市,他的二进制表达为,1 << (y-1)
- 对于数x,要看它的第 i 位是不是1,那么可以通过判断布尔表达式 (((x >> (i - 1) ) & 1) == 1的真值来实现。
- 符号{1,2,3,4}: 表示经过{1,2,3,4}这几个城市,然后回到0。那么题目就是求dp[0][{1,2,3,4}],即dp[0][15]。
(d[i][j]:i 表示某一步的起点城市,j 为需要经过城市集合的十进制,如{1,2,3}为二进制的111,对应的十进制为7。) - 编程过程中的状态转移:
dp[ i ][ j ] = C[ i ][ k ] + dp[ k ][ j ^ (1 << (k - 1)) ],解释如下,等式左边表示问题:dp[ i ][ j ]从 i 出发经过 j 对应的顶点集。等式右边表示将等式左边的问题拆分为子问题:从 i 出发到 j 对应的点集中的 顶点 k,然后从顶点 k 经过 剩余的点集。
j ^ (1 << (k - 1)): 这个其实就是 j 对应的点集中去除 顶点 K 之后的点集。相信很多朋友一下子理解不了,我举个例子来解释一下,当 j = 7时,二进制为111,对应的顶点集为{1,2,3},我们选出顶点 3,则剩下的点集为{1,2},这个过程:j 对应为 111,3 对应为 100 = 1 << (3-1),将 j 的 111 和 3 的100 进行异或便可求出剩余的点集,也可以从减法的角度理解: 111(2) - 100(2) = 11(2)
{∅} | {1} | {2} | {1,2,3} | {1,2,3,4} | |
---|---|---|---|---|---|
二进制 | 0 | 1 | 10 | 111 | 1111 |
十进制 | 0 | 1 | 2 | 7 | 15 |
所以求出的动态规划表就是:
{∅} | {1} | {2} | {1,2} | {3} | {1,3} | {2,3} | {1,2,3} | {4} | {1,4} | {2,4} | {1,2,4} | {3,4} | {1,3,4} | {2,3,4} | {1,2,3,4} | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
索引 i \ j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
0 | ∞ (C00) | 6 (C01+C10) | ∞(C02+C20) | ∞ | 16 | 21 | ∞ | 18 | 18 | 17 | ∞ | 18 | 37 | 32 | 24 | 23 |
1 | 3 (C10) | ∞(C11+C10) | ∞ | ∞ | 18 | ∞ | 15 | ∞ | 14 | ∞ | 15 | ∞ | 33 | ∞ | 20 | ∞ |
2 | ∞ C(20) | 6(C21+C10) | ∞ | ∞ | 12 | 17 | ∞ | ∞ | 12 | 11 | ∞ | ∞ | 31 | 26 | ∞ | ∞ |
3 | 8 (C30) | 13(C31+C10) | ∞ | 10 (min{10+∞, 4+6}) | ∞ | ∞ | ∞ | ∞ | 29 | 24 | 16 | 15 | ∞ | ∞ | ∞ | ∞ |
4 | 9 (C40) | 8(C41+C10) | ∞ | 9 (min{3+∞, 3+6}) | 28 | 23 | 15 | 20 | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ | ∞ |
子问题 | d(i,{∅}) = Ci0 | d(i,{1}) = Ci1+d(1,{∅}) = Ci1 + C10 | d(i,{2}) = Ci2+d(2,{∅}) = Ci2 + C20 | d(i,{1,2})=min{ Ci1+d(1,{2}) ,Ci2+d(2,{1}) } | d(i,{3}) = Ci3+d(3,{∅}) = Ci3+C30 | d(i,{1,3})=min{ Ci1+d(1,{3}) ,Ci3+d(3,{1}) } |
同样,这种方式只能获取到最优值,无法获取获取到最优值情况中的路径,为了获取到最优路径,同样可以采用回溯的方法。
- 回溯:
如:dp[0]{1,2,3,4} = dp[0][15] = min{ C01 + dp[1]{2,3,4},C02 + dp[2]{1,3,4},C03 + dp[3]{1,2,4},C04 + dp[4]{1,2,3} } = C01 + dp[1]{2,3,4}。可得这一路径为:0 -> 1,接下来从 dp[1]{2,3,4} 开始回溯,直到所有城市顶点被遍历完。
算法实现:
package indi.pentiumcm.thought;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.lang.Double.POSITIVE_INFINITY;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.thought
* @className: DpTSP
* @author: pentiumCM
* @email: 842679178@qq.com
* @date: 2020/3/28 22:05
* @describe: 动态规划 - 旅行商问题
*/
public class DpTSP {
// 无穷大
static int max = (int) POSITIVE_INFINITY / 2;
/**
* 旅行商问题算法实现
*
* @param distances 各个顶点之间距离的邻接矩阵的二维数组
*/
public void tsp(int[][] distances) {
int cityNum = distances.length;
int colNum = 1 << cityNum - 1;
// 1. 构建动态规划dp表
int[][] dp = new int[cityNum][colNum];
// 2. 确定一些初始状态:初始化dp表的第一列,即d(i,{∅}) = Ci0
for (int i = 0; i < cityNum; i++) {
// 每个城市回到起点的距离
dp[i][0] = distances[i][0];
}
// 3. 确定状态转移方程,d[i][j]:i - 城市,j - {1,2,3,4}等
for (int j = 1; j < colNum; j++) {
for (int i = 0; i < cityNum; i++) {
dp[i][j] = max;
// {j}中的顶点包含起点i ,就continue
if (((j >> (i - 1)) & 1) == 1) {
continue;
}
// 遍历{ }里面的点集
for (int k = 1; k < cityNum; k++) {
// 排除{V}中不包含的顶点,如j = 5(二进制101)时,{}为{1,3},这时候并不含有2,4,需要排除掉,直接continue
if (((j >> (k - 1)) & 1) == 0) {
continue;
}
// 从i出发到 k,然后从k出发经过{V-k}
if (dp[i][j] > distances[i][k] + dp[k][j ^ (1 << (k - 1))]) {
dp[i][j] = distances[i][k] + dp[k][j ^ (1 << (k - 1))];
}
}
}
}
int minDistance = dp[0][colNum - 1];
// 回溯,求最短路径的流行
List<Integer> path = new ArrayList<>();
// 存放剩余未走的城市顶点,key-value:城市顶点标号
Map<Integer, Integer> remPath = new HashMap<>();
for (int i = 1; i < cityNum; i++) {
remPath.put(i, i);
}
// 将起点先存入列表
path.add(0);
int choseCity = 0;
for (int j = cityNum - 1; j > 0; j--) {
int col2 = 0;
for (Integer cityIndex : remPath.keySet()) {
col2 += 1 << remPath.get(cityIndex) - 1;
}
for (int i = 1; i < cityNum; i++) {
choseCity = path.get(path.size() - 1);
int cDis = distances[choseCity][i];
if (dp[i][col2 ^ (1 << (i - 1))] + cDis == minDistance) {
path.add(i);
remPath.remove(i);
minDistance -= cDis;
break;
}
}
}
path.add(0);
System.out.print("选择的最佳路径为:");
for (int i = 0; i < path.size(); i++) {
System.out.print(" " + path.get(i));
}
System.out.print("\n" + "最佳路径长度为:" + dp[0][colNum - 1]);
}
public static void main(String[] args) {
// 各个城市之间的距离的邻接矩阵
int[][] distances = {
{max, 3, max, 8, 9},
{3, max, 3, 10, 5},
{max, 3, max, 4, 3},
{8, 10, 4, max, 20},
{9, 5, 3, 20, max}
};
new DpTSP().tsp(distances);
}
}
运行结果:
3、最大和子串问题
- 问题描述
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
解题套路一致,分析过程在代码注释中,如下↓:
package indi.pentiumcm.leetcode;
import java.util.Arrays;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.leetcode
* @className: Q15
* @author: pentiumCM
* @email: 842679178@qq.com
* @date: 2020/4/1 10:55
* @describe: 53. 最大子序和 - 动态规划解决
*/
public class Q15 {
public void maxSubArray(int[] nums) {
// 1. 原问题划分为子问题
// 原问题:当序列长度为 n 时,求最大和
// 子问题:当序列长度为 n -1 时,求最大和
// 子问题:当序列长度为 n -2 时,求最大和
// 2. 定义状态:dp[i]为 遍历到第 i 个元素时,子串的最大和
int[] dp = new int[nums.length];
// 3. 初始状态 dp[0] = nums[0]
dp[0] = nums[0];
// 4. 状态转移方程:dp[i] = max{dp[i-1] + nums[i], nums[i]}
for (int i = 1; i < nums.length; i++) {
int maxSum = dp[i - 1] + nums[i] > nums[i] ? dp[i - 1] + nums[i] : nums[i];
dp[i] = maxSum;
}
// 遍历dp[],dp[]最大的值即为连续子串的最大和
int maxSum = dp[0];
int maxIndex = 0;
for (int i = 0; i < dp.length; i++) {
if (dp[i] > maxSum) {
maxSum = dp[i];
maxIndex = i;
}
}
System.out.println("最大子串和:" + dp[maxIndex]);
// 回溯, 求最大连续子串的元素
System.out.print("最大连续子串元素:");
for (int i = maxIndex; i >= 0; i--) {
if (maxSum == nums[i]) {
System.out.print(" " + nums[i]);
break;
} else {
maxSum -= nums[i];
System.out.print(" " + nums[i]);
}
}
}
public static void main(String[] args) {
int[] arr = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
new Q15().maxSubArray(arr);
}
}