常见动态规划问题

1.数组最大不连续递增子序列

arr[] = {3,1,4,1,5,9,2,6,5}的最长递增子序列长度为4。即为:1,4,5,9

设置一个数组temp,长度为原数组长度,数组第i个位置上的数字代表0...i上最长递增子序列,当增加一个数字时,最大递增子序列可能变成前面最大的递增子序列+1,也可能就是前面最大递增子序列,这需要让新增加进来的数字arr[i]跟前面所有数字比较大小,即当 arr[i] > arr[j],temp[i] =temp[j]+1,其中,j 的取值范围为:0,1...i-1,temp[i]的初始值为1,当arr[i] < arr[j],temp[i]不进行更新

public static int MaxChildArrayOrder(int a[]) {
		int n = a.length;
		int temp[] = new int[n];//temp[i]代表0...i上最长递增子序列
		for(int i=0;i<n;i++){
			temp[i] = 1;//初始值都为1
		}
		for(int i=1;i<n;i++){
			for(int j=0;j<i;j++){
				if(a[i]>a[j]&&temp[j]+1>temp[i]){
					//如果有a[i]比它前面所有的数都大,则temp[i]为它前面的比它小的数的那一个temp+1取得的最大值
					temp[i] = temp[j]+1;
				}
			}
		}
		int max = temp[0];
		//从temp数组里取出最大的值
		for(int i=1;i<n;i++){
			if(temp[i]>max){
				max = temp[i];
			}
		}
		return max;
	}

2.数组最大连续子序列和

如arr[] = {6,-1,3,-4,-6,9,2,-2,5}的最大连续子序列和为14。即为:9,2,-2,5

设数组最大连续子序列和为sum, sum的初始值为a[0], 如果数字arr[i]的前面数字和sum为负数,则前面数字和当前数字连续起来不可能有当前数字大,前面数字舍弃,从当前数字开始计算,每次计算后和max比较,最后的max就是最大值。

public static int MaxContinueArraySum(int a[]) {
		int n = a.length;
		int max = a[0];
		int sum = a[0];
		for(int i=1;i<n;i++){
			if(sum > 0)
                sum = sum + a[i]
            else
                sum = a[i]
			if(sum > max){
				max = sum;
			}
		}
		return max;
	}

3、数字塔从上到下所有路径中和最大的路径

数字塔是第i行有i个数字组成,从上往下每个数字只能走到他正下方数字或者正右方数字,求数字塔从上到下所有路径中和最大的路径,如有下数字塔

3

1    5

8    4    3

2    6    7    9

6    2    3    5    1

最大路径是3-5-3-9-5,和为25。我们可以分别从从上往下看跟从下往上看两种动态规划的方式去解这个题

从上往下看:当从上往下看时,每进来新的一行,新的一行每个元素只能选择他正上方或者左上方的元素,也就是说,第一个元素只能连他上方的元素,最后一个元素只能连他左上方的元素,其他元素可以有两种选择,所以需要选择加起来更大的那一个数字,并把这个位置上的数字改成相应的路径值,具体过程如下图所示

3                            3                            3                            3

1    5                      4    8                      4    8                      4    8

8    4    3                8    4    3                12   12  11             12   12   11  

2    6    7    9          2    6    7    9           2    6    7    9         14   18   19   20

6    2    3    5    1    6    2    3    5    1     6    2    3    5    1    20   20   22   25   21

所以最大值就是最底层的最大值也就是25。

具体运算过程就是,建立一个n*n的二维数组dp[][],n是数字塔最后一行的数字个数,二维数组每一行数字跟数字塔每一行数字个数一样,保存的值是从上方到这一个位置最大路径的值,填入边界值dp[0][0]=3,每一行除了第一个值跟最后一个值,其他的值选择上方或者左上方更大的值与这个位置上的值相加得来的值,即dp[i][j]=Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j]
 

public static int minNumberInRotateArray(int n[][]) {
		int max = 0;
		int dp[][] = new int[n.length][n.length];
		dp[0][0] = n[0][0];
		for(int i=1;i<n.length;i++){
			for(int j=0;j<=i;j++){
				if(j==0){
					//如果是第一列,直接跟他上面数字相加
					dp[i][j] = dp[i-1][j] + n[i][j];
				}else if (j == i){
                    dp[i][j] = dp[i-1][j-1] + n[i][j];
                    //如果是最后一列,
                }else{
					//如果不是第一列,比较他上面跟上面左面数字谁大,谁大就跟谁相加,放到这个位置
					dp[i][j] = Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j];
				}
				max = Math.max(dp[i][j], max);
			}
		}
		return max;
	}

