【数据结构与算法】->算法->动态规划(上)->初识动态规划->怎么精准地帮助女朋友薅羊毛

本文通过双十一满减活动场景,介绍了动态规划的概念,用0-1背包问题阐述动态规划解决最优问题的思路,并给出了如何利用动态规划在满足满减条件下,找到商品总价最接近满减金额的解决方案,降低了时间复杂度。
摘要由CSDN通过智能技术生成

Ⅰ 前言

淘宝的双十一大家相比都经历过,双十一里有很多满减活动,比如“满 200 元减 50 元”。假如你的女朋友的购物车中有 n 个(n > 100)想买的商品,她希望能从里面选出来几个,在凑够满减条件的前提下,选出来的商品价格总和最大程度地接近满减条件(200 元),这样就可以极大限度地“薅羊毛”。作为程序员的你,能不能换个女朋友?奥不对,能不能写个代码帮她搞定这件事呢?

要想高效地解决这个问题,就要用到这篇文章要讲的动态规划(Dynamic Programming)

动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。不过,动态规划也是出了名的难学,对于新手入门很不容易。但是,只要入了门,就一马平川了。

这篇文章,我先来带大家从两个经典动态规划的问题模型入手,先理解一下动态规划的思想。在后面的两篇文章,我再从理论层面和与其他思想的比较着手,深入了解动态规划,最后再通过实战应用,解决三个非常经典的动态规划的问题,我们就开始吧。

Ⅱ 0-1 背包问题

在前面讲 贪心算法回溯算法 的文章中,我提到了很多次背包问题。我们再从动态规划来看看这个问题。

对于一组不同重量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?

回溯法处理这类问题就是穷举所有可能的装法,然后找出满足条件的最大值。不过,回溯算法的时间复杂度比较高,是指数级别的。那有没有什么规律,是我们可以找到用以降低时间复杂度的呢?

public void findSolve(int index, int currentWeight, int[] items, 
													int num, int weight) {	
		if (currentWeight == weight || index == num) { //已经装满 || 考察完所有物品
			if (currentWeight > maxWeight) {
				maxWeight = currentWeight;
				return;
			}
		}
		findSolve(index + 1, currentWeight, items, num, weight); //items[index]的物品不装进背包
		if (currentWeight + items[index] <= weight) {
			findSolve(index + 1, currentWeight + items[index], items, num, weight);//装进背包
		}
	}
}

规律是很不好找,我们来画一张图。假设背包的最大承载重量是 9,我们有 5 个不同的物品,每个物品的重量分别是 2,2,4,6,3。如果我们把这个例子的回溯求解过程,用递归树画出来,就是下面这个的样子:

在这里插入图片描述
递归树中的每个节点表示一种状态,我们用 (i, cw) 来表示,其中 i 表示将要决策第几个物品是否要放入背包。cw 表示当前背包中物品的总重量。比如,(2, 2) 表示我们将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。

从递归树中,你应该能发现,有些子问题的求解是重复的,比如图中 f(2, 2) 和 f(3, 4) 都被重复计算了两次。我们可以用“备忘录”的方法解决这个问题:记录已经计算好的 f(i, cw),当再次计算到重复的 f(i, cw) 的时候,就可以直接从备忘录中取出来用。 这样就不用再递归计算了,可以避免很多冗余计算。

package com.tyz.core;

public class ZeroOneBackpack {
	private int maxWeight = Integer.MIN_VALUE; //结果放在maxWeight中
	private int[] weights = {2, 2, 4, 6, 3}; //物品重量
	private int num = 5; //物品个数
	private int weight = 9; //背包承受的最大重量
	private boolean[][] mem = new boolean[5][10]; //备忘录,默认值为false

	public ZeroOneBackpack() {}
	
	public void findSolve(int i, int cw) {
		if (cw == weight || i == num) {
			if (cw > maxWeight) {
				maxWeight = cw;
			}
			return;
		}
		if (mem[i][cw]) {
			return; //重复状态
		}
		mem[i][cw] = true; //记录(i, cw)这个状态
		findSolve(i + 1, cw); //选择不装第i个物品
		if (cw + weights[i] <= weight) {
			findSolve(i + 1, cw + weights[i]); //选择装第i个物品
		}
	}
}

