概述
排序算法用作实现列表的排序,列表元素可以是整数,也可以是浮点数、字符串等其他数据类型。生活中有许多需要排序算法的场景,例如:
- 整数排序: 对于一个整数数组,我们希望将所有数字从小到大排序;
- 字符串排序: 对于一个姓名列表,我们希望将所有单词按照字符先后排序;
- 自定义排序: 对于任意一个 已定义比较规则 的集合,我们希望将其按规则排序;
同时,某些算法需要在排序算法的基础上使用(即在排序数组上运行),例如:
- 二分查找: 根据数组已排序的特性,才能每轮确定排除两部分中的哪一部分;
- 双指针: 例如合并两个排序链表,根据已排序特性,才能通过双指针移动在线性时间内将其合并为一个排序链表。
接下来,本文将从「常见排序算法」、「分类方法」、「时间与空间复杂度」三方面入手,简要介绍排序算法。
常见算法
常见排序算法包括「冒泡排序」、「插入排序」、「选择排序」、「快速排序」、「归并排序」、「堆排序」、「基数排序」、「桶排序」。如下图所示,为各排序算法的核心特性与时空复杂度总结。、
分类方法
排序算法主要可根据 稳定性 、就地性 、自适应性 分类。理想的排序算法具有以下特性:
- 具有稳定性,即相等元素的相对位置不变化;
- 具有就地性,即不使用额外的辅助空间;
- 具有自适应性,即时间复杂度受元素分布影响;
特别地,任意排序算法都 不同时具有以上所有特性 。因此,排序算法的选型使用取决于具体的列表类型、元素数量、元素分布情况等应用场景特点。
稳定性:
根据 相等元素 在数组中的 相对顺序 是否被改变,排序算法可分为「稳定排序」和「非稳定排序」两类。
「稳定排序」在完成排序后,不改变 相等元素在数组中的相对顺序。例如:冒泡排序、插入排序、归并排序、基数排序、桶排序。
「非稳定排序」在完成排序后,相等素在数组中的相对位置 可能被改变。例如:选择排序、快速排序、堆排序。
就地性:
根据排序过程中 是否使用额外内存(辅助数组),排序算法可分为「原地排序」和「异地排序」两类。一般地,由于不使用外部内存,原地排序相比非原地排序的执行效率更高。
「原地排序」不使用额外辅助数组,例如:冒泡排序、插入排序、选择排序、快速排序、堆排序。
「非原地排序」使用额外辅助数组,例如:归并排序、基数排序、桶排序。
自适应性:
根据算法 时间复杂度 是否 受待排序数组的元素分布影响 ,排序算法可分为「自适应排序」和「非自适应排序」两类。
「自适应排序」的时间复杂度受元素分布影响;例如:冒泡排序、插入排序、快速排序、桶排序。
「非自适应排序」的时间复杂度恒定;例如:选择排序、归并排序、堆排序、基数排序。
比较类:
比较类排序基于元素之间的 比较算子(小于、相等、大于)来决定元素的相对顺序;相对的,非比较排序则不基于比较算子实现。
「比较类排序」基于元素之间的比较完成排序,例如:冒泡排序、插入排序、选择排序、快速排序、归并排序、堆排序。
「非比较类排序」不基于元素之间的比较完成排序,例如:基数排序、桶排序。
基于比较的排序算法的平均时间复杂度最优为 log O(N log N) ,而非比较排序算法可以达到线性级别的时间复杂度。
时空复杂度
总体上看,排序算法追求时间与空间复杂度最低。而即使某些排序算法的时间复杂度相等,但实际性能还受 输入列表性质、元素数量、元素分布等 等因素影响。
设输入列表元素数量为 N ,常见排序算法的「时间复杂度」和「空间复杂度」如下图所示。
算法 | 最佳时间 | 平均时间 | 最差时间 | 最差空间 |
冒泡排序 | Ω(N) | Θ(N^2) | O(N^2) | O(1) |
插入排序 | Ω(N) | Θ(N^2) | O(N^2) | O(1) |
选择排序 | Ω(N^2) | Θ(N^2) | O(N^2) | O(1) |
快速排序 | Ω(N logN) | Θ(N logN) | O(N^2) | O(logN) |
归并排序 | Ω(N logN) | Θ(N logN) | O(N logN) | O(N) |
堆排序 | Ω(N logN) | Θ(N logN) | O(N logN) | O(1) |
基数排序 | Ω(Nk) | Θ(Nk) | O(Nk) | O(N+k) |
桶排序 | Ω(N+k) | Θ(N+k) | O(N^2) | O(N) |
对于上表,需要特别注意:
- 「基数排序」适用于正整数、字符串、特定格式的浮点数排序,k 为最大数字的位数;「桶排序」中 k 为桶的数量。
- 普通「冒泡排序」的最佳时间复杂度为 O(N ^2 ) ,通过增加标志位实现 提前返回 ,可以将最佳时间复杂度降低至 O(N) 。
- 在输入列表完全倒序下,普通「快速排序」的空间复杂度劣化至 O(N) ,通过代码优化 Tail Call Optimization 保持算法递归较短子数组,可以将最差递归深度降低至 log N 。
- 普通「快速排序」总以最左或最右元素为基准数,因此在输入列表有序或倒序下,时间复杂度劣化至 O(N^2) ;通过 随机选择基准数 ,可极大减少此类最差情况发生,尽可能地保持 O(NlogN) 的时间复杂度。
- 若输入列表是数组,则归并排序的空间复杂度为 O(N) ;而若排序 链表 ,则「归并排序」不需要借助额外辅助空间,空间复杂度可以降低至 O(1)。
常用方法示例
以下是一些常用排序算法的示例代码(仅供参考,可以优化,c++)
一、冒泡排序
// 实现冒泡排序,对传入的整型数组a进行升序排序
void bsort(int a[], int n) {
// 外层循环遍历数组中的每个元素
for (int i = 0; i < n; i++) {
// 内层循环遍历未排序部分中的相邻元素
// 每次循环把最大的元素移动到未排序部分的末尾
for (int j = 0; j < n - i - 1; j++) {
// 如果当前元素比后面一个元素大,则交换它们的位置
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
bsort
函数接受一个整型数组a
和数组长度n
,并对数组进行升序排序。- 外层循环用变量
i
遍历数组中的每个元素,从第一个元素开始直到最后一个元素。 - 内层循环用变量
j
遍历未排序部分中的相邻元素,从第一个元素开始直到未排序部分的末尾前一个元素。 - 每次内层循环把最大的元素移动到未排序部分的末尾,因此需要在外层循环中减去已排序的元素个数
i
。 - 如果当前元素比后面一个元素大,则交换它们的位置。这样,经过一次内层循环之后,未排序部分的最后一个元素就是未排序部分中的最大值。
- 交换两个元素的位置时,我们使用变量
temp
作为临时存储空间,把第一个元素存储到temp
中,然后用第二个元素覆盖第一个元素,最后把temp
中的元素存储到第二个元素的位置上。 - 循环结束后,整个数组将按照升序排列。
二、选择排序
// 实现选择排序,对传入的整型数组a进行升序排序
void ssort(int a[], int n) {
// 外层循环遍历数组中的每个元素
for (int i=0; i<n; i++) {
// 假设当前元素是未排序部分中的最小值
int min=i;
// 内层循环遍历未排序部分中的其它元素
// 寻找未排序部分中的最小值
for (int j=i+1;j<n;j++)
if (a[j]<a[min])
min=j;
// 将未排序部分的最小值与未排序部分的第一个元素交换位置
int temp=a[i];
a[i]=a[min];
a[min]=temp;
}
}
ssort
函数接受一个整型数组a
和数组长度n
,并对数组进行升序排序。- 外层循环用变量
i
遍历数组中的每个元素,从第一个元素开始直到最后一个元素。每次循环结束后,已经完成了数组中前i
个元素的排序。 - 内层循环用变量
j
遍历未排序部分中的其它元素,从第i+1
个元素开始直到最后一个元素。在内层循环中,我们寻找未排序部分中的最小值。 - 假设当前元素是未排序部分中的最小值,即在内层循环之前,我们假定第
i
个元素是未排序部分中的最小值。然后在内层循环中,我们比较第i+1
到第n-1
个元素的大小,如果找到了更小的元素,则将它的下标赋值给变量min
,表示当前未排序部分的最小值的下标。 - 内层循环结束后,我们已经找到未排序部分中的最小值。然后将未排序部分的最小值与未排序部分的第一个元素交换位置,即将数组中第
i
个元素和第min
个元素交换位置。 - 交换两个元素的位置时,我们使用变量
temp
作为临时存储空间,把第一个元素存储到temp
中,然后用第二个元素覆盖第一个元素,最后把temp
中的元素存储到第二个元素的位置上。 - 循环结束后,整个数组将按照升序排列。
三、插入排序
/*
* 插入排序,升序排列整型数组a中的n个元素
* 输入:整型数组a、数组长度n
*/
void isort(int a[], int n) {
for (int i = 1; i < n; i++) { // 遍历除第一个元素外的所有元素
int j = i - 1; // 将要插入的位置初始化为i前面的位置
int key = a[i]; // 记录当前要插入的数值
while (a[j] > key && j >= 0) { // 如果当前位置比要插入的数值大,则往后移动
a[j + 1] = a[j]; // 将当前位置往后移动一位
j--; // 继续向前搜索合适的位置
}
a[j + 1] = key; // 找到要插入的位置,插入当前数值
}
}
- 首先,这是一个实现插入排序算法的函数,它可以对整型数组a中的n个元素进行升序排列。
- 在函数内部,我们使用了for循环来遍历数组a中除了第一个元素以外的所有元素。因为第一个元素默认已经有序,所以我们从第二个元素开始遍历。
- 然后,我们将要插入的位置初始化为i-1,即当前元素的前一个位置。我们还记录了要插入的数值key,以便在找到要插入的位置后进行插入操作。
- 接下来,我们使用while循环向前搜索合适的位置。如果当前位置比要插入的数值大,则将当前位置往后移动一位,并继续向前搜索直到找到合适的位置或者搜索到数组的起始位置为止。
- 最后,我们在正确的位置上,将要插入的数值key进行插入操作。
四、快速排序
/*
* 快速排序,升序排列整型数组a中low到high之间的元素
* 输入:整型数组a、起始位置low、终止位置high
*/
void qsort(int a[], int low, int high) {
if (low >= high) return; // 递归结束条件:当low>=high时,返回
int key = a[low]; // 以a[low]为基准值
int i = low; // 记录要比较的左端点i
int j = high; // 记录要比较的右端点j
while (i < j) { // 循环比较
while (i < j && a[j] >= key) j--; // 从右往左查找第一个小于key的数
if (i < j) a[i++] = a[j]; // 将这个数赋值给a[i],并将i加1
while (i < j && a[i] <= key) i++; // 从左往右查找第一个大于key的数
if (i < j) a[j--] = a[i]; // 将这个数赋值给a[j],并将j减1
}
a[i] = key; // 将基准值插入到最终位置
qsort(a, low, i - 1); // 对基准值左侧数据进行递归排序
qsort(a, j + 1, high); // 对基准值右侧数据进行递归排序
}
- 这是一个实现快速排序算法的函数,它可以对整型数组a中low到high之间的元素进行升序排列。
- 在函数内部,我们首先设置了递归结束条件。如果low>=high,则表示当前只有一个元素或者没有元素需要排序,直接返回即可。
- 然后,我们将a[low]作为基准值key,并定义左端点i和右端点j。接着,我们使用循环比较的方式来找到基准值的最终位置。具体地,我们从右往左查找第一个小于key的数,将其赋值给a[i]并将i加1;然后从左往右查找第一个大于key的数,将其赋值给a[j]并将j减1。循环直到i=j时,基准值的最终位置就是a[i]。
- 接下来,我们对基准值左侧和右侧的数据分别进行递归排序。
五、归并排序
1.递归版本
/*
* 归并排序,升序排列整型数组a中low到high之间的元素
* 输入:整型数组a、起始位置low、终止位置high
*/
void msort1(int a[], int low, int high) {
if (low == high) return; // 递归结束条件:当low=high时,返回
int mid = (low + high) / 2; // 计算中间位置mid
msort1(a, low, mid); // 对左侧数据进行递归排序
msort1(a, mid + 1, high); // 对右侧数据进行递归排序
int *b = new int[high - low + 1]; // 创建临时数组b
int i = low, j = mid + 1, k = 0; // 初始化i、j、k
while (i <= mid && j <= high) { // 合并两个有序区间
if (a[i] < a[j]) { // 如果左侧数据小于右侧数据,则将左侧数据插入到临时数组b中
b[k++] = a[i++];
} else { // 如果左侧数据大于等于右侧数据,则将右侧数据插入到临时数组b中
b[k++] = a[j++];
}
}
while (i <= mid) { // 将左侧剩余的数据插入到临时数组b中
b[k++] = a[i++];
}
while (j <= high) { // 将右侧剩余的数据插入到临时数组b中
b[k++] = a[j++];
}
k = 0; // 重新初始化k
for (int i = low; i <= high; i++) { // 将临时数组b中的元素复制回原数组a中
a[i] = b[k++];
}
delete[] b; // 释放临时数组b的空间
}
- 这是一个实现归并排序算法的函数,它可以对整型数组a中low到high之间的元素进行升序排列。
- 在函数内部,我们首先设置了递归结束条件。如果low=high,则表示当前只有一个元素或者没有元素需要排序,直接返回即可。
- 然后,我们将待排序区间从中间位置mid分开,并对左侧和右侧的数据分别进行递归排序。递归过程会一直往下执行,直到递归结束条件被满足。
- 接下来,我们创建一个临时数组b,并使用while循环合并两个有序区间。具体地,我们比较左侧数据和右侧数据的大小关系,将较小的数据插入到临时数组b中。当其中一个区间遍历完成后,我们将另一个区间中剩余的数据插入到临时数组b中。
- 最后,我们将临时数组b中的元素复制回原数组a中,并释放临时数组b的空间。
2.非递归版本
/*
* 归并排序,升序排列整型数组a中n个元素
* 输入:整型数组a、数组长度n
*/
void msort2(int a[], int n) {
for (int gap = 1; gap < n; gap *= 2) { // 外层循环,gap从1开始,每次翻倍
int *b = new int[n]; // 创建临时数组b
int k = 0; // 初始化k
for (int i = 0; i < n; i += 2 * gap) { // 内层循环,对相邻的两个有序区间进行合并
int low1 = i; // 左侧区间起始位置
int high1 = i + gap; // 左侧区间结束位置
int low2 = i + gap; // 右侧区间起始位置
int high2 = i + 2 * gap; // 右侧区间结束位置
if (high1 > n) break; // 如果左侧区间已经超出数组范围,则跳出循环
if (high2 > n) high2 = n; // 如果右侧区间已经超出数组范围,则将其结束位置设为n
while (low1 < high1 && low2 < high2) { // 合并两个有序区间
if (a[low1] < a[low2]) { // 如果左侧数据小于右侧数据,则将左侧数据插入到临时数组b中
b[k++] = a[low1++];
} else { // 如果左侧数据大于等于右侧数据,则将右侧数据插入到临时数组b中
b[k++] = a[low2++];
}
}
while (low1 < high1) { // 将左侧剩余的数据插入到临时数组b中
b[k++] = a[low1++];
}
while (low2 < high2) { // 将右侧剩余的数据插入到临时数组b中
b[k++] = a[low2++];
}
for (int j = i; j < high2; j++) { // 将临时数组b中的元素复制回原数组a中
a[j] = b[j];
}
}
delete[] b; // 释放临时数组b的空间
}
}
- 这是一个实现归并排序算法的函数,它可以对整型数组a中n个元素进行升序排列。
- 在函数内部,我们使用for循环对相邻的两个有序区间进行合并。外层循环中,gap从1开始,每次翻倍。内层循环中,我们首先定义左侧和右侧的区间起始位置和结束位置,然后通过while循环将左侧和右侧的数据按照顺序填充到临时数组b中。最后,我们将临时数组b中的元素复制回原数组a中,完成本次合并操作。
- 循环执行完毕后,我们释放临时数组b的空间,排序结束。