笨办法理解动态规划算法

动态规划在编程中有着广泛的应用,对于某些问题我们可以通过动态规划显著的降低程序的时间复杂度。本质上动态规划并不是一种算法,而是解决一类问题的思想。本篇博客通过一些非常简单而又经典的问题(比如数塔、0-1背包、完全背包、走楼梯问题、最长公共子序列等)来帮助大家理解动态规划的一般套路。

欢迎探讨,如有错误敬请指正

如需转载,请注明出处 http://www.cnblogs.com/nullzx/

 


 

1. 动态规划的基本思想

如果我们解决一个问题的时候能将一个大问题转换成一个或者若干个规模较小的同等性质的问题,当我们求解出这些小问题的答案后,大问题的答案很容易解决,对于这样的情况,显然我们可以递归(或者说分治)的方式解决问题。如果在求解这些小问题的过程中发现有些小问题我们需要重复计算多次,那么我们就干脆把已经求解过的小问题的答案记录下来放在一张表中,这样下次遇到这个小问题,我们只需要查表就可以直接得到结果,这个就是动态规划的白话讲解。动态规划的难点在于如何定义问题及子问题。

2. 笨办法的套路

1)如果可以将一个规模较大的问题转换成一个或若干个规模较小的子问题,也就是能找到递推关系,这个时候我们不妨先将程序写成递归的形式。

2)如果使用递归求解规模较小的问题上存在子问题重复求解的现象,那么我们就建立一张表(有可能这个表只有一行)记录需要重复求解的子问题。填表的过程和将大问题划分为子问题的方式相反,我们会从最简单的子问题开始填表。现在我们就利用这个套路解决下面这些经典的问题。

3. 利用套路解题

3.1 菲波那切数列

问题描述:菲波那契数列的定义f(n) = f(n-1) + f(n-2), 且f(1)=1, f(2) = 1,求f(n)的值。斐波那契数列的定义本身就是将大问题转换成两个同性质的子问题,所以我们可以直接根据定义写成递归形式。

 

	public static int recursion(int n) {
		
		if (n < 0) {
			return 0;
		}
		
		if (n == 1 || n == 2) {
			return 1;
		}
		
		return recursion(n-1) + recursion(n-2);
	}

我们以f(6)为例现在把递归的过程画出来

clip_image002

我们发现在求解F(6)时,需要求解F(2)四次,求解F(1)三次,求解F(3)三次,F(4)两次,所以说我们的算法的效率是很低的。提高效率的办法就是将F(1),F(2),F(3) ….的结果放在表中,下次要计算这些问题的时候我们直接从表中获取就好了,这就是一个最简单的动态规划的例子。现在我们按照套路,从最小的子问开始填表就好了。

	public static int dynamic(int n) {
		
		int[] table = new int[n+1];
		
		table[1] = 1;
		table[2] = 1;
		
		/*从小到大填表*/
		for (int i = 3; i < table.length; i++) {
			table[i] = table[i-1] + table[i-2];
		}
		
		return table[n];
	}

需要说明的是,这个例子只是一个入门的例子,实际上它不存在最优子结构的问题,而且也不需要长度为n+1的table数组,只需要两个变量即可(可以理解为动态规划的优化版本),而我们之所以这样讲解只是为了让大家从动态规划的角度去理解问题。

3.2 走楼梯问题

问题描述:总共有n个楼梯,每次可以走2个台阶,或者每次走3个台阶,或者每次走5个台阶,请问这n个楼梯总共有几种走法。

n个阶梯的问题,可以分解成三个小问题,即n-2个阶梯有几种走法,n-3个阶梯有几种走法,n-5个阶梯有几种走法,而n个阶梯的走法就是这三种走法的和。或者可以反过来思考,你已经站在最后一个台阶上了,那么到达最后一个台阶的情况只能是三种情况,最后一步恰好走2个台阶恰好到达,最后一步恰好走3个台阶恰好到达,最后一步恰好走5个台阶恰好到达。通过这个思想,我们就可以写出递归形式的代码。

	public static int recursion(int n) { 
		
		if (n < 0) {
			return 0;
		}
		
		if (n == 0) {
			return 1;
		}
		
		return recursion(n - 5) + recursion(n - 3) + recursion(n - 2);
	}

