前言
本文对常见的排序算法做个总结,程序实现的是升序(降序在原理上与升序并无不同)。
1 冒泡排序
冒泡排序的主要思想是比较相邻两个元素,如果前一个元素比后一个元素大,那么就交换两个元素,直到没有元素需要交换。代码如下:
void sortArray(vector<int>& nums) {
bool need_bubble = true;
while (need_bubble) {
need_bubble = false;
for (int i = 0; i < nums.size() - 1; ++i) {
if (nums[i] > nums[i + 1]) {
swap(nums[i], nums[i + 1]);
need_bubble = true;
}
}
}
}
2 选择排序
选择排序的主要思想是选择区间[i, n - 1)范围内最小的元素,并与array[i]进行交换,随着循环中变量i不断增加,数组也从无序变为有序。代码如下:
void sortArray(vector<int>& nums) {
for (int i = 0; i < nums.size() - 1; ++i) {
/* 选择最小元素 */
int min_index = i;
for (int j = i + 1; j < nums.size(); ++j)
if (nums[j] < nums[min_index])
min_index = j;
swap(nums[i], nums[min_index]);
}
}
3 插入排序
插入排序的主要思想是将待排序数组分成左边、右边两个部分,左边元素有序,右边元素无序。初始时,左边仅有一个元素,而右边有n - 1个元素,每次选取右边的一个元素,将其有序的插入左边的元素中。随着循环的进行,左边元素数量越来越多,最终整个数组有序。代码如下:
void sortArray(vector<int>& nums) {
for (int i = 1; i < nums.size(); ++i) {
/* 有序部分为0~i-1 */
int cur_num = nums[i], j = i - 1;
/* 插入操作 */
while ((j >= 0) && (cur_num < nums[j])) {
nums[j + 1] = nums[j];
--j;
}
nums[j + 1] = cur_num;
}
}
4 希尔排序
希尔排序可以看成是插入排序的改进,插入排序的复杂度主要来源于两个方面:待排序数据的规模,待排序数据的无序程度。希尔排序每次循环都将待排序数组按照下标的间隔分组,然后对组内元素执行插入排序,之后缩小用于分组的间隔,如此循环直到分组的间隔小于1。最开始的时候,由于分组间隔选取较大,因此组内元素数量较小,即便插入排序有着O(n2)的平均复杂度,也不会太耗时;随着分组间隔的减小,虽然组内元素数量增加,但由于之前的工作,组内的有序程度也是增加的,因此也能有效的降低复杂度。
关于希尔排序的复杂度,最好情况是O(nlogn),而最坏情况和平均情况复杂度是跟采用的分组间隔有关的,通常在O(n1.3)~O(n2),希尔排序的复杂度是个复杂的问题,不必过分纠结。
vector<int> sortArray(vector<int>& nums) {
/* 初始化分组间隔(分组间隔的选择不唯一) */
int step = nums.size() / 2;
for (; step > 0; step >>= 1) {
/* 对待排序数组进行分组 */
for (int i = 0; i < step; ++i) {
/* 对每个分组进行插入排序 */
for (int j = i + step; j < nums.size(); ++j) {
int cur_num = nums[j], k = j - step;
for (; (k >= 0) && (cur_num < nums[k]); k -= step)
nums[k + step] = nums[k];
nums[k + step] = cur_num;
}
}
}
return nums;
}
5 归并排序
归并排序的主要思想是分治。具体的,将一个待排序分为两半,然后分别对这两部分进行排序,再将两者归并即可得到有序的序列,这个过程可以继续划分,比如前一半可以再分两半,直到不可分(区间内仅剩一个元素)。代码如下:
void merge_sort(vector<int> &nums, int l, int r) {
if ((r - l) <= 1)
return;
int mid = l + ((r - l) >> 1);
/* 分治 */
merge_sort(nums, l, mid);
merge_sort(nums, mid, r);
/* 归并 */
vector<int> tmp(r - l, 0);
int index_l = l, index_r = mid;
for (int i = 0; i < tmp.size(); ++i) {
if ((index_l >= mid) || ((index_r < r) && (nums[index_r] < nums[index_l])))
tmp[i] = nums[index_r++];
else
tmp[i] = nums[index_l++];
}
/* 将结果写回nums */
for (int i = 0; i < tmp.size(); ++i)
nums[l + i] = tmp[i];
}
/* 对归并排序的简单包装 */
vector<int> sortArray(vector<int>& nums) {
merge_sort(nums, 0, nums.size());
return nums;
}
6 快速排序
快速排序的主要思想也是分治,不过和归并排序不同的是,快速排序每次会选取一个哨兵,然后通过交换元素将待排序序列分为左右两部分,以升序来说,左边都不大于哨兵,右边都不小于哨兵,然后再递归的对左右两边执行相同的操作,直到数组有序。代码如下:
void quick_sort(vector<int> &nums, int l, int r) {
if ((r - l) <= 1)
return;
/* 哨兵的选择有很多优化策略,这里我直接取中间元素 */
int sentinel = nums[(l + r) >> 1];
nums[(l + r) >> 1] = nums[l];
/* 将数组分为两部分 */
int index_l = l, index_r = r - 1;
while (index_l < index_r) {
/* 从右边找比哨兵小的元素 */
for (; index_l < index_r; --index_r) {
if (nums[index_r] < sentinel) {
nums[index_l] = nums[index_r];
break;
}
}
/* 从左边找比哨兵大的元素 */
for (; index_l < index_r; ++index_l) {
if (nums[index_l] > sentinel) {
nums[index_r] = nums[index_l];
break;
}
}
}
/* 将哨兵填入恰当的位置 */
nums[index_l] = sentinel;
/* 分治 */
quick_sort(nums, l, index_l);
quick_sort(nums, index_l + 1, r);
}
/* 对快排的简单包装 */
vector<int> sortArray(vector<int>& nums) {
quick_sort(nums, 0, nums.size());
return nums;
}
值得注意的是,快速排序的空间复杂度在最好情况下是O(logn),最坏情况下是O(n)。这是为什么呢?可能快排实现本身是就地排序,这会让人误认为其空间复杂度为O(1)。这样考虑疏忽了递归过程中栈帧的增长,堆上的内存是内存,栈上的内存当然也是内存,而且栈上的内存增长较堆更需要警惕。
此外,快排为什么快呢?如果只从复杂度上看,似乎看不出它快的理由,反而如果每次选中的元素就是最大/最小元素,快排会退化成O(n2),而堆排序和归并的复杂度是稳定在O(nlogn)的。事实上,最坏情况遇到的概率非常小,因此平均意义而言快排的复杂度就是O(nlogn),并且归并排序需要额外的申请内存,因此较快排慢。和堆排序相比,快排对数据的访问更具有局部性,能够更好的利用Cache,了解计算机体系结构的同学应该知道,这对于性能的影响是非常大的。
7 堆排序
堆排序的主要思想是利用堆这一数据结构的特性来完成排序,关于堆的介绍可以参考:实现一个完全二叉堆。排序的过程很简单,将输入数据逐个加入堆,入堆完成后,再逐个取出,得到的就是有序的序列。代码如下:
vector<int> sortArray(vector<int>& nums) {
vector<int> ret;
/* 建堆 */
make_heap(nums.begin(), nums.end(), greater<int>());
/* 逐个取出堆顶元素 */
for (; !nums.empty(); nums.pop_back()) {
pop_heap(nums.begin(), nums.end(), greater<int>());
ret.emplace_back(nums.back());
}
return ret;
}
8 计数排序
计数排序的主要思想是准备一个大小足够涵盖所有输入的待排序数据的数组,然后以输入数据为索引,找到所述数组的某一项,利用该项元素统计相应输入数据出现的次数。当所有输入数据出现的次数都统计完成时,顺序也就知道了。代码如下(假定输入数据的范围在[-50000, 50000]):
vector<int> sortArray(vector<int>& nums) {
vector<int> k(100001, 0);
vector<int> ret(nums.size(), 0);
/* 统计输入数据出现的次数 */
for (const auto &each_num : nums)
++k[each_num + 50000];
/* 计算各个输入数据在排序后数组中的下标 */
for (int i = 1; i < k.size(); ++i)
k[i] += k[i - 1];
/* 将排序结果输出到ret(从nums.size() - 1开始遍历以保证计数排序的稳定性) */
for (int i = nums.size() - 1; i >= 0; --i)
ret[--k[nums[i] + 50000]] = nums[i];
return ret;
}
不难看出,计数排序比较适用的场景是输入数据量大但分布相对集中。如果数据量少,但分布分散的话,使用计数排序不仅时间复杂度高,空间复杂度也高。
9 桶排序
桶排序的主要思想是建立一组桶,将派排序的数据按照其数值范围分布到各个桶中(每个桶对应一个范围),分散之后,每个桶内的元素数量就相对少了。此时,对每个桶内的数据选用前文所述的排序算法,然后再把每个桶内的元素按顺序收集起来,就可以得到排序后的数据。不难看出,与计数排序不同的是,桶排序适合输入数据的值域范围大且分布均匀的场景。代码如下(假定输入数据的范围在[-50000, 50000]):
vector<int> sortArray(vector<int>& nums) {
/* 建立1001个桶 */
vector<vector<int>> buckets(1001);
/* 将待排序数据分散到各个桶中 */
for (const auto &each_num : nums)
buckets[each_num / 100 + 500].emplace_back(each_num); /* 简单粗暴的映射方法 */
/* 对每个桶中的数据进行排序(这里我选用插入排序) */
for (auto &each_bucket : buckets)
insert_sort(each_bucket);
/* 按顺序把桶中的数据收集起来即可得到排序后数据 */
int index = 0;
for (const auto &each_bucket : buckets)
for (const auto &each_num : each_bucket)
nums[index++] = each_num;
return nums;
}
桶排序的稳定性受到对各个桶使用的排序算法的影响,其复杂度和桶的数量以及对各个桶选用的排序算法有关。个人认为,不专门做算法研究的话不必纠结。
10 基数排序
基数排序也会用到桶,不过在对桶的使用上,基数排序与桶排序并不相同。基数排序将待排序的元素拆分为k个关键字,然后进行k次循环,在每次循环中,按照相应的关键字将待排序元素加入桶中,并按照元素在桶中的位置收集元素。最后一次收集元素后,即可得到有序的序列。具体的,如果是对整数排序,我们可以把整数的各个位(个位、十位…)上的数作为关键字,第一次循环得到按照个位大小排序的序列,第二次循环得到按照个位以及十位排序的序列…最终得到有序的序列。代码如下:
/* 用于计算一个整数有多少位 */
int clac_digit_num(int value) {
int count = 0;
while (value)
++count, value /= 10;
return count;
}
/* 用于获取第nth个关键字 */
int get_key(int value, int nth) {
int key = 0;
for (; nth; --nth, value /= 10)
key = value % 10;
/* 考虑到负数的存在将可能的结果-9~9映射到0~18 */
return key + 9;
}
/* 基数排序 */
vector<int> sortArray(vector<int>& nums) {
/* 建立19个桶 */
vector<vector<int>> buckets(19);
/* 获取nums中数位最多的数的数位个数 */
int max_value = *max_element(nums.begin(), nums.end());
int min_value = *min_element(nums.begin(), nums.end());
int key_count = max(clac_digit_num(max_value), clac_digit_num(min_value));
/* 基数排序过程 */
for (int i = 1; i <= key_count; ++i) {
/* 将每个数按照键值加入桶 */
for (const auto &each_num : nums)
buckets[get_key(each_num, i)].emplace_back(each_num);
/* 按照各个数在桶中的位置收集回来 */
int index = 0;
for (auto &each_bucket : buckets) {
for (const auto &each_num : each_bucket)
nums[index++] = each_num;
/* 清空每个桶方便下一次把数放入其中 */
each_bucket.clear();
}
}
return nums;
}
总结
各种排序算法的复杂度和特点总结为下表:
算法 | 平均时间复杂度 | 最好情况 | 最差情况 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
希尔排序 | O(n1.3)~O(n2) | O(nlogn) | O(n1.3)~O(n2) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | O(logn)~O(n) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
计数排序 | O(n + w) | O(n + w) | O(n + w) | O(w) | 稳定 |
桶排序 | O(nlogn/m) | O(nlogn/m) | O(nlogn) | O(n + m) | 稳定 |
基数排序 | O(nk) | O(nk) | O(nk) | O(n + m) | 稳定 |
注:
- w表示输入的待排序数据的范围
- m表示桶的数量
- k表示基数排序使用的关键字的数量