专栏名称: CPP开发者
伯乐在线旗下账号,「CPP开发者」专注分享 C/C++ 开发相关的技术文章和工具资源。
目录
51好读  ›  专栏  ›  CPP开发者

常用排序算法总结(1)

CPP开发者  · 公众号  ·  · 2018-05-20 19:30

正文

(点击 上方公众号 ,可快速关注)


来源:SteveWang

www.cnblogs.com/eniac12/p/5329396.html#s32


我们通常所说的排序算法往往指的是 内部排序算法 ,即数据记录在内存中进行排序。

排序算法大体可分为两种:

一种是 比较排序 ,时间复杂度O(nlogn) ~ O(n^2),主要有: 冒泡排序 选择排序 插入排序 归并排序 堆排序 快速排序 等。

另一种是 非比较排序 ,时间复杂度可以达到O(n),主要有: 计数排序 基数排序 桶排序 等。

这里我们来探讨一下常用的比较排序算法,非比较排序算法将在下一篇文章中介绍。下表给出了常见比较排序算法的性能:

有一点我们很容易忽略的是 排序算法的稳定性 (腾讯校招2016笔试题曾考过)。

排序算法稳定性的简单形式化定义为: 如果A i = A j ,排序前A i 在A j 之前,排序后A i 还在A j 之前,则称这种排序算法是稳定的。 通俗地讲就是保证排序前后两个相等的数的相对顺序不变。

对于不稳定的排序算法,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是,排序算法是否为稳定的是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。

例如,对于冒泡排序,原本是稳定的排序算法,如果将记录交换的条件改成A[i] >= A[i + 1],则两个相等的记录就会交换位置,从而变成不稳定的排序算法。

其次,说一下排序算法稳定性的好处。 排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,前一个键排序的结果可以为后一个键排序所用。 基数排序就是这样,先按低位排序,逐次按高位排序,低位排序后元素的顺序在高位也相同时是不会改变的。

冒泡排序(Bubble Sort)

冒泡排序是一种极其简单的排序算法,也是我所学的第一个排序算法。它重复地走访过要排序的元素,依次比较相邻两个元素,如果他们的顺序错误就把他们调换过来,直到没有元素再需要交换,排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。

冒泡排序算法的运作如下:

  1. 比较相邻的元素,如果前一个比后一个大,就把它们两个调换位置。

  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。

  3. 针对所有的元素重复以上的步骤,除了最后一个。

  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

由于它的简洁,冒泡排序通常被用来对于程序设计入门的学生介绍算法的概念。冒泡排序的代码如下:

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 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 ( "冒泡排序结果:" );

for ( int i = 0 ; i n ; i ++ )

{

printf ( "%d " , A [ i ]);

}

printf ( "\n" );

return 0 ;

}


上述代码对序列{ 6, 5, 3, 1, 8, 7, 2, 4 }进行冒泡排序的实现过程如下

使用冒泡排序为一列数字进行排序的过程如右图所示:

尽管冒泡排序是最容易了解和实现的排序算法之一,但它对于少数元素之外的数列排序是很没有效率的。

冒泡排序的改进: 鸡尾酒排序

鸡尾酒排序,也叫 定向冒泡排序 ,是冒泡排序的一种改进。此算法与冒泡排序的不同处在于 从低到高然后从高到低 ,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。

鸡尾酒排序的代码如下:

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 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 ( "鸡尾酒排序结果:" );

for ( int i = 0 ; i n ; i ++ )

{

printf ( "%d " , A [ i ]);

}

printf ( "\n" );

return 0 ;

}

使用鸡尾酒排序为一列数字进行排序的过程如右图所示:

以序列(2,3,4,5,1)为例,鸡尾酒排序只需要访问一次序列就可以完成排序,但如果使用冒泡排序则需要四次。但是在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。

选择排序(Selection Sort)

选择排序也是一种简单直观的排序算法。它的工作原理很容易理解:初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列;然后,再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

注意选择排序与冒泡排序的区别:冒泡排序通过依次交换相邻两个顺序不合法的元素位置,从而将当前最小(大)元素放到合适的位置;而选择排序每遍历一次都记住了当前最小(大)元素的位置,最后仅需一次交换操作即可将其放到合适的位置。

选择排序的代码如下:

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 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 ( "选择排序结果:" );

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)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

具体算法描述如下:

  1. 从第一个元素开始,该元素可以认为已经被排序

  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描

  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置

  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置

  5. 将新元素插入到该位置后

  6. 重复步骤2~5

插入排序的代码如下:

#include

// 分类 ------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 最坏情况为输入序列是降序排列的,此时时间复杂度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 ( "插入排序结果:" );

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

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 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 ;

}

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 ( "二分插入排序结果:" );

for ( int i = 0 ; i n ; i ++ )

{

printf ( "%d " , A [ i ]);

}

printf ( "\n" );

return 0 ;

}

当n较大时,二分插入排序的比较次数比直接插入排序的最差情况好得多,但比直接插入排序的最好情况要差,所当以元素初始序列已经接近升序时,直接插入排序比二分插入排序比较次数少。二分插入排序元素移动次数与直接插入排序相同,依赖于元素初始序列。

插入排序的更高效改进:希尔排序(Shell Sort)

希尔排序,也叫 递减增量排序 ,是插入排序的一种更高效的改进版本。希尔排序是 不稳定 的排序算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率

  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位

希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排 序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。
假设有一个很小的数据在一个已按升序排好序的数组的末端。如果用复杂度为O(n^2)的排序(冒泡排序或直接插入排序),可能会进行n次的比较和交换才能将该数据移至正确位置。而希尔排序会用较大的步长移动数据,所以小数据只需进行少数比较和交换即可到正确位置。

