初识动态规划

  最近看图最短路径算法时看到了Floyd算法,这个算法是个经典的动态规划算法。由于是一个算法萌新,所以顺便拿起算法导论,看了一下动态规划的章节。如下是自己的读书笔记加个人理解。

  动态规划(dynamic programming):这是一种经典的算法思想,跟分治思想有点相似。其最基本的原型思想是:对于一个问题,如果能找到其一个最有子问题的结构,然后将该问题分解为若干个最优子结构的问题递归的调用该最优子结构直到达到最小结构为止。初看和分治思想十分相似,但其具体区别在于,动态规划的最优子结构之间是允许重叠的,而分治思想的子结构是不重叠的。反过来想:对于一个问题,子结构是有重叠的,如果用分治思想去做的话就会导致一个问题,它会反复求解那些重叠的子问题,从而使整个问题的求解规模和时间变得相当的大;而用动态规划则只需对每个子问题求解一次,这样可以大大减少计算量。如果分治思想学的比较好的话,其实可以这么对分治算法进行改进,用一个数据或表将分治算法每一个子问题的解保存在里面,在每次计算时先从数组或表中找一下该子问题是否已经计算过,如果是则直接从表中将结果取出,如果没有则将计算的结果保存在数组或表中(其实就是算法设计中一个十分经典的思想:用空间换时间)。

  动态规划算法设计的步骤:

    1.刻画一个最优解的结构特征;

    2.递归的定义最优解的值;

    3.计算最优解的值,通常采用自底向上法(也可以采用上面所介绍的那种空间换时间的方法:这里叫带备忘录的自顶向下法)

    4.利用计算出来的解构造一个最优解。

  这里是算法导论上面的一个钢条切割题。这是一家公司出售的一段长度为i英寸的钢条的价格表,给定一根长度为n的的钢条求出一个切割方案使得该公司的销售收益最大。

长度 1 2 3 4 5 6 7 8 9 10
价格 1 5 8 9 10 17 17 20 24 30

  按照上述步骤:

  1.求解一个最优解的结构特征:对于一个长度为n的钢条一定存在一个切割方法使得前i长度和后n-i长度的收益最大,而前i长度和后n-i长度又可以看做是两个子问题,其又可以再次进行切割。故该问题的最优解是存在的。

    假如我们仅仅依据这个思想也可以得到一个如下的算法

#include<stdio.h>

//比较两数大小的函数 int max(x,y) { if(x>y) { return x; }else { return y; } } //分割算法 int cutrod(int a[],int n) { int i; int q = -99999;  //表示无穷小 if(n==0) { return 0; } for(i=1;i<=n;i++) { q = max(q,a[i]+cutrod(a,n-i));   } return q; } //主函数 int main() { int a[11]={0,1,5,8,9,10,17,17,20,24,30}; printf("%d",cutrod(a,10)); return 0; }

  该算法大家可以试一下,如果将n设置的为大于10的数,你就会明显感觉到这个算法运行起来变慢了,而且当你的数稍微大一点可能就很慢了。出现这种问题的原因就是我们前面介绍时所提到的,由于这个问题中包括很多重复的子问题,而每一次递归调用都会重复求解这些子问题,从而导致该问题的规模会变得十分大。可以证明得该问题的规模是2的n-1次成指数级增长,所以当n稍微大一点问题的规模就会变得十分庞大。

  例如:当n=4时,可以分解为(1,3),(2,2),(3,1),(4,0);对于(1,3)我们又可以分解为((1)(1,2)),((1),(2,1)),((1),(3,0))再分解为((1),(1,(1,1)),),((1),((1,1),1)),((1),((1,2),0)),((1),((1,2),0))这三种,同理(2,2)可以分解为((1,1)(2)),((1,1),(1,1)),((2,0),(2,0)),((2,0),(1,1))。后面都是一样的思路,从中可以看到在我们求解(1,3)这种情况时的很多子问题,在求解(2,2)这种情况重复出现了,这就是造成问题规模成指数增长的原因。

  对上述算法做稍微的改进加一个数组用来存储每次递归计算的子问题的结果:

