排序问题的计算复杂性.docx
《排序问题的计算复杂性.docx》由会员分享,可在线阅读,更多相关《排序问题的计算复杂性.docx(195页珍藏版)》请在冰豆网上搜索。
排序问题的计算复杂性
排序问题的计算复杂性
对排序算法计算时间的分析可以遵循若干种不同的准则,通常以排序过程所需要的算法步数作为度量,有时也以排序过程中所作的键比较次数作为度量。
特别是当作一次键比较需要较长时间,例如,当键是较长的字符串时,常以键比较次数作为排序算法计算时间复杂性的度量。
当排序时需要移动记录,且记录都很大时,还应该考虑记录的移动次数。
究竟采用哪种度量方法比较合适要根据具体情况而定。
在下面的讨论中我们主要考虑用比较的次数作为复杂性的度量。
为了对有n个元素的线性表进行排序,至少必须扫描线性表一遍以获取这n个元素的信息,因此排序问题的计算复杂性下界为Ω(n)。
如果我们对输入的数据不做任何要求,我们所能获得的唯一信息就是各个元素的具体的值,我们仅能通过比较来确定输入序列的元素间次序。
即给定两个元素ai和aj,通过测试aiaj中的哪一个成立来确定ai和aj间的相对次序。
这样的排序算法称为比较排序算法。
下面我们讨论一下比较排序算法在最坏情况下至少需要多少次比较,即比较排序算法的最坏情况复杂性下界。
我们假设每次比较只测试ai≤aj,如果ai≤aj成立则ai排在aj前面,否则ai排在aj后面。
任何一个比较排序算法可以描述为一串比较序列:
(ai,aj),(ak,al),..,(am,an),...
表示我们首先比较(ai,aj),然后比较(ak,al),...,比较(am,an),...,直到我们获取了足够的信息可以确定所有元素的顺序。
显而易见,如果我们对所有的元素两两进行一次比较的话(总共比较了Cn2次),就一定可以确定所有元素的顺序。
但是,如果我们运气足够好的话,我们可能不必对所有元素两两进行一次比较。
比如说对于有三个元素a1,a2,a3的线性表进行排序,如果我们先比较a1和a2,得到a1≤a2;然后比较a2和a3,得到a2≤a3;则不必比较a1和a3,因为根据偏序集的传递性,必有a1≤a3;但是如果a2≥a3,我们还必须比较a1和a3才能确定a1和a3的相对位置。
如果我们适当的安排比较的次序的话,也可以减少比较的次数。
这样我们可以用一棵二叉树表示比较的顺序,如下图所示:
该树的每一个非叶节点表示一次比较,每一根树枝表示一种比较结果,每一个叶节点表示一种排列顺序。
这样的一棵二叉树叫做决策树,它用树枝表示了每次决策做出的选择。
如此我们可以将任何一个比较排序算法用一棵决策树来表示。
请注意上图只表明了对三个元素的一种比较算法,这种比较算法依次比较(a1,a2)(a2,a3)(a1,a3),一旦中间某步得到足够的信息就可以停止比较,但是当算法执行完后(三次比较后),一定可以确定三个元素间的次序。
因此我们有理由将算法在最坏情况下的比较次数作为算法复杂性的度量,对于本例该算法在最坏情况下要进行C32=3次比较。
显然,一棵决策树中最高叶节点的高度就是该决策树对应的算法在最坏情况下所需的比较次数,而决策树中最低叶节点的高度就是该决策树对应的算法在最好情况下所需的比较次数。
我们的问题就变为:
对于任意一棵决策树(任意一种比较排序算法),它的最高的树叶的高度是多少?
这个高度就对应于比较排序算法所需的最多比较次数(在运气最坏的情况下);换句话说,对于任何一个输入,该算法至少需要比较多少次就可以对元素进行排序。
我们发现,决策树的每个叶节点对应一个n个元素的排列,其中可能有重复的;但是由于决策树表明了所有可能遇到的情况,因而n个元素的所有排列都在决策树中出现过。
n个元素共有n!
种排列,即决策树的叶节点数目至少为n!
。
又因为一棵高度为h的二叉树(指二叉树的最高树叶高度为h)的叶节点数目最多为2h个(这时正好是满二叉树,即每个非叶节点都有两个子节点),因此n!
≤2h,得到h≥log(n!
),其中log以2为底。
根据Stirling公式有n!
>(n/e)n,于是h>nlogn-nloge,即h=Ω(nlogn)。
这样我们就证明了对于任意一种利用比较来确定元素间相对位置的排序算法,其最坏情况下复杂性为Ω(nlogn)。
在下文中我们将讨论几种比较排序算法,其中快速排序在平均情况下复杂性为O(nlogn),最坏情况下复杂性为O(n2);堆排序和合并排序在最坏情况下复杂性为O(nlogn),因此堆排序和合并排序是渐进最优的比较排序算法。
排序算法是否还能够改进呢?
从前文我们知道,如果要改进排序算法的效率,就不能只利用比较来确定元素间相对位置。
因此我们还需要知道元素的其他附加信息,光知道元素的大小信息是不够的。
下文中我们介绍的计数排序,基数排序和桶排序是具有线性时间复杂性的排序算法,这些算法无一例外地对输入数据作了某些附加限制,从而增加已知的信息,因此可以不通过比较来确定元素间的相对位置。
比较排序算法
通过比较来确定输入序列的元素间相对次序的排序算法称为比较排序算法。
在下面讨论的排序算法中,冒泡排序、选择排序和插入排序的比较次数为O(n2),快速排序在平均情况下复杂性为O(nlogn),堆排序和合并排序在最坏情况下复杂性为O(nlogn)。
可见,合并排序和堆排序是比较排序算法中时间复杂度最优算法。
o冒泡排序
o选择排序
o插入排序
o快速排序
o合并排序
oShell排序
o堆排序
冒泡排序BubbleSort
最简单的排序方法是冒泡排序方法。
这种方法的基本思想是,将待排序的元素看作是竖着排列的“气泡”,较小的元素比较轻,从而要往上浮。
在冒泡排序算法中我们要对这个“气泡”序列处理若干遍。
所谓一遍处理,就是自底向上检查一遍这个序列,并时刻注意两个相邻的元素的顺序是否正确。
如果发现两个相邻元素的顺序不对,即“轻”的元素在下面,就交换它们的位置。
显然,处理一遍之后,“最轻”的元素就浮到了最高位置;处理二遍之后,“次轻”的元素就浮到了次高位置。
在作第二遍处理时,由于最高位置上的元素已是“最轻”元素,所以不必检查。
一般地,第i遍处理时,不必检查第i高位置以上的元素,因为经过前面i-1遍的处理,它们已正确地排好序。
这个算法可实现如下。
procedureBubble_Sort(varL:
List);
var
i,j:
position;
begin
1fori:
=First(L)toLast(L)-1do
2forj:
=First(L)toLast(L)-ido
3ifL[j]>L[j+1]then
4swap(L[j],L[j+1]);//交换L[j]和L[j+1]
end;
上述算法将较大的元素看作较重的气泡,每次最大的元素沉到表尾。
其中First(L)和Last(L)分别表示线性表L的第一个元素和最后一个元素的位置,swap(x,y)交换变量x,y的值。
上述算法简单地将线性表的位置当作整数用for循环来处理,但实际上线性表可能用链表实现;而且上述算法将线性表元素的值当作其键值进行处理。
不过这些并不影响表达该算法的基本思想。
今后如果不加说明,所有的算法都用这种简化方式表达。
容易看出该算法总共进行了n(n-1)/2次比较。
如果swap过程消耗的时间不多的话,主要时间消耗在比较上,因而时间复杂性为O(n2)。
但是如果元素类型是一个很大的纪录,则Swap过程要消耗大量的时间,因此有必要分析swap执行的次数。
显然算法Bubble_Sort在最坏情况下调用n(n-1)/2次Swap过程。
我们假设输入序列的分布是等可能的。
考虑互逆的两个输入序列L1=k1,k2,..,kn和L2=kn,kn-1,..,k1。
我们知道,如果ki>kj,且ki在表中排在kj前面,则在冒泡法排序时必定要将kj换到ki前面,即kj向前浮的过程中一定要穿过一次ki,这个过程要调用一次Swap。
对于任意的两个元素ki和kj,不妨设ki>kj,或者在L1中ki排在kj前面,或者L2在中ki排在kj前面,两者必居其一。
因此对于任意的两个元素ki和kj,在对L1和L2排序时,总共需要将这两个元素对调一次。
n个元素中任取两个元素有Cn2种取法,因此对于两个互逆序列进行排序,总共要调用Cn2=n(n-1)/2次Swap,平均每个序列要调用n(n-1)/4次Swap。
那么算法Bubble_Sort调用Swap的平均次数为n(n-1)/4。
可以对冒泡算法作一些改进,如果算法第二行的某次内循环没有进行元素交换,则说明排序工作已经完成,可以退出外循环。
可以用一个布尔变量来记录内循环是否进行了记录交换,如果没有则终止外循环。
冒泡法的另一个改进版本是双向扫描冒泡法(Bi-DirectionalBubbleSort)。
设被排序的表中各元素键值序列为:
4836788850255406134592657745683
对该序列进行3次扫描后会发现,第3此扫描中最后一次交换的一对纪录是L[4]和L[5]:
5067255134|406483592657683745888
显然,第3次扫描(i=3)结束后L[5]以后的序列都已经排好序了,所以下一次扫描不必到达Last(L)-i=11-4=7,即第2行的for循环j不必到达7,只要到达4-1=3就可以了。
按照这种思路,可以来回地进行扫描,即先从头扫到尾,再从尾扫到头。
这样就得到双向冒泡排序算法:
procedureBi-Directional_Bubble_Sort(varL:
List);
var
low,up,t,i:
position;
begin
1low:
=First(L);up:
=Last(L);
2whileup>lowdo
begin
3t:
=low;
4fori:
=lowtoup-1do
5ifL[i]>L[i+1]then
begin
6swap(L[i],L[i+1]);
7t:
=i;
end;
8up:
=t;
9fori:
=updowntolow+1do
10ifL[i]begin
11swap(L[i],L[i-1]);
12t:
=i;
end;
13low:
=t;
end;
end;
算法利用两个变量low和up记录排序的区域L[low..up],用变量t记录最近一次交换纪录的位置,4-7行从前向后扫描,9-12行从后向前扫描,每次扫描以后利用t所记录的最后一次交换记录的位置,并不断地缩小需要排序的区间,直到该区间只剩下一个元素。
直观上来看,双向冒泡法先让重的气泡沉到底下,然后让轻的气泡浮上来,然后再让较大气泡沉下去,让较轻气泡浮上来,依次反复,直到排序结束。
双向冒泡排序法的性能分析比较复杂,目前暂缺,那位朋友知道请告诉我。
冒泡排序法和双向冒泡排序法是原地置换排序法,也是稳定排序法,如果算法Bubble_Sort中第3行的比较条件L[j]>L[j+1]改为L[j]>=L[j+1],则不再是稳定排序法。
选择排序SelectionSort
选择排序的基本思想是对待排序的记录序列进行n-1遍的处理,第i遍处理是将L[i..n]中最小者与L[i]交换位置。
这样,经过i遍处理之后,前i个记录的位置已经是正确的了。
选择排序算法可实现如下。
procedureSelection_Sort(varL:
List);
var
i,j,s:
position;
begin
1fori:
=First(L)toLast(L)-1do
begin
2s:
=i;
3forj:
=i+1toLast(L)do
4ifL[j]5s:
=j;//记录L[i..n]中最小元素的位置
6swap(L[i],L[s]);//交换L[i],L[s]
end;
end;
算法Selection_Sort中里面的一个for循环需要进行n-i次比较,所以整个算法需要
次比较。
显而易见,算法Selection_Sort中共调用了n-1次swap过程。
选择排序法是一个原地置换排序法,也是稳定排序法。
插入排序InsertionSort
插入排序的基本思想是,经过i-1遍处理后,L[1..i-1]己排好序。
第i遍处理仅将L[i]插入L[1..i-1]的适当位置,使得L[1..i]又是排好序的序列。
要达到这个目的,我们可以用顺序比较的方法。
首先比较L[i]和L[i-1],如果L[i-1]≤L[i],则L[1..i]已排好序,第i遍处理就结束了;否则交换L[i]与L[i-1]的位置,继续比较L[i-1]和L[i-2],直到找到某一个位置j(1≤j≤i-1),使得L[j]≤L[j+1]时为止。
图1演示了对4个元素进行插入排序的过程,共需要(a),(b),(c)三次插入。
图1对4个元素进行插入排序
在下面的插入排序算法中,为了写程序方便我们可以引入一个哨兵元素L[0],它小于L[1..n]中任一记录。
所以,我们设元素的类型ElementType中有一个常量-∞,它比可能出现的任何记录都小。
如果常量-∞不好事先确定,就必须在决定L[i]是否向前移动之前检查当前位置是否为1,若当前位置已经为1时就应结束第i遍的处理。
另一个办法是在第i遍处理开始时,就将L[i]放入L[0]中,这样也可以保证在适当的时候结束第i遍处理。
下面的算法中将对当前位置进行判断。
插入排序算法如下:
procedureSelection_Sort(varL:
List);
var
i,j:
position;
v:
ElementType;
begin
1fori:
=First(L)+1toLast(L)do
begin
2v:
=L[i];
3j:
=i;
4while(j<>First(L))and(L[j-1]begin
5L[j]:
=L[j-1];//移动元素
6j:
=j-1;
end;
7L[j]:
=v;//插入元素
end;
end;
下面考虑算法Insertion_Sort的复杂性。
对于确定的i,内while循环的次数为O(i),所以整个循环体内执行了∑O(i)=O(∑i),其中i从2到n。
即比较次数为O(n2)。
如果输入序列是从大到小排列的,那么内while循环次数为i-1次,所以整个循环体执行了∑(i-1)=n(n-1)/2次。
由此可知,最坏情况下,Insertion_Sort要比较Ω(n2)次。
如果元素类型是一个很大的纪录,则算法第5行要消耗大量的时间,因此有必要分析移动元素的次数。
经过分析可知,平均情况下第5行要执行n(n-1)/4次,分析方法与冒泡排序的分析相同。
如果移动元素要消耗大量的时间,则可以用链表来实现线性表,这样Insertion_Sort可以改写如下(当然前一个算法同样也适用于链表,只不过没下面这个好,但是下面算法这个比较复杂):
注意:
在下面的算法中链表L增加了一个哨兵单元,其中的元素为-∞,即线性表L的第一个元素是L^.next^
procedureSelection_Sort_II(varL:
PList);
var
i,j,tmp:
Position;
begin
1ifL^.next=nilthenexit;//如果链表L为空则直接退出
2i:
=L^.next;//i指向L的第一个元素,注意,L有一个哨兵元素,因此L^.next^才是L的第一个元素
3whilei^.next<>nildo
begin
4tmp:
=i^.next;//tmp指向L[i]的下一个位置
5j:
=L;
6while(j<>i)and(tmp^.data>=j^.next^.data)do//从前向后找到tmp的位置,tmp应该插在j后面
7j:
=j^.next;
8ifj<>ithen//j=i说明不需要改变tmp的位置
begin
9i^.next:
=tmp^.next;//将tmp从i后面摘除
10tmp^.next:
=j^.next;//在j后面插入tmp
11j^.next:
=tmp;
end
12elsei:
=i^.next;//否则i指向下一个元素
end;
end;
上述改进算法主要是利用链表删除和插入元素方便的特性,对于数组则不适用。
插入排序法是一个原地置换排序法,也是一个稳定排序法。
插入法虽然在最坏情况下复杂性为θ(n2),但是对于小规模输入来说,插入排序法是一个快速的原地置换排序法。
许多复杂的排序法,在规模较小的情况下,都使用插入排序法来进行排序,比如快速排序和桶排序。
快速排序QuickSort
我们已经知道,在决策树计算模型下,任何一个基于比较来确定两个元素相对位置的排序算法需要Ω(nlogn)计算时间。
如果我们能设计一个需要O(n1ogn)时间的排序算法,则在渐近的意义上,这个排序算法就是最优的。
许多排序算法都是追求这个目标。
下面介绍快速排序算法,它在平均情况下需要O(nlogn)时间。
这个算法是由C.A.R.Hoare发明的。
算法的基本思想
快速排序的基本思想是基于分治策略的。
对于输入的子序列L[p..r],如果规模足够小则直接进行排序,否则分三步处理:
▪分解(Divide):
将输入的序列L[p..r]划分成两个非空子序列L[p..q]和L[q+1..r],使L[p..q]中任一元素的值不大于L[q+1..r]中任一元素的值。
▪递归求解(Conquer):
通过递归调用快速排序算法分别对L[p..q]和L[q+1..r]进行排序。
▪合并(Merge):
由于对分解出的两个子序列的排序是就地进行的,所以在L[p..q]和L[q+1..r]都排好序后不需要执行任何计算L[p..r]就已排好序。
这个解决流程是符合分治法的基本步骤的。
因此,快速排序法是分治法的经典应用实例之一。
算法的实现
算法Quick_Sort的实现:
注意:
下面的记号L[p..r]代表线性表L从位置p到位置r的元素的集合,但是L并不一定要用数组来实现,可以是用任何一种实现方法(比如说链表),这里L[p..r]只是一种记号。
procedureQuick_Sort(p,r:
position;varL:
List);
const
e=12;
var
q:
position;
begin
1ifr-p<=ethenInsertion_Sort(L,p,r)//若L[p..r]足够小则直接对L[p..r]进行插入排序
elsebegin
2q:
=partition(p,r,L);//将L[p..r]分解为L[p..q]和L[q+1..r]两部分
3Quick_Sort(p,q,L);//递归排序L[p..q]
4Quick_Sort(q+1,r,L);//递归排序L[q+1..r]
end;
end;
对线性表L[1..n]进行排序,只要调用Quick_Sort(1,n,L)就可以了。
算法首先判断L[p..r]是否足够小,若足够小则直接对L[p..r]进行排序,Sort可以是任何一种简单的排序法,一般用插入排序。
这是因为,对于较小的表,快速排序中划分和递归的开销使得该算法的效率还不如其它的直接排序法好。
至于规模多小才算足够小,并没有一定的标准,因为这跟生成的代码和执行代码的计算机有关,可以采取试验的方法确定这个规模阈值。
经验表明,在大多数计算机上,取这个阈值为12较好,也就是说,当r-p<=e=12即L[p..r]的规模不大于12时,直接采用插入排序法对L[p..r]进行排序(参见SortingandSearchingAlgorithms:
ACookbook)。
当然,比较方便的方法是取该阈值为1,当待排序的表只有一个元素时,根本不用排序(其实还剩两个元素时就已经在Partition函数中排好序了),只要把第1行的if语句该为ifp=rthenexitelse...。
这就是通常教科书上看到的快速排序的形式。
注意:
算法Quick_Sort中变量q的值一定不能等于r,否则该过程会无限递归下去,永远不能结束。
因此下文中在partition函数里加了限制条件,避免q=r情况的出现。
算法Quick_Sort中调用了一个函数partition,该函数主要实现以下两个功能:
1.在L[p..r]中选择一个支点元素pivot;
2.对L[p..r]中的元素进行整理,使得L[p..q]分为两部分L[p..q]和L[q+1..r],并且L[p..q]中的每一个元素的值不大于pivot,L[q+1..r]中的每一个元素的值不小于pivot,但是L[p..q]和L[q+1..r]中的元素并不要求排好序。
快速排序法改进性能的关键就在于上述的第二个功能,因为该功能并不要求L[p..q]和L[q+1..r]中的元素排好序。
函数partition可以实现如下。
以下的实现方法是原地置换的,当然也有不是原地置换的方法,实现起来较为简单,这里就不介绍了。
functionpartition(p,r:
position;varL:
List):
position;
var
pivot:
ElementType;
i,j:
position;
begin
1pivot:
=Select_Pivot(p,r,L);//在L[p..r]中选择一个支点元素pivot
2i:
=p-1;
3j:
=r+1;
4whiletruedo
begin
5repeatj:
=j-1untilL[j]<=pivot;//移动左指针,注意这里不能用while循环
6repeati:
=i+1untilL[i]>=pivot;//移动右指针,注意这里不能用while循环
7ifi8elseifj<>rthenreturnj//返回j的值作为分割点
9elsereturnj-1;//返回j前一个位置作为分割点
end;
end;
该算法的实现很精巧。
其中,有一些细节需要注意。
例如,算法中的位置i和j不会超出A[p..r]的位置界,并且该算法的循环不会出现死循环,如果将两个repeat语句换为while则要注意当L[i]=L[j]=pivot且i另外,最后一个if..then..语句很重要,因为如果pivot取的不好,使得Partition结束时j正好等于r,则如前所述,算法Quick_Sort会无限递归下去;因此必须判断j是否等于r,若j=r则返回j的前驱。
以上算法的一个执行实例如图1所示,其中pivot=L[p]=5:
图1Partition过程的一个执行实例
Partition对L[p..r]进行划分时,以pivot作为划分的基准,然后分别从左、右两端开始,扩展两个区域L[p..i]和L[j..r],使得L[p..i]中元素的值小于或等于pivot,而L[j..r]中元素的值大于或等于pivot。
初始时i=p-1,且j=i+1,从而这两个区域是空的。
在while循环体中,位置j逐渐减小,i逐渐增大,直到L[i]≥pivot≥L[j]。
如果这两个不等式是严格的,则L[i]不会是左边区域的元素,而L[j]不会是右边区域的元素。
此时若i在j之前,就应该交换L[i]