排序算法是算法学中最基础、应用最广的一类算法,其中最简单的就是冒泡排序和简单选择排序法,然而这两种算法的时间复杂度都在O(n^2),并不高效,这里就对八种不同的排序算法进行分析。基本的排序算法分为插入排序、选择排序、交换排序、归并排序、基数排序,其中插入排序分为直接插入排序、希尔排序,选择排序分为简单选择排序和堆排序,交换排序分为冒泡排序和快速排序,总共八种基础的排序算法,其他的排序算法都是在这八种方法上的组合与变种。
直接插入法十分简单,就是将乱序的元素逐个插入到已排序好的序列中,即将被插入元素与排好序的元素逐一比较,插入到合适的位置,时间复杂度为O(n^2)。
希尔排序是在直接插入法的改进,首先构造一增量序列(递减至1),按某个增量d分成若干组子序列,每组中记录的下标相差d,对每组中全部元素进行直接插入排序,然后再用增量序列中下一个较小的增量进行分组,再对每组进行直接插入排序,直至增量为1。其效果如下:
示例代码:
void ShellInsertSort(int a[], int n, int dk) { for(int i= dk; i<n; ++i){ if(a[i] < a[i-dk]){ int j = i-dk; int x = a[i]; a[i] = a[i-dk]; while(x < a[j]){ a[j+dk] = a[j]; j -= dk; } a[j+dk] = x; } } } void shellSort(int a[], int n){ int dk = n/2; while( dk >= 1 ){ ShellInsertSort(a, n, dk); dk = dk/2; } }
希尔排序相比于简单直接插入排序,减少了复制的次数,原因是当增量较大时数据项每一趟排序需要移动的个数很少,尽管数据项的个数很多;当增量减小时,每一趟需要移动的数据增多,但此时已经接近于它们排序后的最终位置,所以希尔排序的效率比插入排序高很多。希尔排序的平均时间复杂度为O( n^1.3 ),没有快速排序算法快,因此对规模非常大的数据排序不是最优选择。但希尔排序在最坏的情况下和平均情况下执行效率相差不是很多,而快速排序在最坏的情况下执行的效率会非常差。所以,大部分排序工作在开始时都可以用希尔排序,若在实际使用中证明它不够快,再改成快速排序这样更高级的排序算法。
简单选择排序即在首次遍历容器的过程中找到最小或最小的元素与第一个元素交换位置,然后在第二次遍历过程中重复上述步骤与第二个元素交换位置,以此类推,时间复杂度为O(n^2)。在此基础上可以做一些改进,比如每次搜索过程中同时搜索最大和最小元素并分别放在容器的头和尾,这样遍历次数能减少一半。
堆排序是一种树形选择排序,对一个n个元素的序列,堆的定义是:
堆对应一棵完全二叉树,且所有非叶结点的值均不大于(或不小于)其子女的值,根结点(堆顶元素)的值是最小(或最大)的。堆排序的过程就是先把数组的n个元素建立成堆结构,然后堆顶元素就是排序后数组的第一个元素,再将剩下 n-1 个元素调整为新的堆结构,其堆顶就是排序后的第二个元素,以此类推,与简单选择排序有一定相似之处。
先说调整堆的方法:
1)设有n个元素的堆,输出堆顶元素后,剩下 n-1 个元素。将堆底元素送入堆顶(最后一个元素与堆顶进行交换),此时根结点不满足堆的性质。
2)将根结点与左、右子树中较小元素的进行交换。
3)若与左子树交换:如果左子树堆被破坏,即左子树的根结点不满足堆的性质,则重复方法 (2).
4)若与右子树交换,如果右子树堆被破坏,即右子树的根结点不满足堆的性质。则重复方法 (2).
5)继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。
建立堆的过程就是反复调整的过程,从第 [n/2] 个元素开始,依次向前直到第一个元素全都重复上述(2)~(5)调整过程,一个堆就被初始化好了。
示例代码:
void HeapAdjust(int H[],int s, int length) { int tmp = H[s]; int child = 2*s+1; 、 while (child < length) { if(child+1 <length && H[child]<H[child+1]) { ++child ; } if(H[s]<H[child]) { H[s] = H[child]; s = child; child = 2*s+1; } else { break; } H[s] = tmp; } print(H,length); } void BuildingHeap(int H[], int length) { for (int i = (length -1) / 2 ; i >= 0; --i) HeapAdjust(H,i,length); } void HeapSort(int H[],int length) { BuildingHeap(H, length); for (int i = length - 1; i > 0; --i) { int temp = H[i]; H[i] = H[0]; H[0] = temp; HeapAdjust(H,0,i); } }
堆排序的平均时间复杂度为O(nlogn),且最差、最好时间复杂度都在这个量级。
冒泡排序是最简单的一种交换排序,其算法是不断遍历数组,每当两相邻的数比较后发现它们的排序与排序要求相反时,就将它们互换,时间复杂度为O(n^2)。
快速排序是另一种交换排序,也是速度最快的一种排序算法,其算法如下:
1)选择一个基准元素,通常选择第一个元素或者最后一个元素,
2)通过一趟排序将待排序的记录分割成独立的两部分,其中一部分记录的元素值均比基准元素值小。另一部分记录的元素值比基准值大。
3)此时基准元素在其排好序后的正确位置。
4)然后分别对这两部分记录用同样的方法继续进行排序,直到整个序列有序。
具体过程如图所示:
示例代码:
int partition(int a[], int low, int high) { int privotKey = a[low]; while(low < high){ while(low < high && a[high] >= privotKey) --high; swap(&a[low], &a[high]); while(low < high && a[low] <= privotKey ) ++low; swap(&a[low], &a[high]); } print(a,10); return low; } void quickSort(int a[], int low, int high){ if(low < high){ int privotLoc = partition(a, low, high); quickSort(a, low, privotLoc -1); quickSort(a, privotLoc + 1, high); } }
快速排序的时间复杂度为O(nlogn),且其平均性能是同数量级算法中最好的。但若初始序列按关键码有序或基本有序时,快排反而蜕化为冒泡排序。
归并排序法的思想是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干有序个子序列,然后再将它们合并为整体有序序列。1 个元素的表总是有序的,所以对n 个元素的待排序列,每个元素可看成1 个有序子表,对子表两两合并生成n/2个子表,所得子表除最后一个子表长度可能为1 外,其余子表长度均为2。再进行两两合并,直到生成n 个元素按关键码有序的表。效果如图:
示例代码:
void Merge(ElemType *r,ElemType *rf, int i, int m, int n) { int j,k; for(j=m+1,k=i; i<=m && j <=n ; ++k){ if(r[j] < r[i]) rf[k] = r[j++]; else rf[k] = r[i++]; } while(i <= m) rf[k++] = r[i++]; while(j <= n) rf[k++] = r[j++]; }
归并排序的平均时间复杂度为O(nlogn),且最好、最坏时间复杂度都在这个量级。
基数排序是一种多关键字排序,分为最高位优先(Most Significant Digit first)法和最低位优先(Least Significant Digit first)法。设待排序列为n个记录,d个关键码,关键程度从k1到kd递减,关键码的取值范围为radix。(关键码的意义是只要k1大,则排序高,k1相等则比较其余关键码,对 k2~kd 同理)
MSD法:先按k1排序分组,同一组中记录,关键码k1相等,再对各组按k2排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码kd对各子组排序后。再将各组连接起来,便得到一个有序序列;
第一步
第二步
第三步
排序算法选取准则:
- 当n较大,则应采用时间复杂度为O(nlogn)的排序方法:快速排序、堆排序或归并排序序。快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短。
- 当n较大,内存空间允许,且要求稳定性:归并排序。
- 当n较小,可采用直接插入或直接选择排序。(当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数。)
- 基数排序是一种稳定的排序算法,但有一定的局限性:(1)关键字可分解;(2)记录的关键字位数较少,如果密集更好;(3)如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开排序。