显然上面递归的处理方式需要重复计算很多子问题,画出递归调用的图就一目了然,由于该图和上一个问题的图很类似,这里就省略了。因此就创建一张表,把子问题的结果都记录下来,dp[i]表示走到第i个阶梯有多少种走法。按照套路,我们应该从小的阶梯数开始填表。

	public static int dynamic(int n) {
		
		int[] record = new int[n+1];
		
		record[0] = 1;
		
		for (int i = 0; i < record.length; i++) {
			
			int n2 = i - 2 >= 0 ? record[i-2] : 0;
			int n3 = i - 3 >= 0 ? record[i-3] : 0;
			int n5 = i - 5 >= 0 ? record[i-5] : 0;
			
			record[i] = n2 + n3 + n5;
		}
		
		return record[n];
	}

同样,这里例子中也不存在最优问题。

3.3 数塔问题

问题描述:从顶部出发在每一个节点可以选择向下或者向右下走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大。

clip_image002[6]

对于上图所示的数塔:最大值为379, 绿色的的数字就是被选择的节点。

这个问题不能使用贪心算法,请大家自己用三层的阶梯列举出反例。我们现在试着将这个问题分解成子问题,如下图所示。想求得最大值,我们只要选择的红色边框数塔最大值和蓝色边框数塔的最大值中更大的那个,然后加上32,就整个数塔的最大值。这样我们就将一个大的问题转化成了两个规小的问题,然后这两个规模较小的问题还可以继续分解成更小的子问题。根据这个思路,我们可以得到如下递归形式的代码。

clip_image004

	/*我们用一个二维数组的左下半数组表示数塔*/
	public static int recursion(int[][] a){
		return recursion(a, 0, 0);
	}
	
	/*参数i表示所在的行,j表示所在的列*/
	private static int recursion(int[][] a, int i, int j){
		
		/*
		 * 当分解问题到最下一层时,
		 * (a.length - 1, j)位置为顶点的数塔实际上数塔只有一个元素,
		 * 直接返回
		*/
		if (i == a.length - 1){
			return a[i][j];
		}
		
		/*求(i+1, j)位置为顶点的数塔最大值*/
		int r1 = recursion(a, i+1, j);
		
		/*求(i+1, j+1)位置为顶点的数塔最大值*/
		int r2 = recursion(a, i+1, j+1);
		
		/*返回(i,j)为顶点位置的数塔的最大值*/
		return Math.max(r1, r2) + a[i][j];
	}

上述代码能够得到正确的结果,但是我们发现计算大一点的数塔计算会很费时间,这主要是重复计算的问题,我们现在来分析一下为什么会出现重复计算的问题。clip_image002[8]

上图中的紫色边框数塔既存在于红色边框数塔中,也存在于蓝色边框数塔中,会重复计算两次。实际上,我们使用递归时重复计算的问题显然不止这一个,所以效率不高。为此我们应该创建一张和数塔形状一样的三角形表用来记录更小的数塔的最大值。我们table表示这个表,表中table[i][j]位置的值表示以(i,j)为顶点的数塔的最大值。我们用a[i][j]表示数塔中第i行,第j列的值。那么table[i][j] = a[i][j] + Math.max(table[i-1][j], table[i-1][j-1])。按照套路,我们应该从最小的数塔开始填表。按照table[i][j]的定义,table表的最下面一行就应该等于数塔表中的最下面一行。

clip_image004[4]

按照定义,我们就可以填倒数第二行的dp[i][j]。

table[4][0] = 79 + Math.max(0, 71) = 150
table[4][1] = 69 + Math.max(71, 51) = 140
table[4][2] = 78 + Math.max(51, 82) = 160
table[4][3] = 29 + Math.max(82, 91) = 120
table[4][4] = 63 + Math.max(91, 64) = 154

填入到table表的倒数第二行,如下图所示

clip_image002[10]