这种解决方法非常好,实际上,它已经和动态规划的执行效率基本上没有差别,但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。

我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。

我们把每一次重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。于是,我们就成功避免了每层状态个数的指数级增长。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。

第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。我们用 states[0][0] = true 和 states[0][2] = true 来表示这两种状态。

第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0 + 0),2(0 + 2 or 2 + 0),4(2 + 2)。我们用 states[1][0] = true ,states[1][2] = true,states[1][4] = true 来表示这三种状态。

以此类推,直到考察完所有的物品后,整个 states 状态数组就计算好了。我把整个计算的过程画了出来,大家可以做个参考,理解这个过程。图中 0 表示 false,1 表示 true。我们只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。

在这里插入图片描述
在这里插入图片描述
我们直接来看代码。

package com.tyz.first.core;

/**
 * 动态规划求0-1背包的装的物品最大重量
 * @author Tong
 */
public class ZeroOneKnapsack {

	public ZeroOneKnapsack() {
	}
	
	/**
	 * 动态规划查找0-1背包的解
	 * @param weights 物品的重量
	 * @param num 物品的数量
	 * @param weight 背包承受的最大重量
	 * @return 最大重量
	 */
	public int findSolve(int[] weights, int num, int weight) {
		boolean[][] states = new boolean[num][weight+1];
		states[0][0] = true;
		if (weights[0] <= weight) {
			states[0][weights[0]] = true;
		}
		//动态规划状态转移
		for (int i = 1; i < num; i++) {
			for (int j = 0; j <= weight; j++) { //不把第i个元素装进背包
				if (states[i-1][j] == true) {
					states[i][j] = states[i-1][j];
				}
			}
			for (int j = 0; j <= weight-weights[i]; j++) { //把第i个元素装进背包
				if (states[i-1][j] == true) {
					states[i][j+weights[i]] = true;
				}
			}
		}
		for (int i = weight; i >= 0; i--) { //输出结果
			if (states[num-1][i] == true) {
				return i;
			}
		}
		
		return 0;
	}

}

实际上,这就是一个用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由来。

我们可以再和其他几个算法做个对比。

  • 贪心算法: 一条路走到黑,就一次机会,每次选一条最优的路线。
  • 回溯算法: 一条路走到黑,但是有无数次重来的机会,选错了就倒回去重新选。
  • 动态规划: 上帝视角,同时选择所有的选择,同时发展出每一个未来

在回溯算法中,我们解决这个问题的时间复杂度是 O(2n),是指数级的。那动态规划解决方案的时间复杂度是多少呢》我们来分析一下。

这个代码的时间复杂度非常很好想,耗时最多的部分就是代码中的两层 for 循环,所以时间复杂度是 O(n * w)。n 表示物品个数,w 表示背包可以承载的总重量。

从理论上来讲,指数级的时间复杂度肯定要比 O(n*w) 高很多,我们举个例子来感受一下。

我们假设有 10000 个物品,重量分布在 1 到 15000 之间,背包可以承载的总重量是 30000。如果我们用回溯算法来解决,用具体的数值表示出时间复杂度,就是 210000,这是一个相当大的数字了。如果我们用动态规划解决,用具体的数值表示出时间复杂度,就是 10000 * 30000。虽然看起来也很大,但是 210000 比起来,要小太多了。

尽管动态规划的执行效率比较高,但是就上面的代码来说,我们需要额外申请一个 n 乘以 w + 1 的二维数组,对空间的消耗比较多。所以,有时候我们说,动态规划是一种空间换时间的解决思路。那有没有什么方法可以降低空间消耗呢?

实际上,我们只需要一个大小为 w+1 的一维数组就可以解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组来操作。代码如下👇

	/**
	 * 一维数组动态规划求0-1背包的解
	 * @param items 物品重量
	 * @param num 物品数量
	 * @param weight 背包能承受的最大重量
	 * @return
	 */
	public int findSolve2(int[] items, int num, int weight) {
		boolean[] states = new boolean[weight+1];
		states[0] = true;
		if (items[0] <= weight) {
			states[items[0]] = true;
		}
		//动态规划
		for (int i = 1; i < num; i++) { //把第i个物品放入背包
			for (int j = weight-items[i]; j >= 0; j--) {
				if (states[j] == true) {
					states[j+items[i]] = true;
				}
			}
		}
		for (int i = weight; i >= 0; i--) { //输出结果
			if (states[i] == true) {
				return i;
			}
		}
		return 0;
	}

