算法思想--动态规划

本文深入讲解动态规划的基本概念和解题思路,通过典型例题详细分析动态规划的应用场景和技术要点,帮助读者掌握动态规划的核心思想。

动态规划:1.相比于递归可以说是避免了重复计算,使运算速度得到提高,动态规划是从确定边界条件开始,这种递推可以看成是递归的逆过程。

                 2.利用动态规划首先是要确定问题中包含的状态,以及我们每个状态所对应的值,每个状态就是子问题的解,每个状态都是由参数组成的,也就是说,要先确定好所需的参数从而确定好状态。然后我们要根据参数来建立相应的数组,数组里的值就是每个状态相对应的值,然后我们先确定数组的边界值,再根据状态转移方程来确定数组中剩下的值(考虑边界值时我们可以先从递归的角度得到递归的返回条件,根据返回条件来确定边界值)。
                 3.动态规划中确定数组长度时,如果状态中有参数存在0的情况,我们声明的数组的长度就应该在原来的基础上加一,比如最长公共子序列中,状态的参数有  其中一个序列长度m,另一个序列长度n,存在m=0,和n=0的情况,所以我们建立的二维数组的长度应该是m+1和n+1.

                 4.在确定状态转移方程时,我们应该将数组之前的数当成已知条件来用。和递归一样不用考虑怎么来的。

                   


数学三角形问题:在一个用数字组成的三角形中,找到一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步只能往左下走或者往右下走。只需要求出这个最大和即可,不必给出具体路径
           7
       3     8
   8      1       0
2     7       4       4

思考:这个问题可以通过递归来解决,代码如下

public int maxsum(int i,int j,int a[][]) {
		int D[][]=new int[4][4];
		if(i==a.length-1) {
			return a[i][j];
		}
		int x=maxsum(i+1,j,a);
		int y=maxsum(i+1,j+1,a);
		if(x<y) {
			return a[i][j]+y;
		}
		return a[i][j]+x;
	}
此时时间复杂度较高,因为存在较多的重复计算,我们为了避免重复计算可以事先定义好一个数组用来存放结果,调用此方法时如果已经得到了结果(及数组中有值),则直接return,就可以避免一些重复计算,避免重复计算的代码如下
public int maxsum(int i,int j,int a[][],int max[][]) {
	    if(max[i][j]!=-1) {
	    	return max[i][j];
	    }
		if(i==a.length-1) {
			max[i][j]=a[i][j];
			return a[i][j];
		}
		int x=maxsum(i+1,j,a,max);
		int y=maxsum(i+1,j+1,a,max);
		if(x<y) {
			max[i][j]=a[i][j]+y;
			
		}
		else max[i][j]=a[i][j]+x;
		return max[i][j];
	}
我们还可以将数学三角形问题用递推来解决用max数组里的最后一行元素和a数组里的元素算出max数组里的各个元素,具体实现代码如下
public static void main(String[] args) {
	int a[][]=new int [5][5];
	a[0][0]=7;
	a[1][0]=3; a[1][1]=8;
	a[2][0]=8; a[2][1]=1; a[2][2]=0;
	a[3][0]=2; a[3][1]=7; a[3][2]=4; a[3][3]=4;
	a[4][0]=4; a[4][1]=5; a[4][2]=2; a[4][3]=6; a[4][4]=3;		
	
	int max[][]=new int[5][5];
	for(int i=0;i<5;i++) {
		max[4][i]=a[4][i];
	}
	
	for(int i=3;i>=0;i--) {
		for(int j=0;j<i+1;j++) {
			if(max[i+1][j]>max[i+1][j+1]) {
				max[i][j]=max[i+1][j]+a[i][j];
			}
			else max[i][j]=max[i+1][j+1]+a[i][j];
		}
	}
     System.out.println(max[0][0]);
	}

我们在解决这个题的过程中一开始是用递归解题,后来因为递归产生了很多重复计算我们又用到了动归