有了倒数第二行,我们就可以推出倒数第三行,依次类推,我们就可以得到最上面table [0][0]的数值,它就表示了整个数塔的最大值。除了最大值,如果我们还需要知道走了哪些路径,我们还应该定义一个path表,在填table[i][j]时,同时填写path[i][j]。path[i][j]表示了以(i, j)为顶点的数塔的最大值是由两个子数塔(table[i-1][j]为顶点的数塔和table[i-1][j+1]为顶点的数塔)中的哪一个得到的。

public class NumbericalTower {

	/*最大值对应的各个顶点位置*/
	private LinkedList<Map.Entry<Integer, Integer>> pathList;
		
	/*存储整个数塔的最大值*/
	private int result;

	public NumbericalTower(int[][] a) {

		pathList = new LinkedList<Map.Entry<Integer, Integer>>();
		dynamic(a);
	}

	
	private void dynamic(int[][] a){

		final int N = a.length;
		
		/*path[i][j] 表示(i+1, j)为顶点的数塔和(i+1,j+1)为顶点的数塔
		 *中较大的那个*/
		int[][] path = new int[N][N];
		
		/*动态规划对应的表*/
		int[][] table = new int[N][N];

		/*从最小的数塔开始填表*/
		for (int i = N - 1; i >= 0; i--) {
			
			/*根据下层数塔的最大值计算上层的数塔的最大值*/
			for (int j = 0; j <= i; j++) {
				
				if (i == N - 1) {
					table[i][j] = a[i][j];
					path[i][j] = -1;
					
				}else if (table[i+1][j] > table[i+1][j+1]) {
					table[i][j] = table[i+1][j] + a[i][j];
					path[i][j] = j;
				}else{
					table[i][j] = table[i+1][j+1] + a[i][j];
					path[i][j] = j+1;
				}
			}
		}
		
		result = table[0][0];
		
		/*记录最大值对应的顶点*/
		int i = 0, j = 0;
		pathList.add(new SimpleEntry<Integer, Integer>(0, 0));
		
		while (true) {
			j = path[i][j];
			i = i + 1;
			pathList.add(new SimpleEntry<Integer, Integer>(i, j));
			
			if (path[i][j] == -1) {
				break;
			}
		}
	}
	
	int max(){
		return result;
	}
	
	List<Map.Entry<Integer, Integer>> path(){
		return pathList;
	}
	
	public static void main(String[] args) {
		int[][] a = {
			{32},
			{83, 68},
			{40, 37, 47},
			{ 5,  4, 67, 22},
			{79, 69, 78, 29, 63},
			{ 0, 71, 51, 82, 91, 64}
		};

		NumbericalTower nt = new NumbericalTower(a);
		int max = nt.max();
		List<Map.Entry<Integer, Integer>> path = nt.path();
		System.out.println("最大值:" + max);
		System.out.println("\n\n路径为:");
		for (Map.Entry<Integer, Integer> entry : path) {
			int r = entry.getKey();
			int c = entry.getValue();
			System.out.println("行 : " + r + ", 列:"+ c);
		}
	}
}

运行结果

最大值:379

路径为:
行 : 0, 列:0
行 : 1, 列:0
行 : 2, 列:1
行 : 3, 列:2
行 : 4, 列:2
行 : 5, 列:3
3.4 零-壹背包问题

问题描述:有n 个物品,它们有各自的重量(weight)和价值(value),现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和此时背包中的物品?一个物品只有不拿和拿两种情况,可以用0和1表示,所以称之为0-1背包问题。

我们来看一个具体的例子。假设有如下物品:

clip_image002

求背包容量在10的时候的能装物品的最大价值,以及装了哪些物品?

3.4.1 解决背包的最大价值

我们可能首先想到的是贪心算法,我们算出每种物品的单位重量价值(weight/value),然后按照单位重量价值排序。我们放入物品时首先选择单位重量价值高的物品,直到放不下为止。但是很遗憾,这样得不到最优解。我们不妨列举一个极端的例子,假设只有两个物品,A的value = 2.9, weight = 2.1;B的value = 3, weight = 3,显然物品A的单位重量价值要大于B的单位重量价值,但对于容量为3的背包,我们应该选择物品B,所以贪心算法失效。对于0-1背包问题,贪心选择之所以不能得到最优解是因为:它无法保证最终能将背包装满,而部分闲置的背包空间使每公斤背包空间的价值降低了。