可能这里的代码还是不太好理解,我再对这个代码做个解释。

states 表示的是当前背包总重量所有可能取值的集合。如果将第 i 个物品放入背包,我们需要在当前背包总重量的所有取值中,找到小于等于 j 的,因此 for 循环中,j = weight-items[i]

还有一个需要特别注意的,for (int j = weight-items[i]; j >= 0; j--),这里为什么要将 j 递减的循环,为什么不还是 j 从 0 开始,用j++循环?这里其实特别巧妙。

在 for 循环里有一个 If 的条件语句

	if (states[j] == true) {
		states[j+items[i]] = true;	
	}

如果条件成立的话,做的操作是 states[j+items[i]] = true;,大家可以仔细想想,这个操作是不是相当于 states[j + X] = true;,这个+ X,是不是就相当于做了很多次 j++? 所以如果 j 从 0 开始遍历,在前面做了一次这个操作后,后面就会再次遍历到那个位置,但是由于那个位置对应的 states[j]在前面已经被设置成 true 了,所以再次到这里,就会进行重复运算,影响了真正的结果。

但是如果我们从后向前遍历,就可以避免这个问题,大家可以再思考一下。

Ⅲ 0-1 背包问题 Plus

基于上面的 0-1 背包,我们再提高一层难度。前面的背包问题,只涉及了背包重量和物品重量。我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?

这个问题依旧可以用回溯算法来解决,这个问题并不复杂,和前面是一样的,我直接贴出代码。

	private int maxValue = Integer.MIN_VALUE;
	private int[] weights = {2, 2, 4, 6, 3}; //物品重量
	private int[] value = {3, 4, 8, 9, 6}; //物品价格
	private int num = 5; //物品个数
	private int weight = 9; //背包承受的最大重量
	private boolean[][] mem = new boolean[5][10]; //备忘录,默认值为false

	public ZeroOneBackpack() {}
	
	public void findSolve(int i, int cw, int cv) {
		if (cw == weight || i == num) {
			if (cv > maxValue) {
				maxValue = cv;
			}
			return;
		}
		findSolve(i + 1, cw, cv);
		if (cw + weights[i] <= weight) {
			findSolve(i + 1, cw + weights[i], cv + value[i]);
		}
	}

针对上面的代码,我们还是画出递归树。

在这里插入图片描述
我们发现,在递归树中,有几个节点的 i 和 cw 是完全相同的。比如 f(2, 2, 4) 和 f(2, 2, 3)。在背包中物品总重量一样的情况下,f(2, 2, 4) 对应的物品总价值更大,我们可以舍弃 f(2, 2, 3) 这种状态,只需要沿着 f(2, 2, 4) 这条决策路线继续往下决策就可以。

也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。

我们还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完以后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。

我还是将代码贴出来,大家可以做一个参考。


	/**
	 * 动态规划求0-1背包中最接近背包承受的最大重量且价格最大的值
	 * @param weights 物品重量
	 * @param value 物品价格
	 * @param num 物品数量
	 * @param weight 背包能承受的最大重量
	 * @return 找到的最接近背包最大重量中的价值最高的值
	 */
	public int findSolve(int[] weights, int[] value, int num, int weight) {
		int[][] states = new int[num][weight+1];
		for (int i = 0; i < num; i++) {
			for (int j = 0; j < weight+1; j++) {
				states[i][j] = -1;
			}
		}
		states[0][0] = 0;
		if (weights[0] <= weight) {
			states[0][weights[0]] = value[0];
		}
		for (int i = 1; i < num; i++) { //动态规划,状态转移
			for (int j = 0; j <= weight; j++) { //不选择第i个物品
				if (states[i-1][j] >= 0) {
					states[i][j] = states[i-1][j];
				}
			}
			for (int j = 0; j <= weight-weights[i]; j++) { //选择第i个物品
				if (states[i-1][j] > 0) {
					int v = states[i-1][j] + value[i];
					if (v > states[i][j+weights[i]]) {
						states[i][j+weights[i]] = v;
					}
				}
			}
		}
		//找到最大值
		int maxValue = -1;
		for (int j = 0; j <= weight; j++) {
			if (states[num-1][j] > maxValue) {
				maxValue = states[num-1][j];
			}
		}
		return maxValue;
	}