int memoized_cut_rod(int p[],int n)  //初始化存储子问题的数组r
{
	int r[n];
	int i;
	for(i=0;i<=n;i++)
	{
		r[i] = -99999;  //子问题最优解的初始值为无穷小
	}
	return memoized_cut_rod_aux(p,n,r);
}

int memoized_cut_rod_aux(int p[],int n,int r[])
{
	int q,i;
	if(r[n]>0)  //每次递归调用时先判断该最优子问题是否已经存在,若存在则r[n]一定为正数(因为是收益不可能为负吧!)。则不用递归计算直接从r[n]中取出结果返回即可
	{
		return r[n];
	}
	if(n==0)
	{
		q=0;
	}else    //若
	{
		q = -99999;
		for(i=1;i<=n;i++)
		{
			q=max(q,p[i]+memoized_cut_rod_aux(p,n-i,r));
		}
	}
	return q;
}

  这里就是加了一个r数组来存放每个子问题的最优解,从而可以大大减少递归的次数。你可以在拿这个算法试一下n=15看看运行时间是不是快了很多。

  上面介绍的两个算法都不是真正的动态规划算法。其标准形式是一种自底向上的求解方法。也就是先求出大问题的小问题,然后一步一步的将小问题合并,最后通过若干次循环合并就能得到最终大问题的解。

bottom_to_top(int p[], int n)
{
	int i,j,r[n+1],q;
	for(i=0;i<=n;i++)
	{
		r[i]=0;  //初始化数组r,用来存放子问题最优解
	}
	for(i=1;i<=n;i++)  //循环每个字长度
	{
		q=-99999;  //最优解先初始化为无穷小
		for(j=1;j<=i;j++)
		{
			q=max(q,p[j]+r[i-j]);  //在求解长度i的钢条时,i-j长度的最优解是已经求出来的
		}
		r[i] = q;  //将最优解保存在r中,注意i是一次从1开始慢慢变长的,这也是上面为什么r[i-j]是可以直接调用的原因
	}
	for(i=1;i<=n;i++)
	{
		printf("%d ",r[i]);
	}
}

  对比这三种算法,我们可以看出来最后一个算法在形式和效率上是最优的,这也是动态规划算法优势体现,其实第二个算法和第三个算法在时间复杂度上面是一样的都是O(n2).只是第三中算法更加简洁明了。

  这只是动态规划算法的一个简单例子,通过这个例子能很好的展现出来动态规划的算法的强大和高效,动态规划算法的更多例子希望大家自行查找阅读。最后我们再来看一下floyd算法的核心代码:

#include<stdio.h>

int e[100][100];

int main()
{
	int i,j,k,n,m,w,a,b;
	scanf("%d %d",&n,&m);
	//图的初始化
	for(i=1;i<=n;i++)
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				e[i][j] = 0;
			}else
			{
				e[i][j] = 999999;  //将图中的两顶点距离初始化为无穷大
			}
		}
	}
	
	//相连的边赋值
	for(i=1;i<=m;i++)
	{
		scanf("%d %d %d",&a,&b,&w);
		e[a][b] = w;
	}
	
	//floyd算法:节点i到j的最短路径经过中间节点k,而i,k最短路径又经过一个节点,k,j的最短路径又可以分为两部分,最优子结构存在,也可以尝试用递归写一个看看两者之间的区别
	for(k=1;k<=n;k++)  //中间节点
	{
		for(i=1;i<=n;i++)  //起始节点
		{
			for(j=1;j<=n;j++)  //终止节点
			{
				if(e[i][k]<999999 && e[k][j]<999999 && e[i][j]>e[i][k]+e[k][j])
				{
					e[i][j] = e[i][k] + e[k][j];
				}
			}
		}
	}
	
	
	for(i=1;i<=n;i++)
	{
		for(j=1;j<=n;j++)
		{
			printf("%d ",e[i][j]);
		}
		printf("\n");
	}
	return 0;

}

  这个算法用于求解图的各点间的最短路径。时间复杂度O(n3)

posted @ 2017-02-25 13:17  OverFitting  阅读(386)  评论(0编辑  收藏  举报