递归到动归的一般转化方法:递归函数有n个参数,就定义一个n维的数组,数组的下标是递归函数的返回值,这样就可以从边界值开始,逐步填充数组,相当于计算递归函数值的逆过程。    比如这个问题我们在利用递归解决时必要参数是i和j,所以我们使用动归解题时是设一个二维数组max,从边界值开始逐步填充此数组。

动规解题的一般思路:1.将原问题分解为子问题  
把原问题分解为若干个子问题,子问题和原问题形式相同或相似,只不过规模变小了。子问题都解决了,原问题就解决了。

子问题的解一旦求出就被保存,所以每个子问题只需要求解一次(这是和存粹的递归的区别)
                                  2.确定状态

在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个“状态”。一个“状态”对应于一个或多个子问题,所谓某个“状态”下的值就是这个“状态”所对应的子问题的解。
所有“状态”的集合,构成问题的“状态空间”。“状态空间“的大小,与用动态规划解决问题的时间复杂度直接相关。在数学三角形的例子里,一共有n*(n+1)/2个数字,所以这个问题的状态空间里一共就有n*(n+1)/2个状态。

        整个问题的时间复杂度就是状态数目乘以计算每个状态所需要的时间。
在数学三角形中每个状态只需要经过一次,而且每个状态上做计算所花的时间是和n无关的整数

       用动态规划解题经常遇到的情况是,k个整形变量能构成一个状态(如数学三角形中的行数和列数能构成状态)。如果这k个整形变量的取值范围分别是n1,n2,……nk,那么我们就可以用一个k维的数组array[n1][n2]……[nk]来存储各个状态的值。这个值不一定就是一个整数或者浮点数,可能是一个结构才能表示的,那么array就可以是一个结构数组。一个”状态“下的值通常是一个或多个子问题的解。

                  3.确定一些初始状态(边界状态)的值
以数学三角形为例,初始状态就是底边数字,值就是底边数字值。

                  4.确定状态转移方程
定义出什么是状态,以及在该状态下的值后,就要找出不同的状态之间如何转移---即如何从一个或多个已知的状态值找出另一个状态的值(人人为我递推形)。状态的转移可以用递推公式表示,此递推公式也可以被称作状态转移方程。

数学三角形的状态转移方程

max[r][j]=a[r][j]         if(r==n)
max[r][j]=Max(max[r+1][j],max[r+1][j+1])+a[r][j]

能用动归解决的问题的特点
1.问题具有最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质

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

例题一:最长上升子序列
对于序列a1,a2,……ai,aj,如果此时它的某个子序列的元素下标从左往右依次增大,元素值也依次增大。比如,(1,7,3,5,9,4,8)中有它的上升子序列,如(1,7),(3,4,8)等,这些子序列中最长的长度是4,所有最长上升子序列的长度是4

找子问题(找子问题时注意子问题是否满足无后效性)我对于无后效性的理解是如果存在哪个子问题对于其下一级的子问题有影响,则此问题不具有无后效性。比如这个问题如果将求序列的前n个元素的最长上升子序列的长度变成n-1一直到1,这样分解子问题不具有无后效性。因为n=2时可以有(1,7)和(1,3)但n=3时如果另一个元素为5,则形成的子问题不同。即同一级的是哪个子问题对其上一级的子问题不能有影响

解题思路:  子问题可以分解为: 求以ak为终点的最长上升子序列的长度。
                 确定状态:子问题只和一个变量--数字的位置有关。因此序列中数的位置k就是状态,而状态k对应的值,就是以ak作为终点的最长子序列的长度

                   找出状态转移方程:  初始状态:max(1)=1
                                                   max(k)=max{max(i): 1=<i<k且ai<ak且k!=1}+1 ,若找不到这样的i,则max(k)=1   

                   max(k)的值,就是在k左边,终点数值小于ak,且长度最大的那个上升子序列的长度再加1。因为ak左边任何终点小于ak的子序列,加上ak后就能形成一个更长的上升子序列。 具体实现代码如下

	public static void main(String[] args) {
	 int n=7;
	 int a[]= {1,7,3,5,9,4,8};
	 int max[]=new int[7];
     max[0]=1;   //max[0]肯定为1,先赋好值,再通过max[0]来递推后面的值
	 
	 for(int i=1;i<n;i++) {    //这个for循环表示以a【i】为终点
		 for(int j=0;j<i;j++) {  //这个循环表示a【i】为终点时,a[i]之前的数
			 if(a[i]>a[j]) {
				 if(max[i]>max[j]+1) {
					 max[i]=max[i];
				 }
				 else max[i]=max[j]+1;
			 }
		 }
	 }
	 
//将max数组里的数输出,max数组里的最大值就是所求的值	 
	 for(int i=0;i<max.length;i++) {
		 System.out.print(max[i]+"   ");
	 }
	}