关于这个例子的时间空间复杂度和前面的例子分析方法是一样的,我不再赘述,直接给出结论,这个例子的时间复杂度和空间复杂度都是 O(n*w)。

Ⅳ 如何利用动态规划来凑满减

在看完前面的内容后,相信你对这个问题已经有了自己的思路了,这个问题其实和前面举的例子都有异曲同工之妙。

解决这个问题我们当然可以用回溯算法,穷举所有的排列组合,看大于等于 200 的组合是哪一个。但是,这样效率也太低了,时间复杂度非常高,是指数级的。当 n 很大的时候,可能双十一都结束了。你的代码还没有运行出结果,那就在女朋友面前太没有面子了。

所以,我们还是可以将这个问题理解成基础的 0-1 背包问题,只是将重量换成了价格,然后用动态规划解决。

购物车中有 n 个商品,我们针对每个商品都决策是否购买。每次决策之后,对应不同的状态集合。我们还是用一个二维数组 states[n][x],来记录每次决策之后所有可达的状态。不过,这里的 x 值是多少呢?

0-1 背包问题中,我们找的是小于等于 w 的最大值,x 就是背包的最大承载重量 w + 1。对于这个问题来说,我们要找的是大于等于 200(满减条件)的值中的最小的,所以就不能设置为 200 加 1 了。就这个实际问题而言,如果要购买的物品的总价格超过 200 太多,比如 1000,那这个羊毛薅得就没有什么意义了。所以,我们可以限定 x 值为 1001。

不过,这个问题不仅要求大于等于 200 的总价格中的最小的,我们还要找出这个最小总价格对应都要买哪些商品。实际上,我们可以利用 states 数组,倒推出这个被选择的商品序列。

我们按照这个思路来写代码👇

package com.tyz.first.core;

/**
 * 用动态规划凑满减
 * @author Tong
 */
public class Double11Advance {

	public Double11Advance() {
	}
	
	/**
	 * 决策要购买的商品以达到凑满减而且花钱最少
	 * @param items 商品价格
	 * @param num 商品数量
	 * @param limit 满减条件
	 */
	public static void double11Advance(int[] items, int num, int limit) {
		boolean[][] states = new boolean[num][3*limit+1];
		states[0][0] = true;
		if (items[0] <= 3*limit+1) {
			states[0][items[0]] = true;
		}
		for (int i = 1; i < num; i++) { //动态规划
			for (int j = 0; j <= 3*limit; j++) { //不购买第i个商品
				if (states[i-1][j] == true) {
					states[i][j] = states[i-1][j];
				}
			}
			for (int j = 0; j <= 3*limit-items[i]; j++) { //购买第i个商品
				if (states[i-1][j] == true) {
					states[i][j+items[i]] = true;
				}
			}
		}
		int k;
		for (k = limit; k < 3*limit+1; k++) {
			if (states[num-1][k] == true) {
				break; //找到大于等于limit的最小值,即满足满家条件的最小金额
			}
		}
		if (k == 3*limit+1) {
			return; //没有可行解
		}
		System.out.print("购买价格为 ");
		for (int i = num-1; i >= 1; i--) {
			if (k-items[i] >= 0 && states[i-1][k-items[i]] == true) {
				System.out.print(items[i] + " "); //购买这个商品
				k = k - items[i];
			}
		}
		System.out.println("的商品");
		if (k != 0) {
			System.out.println(items[0]);
		}
	}

}

这里的前半部分经过前面几个例子想必已经很好理解了,我们着重看一下后半部分,看是如何打印出选择购买哪些商品的。

状态 (i, j) 只有可能从 (i-1, j) 或者 (i-1, j-value[i]) 两个状态是否是可达的,也就是 states[i-1][j] 或者 states[i-1][j-value[i]]是否为 true。

