分治法小结

这几天看完了分治算法,今天来做一个总结。

算法背景:分治法是算法学习中遇到的第一个算法设计思路(递归其实更偏向于一种编程技巧而并非单独的算法),但算法并不像刚接触算法的人该学习的内容。说白了,算法还是比较难的。

分治法是一种说起来简单做起来难的算法。说起来简单是因为算法的设计思路是完全符合人的思维过程的:分而治之嘛,在面向过程的程序设计中我们都有这样的体会,把一个复杂的问题模块化,每个模块实现一个小的功能,最后将各个模块连接起来完成最后的功能要求。这其实就是分治法的思想,把大问题分开成小问题,然后解决每个小问题,最后把解合并起来得到原问题的解。做起来难是因为在算法设计过程中“分”和“合”不像通常我们想的那么简单,并不是所有问题用一句n/2就可以将问题分开。就算是分开了,最后不能合并也是徒劳。

设计思路:从前面的叙述中我们其实已经可以知道其设计思路了,就是分开治理然后合并。在这个过程中合并是关键,而能否成功合并又和怎么分是息息相关的,所以你说分是关键也没问题。总之是分得好才能合,分的时候是要以能否合并为主要参考条件的。另外,分出来的子问题一般都是与原问题性质相同的小规模问题,这就使得递归算法派上了用场。递归和分治是联系很紧密的一对好兄弟,在程序设计过程中它们经常一起出现。

设计方法:分治法的设计方法可以用下面这段伪代码来说明

divide_and_conquer(P(n))
{
	if(n<=n0)//分治阀值
		return adhoc(P(n));
	else
	{
		divide P into smaller subinstances P1,P2,…,Pk//分解问题
			
		for(i=1; i<k; i++)//分别求解
			yi = divide_and_conquer(Pi);

		return merge(y1,y2,…,yk);//最后合并问题的解
	}
}

算法实例:分治法的例子有很多,但具有代表性的却很难找。原因前面也说了,针对每个问题,分和合的过程都是不同的,不同问题的分解合并过程可以没有任何关系。而且这个过程涉及的数学知识比较多,如果对相关数学知识不熟悉,那读懂别人的代码都是很困难的,别说自己去设计了。但例子还是要学习的,越是要根据具体情况来的算法例子越重要,否则就完全“形而上学”了。

例子1:快速排序

快速排序是大家比较熟悉的一种排序算法,所以今天就拿它先开刀吧。

快速排序的具体思想我就不多说了,简单说来就是划分,对子数组部分排序,再对子数组排序过程中还是先划分……从这个叙述中就可以看出,这个问题本身就是分治法和递归的练兵场,问题本身完全符合分治法的设计思路。

那就开始设计算法吧:首先是分,怎么把一个数组分开呢?了解算法的人肯定知道,是找一个枢轴值,让枢轴值左边儿的都小于它,右边儿的都大于它。这个过程我们可以用一个划分函数来实现;接下来就是治,治的过程也比较简单,因为子问题本身是和原问题性质相同的,所以可以利用递归技术来解决。这里主要注意两点,一是递归的出口,即基础步的规模阀值。以前设计递归算法时都是让n==1或n==0,即让规模达到最小,但在分治法中我们并不一定这么做,因为分治法的优势主要是用分和合的过程使问题规模减小了,但我们要知道,分和合的过程本身也是要有时间的。如果规模减小带来的时间优势比不过分和合带来的时间损耗,那分治法就失败了。但这个平衡点不是很好找,且为简单起见,我们在这里还是用n==1来作为递归出口。二是在递归时要用到上一步分的临界点,所以在划分算法中要得到划分的点;最后是合,这个就简单了,分治完以后数组就是排序好的,并不需要额外的处理。

有了上面的分析,我们可以写代码了

template<typename Type>
int split(Type A[],int low,int high)
{
	//功能:对数组元素部分划分,使前面的元素都小于枢轴点,后面的都大于枢轴点
	//输入:数组首地址A,划分起始位置下标low,划分终点位置下标high
	//输出:返回枢轴点新下标,划分好的新数组
	//说明:以A[low]元素作为划分枢点
	int i=low;
	Type x = A[low];
	for(int k = low+1;k <= high;k++)
	{
		if(A[k]<=x)
		{
			i = i+1;
			if(i!=k) swap(A[i],A[k]);
		}
	}
	swap(A[low],A[i]);
	return i;
}

template <typename Type>
void quick_sort(Type A[],int low,int high)
{
<span style="white-space:pre">	</span>//功能:快速排序
<span style="white-space:pre">	</span>//输入:待排序数组首地址A,排序下标范围low-high(含两个端点)
<span style="white-space:pre">	</span>//输出:排序好的数组
<span style="white-space:pre">	</span>//说明:递归调用自身直至low>=high成立,即只剩一个元素为止
	int k;
	if(low < high)
	{
		k = split(A,low,high);
		quick_sort(A,low,k-1);
		quick_sort(A,k+1,high);
	}
}

这个算法是时间复杂度我们都很熟悉,最坏情况下为O(n^2),最好情况下为O(nlogn),平均情况下约为1.44nlogn,这个不是我们讨论重点,就不多说了。另外算法应用了模板函数,以便适应不同类型的数据排序。