优化:动态规划中每一个需要创建一个二维数组的解法,都可以换成只创建一个一维数组的滚动数组解法,依据的规则是一般二维数组中存放的是所有的结果,但是一般我们需要的结果实在二维数组的最后一行的某个值,前面几行的值都是为了得到最后一行的值而需要的,所以可以开始就创建跟二维数组最后一行一样大的一维数组,每次存放某一行的值,下一次根据这一行的值算出下一行的值,在存入这个数组,也就是把这个数组滚动了,最后数组存储的结果就是原二维数组中最后一行的值。

拿到本题来说,开始创建一个一维数组dp[n],初始值只有dp[0]=3,新进来一行时,仍然遵循dp[i][j]=Math.max(dp[i-1][j-1], dp[i-1][j]) + n[i][j],现在为求dp[j],所以现在dp[i-1][j]其实就是数组中这个位置本来的元素即dp[j],而dp[i-1][j-1]其实就是数组中上一个元素dp[j-1],也就是说dp[j]=Math.max(dp[j], dp[j-1])+n[i][j]
 

public static int minNumberInRotateArray2(int n[][]) {
		int[] temp = new int[n.length];
		temp[0] = n[0][0];
		for(int i=1;i<n.length;i++){
			for(int j=i;j>=0;j--){
				if(j==i){
					temp[i]=temp[i-1]+n[i][j];
				}else if(j==0){
					temp[0]+=n[i][0];
				}else{
					temp[j]=Math.max(temp[j], temp[j-1])+n[i][j];
                    //temp[j]需要的是上一行temp[j-1]的值,j进行倒序遍历
				}
			}
		}
		int max = temp[0];
		//从temp数组里取出最大的值
		for(int i=1;i<temp.length;i++){
			if(temp[i]>max){
				max = temp[i];
			}
		}
		return max;
	}

从下往上看时:从下往上看时大体思路跟从上往下看一样,但是要简单一些,因为不用考虑边界数据,从下往上看时,每进来上面一行,上面一行每个数字有两条路径到达下面一行,所以选一条最大的就可以

3                            3                            3                           25

1    5                      1    5                      1    5                     18   22

8    4    3                8    4    3                17  16  17              17  16  17  

2    6    7    9          8    9    12  14         8    9   12  14         8    9    12  14

6    2    3    5    1    6    2    3    5    1     6    2    3    5    1    6    2    3    5    1

所以最大值就是最上面数字就是25.
具体方法也是建立一个二维数组,最下面一行数据添到二维数组最后一行,从下往上填数字,所以状态转化方程是dp[i][j]=Math.max(dp[i+1][j+1], dp[i+1][j]) + n[i][j],具体解决方法跟从上往下看一样,就不写具体代码了。

优化:滚动数组,只创建一个一维数组,数组初始值是数字塔最下面一行的值,每次新加一行值,将数组中的值改变,最后数组中第一个数字就是最大路径的值。状态转化方程就是temp[j] = Math.max(temp[j], temp[j+1])+n[i][j]。具体代码如下

public static int minNumberInRotateArray3(int n[][]) {
		int[] temp = new int[n.length];
		for(int i=0;i<n.length;i++){
			temp[i] = n[n.length-1][i];
		}
		for(int i=n.length-2;i>=0;i--){
			for(int j=0;j<=i;j++){
				temp[j] = Math.max(temp[j], temp[j+1])+n[i][j];
			}
		}
		return temp[0];
	}


从下往上看跟从上往下看相比,虽然逻辑较为简单,但是从下往上看时需要得到完整的数字塔之后才能开始计算,而从上往下看时可以随着数字塔的深入来计算,也可以返回任意一层的结果,是最好的方法。

4、两个字符串最大公共子序列

比如字符串1:BDCABA;字符串2:ABCBDAB,则这两个字符串的最长公共子序列长度为4,最长公共子序列是:BCBA

具体思想:设 X=(x1,x2,.....xn)和 Y={y1,y2,.....ym} 是两个序列,将 X 和 Y 的最长公共子序列记为LCS(X,Y),如果 xn=ym,即X的最后一个元素与Y的最后一个元素相同,这说明该元素一定位于公共子序列中。因此,现在只需要找:LCS(Xn-1,Ym-1)就好,LCS(X,Y)=LCS(Xn-1,Ym-1)+1;如果xn != ym,这下要麻烦一点,因为它产生了两个子问题:LCS(Xn-1,Ym) 和 LCS(Xn,Ym-1)。

动态规划解法:先创建一个解空间即数组,因为给定的是两个字符串即两个一维数组存储的数据,所以要创建一个二维数组,设字符串X有n个值,字符串Y有m个值,需要创建一个m+1*n+1的二维数组,二维数组每个位置(i,j)代表当长度为i的X子串与长度为j的Y的子串他们的最长公共子串,之所以要多创建一个是为了将边界值填入进去,边界值就是第一行跟第一列,指X长度为0或者Y长度为0时,自然需要填0,其他位置填数字时,当这两个位置数字相同,dp[i][j] = dp[i-1][j-1]+1;当这两个位置数字不相同时,dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j])。最后二维数组最右下角的值就是最大子串。
 

public class MaxTwoArraySameOrder {
	public static int MaxTwoArraySameOrderMethod(String str1,String str2) {
		int m = str1.length();
		int n = str2.length();
		/*
		 * 定义一个二维数组保存公共子序列长度
		 * dp[i][j]表示字符串1从头开始长度是i,字符串2从头开始长度是j,这两个字符串的最长公共子序列的长度
		 * 设置数组行列比他们长度大一往二维数组中填写数字时,每个位置的数字跟他上方或者左方或者左上方数字有关系,这样处理边界数字时不用处理这种情况,方便接下来的循环
		 */
		int dp[][] = new int[m+1][n+1];
		/*
		 * 初始化第一行第一列
		 * dp[0,j]表示啥?表示字符串1的长度是0,字符串2的长度是j,这两个字符串的最长公共子序列的长度是0,因为,字符串1 根本就没有嘛
		 */
		for(int i=0;i<=m;i++){
			dp[i][0] = 0;
		}
		for(int i=0;i<=n;i++){
			dp[0][i] = 0;
		}
		for(int i=1;i<=m;i++){
			for(int j=1;j<=n;j++){
                //字符串数组字符从0开始,需要-1
				if(str1.charAt(i-1) == str2.charAt(j-1)){
					dp[i][j] = dp[i-1][j-1]+1;
				}else{
					dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
				}
			}
		}
		return dp[m][n];
	}
	public static void main(String[] args) {
		String str1 = "BDCABA";
		String str2 = "ABCBDAB";
		int array = MaxTwoArraySameOrderMethod(str1,str2);
		System.out.println(array);
	}
 
}

5、找零钱问题

(1)最少硬币找零问题

给定4种面额的硬币1分,2分,5分,6分,如果要找11分的零钱,怎么做才能使得找的硬币数量总和最少。

分析:最少硬币找零问题,是为了求硬币的组合,所以一个大前提是硬币无限量供应。我们建立如下表格来分析问题:

其中每列用j表示零钱总额,每行i表示硬币面额。T[i][j]表示硬币个数,它是我们即将填入表格的数字。

在填写表格之前,我们需要先明确几个规则:

  • 当填写第i行时,使用的硬币面额仅能是i以及小于i的面额。举个例子,比如我填写第0行,i=0,那么这一样只能使用面额为1分的硬币。当我填写第2行,i=2,那么可以使用1分,2分,5分三种面额的硬币。
  • 当填写第j列时,表示当前需要使用硬币凑出的总额。比如j=6,表示需要使用硬币组合出总额为6分的情况。

1. i = 0

当我们只能使用面额为1分的硬币时,根据上面的规则,那么很显然,总额为几分,就需要几个硬币。即T[i][j] = j

2. i = 1

当我们有1分和2分两种面额时,那么组合方式就相对多了点。

i=1 j = 1:总额为1时,只能使用1分的面额。即填1。 i=1 j = 2:总额为2时,可以使用2个1分的,也可以使用1个2分的。因为我们要求最少硬币,所以使用1个2分的。表格所表达的意思是硬币的数量,所以这里也填1。 i=1 j = 3:总额为3时,可以使用3个1分的,也可以使用1个1分加1个2分。因此这里应该填2。 i=1 j = 4:总额为4时,可以使用4个1分的,可以使用2个1分加1个2分,也可以使用2个2分。其中硬币最少的情况应该是2个2分。因此这里填2。 i=1 j = 5:总额为5时,组合就更多了,但是聪明的你应该能想到使用2个2分加1个1分,可以实现最少硬币的需求。因此这里填3。