回到上面具体的这个问题,它可以表述为

maxValue{宝石、剃须刀、ipad、充电宝、iphone | 背包容量10},

每个物品只有选和不选两种结果,我们不妨从第一个物品开始。如果选了宝石,那么问题转化为当前背包已有价值为50,并在剩下的背包容量(10 - 4)的前提下,再剩下的物品中(即剃须刀、ipad、充电宝、iphone)选取出最大的价值;如果不选宝石,那么问题转化为当前背包价值为0,并在剩下的背包容量10的前提下,在剩下的物品中(即剃须刀、ipad、充电宝、iphone)选取出最大的价值。我们只需要选择:

50 + maxValue{剃须刀、ipad、充电宝、iphone | 背包容量6}

0 + maxValue{剃须刀、ipad、充电宝、iphone | 背包容量10}

中较大的那个。而这就直接转化成两个子问题的求解,显然我们已经可以用分治的方式解决这个问题了。我们不妨把递归树(或者说分治树)画出来。

clip_image004[6]

上图就是0-1背包问题的递归树,图左文字边表示当前可选的物品,节点中的值表示背包的容量。我们没有把整个递归树全部都画出来,因为图中我们就已经发现了需要重复计算的子问题。如果背包容量变大,物品种类变多,那么需要重复计算的子问题就越多。需要说明的是上图中有三个背包容量为7的子问题,但是只有被标记的两个子问题才是重复的子问题,因为这两个子问题的背包容量一样,可选物品一样。为了避免子问题的重复求解,我们就建立一张动态规划表,下次遇到重复的子问题,我们就直接查表。下图表示了动态规划表和递归树之间的关系。

clip_image006

那我们现在的主要问题就变成了如何填这样一张表。我们用一个名为dp的二维数组表示这张表,dp[0]行需要单独初始化,从dp[1]行开始填表,规则:从左到右,从上到下。

       clip_image008

dp[i][j]表示前i个物品(包括物品i),在背包容量为j时能装的最大价值。

dp[i][j]为下面两者的最大值:

1)物品i不放入背包中:背包容量为j时,前i-1个物品组合出的最大价值

2)物品i放入背包中:物品i的价值 + 除去物品i占用的重量后,剩余背包容量j-weight(i)由前i-1个物品组合出的最大价值

用公式表示为

clip_image010

3.4.2 解决背包有哪些物品
通过dp表,我们还可以知道哪些物品放入了背包中。从表格的右下角开始(第0个物品要单独处理):

1)如果dp[i][j] > dp[i-1][j],说明该物品i被放入到了背包中,令i = i – 1, j = j – weight[i],然后重复步骤1。

2)如果dp[i][j] == dp[i-1][j],且只想得到背包最大价值的其中一种的物品一种组合方式,不妨认为该物品i没有被放入到了背包中,令i = i – 1, 重复步骤1)。

clip_image012

对于步骤2),如果

dp[i][j] == dp[i-1][j] && dp[i][j – weight(i)] + value(i) == dp[i][j]

说明物品i可以放入背包中(令i = i – 1, j = j – weight[i]),也可以不用放入背包中(令i = i - 1)。这里就产生分支,说明放入背包中的物品组合方式不唯一,为了简单起见,我们找到一种物品的组合方式即可。

package demo;

import java.util.LinkedList;
import java.util.List;


public class KnapsacProblem {
	/*动态规划表*/
	private int[][] dp;
	
	/*背包装的最大价值*/
	private int maxVal;
	
	/*背包最大价值时对应的商品编号*/
	private List<Integer> goodsNumber; 
	