例题二:最长公共子序列

给出两个字符串,求出这样的一个最长的公共子序列的长度:子序列中的每个字符都能在两个原串中找到,而且每个字符的先后顺序和原串中的先后顺序一致。

思考:找子问题  此问题可以看成左边字符串i个字符和另一个字符串j个字符所形成的最长的公共子序列的长度
          确定状态  max【i】【j】

          递推公式  


public static void main(String[] args) {
      char str1[]="abcfbc".toCharArray();
      char str2[]="abfcab".toCharArray();
      
      int length1=str1.length+1;
      int length2=str2.length+1;
      int maxlen[][]=new int[length1][length2];
      for(int i=0;i<length1;i++) {
    	  maxlen[i][0]=0;
      }
      for(int j=0;j<length2;j++) {
    	  maxlen[0][j]=0;
      }
      
      for(int i=1;i<length1;i++) {
    	  for(int j=1;j<length2;j++) {
    		  if(str1[i-1]==str2[j-1]) {
    			  maxlen[i][j]=maxlen[i-1][j-1]+1;
    		  }
    		  else if(maxlen[i][j-1]>maxlen[i-1][j]) {
    			  maxlen[i][j]=maxlen[i][j-1];
    		  }
    		  else  {
    			  maxlen[i][j]=maxlen[i-1][j];
    		  }
    	  }
      }
      for(int i=0;i<length1;i++) {
    	  for(int j=0;j<length2;j++) {
    		  System.out.print(maxlen[i][j]);
    	  }
    	  System.out.println();
      }
    		
}
问题:书写此代码时总是遇到错误原因是我们定义数组maxlen的大小时定义的太小,不能存放全部的结果。maxlen[i][j]表示第一个字符串的前i个字符和另一个字符串的前j个字符所能形成的最长公共子序列

例题二:最佳加法表达式:有一个由1到9组成的数字串,问如果将m个加号插入到这个数字串里,在各种可能形成的表达式里,值最小的那个表达式的值是多少?

思考:假定数字串长度是n,添完加号后,表达式的最后一个加号加在第i个数字后面,那么整个表达式的最小值,就等于在前i个数字里插入m-1个加号所能形成的最小值,加上第i+1到第n个数字所能组成的数的值(i从1开始算)

设v(m,n)表示在n个数字中插入m个加号所能形成的表达式的最小值,那么

if m=0  v(m,n)=n个数字组成的整数

else if (n<m+1 )   v(m,n)=∞

else v(m,n)=min{v(m-1,i)+Num(i+1,n)}  (i=m......n-1)

Num(i,j)表示从第i个数字到第j个数字所组成的数

 
例题:神奇的口袋

有一个口袋可以变出一些物品,这些物品的总体积必须是40.john现在有n个想要的物品,每个物品的体积分别是a1,a2,等等。john可以从这些物品中选择一些,如果选出的物体的总体积是40,那么john有多少种不同的选择物品的方式
思考:这n个物品中每一个都有要和不要两种情况,所以在利用枚举解决时我们是枚举每个物品是要还是不要

我们还可以利用递归解决问题,现在是从n个物品中选出体积为40的组合,在确定第n个是要还是不要后我们可以将问题简化为从n-1个物品中选出体积为什么的组合,代码如下

   public int jisuan(int k,int w,int a[]) {
	   if(w==0) return 1;
	   if(k<=0) return 0;
	    return jisuan(k-1,w,a)+jisuan(k-1,w-a[k-1],a);
   }