我们来看填写完上面5格后的情况:

我们要根据已有的数据,总结出T[i][j]的规律,然后通过填写剩余表格来验证。

我们将硬币面额使用数组coins[i]来表示,根据表格有 1分=coins[0], 2分=coins[1]。 当j<coins[i]时,T[i][j]的值,应该等于它的同列,上一行,即使T[i][j] == T[i-1][j]。 比如我们从表中所看到的,T[1][1]==T[0][1]。 当j>=coins[i]时,根据已有的 i=1行可以推出一个规律,T[i][j]= min(T[i-1][j],1+T[i][j-coins[i]]),即二者比较取最小值。稍微解释一下,当第i行,优先选择这一行的硬币,因为这一行的硬币面额最大,最有可能使得总硬币数量最少。因此j-coins[i],就很好理解了,就是选择了这一行的硬币后,还剩下多少总额。举个例子,当i=1,j=3时,j-coins[1]=1。那么选择2分后,还剩余总额为1,这时候我们再定位到i=1,j=1,即T[1][1],它的值为1,再加上一个常数1,即得最终结果2。当然,也有可能继续沿用上一层的方法,如j=9, i = 3,继续采用[5,2,2],硬币个数最少;T[i][j-coins[i]]同样包含i使用多次的情况,如T[1][4],选用两分硬币,转到状态T[1][2],仍然选用2分硬币。

再举例,i=1 j=5。由于是从左到右填表的,所以i=1,j<5的表格都填完了。j-coins[i]=3,定位到T[1][3]=2,加上常数1,即得最后结果T[1][5]=3

3. 剩余内容

按照上一步所提供的公式,其实所有的T[i][j]都可以填完了。如下表格。

4.伪代码

以上的填表逻辑,使用伪代码表示如下


if(i == 0){
	T[i][j] = j/coins[i]; //硬币找零一定要有个 最小面额1,否则会无解
}else{
	if(j >= coins[i]){
		T[i][j] = min(T[i-1][j],1+T[i][j-coins[i]])
	
	}else{
		T[i][j] = T[i-1][j];
	}
}

5. 寻找组合

至此,填完表格我们已经接近完成了。接下来要寻找从表格中寻找硬币组合。🤔

与填表顺序相反,寻找组合从有下角开始。

首先需要明确的是如果T[i][j] == T[i-1][j],那么就向上搜索。根据图来分析:

1. 定位到T[3][11] ,由于不存在T[i][j] == T[i-1][j],所以不用向上搜索,确定选中一个6分硬币。寻找组合的思路和填写T[i][j]的思路几乎是反过来的

2. 选择一个6分硬币后,剩余的总额为11-6=5。因此定位到T[3][5]中。由于T[3][5]==T[2][5],因此看图中的蓝色箭头,向上搜索,直到T[i][j] != T[i-1]

3. 定位到T[2][5]中,此时coins[i]为5分。选中5分硬币只有,剩余的总额为5-5=0。

4. 当j=0时,搜索结束。由上面步骤确定选中的硬币组合为:1个5分,1个6分。

6.空间优化

也可对空间进行优化,T[i][j]= min(T[i-1][j],1+T[i][j-coins[i]])可优化为T[j]= min(T[j],1+T[j-coins[i]]),其中T[j]为上一行T[j]的值,引入add_table[j]记录添加货币的面值

T[j]更新过程
面值01234567891011
1001234567891011
21011223344556
52011221223323
63011221122322
add_table[j]更新过程
面值01234567891011
10011111111111
21012222222222
52012225555555
63012225666556
   //初始化每种总金额的最小货币数 
    T[0] = 0;
	for(int j = 1; j <= total; j++)
	{
		T[j] = 9999;
	}
	for(int i = 0; i < types; i++) 
	{
		for(int j = 1; j <= total; j++)   
		{
			//若总金额达不到某种货币的面值,就不用分析 
			if(j - coins[i] >= 0) 
			{
				//货币张数 = 减去这种货币面值需要的张数 + 一张该面值的货币 
				num =  T[j - coins[i]] + 1;
				//如果这种换取方式张数更少,更新状态 
				if(num < T[j])
				{
					T[j] = num;
					add_table[j] = coins[i];    //添加的货币面值 
				}
			}
		}
	}
	//输出所有金额的换取方式 
	for(int j = 1; j <= total; j++)
	{
		cout << "换取" << j << "元所需的最少货币数为:" << T[j] << ",换取方式为:" << add_table[j];
		pre = j - add_table[j];
		while(T[pre])
		{
			cout << "+" << add_table[pre];
			pre = pre - add_table[pre];
		}
		cout << endl;
	}
	return 0;

