学习排序算法,看到一篇博客总结的很好,搬运到此自己也总结一下。原文地址:
我们通常所说的排序算法往往指的是内部排序算法,即数据记录在内存中进行排序。
排序算法大体可分为两种:
- 一种是比较排序,时间复杂度O(nlogn) ~ O(n^2),主要有:冒泡排序,选择排序,插入排序,归并排序,堆排序,快速排序等。
- 另一种是非比较排序,时间复杂度可以达到O(n),主要有:计数排序,基数排序,桶排序等。
下表给出了常见比较排序算法的性能:
有一点我们很容易忽略的是排序算法的稳定性(腾讯校招2016笔试题曾考过)。
排序算法稳定性的简单形式化定义为:如果Ai = Aj,排序前Ai在Aj之前,排序后Ai还在Aj之前,则称这种排序算法是稳定的。通俗地讲就是保证排序前后两个相等的数的相对顺序不变。
对于不稳定的排序算法,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。
例如,对于冒泡排序,原本是稳定的排序算法,如果将记录交换的条件改成A[i] >= A[i + 1],则两个相等的记录就会交换位置,从而变成不稳定的排序算法。
其次,说一下排序算法稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,前一个键排序的结果可以为后一个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位排序后元素的顺序在高位也相同时是不会改变的。
冒泡排序(Bubble Sort)
冒泡排序是一种极其简单的排序算法,也是我所学的第一个排序算法。它重复地走访过要排序的元素,依次比较相邻两个元素,如果他们的顺序错误就把他们调换过来,直到没有元素再需要交换,排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序算法的运作如下:
- 比较相邻的元素,如果前一个比后一个大,就把它们两个调换位置;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
由于它的简洁,冒泡排序通常被用来对于程序设计入门的学生介绍算法的概念。冒泡排序的代码如下:
#include <stdio.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果能在内部循环第一次运行时,使用一个旗标来表示有无需要交换的可能,可以把最优时间复杂度降低到O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void Swap(int A[], int i, int j){
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
void BubbleSort( int A[], int n ){
for ( int j = 0; j < n - 1; j++ ){ // 每次最大元素就像气泡一样"浮"到数组的最后
for ( int i = 0; i < n - 1 - j; i++ ){ // 依次比较相邻的两个元素,使较大的那个向后移
if ( A[i] > A[i + 1] ) // 如果条件改成A[i] >= A[i + 1],则变为不稳定的排序算法
Swap( A, i, i + 1 );
}
}
}
int main(){
int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 从小到大冒泡排序
int n = sizeof(A) / sizeof(int);
BubbleSort(A, n);
printf("The result of BubbleSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行冒泡排序的实现过程如下图所示:
使用冒泡排序为一列数字进行排序的过程如下图所示:
尽管冒泡排序是最容易了解和实现的排序算法之一,但它对于少数元素之外的数列排序是很没有效率的。
冒泡排序的改进:鸡尾酒排序
鸡尾酒排序,也叫定向冒泡排序,是冒泡排序的一种改进。此算法与冒泡排序的不同处在于从低到高然后从高到低,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。
鸡尾酒排序的代码如下:
#include <stdio.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果序列在一开始已经大部分排序过的话,会接近O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void Swap(int A[], int i, int j){
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
void CocktailSort(int A[], int n){
int left = 0; // 初始化边界
int right = n - 1;
while (left < right){
for (int i = left; i < right; i++) // 前半轮,将最大元素放到后面
if (A[i] > A[i + 1])
Swap(A, i, i + 1);
--right;
for (int i = right; i > left; i--) // 后半轮,将最小元素放到前面
if (A[i - 1] > A[i])
Swap(A, i - 1, i);
++left;
}
}
int main(){
int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 从小到大定向冒泡排序
int n = sizeof(A) / sizeof(int);
CocktailSort(A, n);
printf("The result of CocktailSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
使用鸡尾酒排序为一列数字进行排序的过程如下图所示:
以序列(2,3,4,5,1)为例,鸡尾酒排序只需要访问一次序列就可以完成排序,但如果使用冒泡排序则需要四次。但是在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。
选择排序(Selection Sort)
选择排序也是一种简单直观的排序算法。它的工作原理很容易理解:初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列;然后,再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
注意选择排序与冒泡排序的区别:冒泡排序通过依次交换相邻两个顺序不合法的元素位置,从而将当前最小(大)元素放到合适的位置;而选择排序每遍历一次都记住了当前最小(大)元素的位置,最后仅需一次交换操作即可将其放到合适的位置。
选择排序的代码如下:
#include <stdio.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(n^2)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
void Swap(int A[], int i, int j){
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
void SelectionSort(int A[], int n){
for (int i = 0; i < n - 1; i++){ // i为已排序序列的末尾
int min = i;
for (int j = i + 1; j < n; j++) // 未排序序列
if (A[j] < A[min]) // 找出未排序序列中的最小值
min = j;
if (min != i)
Swap(A, min, i); // 放到已排序序列的末尾,该操作很有可能把稳定性打乱,所以选择排序是不稳定的排序算法
}
}
int main(){
int A[] = { 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }; // 从小到大选择排序
int n = sizeof(A) / sizeof(int);
SelectionSort(A, n);
printf("The result of SelectionSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
上述代码对序列{ 8, 5, 2, 6, 9, 3, 1, 4, 0, 7 }进行选择排序的实现过程如下图:
使用选择排序为一列数字进行排序的宏观过程:
选择排序是不稳定的排序算法,不稳定发生在最小元素与A[i]交换的时刻。
比如序列:{ 5, 8, 5, 2, 9 },一次选择的最小元素是2,然后把2和第一个5进行交换,从而改变了两个元素5的相对次序。
插入排序(Insertion Sort)
插入排序是一种简单直观的排序算法。它的工作原理非常类似于我们抓扑克牌。
对于未排序数据(右手抓到的牌),在已排序序列(左手已经排好序的手牌)中从后向前扫描,找到相应位置并插入。
插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
具体算法描述如下:
- 从第一个元素开始,该元素可认为已被排序;
- 取出下一个元素,在已排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
插入排序算法的代码如下:
#include <stdio.h>
// 分类 ------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 最坏情况为输入序列是降序排列的,此时时间复杂度O(n^2)
// 最优时间复杂度 ---- 最好情况为输入序列是升序排列的,此时时间复杂度O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void InsertionSort( int A[], int n ){
for ( int i = 1; i < n; i++ ){ // 类似抓扑克牌排序
int get = A[ i ]; // 右手抓到一张扑克牌
int j = i - 1; // 拿在左手上的牌总是排序好的
while ( j >= 0 && A[ j ] > get ){ // 将抓到的牌与手牌从右向左进行比较
A[ j + 1 ] = A[ j ]; // 如果该手牌比抓到的牌大,就将其右移
--j;
}
A[ j + 1 ] = get; // 直到该手牌比抓到的牌小(或二者相等),将抓到的牌插入到该手牌右边(相等元素的相对次序未变,所以插入排序是稳定的)
}
}
int main(){
int A[] = { 6, 5, 3, 1, 8, 7, 2, 4 };// 从小到大插入排序
int n = sizeof(A) / sizeof(int);
InsertionSort(A, n);
printf("The result of InsertionSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行插入排序的实现过程如下:
使用插入排序为一列数字进行排序的宏观过程:
插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,比如量级小于千,那么插入排序还是一个不错的选择。 插入排序在工业级库中也有着广泛的应用,在STL的sort算法和stdlib的qsort算法中,都将插入排序作为快速排序的补充,用于少量元素的排序(通常为8个或以下)。
插入排序的改进:二分插入排序
对于插入排序,如果比较操作的代价比交换操作大的话,可以采用二分查找法来减少比较操作的次数,我们称为二分插入排序,代码如下:
#include <stdio.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void InsertionSortDichotomy( int A[], int n ){
for ( int i = 1; i < n; i++ ){
int get = A[ i ]; // 右手抓到一张扑克牌
int left = 0; // 拿在左手上的牌总是排序好的,所以可以用二分法
int right = i - 1; // 手牌左右边界进行初始化
while ( left <= right ){ // 采用二分法定位新牌的位置
int mid = ( left + right ) / 2;
if ( A[ mid ] > get )
right = mid - 1;
else
left = mid + 1; // left就是新元素应插入的位置
}
for ( int j = i - 1; j >= left; j-- ) // 将欲插入新牌位置右边的牌整体向右移动一个单位
A[ j + 1 ] = A[ j ];
A[ left ] = get; // 将抓到的牌插入手牌
}
}
int main(){
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 }; // 从小到大二分插入排序
int n = sizeof( A ) / sizeof( int );
InsertionSortDichotomy( A, n );
printf("The result of InsertionSortDichotomy is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
当n较大时,二分插入排序的比较次数比直接插入排序的最差情况好得多,但比直接插入排序的最好情况要差,所当以元素初始序列已经接近升序时,直接插入排序比二分插入排序比较次数少。二分插入排序元素移动次数与直接插入排序相同,依赖于元素初始序列。
插入排序的更高效改进:希尔排序(Shell Sort)
希尔排序,也叫递减增量排序,是插入排序的一种更高效的改进版本。希尔排序是不稳定的排序算法。
使用希尔增量时希尔排序的最坏情形运行时间为O(N^2),使用Hibbard增量的希尔排序的最坏情形运行时间为O(N^(3/2)),使用Sedgewick提出的几种增量序列最坏情形运行时间为O(N^(4/3))。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。
希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。
假设有一个很小的数据在一个已按升序排好序的数组的末端。如果用复杂度为O(n^2)的排序(冒泡排序或直接插入排序),可能会进行n次的比较和交换才能将该数据移至正确位置。而希尔排序会用较大的步长移动数据,所以小数据只需进行少数比较和交换即可到正确位置。
使用希尔增量的希尔排序的代码如下:
#include <stdio.h>
// 分类 --------------内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 使用希尔序列的最坏情形运行时间为O(n^2)
// 最优时间复杂度 ---- O(n)
// 平均时间复杂度 ---- 根据步长序列的不同而不同。
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
// 使用Shell增量序列:H(i) = H(i+1) / 2
void ShellSort( int A[], int n ){
int i, j, Increment;
int Temp;
for( Increment = N / 2; Increment > 0; Increment /= 2 ){
for( i = Increment; i < N; i++ ){
Temp = A[ i ];
for( j = i; j >= Increment; j -= Increment )
if( Temp < A[ j - Increment ] )
A[ j ] = A[ j - Increment ];
else
break;
A[ j ] = Temp;
}
}
}
int main()
{
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 从小到大希尔排序
int n = sizeof( A ) / sizeof( int );
ShellSort( A, n );
printf("The result of ShellSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
使用Hibbard增量的希尔排序代码如下:
#include <stdio.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 使用Hibbard序列的最坏情形运行时间为O(n^(3/2))
// 最优时间复杂度 ---- O(n)
// 平均时间复杂度 ---- 根据步长序列的不同而不同。
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
// 使用Hibbard增量序列:H(i) = 2 * H(i-1) + 1
void HibbardShellSort( int A[], int n ){
int h = 0;
while ( h <= n ) // 生成初始增量
h = 2 * h + 1;
while ( h >= 1 ){
for ( int i = h; i < n; i++ ){
int j = i - h;
int get = A[ i ];
while ( j >= 0 && A[ j ] > get ){
A[ j + h ] = A[ j ];
j = j - h;
}
A[ j + h ] = get;
}
h = ( h - 1 ) / 2; // 递减增量
}
}
int main()
{
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 从小到大希尔排序
int n = sizeof( A ) / sizeof( int );
HibbardShellSort( A, n );
printf("The result of ShellSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
以23, 10, 4, 1的步长序列进行希尔排序:
希尔排序是不稳定的排序算法,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱.
归并排序(Merge Sort)
归并排序是创建在归并操作上的一种有效的排序算法,以O(nlogn)最坏情形运行时间运行,所使用的比较次数几乎是最优的。它是在1945年由冯·诺伊曼首次提出。
虽然归并排序的运行时间是O(nlogn),但它很难用于主存排序,因为合并两个排序的表需要线性附加内存,在整个算法中还要花费时间将数据拷贝到临时数组再拷贝回来这样一些附加的工作,其结果严重放慢了排序的速度。对于重要的内部排序应用而言,人们还是选择快速排序。
归并排序的实现分为递归实现与非递归(迭代)实现。递归实现的归并排序是算法设计中分治策略的典型应用,我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。非递归(迭代)实现的归并排序首先进行是两两归并,然后四四归并,然后是八八归并,一直下去直到归并了整个数组。
归并排序算法主要依赖归并(Merge)操作。归并操作指的是将两个已经排序的序列合并成一个序列的操作,归并操作步骤如下:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
- 重复步骤3直到某一指针到达序列尾;
- 将另一序列剩下的所有元素直接复制到合并序列尾。
归并排序的代码如下:
#include <stdio.h>
#include <limits.h>
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(nlogn)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ O(n)
// 稳定性 ------------ 稳定
// Lpos是左半部分的起始元素,Rpos是右半部分的起始元素
void Merge( int A[], int temp[], int Lpos, int Rpos, int RightEnd ){ // 合并两个已排好序的数组A[left...mid]和A[mid+1...right]
int len = right - left + 1;
int index = 0;
int LeftEnd = Rpos - 1;
int TmpPos = Lpos;
while ( Lpos <= LeftEnd && Rpos <= RightEnd)
temp[ index++ ] = A[ Lpos ] <= A[ Rpos ] ? A[ Lpos++ ] : A[ Rpos++ ]; // 带等号保证归并排序的稳定性
while ( Lpos <= LeftEnd ) // 将左半部分剩余的元素拷贝到临时数组中
temp[ index++ ] = A[ Lpos++ ];
while ( Rpos <= RightEnd ) // 将右半部分剩余的元素拷贝到临时数组中
temp[ index++ ] = A[ Rpos++ ];
// 将临时数组拷贝回去
for( int k = 0; k < len; k++, RightEnd-- )
A[ RightEnd ] = temp[ RightEnd ];
}
// 递归实现的归并排序(自顶向下)
void MSort( int A[], int Temp[], int Left, int Right ){
int Center;
if( Left < Right ){
Center = ( Left + Right ) / 2;
MSort( A, Temp, Left, Center );
MSort( A, Temp, Center + 1, Right );
Merge( A, Temp, Left, Center + 1, Right );
}
// MSort的驱动程序
void Mergesort( int A, int N ){
int * Temp;
Temp = malloc( N * sizeof( int ) ); // 严密测试指出,由于Merge位于MSort的最后一行,因此任意时刻只需要一个临时数组Temp活动,而且可以使用该临时数组的人一部分
if( Temp != NULL ){
MSort( A, Temp, 0, N - 1 );
free( Temp );
}
else
FatalError( "No Space for Temp Array!!!" );
}
// 非递归(迭代)实现的归并排序(自底向上)
void MergeSortIteration(int A[], int len)
{
int left, mid, right;// 子数组索引,前一个为A[left...mid],后一个子数组为A[mid+1...right]
int * Temp;
Temp = malloc( N * sizeof( int ) );
if( Temp != NULL ){
for ( int i = 1; i < len; i *= 2 ){ // 子数组的大小i初始为1,每轮翻倍
left = 0;
while ( left + i < len ){ // 后一个子数组存在(需要归并)
mid = left + i - 1;
right = mid + i < len ? mid + i : len - 1;// 后一个子数组大小可能不够
Merge( A, Temp, left, mid, right );
left = right + 1; // 前一个子数组索引向后移动
}
}
free( Temp );
}
else
FatalError( "No Space for Temp Array!!!" );
}
int main()
{
int A1[] = { 6, 5, 3, 1, 8, 7, 2, 4 }; // 从小到大归并排序
int A2[] = { 6, 5, 3, 1, 8, 7, 2, 4 };
int n1 = sizeof( A1 ) / sizeof( int );
int n2 = sizeof( A2 ) / sizeof( int );
Mergesort( A1, n1 ); // 递归实现
MergeSortIteration( A2, n2 ); // 非递归实现
printf("The result of Mergesort is :\n");
for (int i = 0; i < n1; i++)
printf("%d ", A1[i]);
printf("\n");
printf("The result of MergeSortIteration is :\n");
for (int i = 0; i < n2; i++)
printf("%d ", A2[i]);
printf("\n");
return 0;
}
上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行归并排序的实例如下:
使用归并排序为一列数字进行排序的宏观过程:
归并排序除了可以对数组进行排序,还可以高效的求出数组小和(即单调和)以及数组中的逆序对,详见这篇博文。
堆排序(Heap Sort)
堆排序是指利用堆这种数据结构所设计的一种选择排序算法。堆是一种近似完全二叉树的结构(通常堆是通过一维数组来实现的),并满足性质:以最大堆(也叫大根堆、大顶堆)为例,其中父结点的值总是大于它的孩子节点。
堆排序给出了至今所见到的最佳的大O运行时间。但在实践中它却慢于使用Sedgewick增量序列的希尔排序。
我们可以很容易的定义堆排序的过程:
- 由输入的无序数组构造一个最大堆,作为初始的无序区;
- 把堆顶元素(最大值)和堆尾元素互换;
- 把堆(无序区)的尺寸缩小1,并调用PercDown进行下滤;
- 重复步骤2~3,直到堆的尺寸为1。
堆排序的代码如下:(代码来源于《数据结构与算法分析——C语言描述》)
#include <stdio.h>
// LeftChild(i)是第i个节点的左孩子的数组索引
#define LeftChild(i) ( 2 * (i) + 1 )
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(nlogn)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
void Swap( int *A1, int *A2 ){
int temp = *A1;
*A1 = *A2;
*A2 = temp;
}
// 下滤算法
void PercDown( int A[], int i, int N ){
int Child;
int Temp;
for( Temp = A[ i ]; LeftChild( i ) < N; i = Child ){
Child = LeftChild( i ); // Child是左儿子的索引
if( Child != N - 1 && A[ Child + 1 ] > A[ Child ] )
++Child; // 右儿子比左儿子大,就将Child设为右儿子的索引
if( Temp < A[ Child ] )
A[ i ] = A[ Child ]; // 若较大的儿子比i节点大,就把i节点下滑
else
break;
}
A[ i ] = Temp;
}
void HeapSort( int A[], int N ){
int i;
for( i = N / 2; i >= 0; --i )
PercDown( A, i, N ); // 新建具有堆序的树(构建了一个最大堆)
for( i = N - 1; i > 0; --i ){
Swap( &A[ 0 ], &A[ i ] ); // 将堆中的最后一个元素与第一个元素交换
PercDown( A, 0, i ); // 缩小堆的大小并下滤
}
}
int main()
{
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 从小到大堆排序
int n = sizeof(A) / sizeof(int);
HeapSort( A, n );
printf("The result of HeapSort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
堆排序算法的演示过程:
动画中在排序过程之前简单的表现了创建堆的过程以及堆的逻辑结构。
堆排序是不稳定的排序算法,不稳定发生在堆顶元素与A[i]交换的时刻。
比如序列:{ 9, 5, 7, 5 },堆顶元素是9,堆排序下一步将9和第二个5进行交换,得到序列 { 5, 5, 7, 9 },再进行堆调整得到{ 7, 5, 5, 9 },重复之前的操作最后得到{ 5, 5, 7, 9 }从而改变了两个5的相对次序。
快速排序(Quick Sort)
快速排序是由东尼·霍尔所发展的一种排序算法,是在实践中最快的已知排序算法,它的平均运行时间是O(nlogn)。在最坏状况下则需要O(n^2)次比较,但这种状况是可以避免的。快速排序的内部循环可以在大部分的架构上很有效率地被实现出来。
对于很小的数组(N <= 20),快速排序不如插入排序好。
快速排序使用分治策略(Divide and Conquer)来把一个序列分为两个子序列。步骤为:
- 从序列中挑出一个元素来,作为“基准”(pivot);
- 把所有比基准小的元素放在基准前,所有比基准大的元素放在基准后,与基准相等的数可以放在任一边,这个称为“分区”(partition)操作;
- 对每个分区递归地进行步骤1~2,递归结束的条件是序列的大小为0或1,这时整体已经被排好序了。
快速排序的代码如下:(代码来源于《数据结构与算法分析——C语言描述》)
#include <stdio.h>
#define Cutoff 3
// 此处定义截止范围。一种好的截止范围是 N = 10。小数组使用插入排序更好
// 分类 ------------ 内部比较排序
// 数据结构 --------- 数组
// 最差时间复杂度 ---- 每次选取的基准都是最大(或最小)的元素,导致每次只划分出了一个分区,需要进行n-1次划分才能结束递归,时间复杂度为O(n^2)
// 最优时间复杂度 ---- 每次选取的基准都是中位数,这样每次都均匀的划分出两个分区,只需要logn次划分就能结束递归,时间复杂度为O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ 主要是递归造成的栈空间的使用(用来保存left和right等局部变量),取决于递归树的深度,一般为O(logn),最差为O(n)
// 稳定性 ---------- 不稳定
void Swap( int *A1, int *A2 ){
int temp = *A1;
*A1 = *A2;
*A2 = temp;
}
// 插入排序
void InsertionSort(int A[], int n){
for (int i = 1; i < n; i++){ // 类似抓扑克牌排序
int get = A[i]; // 右手抓到一张扑克牌
int j = i - 1; // 拿在左手上的牌总是排序好的
while (j >= 0 && A[j] > get){ // 将抓到的牌与手牌从右向左进行比较
A[j + 1] = A[j]; // 如果该手牌比抓到的牌大,就将其右移
j--;
}
A[j + 1] = get; // 直到该手牌比抓到的牌小(或二者相等),将抓到的牌插入到该手牌右边(相等元素的相对次序未变,所以插入排序是稳定的)
}
}
// 三数中值分割法选取枢纽元pivot
int Median3( int A[], int Left, int Right ){
int Center = ( Left + Right ) / 2;
if( A[ Left ] > A[ Center ] )
Swap( &A[ Left ], &A[ Center ] ); // 将最小者放在Left处
if( A[ Left ] > A[ Right] )
Swap( &A[ Left ], &A[ Right] );
if( A[ Center ] > A[ Right ] )
Swap( &A[ Center ], &A[ Right ] ); // 将最大者放在Right处
Swap( &A[ Center ], &A[ Right - 1 ] ); // 将枢纽元(三数中值)放在Right-1处
return A[ Right - 1 ];
}
void QuickSort( int A[], int Left, int Right ){
int i, j;
int Pivot; // 枢纽元素
if( Left + Cutoff <= Right ){
Pivot = Median3( A, Left, Right );
i = Left;
j = Right - 1; // 此处将i和j初始化为比它们的正确值超出1,使得不存在需要考虑的特殊情况
for( ; ; ){
while( A[ ++i ] < Pivot ) {}
while( A[ --j ] > Pivot ) {}
if( i < j )
Swap( &A[ i ], &A[ j ] ); // 此处Swap为了速度考虑有时需要显式写出
else
break;
}
Swap( &A[ i ], &A[ Right - 1] ); // 把枢纽元放到i指向的位置,左侧全是小于它的元素,右侧全是大于它的元素
QuickSort( A, Left, i - 1 );
QuickSort( A, i + 1, Right );
}
else
InsertionSort( A + Left, Right - Left + 1 ); // 小数组做插入排序更好
}
int main(){
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 }; // 从小到大快速排序
int n = sizeof(A) / sizeof(int);
QuickSort(A, 0, n - 1);
printf("The result of Quick Sort is:\n");
for (int i = 0; i < n; i++)
printf("%d ", A[i]);
printf("\n");
return 0;
}
使用快速排序法对一列数字进行排序的过程:
快速排序是不稳定的排序算法,不稳定发生在基准元素与A[tail+1]交换的时刻。
比如序列:{ 1, 3, 4, 2, 8, 9, 8, 7, 5 },基准元素是5,一次划分操作后5要和第一个8进行交换,从而改变了两个元素8的相对次序。
非比较排序·计数排序(Counting Sort)
计数排序用到一个额外的计数数组C,根据数组C来将原数组A中的元素排到正确的位置。
通俗地理解,例如有10个年龄不同的人,假如统计出有8个人的年龄不比小明大(即小于等于小明的年龄,这里也包括了小明),那么小明的年龄就排在第8位,通过这种思想可以确定每个人的位置,也就排好了序。当然,年龄一样时需要特殊处理(保证稳定性):通过反向填充目标数组,填充完毕后将对应的数字统计递减,可以确保计数排序的稳定性。
计数排序的步骤如下:
- 统计数组A中每个值A[i]出现的次数,存入C[ A[i] ];
- 从前向后,使数组C中的每个值等于其与前一项相加,这样数组C[A[i]]就变成了代表数组A中小于等于A[i]的元素个数;
- 反向填充目标数组B:将数组元素A[i]放在数组B的第C[ A[i] ]个位置(下标为C[ A[i] ] - 1),每放一个元素就将C[ A[i] ]递减。
计数排序的实现代码如下:
#include<iostream>
using namespace std;
// 分类 ------------ 内部非比较排序
// 数据结构 --------- 数组
// 最差时间复杂度 ---- O(n + k)
// 最优时间复杂度 ---- O(n + k)
// 平均时间复杂度 ---- O(n + k)
// 所需辅助空间 ------ O(n + k)
// 稳定性 ----------- 稳定
const int k = 100; // 基数为100,排序[0,99]内的整数
int C[k]; // 计数数组
void CountingSort(int A[], int n)
{
for (int i = 0; i < k; i++) // 初始化,将数组C中的元素置0(此步骤可省略,整型数组元素默认值为0)
{
C[i] = 0;
}
for (int i = 0; i < n; i++) // 使C[i]保存着等于i的元素个数
{
C[A[i]]++;
}
for (int i = 1; i < k; i++) // 使C[i]保存着小于等于i的元素个数,排序后元素i就放在第C[i]个输出位置上
{
C[i] = C[i] + C[i - 1];
}
int *B = (int *)malloc((n) * sizeof(int));// 分配临时空间,长度为n,用来暂存中间数据
for (int i = n - 1; i >= 0; i--) // 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变)
{
B[--C[A[i]]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上
// 当再遇到重复元素时会被放在当前元素的前一个位置上保证计数排序的稳定性
}
for (int i = 0; i < n; i++) // 把临时空间B中的数据拷贝回A
{
A[i] = B[i];
}
free(B); // 释放临时空间
}
int main()
{
int A[] = { 15, 22, 19, 46, 27, 73, 1, 19, 8 }; // 针对计数排序设计的输入,每一个元素都在[0,100]上且有重复元素
int n = sizeof(A) / sizeof(int);
CountingSort(A, n);
printf("计数排序结果:");
for (int i = 0; i < n; i++)
{
printf("%d ", A[i]);
}
printf("\n");
return 0;
}
下图给出了对{ 4, 1, 3, 4, 3 }进行计数排序的简单演示过程:
计数排序的时间复杂度和空间复杂度与数组A的数据范围(A中元素的最大值与最小值的差加上1)有关,因此对于数据范围很大的数组,计数排序需要大量时间和内存。
例如:对0到99之间的数字进行排序,计数排序是最好的算法,然而计数排序并不适合按字母顺序排序人名,将计数排序用在基数排序算法中,能够更有效的排序数据范围很大的数组。
非比较排序·基数排序(Radix Sort)
基数排序的发明可以追溯到1887年赫尔曼·何乐礼在打孔卡片制表机上的贡献。它是这样实现的:将所有待比较正整数统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始进行基数为10的计数排序,一直到最高位计数排序完后,数列就变成一个有序序列(利用了计数排序的稳定性)。
基数排序的实现代码如下:
#include<iostream>
using namespace std;
// 分类 ------------- 内部非比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n * dn)
// 最优时间复杂度 ---- O(n * dn)
// 平均时间复杂度 ---- O(n * dn)
// 所需辅助空间 ------ O(n * dn)
// 稳定性 ----------- 稳定
const int dn = 3; // 待排序的元素为三位数及以下
const int k = 10; // 基数为10,每一位的数字都是[0,9]内的整数
int C[k];
int GetDigit(int x, int d) // 获得元素x的第d位数字
{
int radix[] = { 1, 1, 10, 100 };// 最大为三位数,所以这里只要到百位就满足了
return (x / radix[d]) % 10;
}
void CountingSort(int A[], int n, int d)// 依据元素的第d位数字,对A数组进行计数排序
{
for (int i = 0; i < k; i++)
{
C[i] = 0;
}
for (int i = 0; i < n; i++)
{
C[GetDigit(A[i], d)]++;
}
for (int i = 1; i < k; i++)
{
C[i] = C[i] + C[i - 1];
}
int *B = (int*)malloc(n * sizeof(int));
for (int i = n - 1; i >= 0; i--)
{
int dight = GetDigit(A[i], d); // 元素A[i]当前位数字为dight
B[--C[dight]] = A[i]; // 根据当前位数字,把每个元素A[i]放到它在输出数组B中的正确位置上
// 当再遇到当前位数字同为dight的元素时,会将其放在当前元素的前一个位置上保证计数排序的稳定性
}
for (int i = 0; i < n; i++)
{
A[i] = B[i];
}
free(B);
}
void LsdRadixSort(int A[], int n) // 最低位优先基数排序
{
for (int d = 1; d <= dn; d++) // 从低位到高位
CountingSort(A, n, d); // 依据第d位数字对A进行计数排序
}
int main()
{
int A[] = { 20, 90, 64, 289, 998, 365, 852, 123, 789, 456 };// 针对基数排序设计的输入
int n = sizeof(A) / sizeof(int);
LsdRadixSort(A, n);
printf("基数排序结果:");
for (int i = 0; i < n; i++)
{
printf("%d ", A[i]);
}
printf("\n");
return 0;
}
下图给出了对{ 329, 457, 657, 839, 436, 720, 355 }进行基数排序的简单演示过程:
基数排序的时间复杂度是O(n * dn),其中n是待排序元素个数,dn是数字位数。这个时间复杂度不一定优于O(n log n),dn的大小取决于数字位的选择(比如比特位数),和待排序数据所属数据类型的全集的大小;dn决定了进行多少轮处理,而n是每轮处理的操作数目。
如果考虑和比较排序进行对照,基数排序的形式复杂度虽然不一定更小,但由于不进行比较,因此其基本操作的代价较小,而且如果适当的选择基数,dn一般不大于log n,所以基数排序一般要快过基于比较的排序,比如快速排序。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序并不是只能用于整数排序。
非比较排序·桶排序(Bucket Sort)
桶排序也叫箱排序。工作的原理是将数组元素映射到有限数量个桶里,利用计数排序可以定位桶的边界,每个桶再各自进行桶内排序(使用其它排序算法或以递归方式继续使用桶排序)。
桶排序的实现代码如下:
#include<iostream>
using namespace std;
// 分类 ------------- 内部非比较排序
// 数据结构 --------- 数组
// 最差时间复杂度 ---- O(nlogn)或O(n^2),只有一个桶,取决于桶内排序方式
// 最优时间复杂度 ---- O(n),每个元素占一个桶
// 平均时间复杂度 ---- O(n),保证各个桶内元素个数均匀即可
// 所需辅助空间 ------ O(n + bn)
// 稳定性 ----------- 稳定
/* 本程序用数组模拟桶 */
const int bn = 5; // 这里排序[0,49]的元素,使用5个桶就够了,也可以根据输入动态确定桶的数量
int C[bn]; // 计数数组,存放桶的边界信息
void InsertionSort(int A[], int left, int right)
{
for (int i = left + 1; i <= right; i++) // 从第二张牌开始抓,直到最后一张牌
{
int get = A[i];
int j = i - 1;
while (j >= left && A[j] > get)
{
A[j + 1] = A[j];
j--;
}
A[j + 1] = get;
}
}
int MapToBucket(int x)
{
return x / 10; // 映射函数f(x),作用相当于快排中的Partition,把大量数据分割成基本有序的数据块
}
void CountingSort(int A[], int n)
{
for (int i = 0; i < bn; i++)
{
C[i] = 0;
}
for (int i = 0; i < n; i++) // 使C[i]保存着i号桶中元素的个数
{
C[MapToBucket(A[i])]++;
}
for (int i = 1; i < bn; i++) // 定位桶边界:初始时,C[i]-1为i号桶最后一个元素的位置
{
C[i] = C[i] + C[i - 1];
}
int *B = (int *)malloc((n) * sizeof(int));
for (int i = n - 1; i >= 0; i--)// 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变)
{
int b = MapToBucket(A[i]); // 元素A[i]位于b号桶
B[--C[b]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上
// 桶的边界被更新:C[b]为b号桶第一个元素的位置
}
for (int i = 0; i < n; i++)
{
A[i] = B[i];
}
free(B);
}
void BucketSort(int A[], int n)
{
CountingSort(A, n); // 利用计数排序确定各个桶的边界(分桶)
for (int i = 0; i < bn; i++) // 对每一个桶中的元素应用插入排序
{
int left = C[i]; // C[i]为i号桶第一个元素的位置
int right = (i == bn - 1 ? n - 1 : C[i + 1] - 1);// C[i+1]-1为i号桶最后一个元素的位置
if (left < right) // 对元素个数大于1的桶进行桶内插入排序
InsertionSort(A, left, right);
}
}
int main()
{
int A[] = { 29, 25, 3, 49, 9, 37, 21, 43 };// 针对桶排序设计的输入
int n = sizeof(A) / sizeof(int);
BucketSort(A, n);
printf("桶排序结果:");
for (int i = 0; i < n; i++)
{
printf("%d ", A[i]);
}
printf("\n");
return 0;
}
下图给出了对{ 29, 25, 3, 49, 9, 37, 21, 43 }进行桶排序的简单演示过程:
桶排序不是比较排序,不受到O(nlogn)下限的影响,它是鸽巢排序的一种归纳结果,当所要排序的数组值分散均匀的时候,桶排序拥有线性的时间复杂度。