	public KnapsacProblem(int[] weight, int[] values, int capacity){
		
		if ( weight.length != values.length ){
			throw new IllegalArgumentException();
		}
		
		int goodsLen = weight.length;
		
		/*第0列不使用*/
		this.dp = new int[goodsLen][capacity + 1];
		
		goodsNumber = new LinkedList<Integer>();
		
		
		/*单独初始化第0行*/
		for ( int j = 1; j < capacity + 1; j++){
			if (j >= weight[0]){
				dp[0][j] = values[0];
			}
		}
		
		/*填dp表*/
		for ( int i = 1; i < goodsLen; i++ ) {
			for ( int j = 1; j < capacity + 1; j++ ) {
				if ( weight[i] <= j ) {
					dp[i][j] = Math.max(dp[i-1][j], values[i] + dp[i-1][j - weight[i]]);
				} else {
					dp[i][j] = dp[i-1][j];
				}
			}
		}
		
		maxVal = dp[goodsLen - 1][capacity - 1];
		
		/*找出使用了哪些物品*/
		int j = capacity;
		for (int i = goodsLen - 1; i > 0; i-- ) {
			if ( dp[i][j] > dp[i-1][j] ) {
				goodsNumber.add(i);
				j = j - weight[i];
			}
		}
		
		/*单独处理第0行,回退到第0行时发现背包中还有物品,说明物品0在背包中*/
		if (j > 0){
			goodsNumber.add(0);
		}
	}
	
	public int  getPackageMaxValue(){
		return this.maxVal;
	}
	
	public List<Integer> getGoodsNumber(){
		return this.goodsNumber;
	}
	
	public static void main(String[] args){
		
		int[] weight = {4, 5, 2, 1, 2};
		int[] values = {50, 40, 60, 20, 30};
		int capacity = 10;
		
		KnapsacProblem kp = new KnapsacProblem(weight, values, capacity);
		
		System.out.println(kp.getPackageMaxValue());
		System.out.println(kp.getGoodsNumber());
	}

}

运行结果

160
[4, 3, 2, 0]

如果我们仅仅需要知道最大的价值,不需要知道装了哪些物品,我们就可以对空间复杂度进行优化,动态规划表只需要一维,因为dp[i][?]仅和dp[i-1][?]有关。

3.5 切分“和相等”的子集

Given a non-empty array containing only positive integers, find if the array can be partitioned into two subsets such that the sum of elements in both subsets is equal.

Note:

1. Each of the array element will not exceed 100.

2. The array size will not exceed 200.

Example 1:

Input: [1, 5, 11, 5]

Output: true

Explanation: The array can be partitioned as [1, 5, 5] and [11].

Example 2:

Input: [1, 2, 3, 5]

Output: false

Explanation: The array cannot be partitioned into equal sum subsets.

这是LeetCode的原题。这个问题本质上还是0-1背包问题,背包容量是数组之和的一半,物品的价值和体积是1比1的关系,额外条件是需要把背包装满。

3.6 完全背包问题

问题描述:有n 种物品,它们有各自的重量(weight)和价值(value),现有给定容量的背包,每种物品可以拿任意多个,如何让背包里装入的物品具有最大的价值,以及每种物品装了几个?

clip_image002

假设,我们还是利用0-1背包中的物品,背包容量为11。

完全背包问题也可以转化成0-1背包问题。因为第i个物品最多拿“背包重量/(物品i的重量)”个,也就是说在0-1背包问题中每个物品i占一行,完全背包问题中,每个物品占“背包重量/(物品i的重量)” 个行,按照这个思路显然已经能够解决这个问题。现在我们不把这个问题转化为0-1背包问题,而从这个问题的根源直接思考。

3.6.1  解决背包的最大价值

完全背包问题可以表述为

maxValue{宝石、剃须刀、ipad、充电宝、iphone | 背包容量10}

每个物品只有选和不选两种结果,我们不妨从第一个物品开始。如果选了宝石,那么问题转化为当前背包已有价值为50,并在剩下的背包容量(10 - 4)的前提下,继续在{宝石、剃须刀、ipad、充电宝、iphone}选取出最大的价值;如果不选宝石,那么我们就在{剃须刀、ipad、充电宝、iphone}中选择一种,那么问题转化为当前背包价值为0,并在剩下的背包容量10的前提下,再剩下的物品中即{剃须刀、ipad、充电宝、iphone }选取出最大的价值。

因此我们只需要选择:

50 + maxValue{宝石、剃须刀、ipad、充电宝、iphone | 背包容量6}

0 + maxValue{剃须刀、ipad、充电宝、iphone | 背包容量10}

中较大的那个。