希尔排序的代码如下:

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- 根据步长序列的不同而不同。已知最好的为O(n(logn)^2)

// 最优时间复杂度 ---- O(n)

// 平均时间复杂度 ---- 根据步长序列的不同而不同。

// 所需辅助空间 ------ O(1)

// 稳定性 ------------ 不稳定

void ShellSort ( int A [], int n )

{

int h = 0 ;

while ( h <= n ) // 生成初始增量

{

h = 3 * 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 ) / 3 ; // 递减增量

}

}

int main ()

{

int A [] = { 5 , 2 , 9 , 4 , 7 , 6 , 1 , 3 , 8 }; // 从小到大希尔排序

int n = sizeof ( A ) / sizeof ( int );

ShellSort ( A , n );

printf ( "希尔排序结果:" );

for ( int i = 0 ; i n ; i ++ )

{

printf ( "%d " , A [ i ]);

}

printf ( "\n" );

return 0 ;

}


以23, 10, 4, 1的步长序列进行希尔排序:

希尔排序是不稳定的排序算法, 虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱。

比如序列:{ 3, 5, 10, 8, 7, 2, 8, 1, 20, 6 },h=2时分成两个子序列 { 3, 10, 7, 8, 20 } 和  { 5, 8, 2, 1, 6 } ,未排序之前第二个子序列中的8在前面,现在对两个子序列进行插入排序,得到 { 3, 7, 8, 10, 20 } 和 { 1, 2, 5, 6, 8 } ,即 { 3, 1, 7, 2, 8, 5, 10, 6, 20, 8 } ,两个8的相对次序发生了改变。

归并排序(Merge Sort)

归并排序是创建在归并操作上的一种有效的排序算法,效率为O(nlogn),1945年由冯·诺伊曼首次提出。

归并排序的实现分为 递归实现 非递归(迭代)实现 。递归实现的归并排序是算法设计中分治策略的典型应用,我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。非递归(迭代)实现的归并排序首先进行是两两归并,然后四四归并,然后是八八归并,一直下去直到归并了整个数组。

归并排序算法主要依赖归并(Merge)操作。归并操作指的是将两个已经排序的序列合并成一个序列的操作, 归并操作 步骤如下:

  1. 申请空间 ,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列

  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置

  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置

  4. 重复步骤3直到某一指针到达序列尾

  5. 将另一序列剩下的所有元素直接复制到合并序列尾

归并排序的代码如下:

#include

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- O(nlogn)

// 最优时间复杂度 ---- O(nlogn)

// 平均时间复杂度 ---- O(nlogn)

// 所需辅助空间 ------ O(n)

// 稳定性 ------------ 稳定

void Merge ( int A [], int left , int mid , int right ) // 合并两个已排好序的数组A[left...mid]和A[mid+1...right]

{

int len = right - left + 1 ;

int * temp = new int [ len ]; // 辅助空间O(n)

int index = 0 ;

int i = left ; // 前一数组的起始元素

int j = mid + 1 ; // 后一数组的起始元素

while ( i <= mid && j <= right )

{

temp [ index ++ ] = A [ i ] <= A [ j ] ? A [ i ++ ] : A [ j ++ ]; // 带等号保证归并排序的稳定性

}

while ( i <= mid )

{

temp [ index ++ ] = A [ i ++ ];

}

while ( j <= right )

{

temp [ index ++ ] = A [ j ++ ];

}

for ( int k = 0 ; k len ; k ++ )

{

A [ left ++ ] = temp [ k ];

}

}

void MergeSortRecursion ( int A [], int left , int right ) // 递归实现的归并排序(自顶向下)

{

if ( left == right ) // 当待排序的序列长度为1时,递归开始回溯,进行merge操作

return ;

int mid = ( left + right ) / 2 ;

MergeSortRecursion ( A , left , mid );

MergeSortRecursion ( A , mid + 1 , right );

Merge ( A , left , mid , right );

}

void MergeSortIteration ( int A [], int len ) // 非递归(迭代)实现的归并排序(自底向上)

{

int left , mid , right ; // 子数组索引,前一个为A[left...mid],后一个子数组为A[mid+1...right]

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 , left , mid , right );

left = right + 1 ; // 前一个子数组索引向后移动

}

}

}

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 );

MergeSortRecursion ( A1 , 0 , n1 - 1 ); // 递归实现

MergeSortIteration ( A2 , n2 ); // 非递归实现

printf ( "递归实现的归并排序结果:" );

for ( int i = 0 ; i n1 ; i ++ )

{

printf ( "%d " , A1 [ i ]);

}

printf ( "\n" );

printf ( "非递归实现的归并排序结果:" );

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)

堆排序是指利用堆这种数据结构所设计的一种选择排序算法。堆是一种近似完全二叉树的结构(通常堆是通过一维数组来实现的),并满足性质:以最大堆(也叫大根堆、大顶堆)为例,其中父结点的值总是大于它的孩子节点。

我们可以很容易的定义堆排序的过程:

  1. 由输入的无序数组构造一个最大堆,作为初始的无序区

  2. 把堆顶元素(最大值)和堆尾元素互换

  3. 把堆(无序区)的尺寸缩小1,并调用heapify(A, 0)从新的堆顶元素开始进行堆调整

  4. 重复步骤2,直到堆的尺寸为1

堆排序的代码如下:

#include

// 分类 -------------- 内部比较排序

// 数据结构 ---------- 数组

// 最差时间复杂度 ---- O(nlogn)

// 最优时间复杂度 ---- O(nlogn)

// 平均时间复杂度 ---- O(nlogn)

// 所需辅助空间 ------ O(1)







请到「今天看啥」查看全文