动态规划比较适合用来求解最值得问题,如求最大值和最小值等。它可以显著得降低时间复杂度,提高代码得执行效率。不过学习难度比较高,这一篇博客不讨论动态规划得理论知识,先主要看两个例子,一步一步走进动态规划。
利用动态规划解决0-1背包问题
‘对于背包问题,我们前面一篇博客讨论过用回溯算法解决不可分割得背包问题。
对于0-1背包问题,我们先重新回顾一下:对于一组不同重量,不可分割得物品,选择其中一些物品装入背包,在不超过背包可承载得最大重量得前提下,背包中可装载物品总重量得最大值是多少?对于这个问题,回溯算法是穷举所有可能得装法,然后找出满足条件得最大值。看看代码
private int maxW = Integer.MIN_VALUE;//结果放到maxW中
private int[] weight = {2,2,4,6,3};//物品重量
private int n = 5;//物品个数
private int w = 9;//背包可承载得最大容量
public void f(int i,int cw){//调用f(0,0)
//cw表示当前已经装进去得物品得重量和,i表示考察到哪个物品了
if(cw == w || i == n){
//cw == w表示装满了,i == n表示物品都考察完了
if(cw > maxW) maxW = cw;
return;
}
f(i+1,cw);选择不装第i个物品
if(cw + weight[i] <= w){
f(i+1,cw + weight[i]);//选择装第i个物品
}
}
缺点就是回溯算法得时间复杂度比较高,是指数级别的。那么有什么办法可以降低时间复杂度呢?我们通过一个具体的例子找一下规律。假设背包的最大承重重量是9,现在有5个不同的物品,每个物品的重量分别是2,2,4,6,3。把这个例子的回溯求解过程用递归树表示。
递归树的每一个节点表示一种状态,我们用(i,cw)来表示。其中,i表示将要决策第几个物品是否装入背包,cw表示当前背包中物品的总重量。比如(2,2)表示我们要决策第2个物品是否要装入背包,装入之前的总重量是2.
从递归树中我们可以看到有些子问题的求解过程是有重复的,图中标黄的部分,那么如何去解决这个问题呢?有一种叫”备忘录“的解决方式,记录已经计算好的f(i,cw),当再次计算到重复的f(i,cw)时,我们直接从备忘录中取出结果来用,不需要再用递归计算,这样就可以避免子问题被重复求解。
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {2,2,4,6,3}; // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; // 备忘录,默认值false
public void f(int i,int cw){
if(cw == w || i == n){
if(cw > maxW) maxW = cw;
return;
}
if(mem[i][cw]) return;//重复状态
mem[i][cw] = true;//记录(i,cw)这个状态
f(i+1,cw);//选择不装第i个物品
if(cw + weught[i] <= w){
f(i+1,cw + weight[i]);//选择装第i个物品
}
}
实际上,在执行效率方面,基于备忘录去重的递归求解方法与动态规划基本上没差别。不过,动态规划更优雅,更具有普适性。
我们把整个求解的过程分为n个阶段,每个阶段会决策一个物品是否放到背包中。在对每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。需要注意的是,这里的节点状态与回溯算法的节点状态的定义不同,在回溯算法中,(i,cw)表示在考察第i个物品前,背包中的物品重量是cw,这里表示在第i个物品考察完之后,背包中的物品重量是cw,之所以有区别,主要是方便编程。
我们把每一层重复的状态合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。通过合并每一层重复的状态,就能保证每一层的状态个数不会超过w个(w表示背包可承载的最大重量),就是例子中的9.这样我们就可以避免回溯算法对应的递归树中每层状态个数的指数级增长。
我们用一个boolean类型的二位二维数组states[n][w+1]来记录每层可以达到的不同状态,二维数组的初始值为false。
然后看看代码
weight:物品重量,n:物品个数,w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
boolean[][] states = new boolean[n][w+1]; // 默认值false
states[0][0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (weight[0] <= w) {
states[0][weight[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划状态转移
for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
if (states[i-1][j] == true) states[i][j] = states[i-1][j];
}
for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
if (states[i-1][j]==true) states[i][j+weight[i]] = true;
}
}
for (int i = w; i >= 0; --i) { // 输出结果
if (states[n-1][i] == true) return i;
}
return 0;
}
public static int knapsack2(int[] items, int n, int w) {
boolean[] states = new boolean[w+1]; // 默认值false
states[0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (items[0] <= w) {
states[items[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划
for (int j = w-items[i]; j >= 0; --j) {//把第i个物品放入背包
if (states[j]==true) states[j+items[i]] = true;
}
}
for (int i = w; i >= 0; --i) { // 输出结果
if (states[i] == true) return i;
}
return 0;
}
0-1 背包问题升级版
private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] items = {2,2,4,6,3}; // 物品的重量
private int[] value = {3,4,8,9,6}; // 物品的价值
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
if (cv > maxV) maxV = cv;
return;
}
f(i+1, cw, cv); // 选择不装第i个物品
if (cw + weight[i] <= w) {
f(i+1,cw+weight[i], cv+value[i]); // 选择装第i个物品
}
}
public static int knapsack3(int[] weight, int[] value, int n, int w) {
int[][] states = new int[n][w+1];
for (int i = 0; i < n; ++i) { // 初始化states
for (int j = 0; j < w+1; ++j) {
states[i][j] = -1;
}
}
states[0][0] = 0;
if (weight[0] <= w) {
states[0][weight[0]] = value[0];
}
for (int i = 1; i < n; ++i) { //动态规划,状态转移
for (int j = 0; j <= w; ++j) { // 不选择第i个物品
if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
}
for (int j = 0; j <= w-weight[i]; ++j) { // 选择第i个物品
if (states[i-1][j] >= 0) {
int v = states[i-1][j] + value[i];
if (v > states[i][j+weight[i]]) {
states[i][j+weight[i]] = v;
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j <= w; ++j) {
if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
}
return maxvalue;
}
双11购物时的凑单问题
// items商品价格,n商品个数, w表示满减条件,比如200
public static void double11advance(int[] items, int n, int w) {
boolean[][] states = new boolean[n][3*w+1];//超过3倍就没有薅羊毛的价值了
states[0][0] = true; // 第一行的数据要特殊处理
if (items[0] <= 3*w) {
states[0][items[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划
for (int j = 0; j <= 3*w; ++j) {// 不购买第i个商品
if (states[i-1][j] == true) states[i][j] = states[i-1][j];
}
for (int j = 0; j <= 3*w-items[i]; ++j) {//购买第i个商品
if (states[i-1][j]==true) states[i][j+items[i]] = true;
}
}
int j;
for (j = w; j < 3*w+1; ++j) {
if (states[n-1][j] == true) break; // 输出结果大于等于w的最小值
}
if (j == 3*w+1) return; // 没有可行解
for (int i = n-1; i >= 1; --i) { // i表示二维数组中的行,j表示列
if(j-items[i] >= 0 && states[i-1][j-items[i]] == true) {
System.out.print(items[i] + " "); // 购买这个商品
j = j - items[i];
} // else 没有购买这个商品,j不变。
}
if (j != 0) System.out.print(items[0]);
}
真的好难啊!
摘自《数据结构与算法之美》