而这就直接转化成两个子问题的求解,显然我们已经可以用分治的方式解决这个问题了。我们同样可以把递归树画出来,同样还会发现存在需要重复求解的子问题,为了避免子问题的重复求解,我们还是建立一张动态规划表,下次遇到重复的子问题,我们就直接查表。这里我们直接给出动态规划表,我们用一个名为dp的二维数组表示这张表,dp[0]行单独初始化,从dp[1]行开始填表,规则:从左到右,从上到下。

clip_image004

dp[i][j]表示前i个物品(包括物品i),在背包容量为j时能装的最大价值。

dp[i][j]为下面二者的最大值:

clip_image006[4]

clip_image008[5]

3.6.2 解决背包中物品的种类和个数

同样,从dp表中我们还可以知道哪些物品被选择了,选择多少次。我们还是从右下角开始回溯。

1)dp[i][j] > dp[i-1][j] 说明i号物品被选择了,j = j – weight[i]

2)dp[i][j] == dp[i-1][j] 为了简单起见,我们认为i号物品没有被选择,令i = i -1(实际上这里同样可能存在分支,即最大价值时物品的组合方式和数量并不唯一,我们这里为了简单处理,就不考虑这个问题了)。

clip_image009

package demo;

import java.util.AbstractMap.SimpleEntry;
import java.util.LinkedList;

public class AllKnapsacProblem {
	
	private int maxVal;
	
	private LinkedList<SimpleEntry<Integer, Integer>> goodsIdCount;
	
	public int getPackageMaxValue(){
		return maxVal;
	}
	
	public LinkedList<SimpleEntry<Integer, Integer>> getGoodsCount(){
		return goodsIdCount;
	}
	
	public AllKnapsacProblem(int[] weight, int[] values, int capacity){
		/*处理最大价值问题============================================*/

		if ( weight.length != values.length ){
			throw new IllegalArgumentException();
		}
		
		int goodsLen = weight.length;
		
		/*第0列不使用*/
		int[][] dp = new int[goodsLen][capacity + 1];
		
		/*第0行单独处理*/
		for (int j = weight[0]; j <= capacity; j++){
			dp[0][j] = dp[0][j - weight[0]] + values[0];
		}
		
		for (int i = 1; i < goodsLen; i++){
			
			for (int j = 1; j <= capacity; j++){
				
				int max1 = dp[i-1][j];
                int max2 = j - weight[i] >= 0 ? values[i] + dp[i][j - weight[i]] : 0;
                
                dp[i][j] = Math.max(max1, max2);
			}
		}
		
		maxVal = dp[goodsLen-1][capacity];
		
		/*处理物品种类和个数问题问题============================================*/
		
		/*SimpleEntry<Integer, Integer>:key表示物品编号,value表示物品个数*/
		goodsIdCount = new LinkedList<SimpleEntry<Integer, Integer>>();
		
		int i = goodsLen - 1;
		int j = capacity;
		
		SimpleEntry<Integer, Integer> entry = new SimpleEntry<Integer, Integer>(i, 0);
		while (i > 0){
			
			if (dp[i][j] > dp[i-1][j]){
				int n = entry.getValue();
				entry.setValue(n+1);
				j = j - weight[i];
			}
			
			if (dp[i][j] == dp[i-1][j]){
				if (entry.getValue() > 0) {					
					goodsIdCount.add(entry);
				}
				i--;
				entry = new SimpleEntry<Integer, Integer>(i, 0);
			}
		}
				
		/*单独处理第0行*/
		if (j > 0) {
			goodsIdCount.add(new SimpleEntry<Integer, Integer>(0, j/weight[0]));
		}
	}
	
	public static void main(String[] args){
		
		int[] values = {50, 40, 60, 20, 30};
		int[] weight = {4,   5,  2,  1, 2};
		int capacity = 11;
		
		AllKnapsacProblem ap = new AllKnapsacProblem(weight, values, capacity);
		
		System.out.println("背包价值" + ap.getPackageMaxValue());
		for (SimpleEntry<Integer, Integer> entry : ap.goodsIdCount) {
			System.out.printf("物品%d : %d个\n", entry.getKey(), entry.getValue());
		}
		
	}
	
}
运行结果
320
物品3 : 1个
物品2 : 5个

 

