10大经典排序算法总结

0、算法概述

0.1 算法分类

①:十种常见排序算法可以分为两大类:

非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。

线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。

②:另外一种分类排序方法——我们通常所说的排序算法往往指的是内部排序算法,即数据记录在内存中进行排序。

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

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

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

0.2 算法复杂度

0.3 相关概念

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。通俗地讲就是保证排序前后两个相等的数的相对顺序不变。

不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。

时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。

空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。

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

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

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

0.4 技巧

记忆口诀

不稳定排序算法口诀:快些选队(快希选堆)
其余为稳定的。

算法复杂度和关键字顺序无关的有:

顺口溜:一堆(堆排序)海龟(归并排序)(选择排序)(基数排序)

快些以 nlog2 n 的速度归队

快=快速排序,些=希尔排序,归=归并排序,队=堆排序

这四种排序算法,时间都是 n log2 n 的,除了这四个之外,其他的排序算法平均时间都为 n^2

一趟排序,保证一个元素为最终位置的有两类排序算法:交换类(冒泡和快速)排序和选择类排序(简单和堆)

元素比较次数和原始序列无关的算法:简单选择排序,折半插入排序

排序趟数和原序列有关的算法:交换类,其余类无关

借助于比较进行排序的算法,在最坏的时候,最好的时间复杂度为 n log2 n

堆排序和简单选择排序的时间复杂度和初始序列无关

0.5 总结:

(1)在比较类排序中,归并排序号称最快,其次是快速排序堆排序,两者不相伯仲,但是有一点需要注意,数据初始排序状态对堆排序不会产生太大的影响,而快速排序却恰恰相反。

(2)线性时间非比较类排序一般要优于非线性时间比较类排序,但前者对待排序元素的要求较为严格,比如计数排序要求待排序数的最大值不能太大,桶排序要求元素按照hash分桶后桶内元素的数量要均匀。线性时间非比较类排序的典型特点是以空间换时间。

1、冒泡排序(Bubble Sort)

冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

1.1 算法描述

  • 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
  • 针对所有的元素重复以上的步骤,除了最后一个;
  • 重复步骤1~3,直到排序完成。

1.2 动图演示

1.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果能在内部循环第一次运行时,使用一个旗标来表示有无需要交换的可能,可以把最优时间复杂度降低到O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void (RecordType arr[]) {
var len = arr.length;
change = TRUE;
for (var i = 0; i < len - 1 && change; i++) {
change = FALSE;
for (var j = 0; j < len - 1 - i; j++) {// 依次比较相邻的两个元素,使较大的那个向后移
if (arr[j].key > arr[j+1].key) { // 相邻元素两两对比。 如果条件改成arr[j].key >= arr[j+1].key,则变为不稳定的排序算法
var temp = arr[j+1]; // 元素交换
arr[j+1] = arr[j];
arr[j] = temp;
change = TRUE;
}
}
}
return arr;
}

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

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

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

1.1、冒泡排序的改进:鸡尾酒排序(定向/双向冒泡排序)

​ 鸡尾酒排序,也叫定向冒泡排序,是冒泡排序的一种改进,即排序过程中交替改变扫描方向。
先从底向上冒一个最小元素,再从上向低冒一个最大元素。
此算法与冒泡排序的不同处在于从低到高然后从高到低,而冒泡排序则仅从低到高去比较序列里的每个元素。他可以得到比冒泡排序稍微好一点的效能。

  鸡尾酒排序的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59



// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 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;
bool flag = TRUE;
while (flag)
{
flag = FLASE;
for (int i = left; i < right; i++) // 前半轮,从左到右扫描,将最大元素放到最右边
{
if (A[i] > A[i + 1])
{
Swap(A, i, i + 1);
flag = TRUE;
}
}
right--;
for (int i = right; i > left; i--) // 后半轮,从右到左扫描,将最小元素放到最左边
{
if (A[i - 1] > A[i])
{
Swap(A, i - 1, i);
flag = TRUE;
}
}
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)为例,鸡尾酒排序只需要访问一次序列就可以完成排序,但如果使用冒泡排序则需要四次。但是在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。

2、选择排序(Selection Sort)

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

2.1 算法描述

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  • 初始状态:无序区为R[1..n],有序区为空;
  • 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
  • n-1趟结束,数组有序化了。

2.2 动图演示

2.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49



// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 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的相对次序。

3、插入排序(Insertion Sort)

​ 插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。它的工作原理非常类似于我们抓扑克牌 。

​ 对于未排序数据(右手抓到的牌),在已排序序列(左手已经排好序的手牌)中从后向前扫描,找到相应位置并插入。

  插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

3.1 算法描述

一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:

  • 从第一个元素开始,该元素可以认为已经被排序;
  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  • 如果该元素(已排序)大于新元素,将该元素移到下一位置;
  • 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
  • 将新元素插入到该位置后;
  • 重复步骤2~5。

3.2 动图演示

3.2 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38


// 分类 ------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 最坏情况为输入序列是降序排列的,此时时间复杂度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个或以下)。

3.1、插入排序的改进:二分插入排序

​ 对于插入排序,如果比较操作的代价比交换操作大的话,可以采用二分查找法来减少比较操作的次数,我们称为二分插入排序,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47



// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 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较大时,二分插入排序的比较次数比直接插入排序的最差情况好得多,但比直接插入排序的最好情况要差,所当以元素初始序列已经接近升序时,直接插入排序比二分插入排序比较次数少。二分插入排序元素移动次数与直接插入排序相同,依赖于元素初始序列。

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

​ 1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫递减增量排序。希尔排序是不稳定的排序算法。

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

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位

4.1 算法描述

先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:

  • 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
  • 按增量序列个数k,对序列进行k 趟排序;
  • 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

4.2 动图演示

4.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>  


// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 根据步长序列的不同而不同。已知最好的为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;
}

4.4 算法分析

以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的相对次序发生了改变。

​ 希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。 

5、归并排序(Merge Sort)

​ 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。

5.1 算法描述

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

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

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置
  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
  4. 重复步骤3直到某一指针到达序列尾
  5. 将另一序列剩下的所有元素直接复制到合并序列尾

5.2 动图演示

5.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

#include <limits.h>


// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 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 }进行归并排序的实例如下 

  使用归并排序为一列数字进行排序的宏观过程:   

  归并排序除了可以对数组进行排序,还可以高效的求出数组小和(即单调和)以及数组中的逆序对。

5.4 算法分析

归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。

6、快速排序(Quick Sort)

​ 快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

6.1 算法描述

​ 选择一个基准,小于放左边,大于放右边。

