相应的练习代码:https://github.com/liuxuan320/Algorithm_Exercises
0.写在前面
这一次,我们终于接触到实质性的算法了。其实常用的经典算法不多,大概有:分治法、贪心法、动态规划、回溯法、分支限界法等,这次我们来认识一下:大事化小,小事化了的分治法。
1. 分治法的定义
1. 分治法的提出
分治法作为一种重要的算法,具有一种标志性的意义。虽然分治法并不能在根本上提出一种新型的解决办法,不过它极大的扩展了一种算法,也就是并行算法,很多大规模问题,在第一步处理上,都是使用分治法从而在数量级上改变了运算速度。
2.分治法的一般描述
如果一个问题,其输入规模为n,且取值非常大时,就需要使用分治法。在将这n各输入分成k各不同子集合的情况下,如果能得到k个不同的可独立求解的子问题,其中1
2. 二分检索
二分检索作为一种经典的分治法第一步骤的例子,也是我们最为熟悉的例子。通常猜数大小时,我们就会使用这种策略,每次都删掉近乎一半的错误答案,从而最终获得最终答案。但是这种策略通常有一个先验假设,也就是说每种答案的可能性都是一样的,这样可以采用均分的策略最快求出答案,因为基于比较的检索的时间下限就在于此,接下来我们会讲到。
1. 二分检索的定义
分治法的分解策略通常会把原问题分解成类型相同的两个子问题,因此就出现了二分检索。下面是一种二分检索算法:
procedure BINSRCH(A,n,x,j)
//给定一个按非降次序排列的元素数组A(1:n),n≥1,判断x是否出现,若是,置j,使得x=A(j),若非,j=0
integer low,high,mid,j,n;
low<-1;high<-n
while low≤high do
mid<-(low+high)/2 //向下取整
case
:x<A(mid):high<-mid-1
:x>A(mid):low<-mid+1
:else: j<-mid;return
endcase
repeat
j<-0
end BINSRCH
2. 二分检索的时间复杂度
这样一个二分检索就完成了,但是这只是第一步,我们还要分析以下二分检索的时间复杂度。具体的证明由于篇幅有限,我们并不给出,不过给出的是其结果,顺便提醒以下使用内部路径长度与外部路径长度公式:
以及检索路径图来解决。
下表就是二分检索算法的时间复杂度:
结果 | 最好情况 | 最坏情况 | 平均情况 |
---|---|---|---|
成功 | Θ(1) | Θ( logn ) | Θ( logn ) |
失败 | Θ( logn ) | Θ( logn ) | Θ( logn ) |
3. 以比较为检索时间的下界
由上图我们可以知道,二分检索的时间复杂度为Θ(
logn
)那么,是不是最好的呢?
下面我们来介绍一个结论:
定理: 设A(1:n)含有n(n≥1)个不同的元素,排序为A(1)<…A(n);又设用以比较为基础去判断是否x∈A(1:n)的任何算法再最坏情况下所需要的最小比较次数是FIND(n),那么FIND(n)≥ ⌈ log(n+1) ⌉ 。
当然我们要去证明这个定理,并且我们想以此通过这个定理来给上面二分检索的时间复杂度提供一个证明的思路。
证明:通过考察模拟求解检索问题的各种可能算法的比较树可知,FIND(n)不大于树中由根到一个叶子的最长路径的距离。在这所有的树中都必定由n个内节点与x在A中可能的n种出现情况相对应。如果一颗二元树的所有内节点所在的级数小于或等于级数k,则最多由
2k−1
个内节点。因此,n≤
2k−1
,即FIND(n)≥
⌈
log(n+1)
⌉
,证毕。
因此,我们说二分检索是解决检索问题的最优的最坏情况算法。但实际上,检索算法未必需要基于比较,例如以计算为基础的检索算法:哈希算法,其时间复杂度近乎为O(1)。
3. 归并排序
上面的二分检索是把大规模分解为2个小规模,然后不断递归,从而得到最终的解。下面介绍归并排序,则是把若干个小规模的解合并为大规模的解的过程。
1. 归并排序的定义
排序的方法我们知道有很多,最简单的就是插入排序和选择排序两种排序,因为这两种排序就是我们在日常中通常使用的排序方法。
插入排序和选择排序都是有待排集合和解集合两个部分构成,插入排序是通过不断的对待排集合中与解集合相邻的元素进行排序纳入到解集合中,从而扩大解集合而减小待排集合的大小。选择排序则是挑选待排序中当前最符合条件的元素纳入解集中,从而扩大解集合而减小待排集合的大小。它们在小规模和人类认知活动中,效果十分显著,因为它们简单,但那时并不高效。
下面我们介绍以下归并分类算法
//归并分类
procedure MERGESORT(low,high)
integer low,high;
if low<high
then mid<-(low+high)/2 //求这个集合的分割点,去下界
call MERGESORT(low,mid) //将一个子集合分类
call MERGESORT(mid+1,high) //将另一个集合分类
call MERGE(low,mid,high) //归并两个已分类的子集合
endif
end MERGESORT
//使用辅助数组归并两个已分类的集合
procedure MERGE(low,mid,high)
integer h,i,j,k,low,mid,high; //low≤mid<high
global A(low:high) local B(low:high)
h<-low;i<-low:j<-mid+1
while h≤mid and j≤high do //当两个集合都没有取尽时
if A(h)≤A(j) then B(i)<-A(h);h<-h+1
else B(i)<-A(j);j<-j+1
endif
i<-i+1
repeat
if h>mid then for k<-j to high do //处理剩余的元素
B(i)<-A(k);i<-i+1
repeat
else for k<-h to mid do
B(i)<-A(k);i<-i+1
repeat
endif
for k<-low to high do //将已归并的集合复制到A
A(k)<-B(k)
repeat
end MERGE
2. 归并排序的时间复杂度
上述我们已经给出了归并排序算法,现在,我们对归并算法进行时间复杂度的分析。
如果归并运算的时间与n成正比,则归并分类的计算时间可用递归关系式描述如下:
当n是2的幂即n= 2k 是,可以通过逐次带入求出其解:
T(n)=2(2T(n/4)+cn/2)+cn
=4T(n/4)+2cn
…
= 2k T(1)+kcn
=an+cnlogn
如果 2k<n<2k+1 ,易于看出T(n)≤T( 2k+1 )。因此, T(n)=O(nlogn)
这已经是基于比较的排序的下界了。
3. 归并排序的改进思路
上面的算法其实在时间复杂度上我们已经能够接受,但是实际上,其常数系数C会随着n的大小不断变大,当n极大时,Cnlogn的时间复杂度也不一定能够接受。因此,我们考虑以下2个方面进行改进。
1. 在最小集合上不再迭代使用归并,而是使用插入排序代替。
2. 添加归并中每次对于元素的查找信息,减少归并查询次数。
改进后的算法不在此列出,有兴趣的可以自己尝试。
4. 以比较为基础的排序的时间下界
我们知道,对于一个n个数的排序,最终的结果有n!种可能,这也就是变成了检索的套路上。同理以比较为基础的检索下界,我们可以看到,令T(n)=k,则
而当n>1时有
因此对于n≥4有
4. 快速排序
第2部分介绍的是二分检索,第3部分介绍的是归并排序,那么如果把这两个部分有机的结合起来,则造就了基于比较的排序的最为有特点的排序——快速排序,没错,它以“快速”来命名,以此可知其速度。
1. 快速排序的定义
快速排序是由著名的计算机科学家霍尔根据分治算法设计出的一种高效的分类算法,尤其擅长处理无序和超大规模排序问题。它与归并排序不同的是,它使用一个划分元素r来模拟类似二分排序的划分,从而不用对结果进行归并。加快了运算速度。
下面给出快速排序的算法:
//用A(m)划分集合A(m:p-1)
procedure PARTITION(m,p)
//最终划分结果为,对于m到p-1之间的数,找出下标q,使得该数左边的数全部小于A(q),右边的数全部大于等于A(q),A(p)为划分元素所在的下标位置。
integer m,p,i;global A(m:p-1)
v<-A(m);i<-m; A(m)是划分元素
loop
loop i<-i+1 until A(i)≥v repeat //i由左向右移
loop p<-p-1 until A(p)≤v repeat //p由右向左移
if i<p
then call INTERCHANGE(A(i),A(p)) //A(i)和A(p)换位
else exit
endif
repeat
A(m)<-A(p);A(p)<-v //划分元素在位置P
end PARTITION
//快速排序主程序
procedure QUICKSORT(p,q)
//A(n+1)已经被定义且为最大值。
integer p,q;global n,A(1:n)
if p<q
then j<-q+1
call PARTITION(p,j)
call QUICKSORT(p,j-1) //j是划分位置
call QUICKSORT(j+1,q)
endif
end QUICKSORT
2. 快速排序的时间复杂度
对于快速排序来讲,最好不要对接近有序的数组使用快排,因为那样会浪费很多性能。快排的平均时间复杂度为O(nlogn),最坏情况为O( n2 ),
5. 选择问题
上面是对一组数进行排序,但是有时候我们只需要查找第K小的数时,这时候就是选择问题了,如何直接找到第K小的呢。想一想我们日常的时候怎么找呢?可能就是先排序,再去找最小的,这样的话,时间复杂度不可能低于O(nlogn)的,那用什么方法呢?想一想程序PARTITION的分割,有想法了没?
1. 平均时间为O(n)的选择算法
接下来我们提供一个平均时间为O(n)的选择算法,可以在可接受的范围内实现:
//找第K小元素
procedure SELECT(A,n,k)
integer n,k,m,r,j
m<-1;r<-n+1;A(n+1)<-+∞
loop //每当进入这一循环时,1≤m≤k≤r≤n+1
j<-r //将剩余元素的最大下标加1后置给j
call PARTITION(m,j) //返回j,它使得A(j)是第j小的值
case
:k=j: return
:k<j: r<-j //j是新的上界
:else: m<-j+1 //j+1是新的下界
endcase
repeat
end SELECT
可以证明,这个算法的平均时间复杂度为O(n),如果有时间的话,给出证明。但是,这是平均时间复杂度,我们想要一个即使在最坏情况下,仍然是O(n)的算法,这真的可以吗?答案是可以的。
2. 最坏情况时间为O(n)的选择算法
事实上,我们可以通过精心挑选划分元素v,从而使得最坏情况的时间复杂度达到O(n)。我们现在讲解以下这种选择算法的核心思想:
1. 把n个元素分成
⌊n/r⌋
组,每组r个元素,其余不参与挑选
2. 把每组r个元素用插入排序进行排序,找到每组的中间值
mi
3. 从所有的
mi
种找到中中间值mm
4. 使用mm作为监视哨进行查找
经过以上几个步骤以后,所有元素就从无序变为相对有序,由此可知,至少有
⌈r/2⌉⌈⌊n/r⌋/2⌉
个元素小于或等于mm。
下面给出这个算法:
procedure SEL(A,m,p,k)
global r
integer n,i,j
if p-m+1≤r then call INSERTIONSORT(A,m,p)
return (m+k-1)
endif
loop
n<-p-m+1 //元素数
for i<-1 to (n/r)取整 do //计算中间值
call INSERTIONSORT(A,m+(i-1)*r,m+i*r-1)
//将中间值收集到A(m:p)的前部
call INTERCHANGE(A(m+i-1),A(m+(i-1)*r+(r/2)取整-1))
repeat
j<-SEL()
call INTERCHANGE(A(m),A(j)) //产生划分元素
j<-p+1
call PARTITION(m,j)
case
:j-m+1=k: return(j)
:j-m+1>k: p<-j-1
:else: k<-k-(j-m+1);m<-j+1
endcase
repeat
end SEL
6. 分治法小结
本章主要介绍了经典算法中的第一种算法——分治法。并根据分治法的整个流程向大家介绍了二分检索和归并排序两种分治侧重点。并以此结合出了伟大的快速排序,并在快速排序的基础上衍生出了选择问题的优秀算法。本章的内容较多,还需要多多消化。