如果states[i-1][j] 可达,就说明我们没有选择购买第 i 个商品,如果 states[i-1][j-value[i]] 可达,那就说明我们选择了购买第 i 个商品。我们从中选择一个可达的状态(如果两个都可达,就随便选择一个),然后,继续迭代地考察其他商品是否有选择购买。

测试代码如下:

package com.tyz.first.test;

import com.tyz.first.core.Double11Advance;

public class Test {

	public static void main(String[] args) {
		int[] items = {66, 21, 31, 48, 18, 9, 47, 12, 6, 3, 71, 99, 130, 170, 88, 100};
		Double11Advance.double11Advance(items, 16, 300);
	}

}

测试结果如下👇
在这里插入图片描述
如果你还要薅羊毛到毛到分,那把每个价格都乘以 10 或者 100 就好,结果是一样的。

在给女朋友展示了这个程序之后,她表示不满意,要让我显示三组,那我就再改一下,我们可以显示前三名最优的组合,给女朋友一个选择的机会。

package com.tyz.first.core;

/**
 * 用动态规划凑满减
 * @author Tong
 */
public class Double11Advance {

	public Double11Advance() {
	}
	
	/**
	 * 决策要购买的商品以达到凑满减而且花钱最少
	 * @param items 商品价格
	 * @param num 商品数量
	 * @param limit 满减条件
	 */
	public static void double11Advance(int[] items, int num, int limit) {
		boolean[][] states = new boolean[num][3*limit+1];
		states[0][0] = true;
		if (items[0] <= 3*limit+1) {
			states[0][items[0]] = true;
		}
		for (int i = 1; i < num; i++) { //动态规划
			for (int j = 0; j <= 3*limit; j++) { //不购买第i个商品
				if (states[i-1][j] == true) {
					states[i][j] = states[i-1][j];
				}
			}
			for (int j = 0; j <= 3*limit-items[i]; j++) { //购买第i个商品
				if (states[i-1][j] == true) {
					states[i][j+items[i]] = true;
				}
			}
		}
		
		int[] index = new int[3];
		for (int i = 0; i < index.length; i++) {
			index[i] = -1;
		}
		
		int k;
		int j = 0;
		for (k = limit; k < 3*limit+1; k++) {
			if (states[num-1][k] == true) {
				if (index[index.length-1] != -1) {
					break;
				}
				index[j++] = k;
				continue; //找到大于等于limit的最小值,即满足满家条件的最小金额
			}
		}
		if (k == 3*limit+1) {
			return; //没有可行解
		}
		System.out.print("最优购买价格为 ");
		for (int i = num-1; i >= 1; i--) {
			if (index[0]-items[i] >= 0 && states[i-1][index[0]-items[i]] == true) {
				System.out.print(items[i] + " "); //购买这个商品
				index[0] = index[0] - items[i];
			}
		}
		if (index[0] != 0) {
			System.out.println(items[0]);
		}
		System.out.println("的商品");
		
		System.out.print("次优购买价格为 ");
		for (int i = num-1; i >= 1; i--) {
			if (index[1]-items[i] >= 0 && states[i-1][index[1]-items[i]] == true) {
				System.out.print(items[i] + " "); //购买这个商品
				index[1] = index[1] - items[i];
			}
		}
		if (index[1] != 0) {
			System.out.println(items[0]);
		}
		System.out.println("的商品");
		
		System.out.print("次次优购买价格为 ");
		for (int i = num-1; i >= 1; i--) {
			if (index[2]-items[i] >= 0 && states[i-1][index[2]-items[i]] == true) {
				System.out.print(items[i] + " "); //购买这个商品
				index[2] = index[2] - items[i];
			}
		}
		if (index[2] != 0) {
			System.out.println(items[0]);
		}
		System.out.println("的商品");
	}

}

测试代码运行结果如下👇
在这里插入图片描述
关于动态规划更进一步的内容,可以跳转去看我下面的文章👇

【数据结构与算法】->算法->动态规划(中)->详解动态规划理论

【数据结构与算法】->算法->动态规划(下)->如何实现搜索引擎的拼写纠错功能?

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值