(2) 有多少种找法

问题描述

假设你是一名超市收银员,现有n种不同面值的货币,每种面值的货币可以使用任意张。顾客结账时,你需要找给顾客aim元零钱,你可以给出多少种方法。例如,有1、2、3元三种面值的货币,你需要找零3元,那么共有3种方法:1张1元+1张2元、3张1元、1张3元。

问题分析

假设长度为n的一维数组,其中每个元素对应每种货币的面值。找零钱问题可以抽象为使用penny中的元素可以有多少种方法组成数值aim
简单的,我们可以遍历数组,对下标index的元素使用i(0<=i<=aim/penny[index])次, 计算剩余的数组元素和剩余数值满足要求的方法数。把每一次的方法数相加求和即为该问题的解。不难发现,每一次要求解的都是和父问题具有同样性质的子问题,即使用中的元素有多少种方法组成数值。
由此,很容易写出该问题的暴力搜索(即递归)方法和记忆搜索方法。但是如果要直接写出动态规划的状态转移方程可能需要费点功夫。不过,我们可以按照算法思想之动态规划(一)讨论的动态规划的一般步骤进行思考。
(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。

对于上面4条,我们一一来看。
(1) 划分阶段
构造有序或可排序的阶段,要求我们计算的时候肯定当前计算结果依赖于前面阶段的结果。如果问题中的aim=3,penny=[1,2,3],如何划分阶段呢?对于penny来说,可按照下标进行划分,即1,2,3这3个阶段;对于aim来说,由于每一阶段下都要求aim是非负整数,那么我们可以划分为0~aim,即0, 1, 2, 3这4个阶段。
(2) 确定状态和状态变量
由第(1)步,我们可以得到一个n*(aim+1)的的矩阵dp,每个矩阵元素代表的含义是使用数组penny前i个元素组成数值j的方法总数。
(3) 确定决策并写出状态转移方程
由第(2)步总结可得出规律:

dp[i][j] = dp[i-1][j] + dp[i-1][j-penny[i]]+...+dp[i-1][j-k*penny[i]].

例如:仍然以(1)中的问题为例,dp[1][2] = dp[0][2] + dp[0][2-2*1](d[0][0] = 1, 既组成0元的方法为一张不拿,只有一种方法),代表使用前2个元素(即1,2)组成2的方法数 = 不使用2组成2的方法数 + 使用1个2组成2的方法数。
其实,对于该状态转移方程还可以继续优化:令z = j - penny[i], 由于dp[i][z] = dp[i-1][z] + dp[i-1][z-penny[i]]+... +dp[i-1][z-k'*penny[i]],可以看出dp[i][z]与dp[i][j]展开式中第2项之后的值是一样的,因此状态方程可化简为:

dp[i][j] = dp[i-1][j] + dp[i][j-penny[i]]

该方程也可理解为,不适用penny[i]+至少使用一次penny[i]的情况。
(4) 寻找边界条件
由第(3)步,可得到化简前的边界条件为:j - k*penny[i] >=0,化简后的边界条件为:j - penny[i] >= 0。其中d[i][0]均为1, 即组成0元的方法为一张不拿,只有一种方法。
总结来看,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)

代码实现

/**
     * 动态规划优化
     * @param penny
     * @param n
     * @param aim
     * @return
     */
    public static int core4(int[] penny, int n, int aim) {
        int[][] dp = new int[n][aim + 1];
        for (int j = 0; j < aim + 1; j++) {
            dp[0][j] = j % penny[0] == 0 ? 1 : 0;
        }
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < aim + 1; j++) {
                if (j < penny[i]) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i][j - penny[i]];
                }
            }
        }
        return dp[n - 1][aim];
    }

    public static void main(String[] args) {
        int[] penny = new int[]{3, 4, 7};
        int n = penny.length;
        int aim = 33;
        System.out.println(countWays(penny, n, aim));
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值