3.7 找零钱问题

You are given coins of different denominations ([dɪˌnɑ:mɪˈneɪʃn] 面额) and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1。

Example 1:

Input: coins = [1, 2, 5], amount = 11

Output: 3

Explanation: 11 = 5 + 5 + 1

Example 2:

Input: coins = [2], amount = 3

Output: -1

这道题目是LeetCode上面的原题。假设在一堆面值为 1,2,5,11面值的硬币,问最少需要多少个硬币才能找出总值为以兑换15元。面对这个问题我们也会首先想到贪心算法,但是贪心算法给出的组合方案为{11,1,1,1,1},但其实最优方案为{5,5,5}。如果使用枚举算法,每种硬币都有选0个,选1个,选2个,选…,这样时间复杂度太高。这个问题本质上还是完全背包问题,物品的价值和重量比是1比1,额外条件是需要把背包装满,所以我们可以使用动态规划算法去解决它,代码这里就不给出了。

3.8 最长公共子序列

我们首先看一下子序列的定义。假设给定一个字符串,我们抽取任意多个不超过字符串长度的字符,并保持它们的前后关系,这样的字符我们称之为子序列。对于字符串ABCDEFG而言, BEF、C、AG等等都是它的一个子序列。

Longest common sequence问题:给定两个字符串s1和s2,求这两个字符串中最长的公共子序列。比如给定两个字符串s1:bdcaba和s2:abcbdab,它们的公共子序列

长度为4,最长公共子序列是:bcba。

字符串s1的长度用n表示,字符产s2的长度用m表示,字符串s1和s2的最长公共字串用lcs(n,m)。那么这个问题可以转化为三个子问题

1)求lcs(n-1, m-1)

2)求lcs(n-1, m)

3)求lcs(n, m-1)

当我们求的上述三个子问题的答案,那么lcs(n, m)的结果就可以通过如下方式得到:

如果s1[n] == s2[m]

    lcs(n, m) = lcs(n-1, m-1)+1

如果s1[n] != s2[m] :

    lcs(n, m) = max{ lcs(n-1, m-1), lcs(n-1, m), lcs(n, m-1) }

但是实际上lcs(n,m)只要转化成两个子问题lcs(n-1, m)和lcs(n, m-1)就好了。

而子问题lcs(n-1, m-1)是没有必要的,因为lcs(n-1, m-1)必定小于等于lcs(n-1, m)和lcs(n, m-1)中的en任意一个。从常理上来说很好理解,不可能两个字符串中的任意一个变长了,公共子序列反而减少了。而本质上是由于lcs(n-1, m-1)也是lcs(n-1, m)和lcs(n, m-1)这两个问题的子问题。

通过上面的分析,我们把大的问题转化成小的问题,就可以通过递归(或者说分治)的方式把问题解决了,下面就是递归对应的代码。

	public static void recursion (char[] s1, char[] s2) {
		maxLen = recursion0 (s1, s1.length-1, s2, s2.length-1);
	}
	
	private static int recursion0 (char[] s1, int idx1, char[] s2, int idx2){
		
		if(idx1 < 0 || idx2 < 0){
			return 0;
		}
		
		int max1, max2;
		
		max1 = recursion0 (s1, idx1, s2, idx2 - 1);
		max2 = recursion0 (s1, idx1 - 1, s2,  idx2);
		
		if (s1[idx1] == s2[idx2]){
			return Math.max(max1, max2) + 1;
		}else{
			return Math.max(max1, max2);
		}
	}

显然上述也同样存在很多重复计算的子问题,为了降低时间复杂度,要一张二维表记录重复计算的子问题的结果,这张表我们用dp表示, dp[i][j]就表示以s1[i]和s2[j]结尾的字符串最长公共子序列。按照套路填表规则要从最小的子问题开始,

clip_image002[3]

第0行,表示“b”和“bdcaba”的公共子序列,可以单独处理,同理第0列也可以单独处理,填表完成后如上图所示。从第二行开始,dp表按照从上到下,从左到右的填表顺序填表。根据子递归中子问题的定义,dp[i][j]的取值如下:

