动态规划介绍
动态规划基本思想
动态规划将一个问题分解为若干个互相重叠的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
跟分治有些类似(“分”与“合”体现在 状态转移方程),但是通常这些子问题都相互重叠。
所以动态规划的重点就是找到这个递推关系,即较小规模问题如何推断出更大问题的解。
判断一个问题是否可以使用动态规划解决
1、最优子结构
原问题的最优解是从子问题的最优解构建得来的。
从状态的角度理解,就是后面阶段的状态可以由前面阶段的状态推导出来。
2、无后效性
给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。
换句话说,各个子问题的只与它前面的子问题的解相关。而且各子问题的解都是相对于当前状态的最优解,整个问题的最优解是由各个子问题的最优解构成。
3、重叠子问题
重叠子问题是指在问题的求解过程中,会反复遇到相同的子问题。这些子问题可能在不同的情况下多次出现,但其解决方法是相同的。
动态规划求解过程
1、划分子问题(定义初始状态)
将原问题分解为若干个子问题,每个子问题对应一个决策阶段,并且这些子问题直接具有重叠关系。这一步主要就是定义初始状态。
2、确定动态规划函数(状态转移方程)
找出最优子结构,然后根据子问题直接的重叠关系找到子问题之间的递推关系,这一步是动态规划的关键。
3、填表
设计表格,自底向上计算各个子问题的解并填写DP表。
数学归纳法
其实,动态规划的思想就像是数学归纳法的思想,状态转移方程就像是数学归纳法的递推函数,定义初始状态就像是数学归纳法中的初始状态一样。
简单回顾一下数学归纳法:
证明前n项和 S ( n ) = 1 + 2 + 3 … . + n = n ∗ ( n + 1 ) / 2 S(n) = 1 + 2 + 3 …. + n = n*(n + 1) / 2 S(n)=1+2+3….+n=n∗(n+1)/2
1、确定初始状态: n = 1 , S ( 1 ) = 1 = 1 ∗ ( 1 + 1 ) / 2 n = 1, S(1) = 1 = 1*(1+1) /2 n=1,S(1)=1=1∗(1+1)/2
2、确定递推关系 S ( n ) = n ∗ ( n + 1 ) / 2 S(n) = n*(n + 1) / 2 S(n)=n∗(n+1)/2
假设n=n时命题成立,那么对于n+1来说:
S ( n + 1 ) = S ( n ) + n + 1 = n ( n + 1 ) / 2 + n + 1 = ( n + 1 ) [ ( n + 1 ) + 1 ] / 2 S(n + 1) = S(n) + n + 1= n(n + 1)/2 + n + 1= (n + 1)[(n + 1)+1]/ 2 S(n+1)=S(n)+n+1=n(n+1)/2+n+1=(n+1)[(n+1)+1]/2
所以
S ( n ) = n ∗ ( n + 1 ) / 2 S(n) = n*(n + 1) / 2 S(n)=n∗(n+1)/2
举例:
1、问题描述:
爬楼梯(斐波那契数列):
给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶?
2、分析:
由于每轮只能上 1 阶或 2 阶,因此当我们站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖−1 阶或第 𝑖−2 阶上。
换句话说,我们只能从第 𝑖−1 阶或第 𝑖−2 阶迈向第 𝑖 阶。
由此便可得出一个重要推论:
爬到第 𝑖−1 阶的方案数加上爬到第 𝑖−2 阶的方案数就等于爬到第 𝑖 阶的方案数。
公式如下:
d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i]=dp[i-1]+dp[i-2] dp[i]=dp[i−1]+dp[i−2]
而其中第一阶有1种方案,第二阶楼梯有两种方案,即:
d p [ 1 ] = 1 , d p [ 2 ] = 2 dp[1]=1, dp[2]=2 dp[1]=1,dp[2]=2
3、暴力搜索
这样就可以通过递归不断的将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 𝑑𝑝[1] 和 𝑑𝑝[2] 时返回。
int climbingStairsDFS(int n) {
// 已知 dp[1] 和 dp[2] ,返回
if (i == 1 || i == 2)
return i;
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
4、动态规划
观察上图 ,指数阶的时间复杂度是“重叠子问题”导致的。例如 𝑑𝑝[9] 被分解为 𝑑𝑝[8] 和 𝑑𝑝[7] ,𝑑𝑝[8] 被分解为 𝑑𝑝[7] 和 𝑑𝑝[6] ,两者都包含子问题 𝑑𝑝[7] 。
以此类推,子问题中包含更小的重叠子问题。绝大部分计算资源都浪费在这些重叠的子问题上。
分析:
可以发现,这个问题具有最优子结构、无后效性、重叠子问题的三个特点的,那么就可以尝试使用动态规划进行解决。
划分子问题,定义初始状态:
原问题 d p [ n ] dp[n] dp[n]可以被划分为求解 d p [ n − 1 ] dp[n-1] dp[n−1]和 d p [ n − 2 ] dp[n-2] dp[n−2]的解。
初始状态为 d p [ 1 ] = 1 , d p [ 2 ] = 2 dp[1]=1,dp[2]=2 dp[1]=1,dp[2]=2
确定动态规划函数(状态转移方程):
d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i]=dp[i-1]+dp[i-2] dp[i]=dp[i−1]+dp[i−2]
接下来填表即可:
int climbingStairsDP(int n) {
if (n == 1 || n == 2)
return n;
// 初始化 dp 表,用于存储子问题的解
int[] dp = new int[n + 1];
// 初始状态:预设最小子问题的解
dp[1] = 1;
dp[2] = 2;
// 状态转移:从较小子问题逐步求解较大子问题
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
动态规划案例-01背包
问题描述
有n个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?
背包体积:8
物品编号 | 体积(vol) | 价值(val) |
---|---|---|
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 5 |
4 | 5 | 6 |
蛮力法
只需要将所有情况都考虑到,每个物品放与不放的所有组合均计算一边,然后取其中价值最大的情况即可。
现在需要采用合适的方式去表示所有的状态
可以想到,数字在内存中存储形式是二进制,比如1(0000 0001),2(0000 0010),3(0000 0011)
可以用数字二进制中为1的位置表示该位置的物品是否放入。
这样4个物品就只需要 2 4 2^4 24这个数字表示,遍历从1到 2 4 2^4 24的所有情况即可。
public static int execute(List<Item> itemList, int maxVolume) {
int n = itemList.size(), pow = (int) Math.pow(2, n), resultVal = 0, resultIndex = -1;
// 遍历每一种情况
for (int i = 1; i < pow; i++) {
int thisVol = 0, thisVal = 0, tempIndex = i, thisIndex = 0;
//tempIndex每次右移一位,其中右起第一位与1按位与表示这个物品是否放入
while (tempIndex > 0) {
if ((tempIndex & 1) == 1) {
Item item = itemList.get(thisIndex);
// 如果当前物品体积加上已经超出,则直接跳出此次循环
if ((thisVol + item.getVolume()) <= maxVolume) {
thisVal += item.getValue();
thisVol += item.getVolume();
} else {
break;
}
}
tempIndex = tempIndex >> 1;
thisIndex++;
}
// resultVal:记录最大价值; resultIndex:记录最大价值对应的情况
if (thisVal > resultVal) {
resultVal = thisVal;
resultIndex = i;
}
}
// 输出最大情况哪些物品放入
for (int i = 0; resultIndex > 0; i++) {
if ((resultIndex & 1) == 1) {
System.out.println("第" + i + 1 + "个物品放入背包");
}
resultIndex = resultIndex >> 1;
}
return resultVal;
}
class Item {
int volume;
int value;
public Item(int volume, int value) {
this.volume = volume;
this.value = value;
}
public int getValue() {
return value;
}
public int getVolume() {
return volume;
}
}
动态规划
分析上面蛮力法,其实可以发现不同组合之间,出现了大量的重复计算,比如:1111、0111、1011、0011都会计算0011,也就是第一个和第二个物品放入背包的情况。(1011:表示第1、2、4个物品放,第3个不放,从右往左看)
这里已经出现了重复子问题,可以尝试考虑使用动态规划解决
现在看是否满足最优子结构和无后效性:
这里就直接给出解释:比如第n个物品的体积是 V o l ( n ) Vol(n) Vol(n),价值为 V a l ( n ) Val(n) Val(n),且背包总体积大于物品的体积。那么对于第n个物品是否放入的最优解,应该在下面二选一:
1、不放入,那么当前的最大价值就是前n-1个物品的最大价值
2、放入,那么最大价值就应该是在体积 V − V o l ( n ) V-Vol(n) V−Vol(n)(保证当前物品能放下)情况下前 n − 1 n-1 n−1个物品的最大价值 + 第n个物品的价值 V a l ( n ) Val(n) Val(n)
可见,这个是满足最优子结构(后面阶段的状态可以由前面阶段的状态推导出来)和无后效性(各个子问题的只与它前面的子问题的解相关)的。
根据上面的分析,我们需要记录每种体积下,前n个物品的最优解。那么可以使用一个二维数组 d p [ i , j ] dp[i, j] dp[i,j]来表示这个情况:
其中i表示前i个物品,j表示体积。
那么 d p [ i ] [ j ] dp[i][j] dp[i][j]就表示在体积为j的情况下,前i个物品的最优解。
定义初始状态:
当i=0时,表示没有物体,那么最大价值就应是0,即 d p [ 0 ] [ j ] = 0 dp[0][ j]=0 dp[0][j]=0
当j=0时,表示体积为0,那么一个物体都放不进去,那么最大价值就应是0,即 d p [ i ] [ 0 ] = 0 dp[i][0]=0 dp[i][0]=0
确定状态转移方程:
d p [ i , j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − V o l ( i ) ] ) + V a l ( i ) ) dp[i,j] = max( dp[ i-1 ][j] , dp[i-1][j-Vol(i)]) + Val(i)) dp[i,j]=max(dp[i−1][j],dp[i−1][j−Vol(i)])+Val(i))
填表:
背包体积:8
物品编号 | 体积(vol) | 价值(val) |
---|---|---|
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 5 |
4 | 5 | 6 |
1、初始状态
i\j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | ||||||||
2 | 0 | ||||||||
3 | 0 | ||||||||
4 | 0 |
2、填写前1个物品行:(第1个物品:体积为2,价值为3)
a.在体积为1时,也就是 d p [ 1 ] [ 1 ] dp[1][1] dp[1][1]:
此时体积为1,而第一个物品体积为2,放不下,所以 d p [ 1 ] [ 1 ] = 0 dp[1][1]=0 dp[1][1]=0
b.对于体积2~8,也就是 d p [ 1 ] [ 2 ] — d p [ 1 ] [ 8 ] dp[1][2] — dp[1][8] dp[1][2]—dp[1][8]:
代入上面状态转移公式,得到 d p [ 1 ] [ 2 ] = 3 , d p [ 1 ] [ 3 ] = 3 , . . . , d p [ 1 ] [ 8 ] = 3 dp[1][2] =3,dp[1][3]=3,..., dp[1][8] = 3 dp[1][2]=3,dp[1][3]=3,...,dp[1][8]=3
也就是说,此时任意体积下,前n-1(前0个)物品的的最大价值都是0,所以最大价值就一定是第一个物品的价值。
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 | ||||||||
3 | 0 | ||||||||
4 | 0 |
3、填写前2个物品行:(第2个物品:体积为3,价值为4)
a.在体积为为1、2时,也就是 d p [ 2 ] [ 1 ] 、 d p [ 2 ] [ 2 ] dp[2][1]、dp[2][2] dp[2][1]、dp[2][2]:
此时体积1或2,而第2个物品体积为3,放不下,所以此时最大价值就是前n-1(1个物品)的最大价值,即 d p [ 2 ] [ 1 ] = 0 、 d p [ 2 ] [ 2 ] = 3 dp[2][1]=0、dp[2][2]=3 dp[2][1]=0、dp[2][2]=3
b.在体积为3、4时,也就是 d p [ 2 ] [ 3 ] 、 d p [ 2 ] [ 4 ] dp[2][3]、dp[2][4] dp[2][3]、dp[2][4]:
此时体积为3或4,放得下第二个物品,带入上面状态转移公式:
不放入第2个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 2 ] [ 3 ] = 3 、 d p [ 2 ] [ 4 ] = 3 dp[2][3]=3、dp[2][4]=3 dp[2][3]=3、dp[2][4]=3
放入第2个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值:
d p [ 1 ] [ 0 ] + 4 = 4 、 d p [ 1 ] [ 1 ] + 4 = 4 dp[1][0]+4=4、dp[1][1]+4=4 dp[1][0]+4=4、dp[1][1]+4=4
4>3,所以 d p [ 2 ] [ 3 ] = 4 dp[2][3]=4 dp[2][3]=4
也就是说,在体积为3的时候,此时放入第2个物品要比单独放入第1个物品价值要大
c.在体积为5~8时,也就是 d p [ 2 ] [ 5 ] — d p [ 2 ] [ 8 ] dp[2][5] — dp[2][8] dp[2][5]—dp[2][8]L
此时体积大于等于5,带入状态转移公式:
不放入第2个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 1 ] [ 5 — 8 ] = 3 dp[1][5—8]=3 dp[1][5—8]=3
放入第2个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值: d p [ 1 ] [ 5 — 8 ] + 4 = 7 dp[1][5—8]+4=7 dp[1][5—8]+4=7
7>3,所以 d p [ 2 ] [ 4 ] — d p [ 2 ] [ 8 ] = 7 dp[2][4]—dp[2][8]=7 dp[2][4]—dp[2][8]=7
也就是说,在体积大于等于5时,此时可以将前n-1个(第1个)和当前第n个(第2个)都装下。
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 | 4 | 7 | 7 | 7 | 7 |
3 | 0 | ||||||||
4 | 0 |
填写前3个物品行:(第3个物品:体积为4,价值为5)
a.在体积为1、2、3时,也就是 d p [ 3 ] [ 1 — 3 ] dp[3][1—3] dp[3][1—3]
此时体积小于4,放不下第3个物品,那么最大值就是前n-1个(前2个)物品的最大价值:
d p [ 3 ] [ 1 ] = 0 、 d p [ 3 ] [ 2 ] = 3 、 d p [ 3 ] [ 3 ] = 4 dp[3][1]=0、dp[3][2]=3、dp[3][3]=4 dp[3][1]=0、dp[3][2]=3、dp[3][3]=4
b.在体积为4时。也就是 d p [ 3 ] [ 4 ] dp[3][4] dp[3][4]
此时体积等于4,可以放下第3个物品
不放入第3个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 2 ] [ 4 ] = 4 dp[2][4]=4 dp[2][4]=4
放入第3个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值: d p [ 2 ] [ 0 ] + 5 = 5 dp[2][0]+5=5 dp[2][0]+5=5
5>4,则 d p [ 3 ] [ 4 ] = 5 dp[3][4]=5 dp[3][4]=5
也就是说,在体积为4的情况下,只放入第3个物品,要比不放入第3个物品时,前两个物品的最大价值要高。
c.在体积为5时,也就是 d p [ 3 ] [ 5 ] dp[3][5] dp[3][5]
此时体积为5,放得下第3个物品
不放入第3个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 2 ] [ 5 ] = 7 dp[2][5]=7 dp[2][5]=7
放入第3个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值: d p [ 2 ] [ 1 ] + 5 = 5 dp[2][1]+5=5 dp[2][1]+5=5
7>5,则 d p [ 3 ] [ 5 ] = 7 dp[3][5]=7 dp[3][5]=7
也就是说,在体积为5的情况下,放入第3个物品(导致前两个物品被拿出)要比前两个物品在体积为5的情况下最大价值要低。
d.在体积为6时,也就是 d p [ 3 ] [ 6 ] dp[3][6] dp[3][6]
不放入第3个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 2 ] [ 6 ] = 7 dp[2][6]=7 dp[2][6]=7
放入第3个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值: d p [ 2 ] [ 2 ] + 5 = 8 dp[2][2]+5=8 dp[2][2]+5=8
8>7,也就是 d p [ 3 ] [ 6 ] = 8 dp[3][6]=8 dp[3][6]=8
也就是说,放入第3个物品(导致前1个物品中第2个被去掉)要比在体积为6的情况下最大价值要高
e.在体积为7、8时,也就是 d p [ 3 ] [ 7 ] 、 d p [ 3 ] [ 8 ] dp[3][7]、dp[3][8] dp[3][7]、dp[3][8]
不放入第3个物品,则最大价值为当前体积下前n-1个物品的最大价值: d p [ 2 ] [ 7 ] = d p [ 2 ] [ 8 ] = 7 dp[2][7]=dp[2][8]=7 dp[2][7]=dp[2][8]=7
放入第3个物品,则最大价值为当前体积-第n个物品体积下前n-1个物品的最大价值:
d p [ 2 ] [ 3 ] + 5 = 9 、 d p [ 2 ] [ 4 ] + 5 = 9 dp[2][3]+5=9、dp[2][4]+5=9 dp[2][3]+5=9、dp[2][4]+5=9
9>7, d p [ 3 ] [ 7 ] = d p [ 3 ] [ 8 ] = 9 dp[3][7]=dp[3][8]=9 dp[3][7]=dp[3][8]=9
也就是说,在体积为7、8时,放入第3个物品(导致前2个物品中第1个被去掉)要比不放入第3个前两个物品最大价值要高
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 | 4 | 7 | 7 | 7 | 7 |
3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
4 | 0 |
填写前4个物品行:
过程与上面类似
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 | 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 |
最终,得到在体积为8的情况下,4个物品的最优解为 d p [ 4 ] [ 8 ] = 10 dp[4][8]=10 dp[4][8]=10。
代码:
/**
* 使用动态规划获得最大的价值
*
* @param itemList 物品
* @param maxVolume 背包最大体积
* @return 最大价值
*/
public static int execute(List<Item> itemList, int maxVolume) {
// 初始化dp表,第一列初始赋值为0(体积为0什么都放不下),第一行初始赋值为0(没有物品默认为0)。
int[][] dpTable = new int[itemList.size() + 1][maxVolume + 1];
int[][] dpTableTrance = new int[itemList.size() + 1][maxVolume + 1];
// 填表
for (int i = 1; i < itemList.size() + 1; i++) {
for (int j = 1; j < maxVolume + 1; j++) {
// 当前背包体积 < 当前物品体积,即放不下。
// itemList.get(i - 1) 是因为物品List从下标0开始,不是从1开始
if (j < itemList.get(i - 1).getVolume()) {
// 放不下当前物品,那么当前情况下最大价值为 前i-1个物品的在该体积下的最大价值
dpTable[i][j] = dpTable[i - 1][j];
} else {
// 当前背包体积 > 当前物品体积,可以放得下
// 当前容量下,只有两个选择,放入当前物品,不放入当前物品
// 不放入当前物品:最大价值为当前体积下前i-1个物品的最大值
// 放入当前物品:最大价值为前i-1个物品在背包体积为(当前体积-当前物品体积)的最大值
dpTable[i][j] = Math.max(dpTable[i - 1][j],
dpTable[i - 1][j - itemList.get(i - 1).getVolume()] + itemList.get(i - 1).getValue());
}
}
}
return dpTable[itemList.size()][maxVolume];
}
class Item {
int volume;
int value;
public Item(int volume, int value) {
this.volume = volume;
this.value = value;
}
public int getValue() {
return value;
}
public int getVolume() {
return volume;
}