快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:

  • 从数列中挑出一个元素,称为 “基准”(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

6.2 动图演示

​ 上图是每次将基准交换。

​ 或者如下,最后再将基准交换。

​ 方法其实很简单:分别从初始序列“6 1 2 7 9 3 4 5 10 8”两端开始“探测”。先从右往左找一个小于6的数,再从左往右找一个大于6的数,然后交换他们。这里可以用两个变量i和j,分别指向序列最左边和最右边。我们为这两个变量起个好听的名字“哨兵i”和“哨兵j”。刚开始的时候让哨兵i指向序列的最左边(即i=1),指向数字6。让哨兵j指向序列的最右边(即=10),指向数字。

​ 首先哨兵j开始出动。因为此处设置的基准数是最左边的数,所以需要让哨兵j先出动,这一点非常重要(请自己想一想为什么)。哨兵j一步一步地向左挪动(即j–),直到找到一个小于6的数停下来。接下来哨兵i再一步一步向右挪动(即i++),直到找到一个数大于6的数停下来。最后哨兵j停在了数字5面前,哨兵i停在了数字7面前。

​ 现在交换哨兵i和哨兵j所指向的元素的值。交换之后的序列如下:

​ 6 1 2 5 9 3 4 7 10 8

​ 到此,第一次交换结束。接下来开始哨兵j继续向左挪动(再友情提醒,每次必须是哨兵j先出发)。他发现了4(比基准数6要小,满足要求)之后停了下来。哨兵i也继续向右挪动的,他发现了9(比基准数6要大,满足要求)之后停了下来。此时再次进行交换,交换之后的序列如下:

6 1 2 5 4 3 9 7 10 8

​ 第二次交换结束,“探测”继续。哨兵j继续向左挪动,他发现了3(比基准数6要小,满足要求)之后又停了下来。哨兵i继续向右移动,糟啦!此时哨兵i和哨兵j相遇了,哨兵i和哨兵j都走到3面前。说明此时“探测”结束。我们将基准数6和3进行交换。交换之后的序列如下:

3 1 2 5 4 6 9 7 10 8



​ 到此第一轮“探测”真正结束。此时以基准数6为分界点,6左边的数都小于等于6,6右边的数都大于等于6。回顾一下刚才的过程,其实哨兵j的使命就是要找小于基准数的数,而哨兵i的使命就是要找大于基准数的数,直到i和j碰头为止。

注:最好的是设置一个临时变量,做覆盖操作而不是一直做交换操作。

6.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <stdio.h>

// 分类 ------------ 内部比较排序
// 数据结构 --------- 数组
// 最差时间复杂度 ---- 每次选取的基准都是最大(或最小)的元素,导致每次只划分出了一个分区,需要进行n-1次划分才能结束递归,时间复杂度为O(n^2)
// 最优时间复杂度 ---- 每次选取的基准都是中位数,这样每次都均匀的划分出两个分区,只需要logn次划分就能结束递归,时间复杂度为O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ 主要是递归造成的栈空间的使用(用来保存left和right等局部变量),取决于递归树的深度,一般为O(logn),最差为O(n)
// 稳定性 ---------- 不稳定

void Swap(int A[], int i, int j)
{
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}

int Partition(int A[], int left, int right) // 划分函数
{
int pivot = A[right]; // 这里每次都选择最后一个元素作为基准
int tail = left - 1; // tail为小于基准的子数组最后一个元素的索引
for (int i = left; i < right; i++) // 遍历基准以外的其他元素
{
if (A[i] <= pivot) // 把小于等于基准的元素放到前一个子数组末尾
{
Swap(A, ++tail, i);
}
}
Swap(A, tail + 1, right); // 最后把基准放到前一个子数组的后边,剩下的子数组既是大于基准的子数组
// 该操作很有可能把后面元素的稳定性打乱,所以快速排序是不稳定的排序算法
return tail + 1; // 返回基准的索引
}

void QuickSort(int A[], int left, int right)
{
if (left >= right)
return;
int pivot_index = Partition(A, left, right); // 基准的索引
QuickSort(A, left, pivot_index - 1);
QuickSort(A, pivot_index + 1, right);
}

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("快速排序结果:");
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的相对次序。

Java系统提供的Arrays.sort函数。对于基础类型,底层使用快速排序。对于非基础类型,底层使用归并排序。请问是为什么?

  答:这是考虑到排序算法的稳定性。对于基础类型,相同值是无差别的,排序前后相同值的相对位置并不重要,所以选择更为高效的快速排序,尽管它是不稳定的排序算法;而对于非基础类型,排序前后相等实例的相对位置不宜改变,所以选择稳定的归并排序。

7、堆排序(Heap Sort)

​ 堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

7.1 算法描述

  • 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
  • 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
  • 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

7.2 动图演示

构建初始堆

整体排序流程

7.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <stdio.h>


// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(nlogn)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定


void Swap(int A[], int i, int j)
{
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}

void Heapify(int A[], int i, int size) // 从A[i]向下进行堆调整
{
int left_child = 2 * i + 1; // 左孩子索引
int right_child = 2 * i + 2; // 右孩子索引
int max = i; // 选出当前结点与其左右孩子三者之中的最大值
if (left_child < size && A[left_child] > A[max])
max = left_child;
if (right_child < size && A[right_child] > A[max])
max = right_child;
if (max != i)
{
Swap(A, i, max); // 把当前结点和它的最大(直接)子节点进行交换
Heapify(A, max, size); // 递归调用,继续从当前结点向下进行堆调整
}
}

int BuildHeap(int A[], int n) // 建堆,时间复杂度O(n)
{
int heap_size = n;
for (int i = heap_size / 2 - 1; i >= 0; i--) // 从每一个非叶结点开始向下进行堆调整
Heapify(A, i, heap_size);
return heap_size;
}

void HeapSort(int A[], int n)
{
int heap_size = BuildHeap(A, n); // 建立一个最大堆
while (heap_size > 1)        // 堆(无序区)元素个数大于1,未完成排序
{
// 将堆顶元素与堆的最后一个元素互换,并从堆中去掉最后一个元素
// 此处交换操作很有可能把后面元素的稳定性打乱,所以堆排序是不稳定的排序算法
Swap(A, 0, --heap_size);
Heapify(A, 0, heap_size); // 从新的堆顶元素开始向下进行堆调整,时间复杂度O(logn)
}
}

int main()
{
int A[] = { 5, 2, 9, 4, 7, 6, 1, 3, 8 };// 从小到大堆排序
int n = sizeof(A) / sizeof(int);
HeapSort(A, n);
printf("堆排序结果:");
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的相对次序。


以上为比较排序。

以下为非比较排序。


8、计数排序(Counting Sort)

​ 计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

​ 通俗地理解,例如有10个年龄不同的人,假如统计出有8个人的年龄不比小明大(即小于等于小明的年龄,这里也包括了小明),那么小明的年龄就排在第8位,通过这种思想可以确定每个人的位置,也就排好了序。当然,年龄一样时需要特殊处理(保证稳定性):通过反向填充目标数组,填充完毕后将对应的数字统计递减,可以确保计数排序的稳定性。

8.1 算法描述

  • 找出待排序的数组中最大和最小的元素;
  • 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
  • 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
  • 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

8.2 动图演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#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;
}

​ 计数排序的时间复杂度和空间复杂度与数组A的数据范围(A中元素的最大值与最小值的差加上1)有关,因此对于数据范围很大的数组,计数排序需要大量时间和内存。

  例如:对0到99之间的数字进行排序,计数排序是最好的算法,然而计数排序并不适合按字母顺序排序人名,将计数排序用在基数排序算法中,能够更有效的排序数据范围很大的数组。

8.4 算法分析

​ 计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

9、桶排序(Bucket Sort)

​ 桶排序也叫箱排序。桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:将数组元素映射到有限数量个桶里,利用计数排序可以定位桶的边界,每个桶再各自进行桶内排序(使用其它排序算法或以递归方式继续使用桶排序)。

9.1 算法描述

  • 设置一个定量的数组当作空桶;
  • 遍历输入数据,并且把数据一个一个放到对应的桶里去;
  • 对每个不是空的桶进行排序;
  • 从不是空的桶里把排好序的数据拼接起来。

9.2 图片演示

9.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#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)下限的影响,它是鸽巢排序的一种归纳结果,当所要排序的数组值分散均匀的时候,桶排序拥有线性的时间复杂度。

9.4 算法分析

​ 桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

10、基数排序(Radix Sort)

​ 基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。

10.1 算法描述

  • 取得数组中的最大数,并取得位数;
  • arr为原始数组,从最低位开始取每个位组成radix数组;
  • 对radix进行计数排序(利用计数排序适用于小范围数的特点);

10.2 动图演示

10.3 代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#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,所以基数排序一般要快过基于比较的排序,比如快速排序。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序并不是只能用于整数排序。

10.4 算法分析

​ 基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。

​ 基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。