排序算法是我们工作中使用最普遍的算法,常见的语言库中基本都会有排序算法的实现,比如c标准库的qsort,stl的sort函数等。本文首先介绍直接插入排序,归并排序,堆排序,快速排序和基数排序等比较排序算法,然后介绍计数排序,基数排序等具有线性时间的排序算法。本文主要讨论算法的实现方法,并不会过多介绍基本理论。
评价一个排序算法优劣适用与否,一般需要从三个方面来分析
时间复杂度。用比较操作和移动操作数的最高次项表示,由于在实际应用中最在乎的是运行时间的上限,所以一般取输入最坏情况的下的运行时间作为算法的时间复杂度,快速排序例外。
空间复杂度。指除了输入数据外,临时需要的内存空间。在内存紧张的系统中需要首先考虑这一因素。空间复杂度是O(1)的算法称为原地排序算法。
稳定性。如果一个算法可以保证两个键值相等元素的位置关系在排序前后一样,那么我们就说这个算法是稳定的。算法的稳定性有时非常重要,比如计数排序的稳定性保证了基数排序的正确性。
一. 比较排序
比较排序算法由于受决策树的限制,时间复杂度具有下限O(nlgn)(即lgn!)。
1. 直接插入排序
直接插入排序是最简单的排序算法,基本操作是,从第2个元素开始,将前面的元素当作一个有序表,把当前元素插入到有序表的合适位置,形成新的长度加1的有序表,依此类推至最后一个元素。最直观的实现方法如下:
void InsertSort(int *array, int length){ for(int i=1; i<length; ++i){ if(array[i]>=array[i-1]) continue; int j; int val = array[i]; for(j=i-1; j>=0&&array[j]>val; --j){ array[j+1] = array[j]; } array[j+1] = val; } }
为了减少判断次数,可以在array[0]增加一个哨兵,
void InsertSortGard(int *array, int length){ for(int i=2; i<length; ++i){ if(array[i]>=array[i-1]) continue; array[0] = array[i]; int j; for(j=i-1; array[j]>array[0]; --j){ array[j+1] = array[j]; } array[j+1] = array[0]; } }
这样虽然需要增加一个元素的空间,不过在第二层for循环中不用再判断 j是否越界,对于使用频繁的排序算法来说是非常值得的。直接插入排序的空间复杂度是O(1),时间复杂度O(n^2),属于稳定排序。不过由于插入排序的常数因子小,最坏情况下比较操作次数是n(n-1)/2,最好情况下只要比较n次,所以对于小规模的排序,直接插入排序常常有比较好的效率。
2. 归并排序
分治策略是程序设计中经常采用的方法,归并排序算法是分治策略的典型应用。基本操作包括:
分解:将n个元素分解成各含n/2个元素的子序列
解决:用归并排序递归处理各个子序列
合并:将两个排好序的子序列合并成一个有序序列。
很明显这是一个递归过程,算法实现如下:
void Merge(int *array, int s1, int e1, int s2, int e2){ int size = e1-s1+e2-s2+2; int *temp = (int*)malloc(size); int i=0, j=s1, k=s2; while (i<size) { if (j>e1 || k>e2) break; if (array[j]<=array[k]) temp[i++] = array[j++]; else temp[i++] = array[k++]; } while (j <= e1) temp[i++] = array[j++]; while (k <= e2) temp[i++] = array[k++]; for (i=0,j=s1; i<size;) array[j++] = temp[i++]; free(temp); } void MergeSort(int *array, int s, int e){ if (s<e) { int mid = (s+e)/2; MergeSort(array, s, mid); MergeSort(array, mid+1, e); Merge(array, s, mid, mid+1, e); } }
Merge中需要分配临时数组,所以空间复杂度是O(n),应用中由于malloc往往非常消耗时间,可以考虑使用全局数组,分配一次,重复使用。在最好最坏的输入情况下时间复杂度都是O(nlgn),也属于稳定的排序算法。
不过递归算法频繁调用函数既消耗栈空间又消耗时间,可以进一步优化为非递归版本。递归归并算法其实可以看作是一个先自顶向下后自底向上的过程,通过直接构造自底向上的过程可以解除递归。
void MergeSortNonRecur(int *array, int length){ int step = 1; while (step < length) { for(int i=0; i<length; i+=step<<1){ if (i+2*step-1<length) Merge(array, i, i+step-1, i+step, i+2*step-1); else if (i+step < length) Merge(array, i, i+step-1, i+step, length-1); } step = step<<1; } }
下面是在我的电脑上测试的数据(us),环境是ubuntu12.04.3 LTS+gcc4.6.3。各个算法在50个元素和1000个元素的输入集的运行时间如下(单位微秒):
|
直接插入 |
直接插入(哨兵) |
递归归并 |
非递归归并 |
50 |
12 |
10 |
20 |
18 |
1000 |
2900 |
2700 |
500 |
470 |
可以发现带哨兵的直接插入排序比不带哨兵的要高,非递归归并排序比递归排序效率要高,同时,在规模较小时,由于直接插入排序的常数因子小,所以效率要比归并排序好,但是规模大了以后,归并排序要远远优于直接插入排序。
3. 堆排序
堆排序将数组看做一棵完全二叉树,这使得它和归并排序一样具有O(nlgn)的复杂度,不同的是,堆排序完全在数组内部,空间复杂度是O(1),属于原地排序(in place)算法。
堆排序的第一步是建堆,从最后一个非叶子节点开始,将大的元素上移,保证每个子树都是符合最大或最小堆
void MaxHeap(int *array, int i, int length) { int l = (i<<1)+1; int r = (i<<1)+2; int largest = i; if (l<=length && array[i]<array[l]) largest = l; if(r<=length && array[largest]<array[r]) largest = r; if(largest != i) { swap(&array[i],&array[largest]); MaxHeap(array, largest, length); } } void BuildMaxHeap(int *array, int length){ for(int i=(length>>1)-1; i>=0; --i) MaxHeap(array, i, length); }
第二部排序,从最后一个元素开始,依次和第一个即最大的元素交换,然后重新建堆。
void HeapSort(int *array, int length) { BuildMaxHeap(array, length); for(int i=length-1; i>0; --i){ swap(&array[i], &array[0]); length--; MaxHeap(array, 0, length-1); } }
可以看出堆排序的元素交换是跳跃式的,这导致两个问题,一,堆排序是不稳定的排序方式 ;二,实践中不能充分利用cache。不过堆排序非常适合用来实现优先级队列,解决topk问题。
4. 快速排序
快速排序是非常经典的算法,最坏情况下时间复杂度是O(n^2),最佳情况下是O(nlgn),但是由于其平均情况下与最佳情况下时间复杂度非常接近,而且与堆排序相比较,能更有效地利用硬件缓存,并且属于原地排序,所以实践中用的比较多。
经典的实现方法如下
int partition1(int *array, int low, int high) { int val = array[low]; while(low < high) { while(low<high && array[high]>=val) --high; swap(&array[low], &array[high]); while(low<high && array[low]<=val) ++low; swap(&array[low], &array[high]); } return low; } void Quicksort1(int *array, int b, int e) { if (b >= e) return; int p = partition1(array, b, e); Quicksort1(array, b, p-1); Quicksort1(array, p+1, e); }
快速排序的核心在于partion方法,我更喜欢下面这种方式,
int partition2(int *array, int low, int high) { int i,j; for(i=low,j=low; j<high;){ if(array[j]<array[high]) { if(i!=j) swap(&array[i], &array[j]); ++i;++j; } else { ++j; } } return i; }
在上一篇文章中,我还介绍了一种非递归的方法
void QuicksortNonRecur(int *array, int b, int e) { if (b >= e) return; std::stack< std::pair<int, int> > stk; stk.push(std::make_pair(b, e)); while(!stk.empty()) { std::pair<int, int> pair = stk.top(); stk.pop(); if(pair.first >= pair.second) continue; int p = partition1(array, pair.first, pair.second); if(p < pair.second) stk.push(std::make_pair(p+1, e)); if(p > pair.first) stk.push(std::make_pair(b, p-1)); } }
二 线性时间排序
下面介绍的算法不使用比较操作,不依赖决策树模型,所以时间复杂度也就没有下界O(nlgn)。
1. 计数排序
计数排序有一个前提条件,待排序的元素必须都在一个固定范围内,比如n个输入元素,每个元素都在0到k之间,那么就可以用一个k大小的数组记录每个元素的出现频率,从而确定每个元素在所有输入元素中的位置。
void CountingSort(int *array, int length){ for(int i=0; i<length; ++i) array_count[array[i]]++; for(int j=1; j<MAXNUM; ++j) array_count[j] += array_count[j-1]; for(int i=0; i<length; ++i) { array_sort[array_count[array[i]]] = array[i]; array_count[array[i]]--; } }
时空复杂度都是O(n+k),当k=O(n)时,复杂度就是O(n)。
2. 基数排序
对于给定的n个输入数列,一般的比较排序算法是从最高位开始比较,然后对于每个子集递归排序,而基数排序恰恰相反,从低位开始比较,每次都是在全集中排序,这么做的好处是不用多余的空间记录每个子集的位置,不用递归排序子集。基数排序在每一位都要用到计数排序,计数排序是稳定的,这可以保证基数排序的正确性,但是基数排序本身却不是稳定的。
下面程序利用基数排序可以在在O(n)时间内对0到n^2-1之间的n个数排序。
#define NUM 10
#define NUM2 (NUM*NUM)
int array[NUM]; int array_tmp[NUM]; int array_count[NUM]; void ArrayInit(int *array){ srand((unsigned)time(0)); for(int i=0; i<NUM; ++i){ array[i] = rand()%NUM2; } } void ArrayReset(int *array) { for(int i=0; i<NUM; ++i) array[i] = 0; } void ArrayCopy(int *src, int *des) { for (int i=0; i<NUM; ++i) des[i] = src[i]; } int RadixSort(int *array) { for(int i=0; i<2; ++i) { for(int j=0; j<NUM; ++j){ int r = i, index = array[j]; while (r-->0) index /= NUM; array_count[index%NUM]++; } for(int k=1; k<NUM; ++k) array_count[k] += array_count[k-1]; for(int m=NUM-1; m>=0; --m){ int r = i, index = array[m]; while (r-->0) index /= NUM; array_tmp[array_count[index%NUM]-1] = array[m]; array_count[index%NUM]--; } ArrayCopy(array_tmp, array); ArrayReset(array_count); } } int main(){ ArrayInit(array); RadixSort(array); }
code: