目录
在掌握了冒泡、选择和插入这三种基础的平方级时间复杂度排序算法后,我们在蓝桥杯等算法竞赛中往往会面临更大的数据规模。当数据量 N 达到十万级别(10的5次方)时,基础排序就会因为超时(TLE)而无法满足需求。
此时,我们需要引入时间复杂度更优的进阶排序算法。本文将深入探讨三种极具代表性的高效排序算法:快速排序、归并排序以及跳出比较排序框架的桶排序。
1.快速排序
1.1快速排序思想
快速排序是计算机科学中最经典、应用最广泛的排序算法之一。它的核心思想是分治法(Divide and Conquer)。
快速排序的完整流程可以概括为以下三个步骤:
-
选定基准(Pivot):从当前待排序的数组区间中挑选一个元素作为基准值(通常可以选择最右边的元素、最左边的元素或者中间元素)。
-
划分区间(Partition):通过双指针扫描,将数组重新排列。使得所有小于基准值的元素都被放到基准的左边,所有大于或等于基准值的元素都被放到基准的右边。这个操作完成后,基准元素就已经落在了它最终排序后应该在的绝对正确的位置上。
-
递归求解:对基准元素左边的子区间和右边的子区间,重复上述步骤,直到区间长度为1或0,此时整个数组就天然有序了。
快速排序的平均时间复杂度为 O(N log N),在绝大多数实际应用场景中表现极其优异。
1.2快速排序模板
这里的核心是 partition 函数,它使用了左右双指针向中间逼近的技巧。
void Quicksort(int a[], int l, int r)
{
if(l < r)
{
int mid = Partition(a, l, r); // 将数组a[l]~a[r]区间中某个基准数字放到正确的位置并将这个位置返回
Quicksort(a, l, mid - 1);
Quicksort(a, mid + 1, r);
}
}
int partition(int a[], int l, int r)
{
// 设a[r]为基准元素,partition函数的作用是将数组a[l..r]划分成两部分,使得左边部分的元素都不大于a[r],右边部分的元素都不小于a[r],并返回基准元素a[r]在划分后的数组中的位置。
int pivot = a[r];
// 设两个下标i, j分别从l,r开始往中间走
int i = l, j = r;
while (i < j) {
while (i < j && a[i] < pivot) {
i++;
}
// 从上面的循环结束后,i指向第一个不小于pivot的元素
while (i < j && a[j] >= pivot) {
j--;
}
// 从上面循环结束后,j指向第一个小于pivot的元素
// 如果i < j,说明a[i]不小于pivot,a[j]小于pivot,需要交换a[i]和a[j]
if (i < j) {
swap(a[i], a[j]);
}
else{
swap(a[i], a[r]);
}
}
return i;
}
1.3例题
https://www.lanqiao.cn/problems/3226/learning/?page=1&first_category_id=1&problem_id=3226
深度拓展: 在真正的蓝桥杯赛场上,如果仅仅是要求把一个数组从小到大排序,我们极少会去手写快速排序。因为 C++ 标准模板库(STL)中提供的 sort() 函数,其底层正是基于快速排序(结合了插入排序和堆排序的内省式排序),不仅性能极致,而且一行代码即可调用。
手写快排的核心价值在于解决**"寻找第K大/小的数"**这类问题。利用 Partition 思想,我们可以在 O(N) 的时间复杂度内找到特定排位的元素,而不需要将整个数组完全排序。
直接调用 STL 的参考代码:
#include <iostream>
#include <algorithm> // 引入 sort 函数
using namespace std;
const int N = 1e5 + 9;
int a[N];
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
int n;
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
// 直接调用 STL,传入起始和结束迭代器(地址)
sort(a + 1, a + n + 1);
for(int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
return 0;
}
2.归并排序
2.1归并排序的思想
归并排序同样使用了分治思想,但它的侧重点与快速排序截然不同。 快速排序是"先划分,再递归",而归并排序是"先递归拆分,再合并"。
它将大数组无脑地从中间劈成两半,一直拆分到每个子数组只有一个元素(此时可以认为这个单元素数组是有序的)。然后,利用一个核心操作:将两个已经有序的数组,合并成一个更大的有序数组。不断向上合并,最终得到完整的有序序列。
归并排序的最大优势在于它是稳定排序(相等的元素排序后相对位置不变),且在任何情况下时间复杂度都是严格的 O(N log N)。代价是需要一个额外的 O(N) 规模的辅助数组来暂存合并过程的数据。
2.2模板
在合并(Merge)步骤中,我们需要非常严谨地处理四个指针状态分支,这是归并排序最容易写错的地方。
void MergeSort(int a[], int l, int r)
{
if (l == r) {
return;
}
int mid = l + (r - l) / 2;
MergeSort(a, l, mid);
MergeSort(a, mid + 1, r);
// 排序完成后,a[i, mid]和a[mid+1, r]都是有序的了
// 将a[l..mid]和a[mid+1..r]合并成一个有序的数组
int p1 = l, pr = mid + 1, pb = l;
while(p1 <= mid || pr <= r)
{
if(p1 > mid) {
// 说明左边的数组已经合并完了,直接将右边的数组剩余部分合并到b中
b[pb++] = a[pr++];
}
else if(pr > r) {
// 说明右边的数组已经合并完了,直接将左边的数组剩余部分合并到b中
b[pb++] = a[p1++];
}
else if(a[p1] < a[pr]) {
// 说明a[p1]应该排在a[pr]前面
b[pb++] = a[p1++];
}
else {
// 说明a[pr]应该排在a[p1]前面
b[pb++] = a[pr++];
}
}
// 将b数组中的元素复制回a数组中
for (int i = l; i <= r; i++) {
a[i] = b[i];
}
}
2.3例题
https://www.lanqiao.cn/problems/3226/learning/?page=1&first_category_id=1&problem_id=3226
深度拓展: 在蓝桥杯中,归并排序有一个极其经典的独门绝技------求逆序对。在上述模板的"分支4"(即右边元素小于左边元素)被触发时,意味着左半边剩下的所有元素都比当前右边的这个元素大,这就构成了一批逆序对。利用归并排序,我们可以在 O(N log N) 的极速下统计出几十万规模数组的逆序对总数。
完整参考代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int a[N], b[N];
void Mergesort(int a[], int l, int r)
{
if(l == r) return;
int mid = (l + r) / 2;
Mergesort(a, l, mid);
Mergesort(a, mid + 1, r);
int pl = l, pr = mid + 1, pb = l;
while(pl <= mid || pr <= r)
{
if(pl > mid) b[pb++] = a[pr++];
else if(pr > r) b[pb++] = a[pl++];
else if(a[pl] < a[pr]) b[pb++] = a[pl++];
else b[pb++] = a[pr++];
}
for(int i = 1; i <= r; i++) a[i] = b[i];
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
Mergesort(a, 1, n);
for(int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
return 0;
}
3.桶排序
3.1桶排序概念
前文提到的所有排序(冒泡、插入、快排、归并)都属于基于比较的排序 ,数学上已经证明,基于比较的排序时间复杂度下限就是 O(N log N)。 如果我们想要突破这个极限,达到令人惊叹的 O(N) 线性时间复杂度,就需要借助非比较排序算法,桶排序就是其中的代表。
桶排序的核心在于:它利用了数据的"值"本身的特征,通过建立数据到空间下标的映射关系来实现排序。它本质上是用极致的空间换取时间。
3.2原理及实现
桶排序的步骤:
- 将值域分为若干段,每段对应一个桶
- 将待排序元素放入对应的桶中
- 将个桶内的元素进行排序
- 将桶中的元素依次取出
3.3模板
模板一:计数排序(特殊的桶排序) 当数据范围比较集中且全为整数时(例如对学生考试成绩 0-100分 排序),我们可以让"每一个数值对应一个单独的桶"。这种极限情况也就是著名的计数排序。数组的下标直接就代表了数值,数组里面存的是该数值出现的次数。
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 5e5 + 7;
int n;
int bucket[MAXN]; // 一个值对应一个桶,桶里存储这个值出现的次数
int main() {
cin >> n;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
bucket[x]++; // 将这个值放入对应的桶中,桶里存储这个值出现的次数
}
for (int i = 0; i < MAXN; i++) {
for(int j = 1; j <= bucket[i]; j++) { // 将桶里的值输出出来,输出次数为这个值出现的次数
cout << i << " ";
}
}
cout << endl;
return 0;
}
模板二:通用桶排序(利用 Vector 管理区间) 当数据跨度很大或者包含浮点数时,我们不能为一个值单独开一个数组下标,这时就需要利用 vector 作为容器,让一个桶管理一个区间的值。
vector<int> bucket[MAXN];
int main()
{
cin >> n;
for(int i = 1; i <= n; i++)
{
int x;
cin >> x;
bucket[x / 1000].push_back(x); // 将这个值放入对应的桶中,桶里存储这个值出现的次数
}
for(int i = 0; i < MAXN; i++)
{
// 对每个桶进行排序,方法随意
// sort(bucket[i].begin(), bucket[i].end());
}
for(int i = 0; i < MAXN; i++)
{
for(auto item : bucket[i]) { // 将桶里的值输出出来,输出次数为这个值出现的次数
cout << item << " ";
}
}
cout << endl;
return 0;
}
总结: 快速排序追求极致的比较效率,归并排序保障绝对的稳定且是处理逆序对的利器,而桶排序则在特定数据特征下用空间打开了 O(N) 排序的大门。掌握它们,足以应对蓝桥杯绝大多数的排序拓展题。
本章完。