反思:我们的回归条件的先后也会影响到最终结果,比如此题中当k等于0时w也可能等于0,如果先是return0,就跳过了一种情况。    我们考虑方法的参数时,应该是让这些参数能够表示这个问题。
动归解法:代码如下
public static void main(String[] args) {

		int c[]= {0,20,20,20};
	    int max[][]=new int[4][41];
		for(int i=0;i<41;i++) {
			max[0][i]=0;
		}
		for(int i=0;i<4;i++) {
			max[i][0]=1;
		}
		
		for(int i=1;i<=3;i++) {
			for(int j=1;j<=40;j++) {
				
				max[i][j]=max[i-1][j];
				if(j-c[i]>=0) {
					max[i][j]+=max[i-1][j-c[i]];
				}
			}
		}
		
		System.out.print(max[3][40]);
		}

	}

思考:我们在利用动归解题时,就像是递归的逆方法,递归是将规模较大的问题变成规模较小的问题进行解决,而动归就像是从边界条件给目标数组赋值,然后通过边界条件和状态转移方程得到一开始的问题的解。而我们一开始对目标数组赋初值的时候是考虑为0的情况,比如这个题的边界条件就是考虑能选的物品为0的情况,因为能选的物品有n种,所以我们新建数组时数组行数应该是n+1,同理设置列数的大小。
确定初始状态时应该注意顺序问题,比如此题中我们应该先初始化0;后初始化1,因为1要将0覆盖住。

这个题中数组里的值表示有n个物品m容积中填满这m容积有多少选择,所以当其等于0时我们得到1;但是下一个问题中因为问题有多个需要满足的条件所以下一题时我们的数组表示在n个物品m容积背包情况下,价值和最大是多少。

例题:背包问题
有n个物品和一个容积为M的背包,第i件物品的体积是w[i],价值是d【i】求解将哪些物品放入背包可使价值总和最大。每种物品只有一件,可以选择放和不放。代码如下:
public static void main(String[] args) {

		int w[]= {0,20,20,20};
		int d[]= {0,2,5,2};
		int m=40;
		int n=3;
		
	    int max[][]=new int[4][41];
	    
	    for(int j=0;j<41;j++) {
	    	if(j>=w[1]) max[1][j]=d[1];
	    	else max[1][j]=0;
	    }
		
		for(int i=1;i<=3;i++) {
			for(int j=1;j<=40;j++) {
				max[i][j]=max[i-1][j];
				if(j-w[i]>=0) {
					max[i][j]=Math.max(max[i-1][j], max[i-1][j-w[i]]+d[i]);
				}
			}
		}
		
		System.out.print(max[3][40]);
		}
	}

思考:这个题跟那个神奇背包情况类似,神奇背包是求n个物品放满k空间的放法,这个题是求n个物品放慢k空间的情况下让物品的总价值最大。所以这两个题是有相似之处的,但是也有区别,比如一开始确定初始状态时,神奇背包的临界条件是n==0和k==0的情况,因为这两种情况能够确定放法,而这个问题的数组里放置的不是放法而是最大的总价值,而我们一可以确定的最佳总价值只有n==1时这时候在考虑剩下的空间能不能放下这个物品

因为我们的max[i][j]是表示前i个物品所以我们给边界条件max【1】【j】赋值赋的是d【1】而不是其他

例题:城墙问题
X国的一段古城墙的顶端可以看成 2*N个格子组成的矩形(如图所示)
现需要把这些格子刷上保护漆。 你可以从任意一个格子刷起,刷完一格,可以移动到和它相邻的格子(对角相邻也算数),但不能移动到较远的格子(因为油漆未干不能踩!) 比如:a d b c e f 就是合格的刷漆顺序。 c e f d a b 是另一种合适的方案。 当已知 N 时,求总的方案数。当N较大时,结果会迅速增大,请把结果对 1000000007 (十亿零七) 取模。 输入数据为一个正整数(不大于1000) 输出数据为一个正整数。 例如: 用户输入: 2 程序应该输出: 24

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值