排序(Sorting)是算法应用中最重要的工具。据统计,计算机运行时间的四分之一都耗费在了排序上。判断排序算法优劣的方法是进行时间和空间效率分析。分析时间效率的三个重要记号是O、Ω和Θ。
O记号起源于P.Bachmann在1892年发表的一篇数论文章。O在一个常数因子内给出某函数的一个上界。对一个函数g(n), O(g(n))表示一个函数集合。O(g(n)) = {f(n):存在正常数c和n0,使对于所有的n>=n0,有0<=f(n)<=cg(n)}。
在使用O记号进行效率统计之前,先假定分析的机器模型。我们使用单处理器,随机存取器(random-access machine, RAM)。即指令一条一条执行,没有并发操作。进一步假定每次运算只消耗一条指令。把每条指令记为1次(time)。比如:
int n = 10; //1 time
for(int i=0;i < n;i++)//n times
{
...//c条语句
}
该算法总的运行时间就是每一条语句执行次数之和1+c*n。通常,算法运行时间(或称时间复杂度)不仅与数据规模n相关,并且与数据的输入形式相关。例如,在一些排序算法中,若输入的数组已经排好序了,那么世纪运行时间会大大减少(或增加)。所以,我们在不能够明确输入数据状态的情况下,定义算法的“最坏情况”和“最好情况”。在实际分析中,一般考察最坏情况的运行时间,即对于规模为n的情况下,算法的最长运行时间。这是因为最坏情况在应用当中频繁出现,而且即使考察一个随机的数据输入,它的结果与最坏情况一样差(比如都是输入规模的二次函数)。
很明显,用来表示上界的O记号可以很自然的描述这种最坏情况。设算法最坏运行时间为f(n),可以找到一个简单的函数g(n)使f(n)可以用O(g(n))来描述。通常我们说“运行时间是O(n^2)”便表示对于f(n),不管n为何值,也不管具体是怎样输入,它都有最坏运行时间O(n^2)。通常选取的g(n)有1, n, n*lgn, n^2 和 n^3, 它们在O记号下的时间复杂度依次递增。
(注1:我们在这里说的lgn,是以2为底的对数)
(注2:通常,指数函数比多项式函数增长快,多项式又比对数增长快。对于对数来说,不管底数是多少,其渐进线都是一样的。Exponential functions grow faster than polynomial functions, which grow faster than polylogarithmic functions)
类似,Ω记号给出了函数的渐进下界。即对一个函数g(n), Ω(g(n))= {f(n):存在正常数c和n0,使对于所有的n>=n0,有0<=cg(n)<=f(n)}。Θ记号则最强,它同时表示上下界。Θ由Knuth提出,是最准确的记号。但是,许多人至今仍然偏爱使用O记号。
注意,我们假定g(n)是渐进非负函数,以上记号才可以成立。
下面分析一些具体的排序算法。
1. 插入排序
插入排序如同打牌,一次从桌上摸起一张牌,并将它插入手中牌中的正确位置上。此算法在排序过程中将牌分成了2个部分:还在桌上的牌(未排序)和手中的牌(有序)。排序当中有一个比较过程以及在插入后将插入项之后的牌向后移动的过程。在最坏情况下,原始牌是逆序排列的,那么每插入一张牌,所有手中的牌都要向后移动一格。假设有n张牌,那么移动的次数就是1+2+3+4+...+n = n*(n-1)/2次。即此算法是O(n^2)的。
程序实现:
void insertionSort(int input[]) { int tmp,i; for(int j=1;j < MAX;j++) { tmp=input[j]; i=j-1; while(i>=0 && tmp < input[i]) { input[i+1]=input[i]; i--; } input[i+1]=tmp; } }
2.冒泡排序
冒泡排序也许是实现最简单的排序算法。时间复杂度也十分容易计算,也是O(n^2),而且当输入数据本身有序的时候效率也不会提高!它唯一的用途大概就是用来测试一个程序员是不是弱智。
void bubble(int *input) { for(int i=0;i < (MAX-1);i++) for(int j=0;j<(MAX-1-i);j++) if(input[j]>input[j+1]) swap(input[j],input[j+1]); }
3.快速排序
快速排序是一种递归算法:将原问题分成若干个规模小但是结构相似的问题,递归地解决这些问题,再合并结果,就得到了原问题的解。快速排序首先在输入数据中选择一个元素作为“主元”,然后依据主元把数据分成2个部分,前一个部分的每个元素都比主元小,后一个部分都比主元大。然后分别在两个部分快速排序,直到不能再分为止。这样整个数据就有序了。
void qsort(int input[],int start,int end) { int i,j; if(start < end) { i=start;j=end+1; while(1){ do i++; while(!(input[i]>=input[start]||i==end)); do j--; while(!(input[j]<=input[start]||j==start)); if(i < j) swap(input[i],input[j]); else break; } swap(input[start],input[j]); qsort(input,start,j-1); qsort(input,j+1,end); } }
另一个版本的快速排序:
int Partition(int input[],int start,int end) { int x = input[end]; int i = start - 1; for(int j = start; j < end; j++) { if( input[j] <= x) { i++; swap(input[i],input[j]); } } swap(input[i+1],input[end]); return i+1; } void qsort(int input[],int start,int end) { if(start < end) { int q = Partition(input, start, end); qsort(input, start, q-1); qsort(input, q+1, end); } }
考察快速排序的最坏效率,要考虑两种极端情况。一、假如每次划分的两个部分的元素相等,那么总递归时间T(n)为2*T(n/2)+n。其中后面的n为划分的开销。可证明,T(n)是O(n*logn)的(事实上,这个解是先猜测,再靠数学归纳法加以证明的)。二、假如划分严重不对称,即分成一边是1个元素,一边是n-1个元素。那么有T(n)=T(1)+T(n-1)+n,实际上与插入排序一样,是一个算术级数!也就是说,在这种情况下,快速排序蜕变成了插入排序,所以时间复杂度为O(n^2)。
快速排序最神奇的地方在于,对于随机输入的数据,它能够自动调整到好的划分上去,其运行时间与最佳时间非常相似。例如,产生一个99:1的划分。看似非常的不平衡吧。但是可以证明,它的运行时间也是O(n*lgn)!
总的来说,虽然算法的时间是O(n^2),但是快速排序在绝大多数情况下都能达到O(n*logn)的效率,使之成为了居家旅行,杀人越货当中不可或缺的排序利器。
4.合并排序
前面提到,既然把数据平均划分成两个部分分别排序,就可以达到很好的效率O(n*lgn),那么,是不是存在这样一种算法呢?合并算法是这样一种算法:将n个元素分成各含n/2个元素的子序列,然后对两个子序列分别排序。
void mergeSort(int *input,int start,int end) { int mid; if(start < end) { mid=(start+end)/2; mergeSort(input,start,mid); mergeSort(input,mid+1,end); merge(input,start,mid,end); } } void merge(int *input,int start,int mid, int end) //辅助函数 { int n1=mid-start+1; int n2=end-mid; int *L=new int[n1+1]; int *R=new int[n2+1]; for(int i=0;i < n1;i++) L[i]=input[start+i]; for(int i=0;i < n2;i++) R[i]=input[mid+1+i]; L[n1]=INF; R[n2]=INF; int i=0,j=0; for(int k=start;k<=end;k++) { if(L[i]<=R[j]) input[k]=L[i++]; else input[k]=R[j++]; } delete L,R; }
可以看出时间复杂度为T(n)=2*T(n/2)+n,也就是O(n*lgn)!但需要指出的是,合并排序算法必须建立辅助数组L和R,它们的规模如同n一样线性增长。即它不是一个原地(in place)排序算法,在虚拟环境中不能够很好的工作。
5.堆排序
堆可以被视为一棵完全二叉树(除去最后一层以外就是一个满二叉树,最后一层结点从左到右开始填)。我们在堆排序中使用“最大堆”(MAX-HEAP),即每个结点的值都比它的父结点要小。当然也有“最小堆”,原理都是一样的。
我们把需要排序的数组看作一个堆,堆的每个结点和数组中放该结点的那个元素对应。注意,堆的长度heap-size与数组长度length不尽相同,前者可能小于后者,任何在heap-size之后的元素都不属于相应的堆。
堆具有完全二叉树的一些有趣性质:
设n0为度为0的结点总数(叶子结点);
n1为度为1的结点总数(完全二叉树中只有一个,或者没有);
n2为度为2的结点总数。
现在我们来求解叶子数量n0。
有n0+n1+n2=n---(1)
0*n0+1*n1+2*n2=n-1----(2)
消去n2,有n0=(n-n1+1)/2----(3)
根据式(3), 既然n1要么为1,要么为0,那么叶子节点要么等于n/2,要么等于(n+1)/2。这意味着在整个堆中,有一半(当n1=1),或者略少于一半(当n1=0)的结点是有子结点的。且这些结点都集中在数组的低位部分。
以下函数对于制定的输入input,使根为i的子树成为最大堆。这是堆排序中最重要的操作,称为堆的保持。此算法将遍历根为1的所有子树。在最坏的情况下,子树的底层恰好半满,这时,需要遍历的结点数为:
n * (0+1+2+...+2^(n-1)) / (1+2+3+...+2^(n-1)+2^(n-1))
= n * (2^n - 1) / (2^n + 2^(n-1)-1)
< n * 2/3.
即所有结点的三分之二。计算运行时间:T(n) <= T(2/3*n)+ Θ(1).可证明T(n)=O(lgn).
void MaxHeapify(int *input,int i) { int largest; int l = i*2; int r = i*2+1; if( l <= HeapSize && input[l] > input[i]) largest = l; else largest = i; if( r <= HeapSize && input[r] > input[largest]) largest = r; if(largest != i) { swap(input[i],input[largest]); MaxHeapify(input, largest); } }
针对一个数组建立堆。注意只要对有子结点的结点进行堆的保持就可以了。可以证明建立堆是O(n)的。
int HeapSize = 0; void BuildMaxHeap(int *input) { HeapSize = MAX-1; for(int i = MAX/2; i >= 0; i--) MaxHeapify(input,i); }
以下是堆排序的调用算法。此过程调用了O(lgn)的堆保持函数并循环n-1次。即得到了O(n*lgn)的时间复杂度。
void HeapSort(int *input) { BuildMaxHeap(input); for(int i = MAX-1; i > 0; i--) // no need to process when i = 0 { swap(input[0],input[i]); HeapSize--; MaxHeapify(input,0); } }
堆排序总能达到O(n*lgn)的运行效率,就像合并排序一样。堆排序又是一种原地排序算法,就像快速排序和插入排序一样。因此,堆排序结合了以上几种排序的优点。但是,堆排序的实现不如快速排序的紧凑和简单。有资料表明,快速排序在实际应用中优于堆排序。
参考文献:
Thomas H. Cormen, Charles E. Leiserson, Introduction to Algorithms, 2ed