这个代码对于熟悉快排的人很简单,关键注意里面分治法的应用。

例子2:求无序数组中第k大的元素

这个例子也是一个比较经典的问题,对应其分治算法又称为选择算法。如果采用蛮力法,那算法的时间复杂度为O(kn)即算法的时间复杂度和k有直接关系,当k值较大(如n/2)时,那算法的时间复杂度趋向于O(n^2)。而采用选择算法可以使其算法的复杂度降为O(n)。

那就来说一说该怎么设计算法吧。

先是分,这个就比较烦人,怎么分呢?你想想,给你一个数组,没有顺序,从中间分?分开后分别找每个数组第k个大的元素?……这显然行不通。那怎么办呢?这个问题说实在的,一般人是真想不到。那就看看高手是怎么干的吧。先找出这个数组的近似中值元素,然后用这个中值元素将数组分为三部分:小于中值的、等于中值的、大于中值的。然后看这三部分元素个数跟k的关系,通过这个关系可以排除一部分数据,对剩下的数据进行继续处理即可。(这其实是一个剪枝类型的算法)

至于怎么找近似中值元素,我们采用这样的办法:把数据每几个分成一个小组,然后用一个常数时间求出每个小组的中值,然后再将这些中值分组,最后得到一个近似中值元素。有了这个元素可以通过一次历遍将整个数组分成三部分了,我们记为P,Q,R。

下面是治,这个也简单,看P、Q、R的大小跟k的关系,如果k<P,那第k大的元素就在P里面;如果P<k<=P+Q,那Q里面的元素就是第k大的元素;如果k>P+Q,那第k大元素就在Q里。接下来就是递归调用了。

最后的合同样可以省略了(两个例子都没有合的过程,失误啊,大家还是要多看别的例子)。

下面我们来看代码。

template <typename Type>
Type select(Type A[],size_t n,size_t k)
{
	int i,j,s,t;
	Type m,*p,*q,*r;
	if(n<=38)
	{
		merge_sort(A,n);
		return A[k-1];
	}
	p = new Type[3*n/4];
	q = new Type[3*n/4];
	r = new Type[3*n/4];
	for(i = 0;i<n/5;i++)
		mid(A,i,p);
	m = select(p,i,i/2+i%2);
	i = j = s = 0;
	for(t = 0; t<n; t++)
		if(A[t]<m)
			p[i++] = A[t];
		else if(A[t]==m)
			q[j++] = A[t];
		else
			r[s++] = A[t];
	if(i>k)
		return select(p,i,k);
	else if(i+j>=k)
		return m;
	else
		return select(r,s,k-i-j);
}

这个代码里面还涉及一个merge_sort(归并排序)算法和mid(求近似中值)算法。为了方便大家,在下面给出:

template <typename Type>
void merge(Type A[],size_t low,size_t mid,size_t high)
{
	//归并算法,low、mid、high都是下标
	Type *B = new Type[high-low+1];
	int i = low;
	int j = mid;
	int k = 0;
	while(i<mid && j<=high)
	{
		if(A[i]<A[j])
		{
			B[k++]=A[i++];
		}
		else
		{
			B[k++]=A[j++];
		}
	}
	while(i<mid) B[k++]=A[i++];
	while(j<=high) B[k++]=A[j++];
	for(int i=0; i<high-low+1; i++)
		A[i]=B[i];
	delete[] B;
}
//merge_sort的模板函数
template <typename Type>
void merge_sort(Type A[],size_t n)
{
	//归并算法
	n = n -1;//将n转化为下标
	if(n<=0) return;
	else
	{
		merge_sort(A,n/2+1);
		merge_sort(A+n/2+1,n-n/2);
		merge(A,0,n/2+1,n);
	}
}
//取A中每5个元素的中值
template <typename Type>
void mid(Type A[],size_t i,Type p[])
{
	size_t k = 5*i;
	if(A[k]>A[k+2])
		swap(A[k],A[k+2]);
	if(A[k+1]>A[k+3])
		swap(A[k+1],A[k+3]);
	if(A[k]>A[k+1])
		swap(A[k],A[k+1]);
	if(A[k+2]>A[k+3])
		swap(A[k+2],A[k+3]);
	if(A[k+1]>A[k+2])
		swap(A[k+1],A[k+2]);
	if(A[k+4]>A[k+2])
		p[i]=A[k+2];
	else if(A[k+4]>A[k+1])
		p[i]=A[k+4];
	else
		p[i]=A[k+1];
	
}

这里面涉及的程序处理也比较多,有精力大家可以看一看。最重要的还是理解分治法的应用过程。在select算法中,注意分治的阀值,正如前面所说,阀值不一定为n==1,这里取n==38,即当n小于38时不再分组,而是直接用归并排序得到其第k个大的元素。这也说明这个算法的应用条件是n非常大的情况,只有在这种情况下,其优点才能发挥出来。

最后总结:分治思想是程序设计中非常常见的一种思想,但真正的分治算法还是有一定难度的。关键在于分和合的处理过程,这个说多了也没啥用,还是慢慢体会吧。共勉





    原文作者:递归与分治算法
    原文地址: https://blog.csdn.net/cyfcsd/article/details/49924291
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