这几天看完了分治算法,今天来做一个总结。
算法背景:分治法是算法学习中遇到的第一个算法设计思路(递归其实更偏向于一种编程技巧而并非单独的算法),但算法并不像刚接触算法的人该学习的内容。说白了,算法还是比较难的。
分治法是一种说起来简单做起来难的算法。说起来简单是因为算法的设计思路是完全符合人的思维过程的:分而治之嘛,在面向过程的程序设计中我们都有这样的体会,把一个复杂的问题模块化,每个模块实现一个小的功能,最后将各个模块连接起来完成最后的功能要求。这其实就是分治法的思想,把大问题分开成小问题,然后解决每个小问题,最后把解合并起来得到原问题的解。做起来难是因为在算法设计过程中“分”和“合”不像通常我们想的那么简单,并不是所有问题用一句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非常大的情况,只有在这种情况下,其优点才能发挥出来。
最后总结:分治思想是程序设计中非常常见的一种思想,但真正的分治算法还是有一定难度的。关键在于分和合的处理过程,这个说多了也没啥用,还是慢慢体会吧。共勉