clip_image002[1]

clip_image006[4]

当填完整张表时,右下角的值就是公共子序列的最大长度。如果我们还需要知道公共子序列是什么,那么我们可以从右下角开始回溯,如果dp[i][j] > dp[i-1][j] 且 dp[i][j] > dp[i][j-1], 说明s1[i]或者s2[j]是公共子序列,否则选择走dp[i-1][j]和dp[i][j-1]中较大的那个,同样第0行要单独处理。

package demo;

public class LongestCommonSequence {
	
	private int[][] dp;
	private int maxLen;
	private String lcs;
	
	private char[] s1, s2;
		
	public int maxLen(){
		return maxLen;
	}
	
	public String getLCS() {
		return lcs;
	}
	
	public LongestCommonSequence(String str1, String str2) {
		s1 = str1.toCharArray();
		s2 = str2.toCharArray();
		dynamic();
		getString();
	}
	
	/*动态规划算法*/
	private void dynamic(){
		
		dp = new int[s1.length][s2.length];
		
		/*单独处理第0行*/
		for(int j = 0, x = 0; j < s2.length; j++){
			if (s1[0] == s2[j]){
				x = 1;
			}
			dp[0][j] = x;
		}
		
		/*单独处理第0列*/
		for (int i = 0, x = 0; i < s1.length; i++) {
			if (s2[0] == s1[i]){
				x = 1;
			}
			dp[i][0] = x;
		}
		
		for (int i = 1; i < s1.length; i++) {
			
			for(int j = 1; j < s2.length; j++){
				
				if(s1[i] == s2[j]){
					dp[i][j] = 1 + dp[i-1][j-1];
				}else{
					dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
				}
			}
		}
		
		maxLen = dp[s1.length - 1][s2.length - 1];
		
	}
	
	/*回溯求出公共子序列*/
	private void getString(){
		
		int cnt = maxLen;
		StringBuffer sb = new StringBuffer();
		
		int i = s1.length - 1, j = s2.length - 1;
		
		while (i > 0 && j > 0){
			if (dp[i][j] > dp[i-1][j] && dp[i][j] > dp[i][j-1]){
				sb.append(s1[i]);
				i--;
				j--;
				cnt--;
			}else{
				if (dp[i-1][j] > dp[i][j-1]){
					i--;
				}else{
					j--;
				}
			}
		}
		
		/*单独处理第0行, i和j必然有一个为0*/
		if (cnt > 0){
			
			while (true){
				
				if (s1[i] == s2[j]){
					sb.append(s1[i]);
					break;
				}
				
				if (i > 0){
					i--;
				}
				
				if (j > 0){
					j--;
				}
			}
			
			cnt--;
		}
		
		lcs = sb.reverse().toString();
	}
	
	public static void main(String[] args){
		LongestCommonSequence lcs = new LongestCommonSequence("bcba", "bdcaba");
		System.out.println(lcs.maxLen);
		System.out.println(lcs.getLCS());
	}

}

4. 动态规划算法总结

枚举算法:如果为了方便的解决这个问题,我们需要将大问题化简成小问题,将所有小问题中的最优解作为我们解决大问题的基础。

贪心算法:如果为了方便的解决这个问题,我们需要将大问题化简成小问题,在所有小问题中,仅选择对当前最有利的小问题作为我们解决大问题的基础。

动态规划:如果为了方便的解决这个问题,我们需要将大问题化简成小问题,记录已解决过的小问题,将所有小问题中的最优解作为我们解决大问题的基础。换句话说,能用贪心算法解决的,动态规划算法也肯定能解决,反之不成立。

能用动规解决的问题的特点

1) 问题具有最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质。

2) 无后效性。当前的若干个状态值一旦确定,则此后过程的演变就只和这若干个状态的值有关和之前是采取哪种手段或经过哪条路径演变到当前的这若干个状态,没有关系。

5. 参考内容

[1]. 动态规划:最长上升子序列(LIS)

[2]. 什么是动态规划?动态规划的意义是什么?

[3]. 漫画:什么是动态规划?

 

posted @ 2019-06-08 18:19  nullzx  阅读(7190)  评论(4编辑  收藏  举报