排序是程序设计中再基础不过的操作,但它的算法实现却包含着深刻的分治、递归和空间换时间思想。在C++里,标准库提供了高度优化的std::sort,绝大多数业务代码直接调用即可;然而,理解底层排序算法对于性能调优、面试以及某些特殊数据场景仍然不可或缺。接下来,本文会从算法选择讲到手写实现,再对比STL封装,帮助读者建立完整的排序知识体系。

一、排序算法的分类与选择依据
排序算法通常分为比较排序和非比较排序两大类。比较排序依靠元素之间的比较操作来决定顺序,常见的有冒泡排序、插入排序、选择排序、快速排序、归并排序和堆排序;非比较排序则利用元素本身的数值特性,例如计数排序、基数排序和桶排序,它们往往能做到线性时间复杂度,但对数据范围有严格要求。本文重点讨论比较排序,因为它们是C++工程中最常用的通用方案。
选择排序算法时,需要综合考量时间复杂度、空间复杂度、稳定性以及数据分布特征。下表列出了几种经典排序算法的关键指标。其中稳定性指相等元素在排序后能否保持原有相对顺序,这在多关键字排序场景中非常重要。例如,先按部门排序再按薪资排序,如果薪资排序不稳定,相同薪资的员工可能会打乱前一次排序的部门分组。
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
从表格可以看出,快速排序在平均情况下性能优秀且空间占用低,但最坏情况会退化到O(n²);归并排序性能稳定且是稳定的排序算法,但需要O(n)额外内存;堆排序原地排序且最坏情况也有O(n log n),但实际缓存性能较差。因此,对于通用场景,快速排序往往是默认选择;当稳定性成为硬性要求时,优先考虑归并排序;而在内存受限的嵌入式环境中,堆排序更合适。
二、经典排序算法的C++实现
快速排序的核心在于分区操作:选择一个基准元素,将小于基准的元素移到左边,大于基准的移到右边,然后递归处理左右子数组。简单实现通常选取最后一个元素作为基准,但这种方式在数组已经有序时会退化为O(n²)。解决办法是随机选取基准或使用三数取中法,以降低最坏情况的出现概率。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int partition(vector<int>& arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; ++j) {
if (arr[j] <= pivot) {
++i;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
return i + 1;
}
void quickSort(vector<int>& arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
上述代码实现了最基础的快速排序。分区函数从low到high-1扫描,每遇到小于等于基准的元素就与i前进后的位置交换,最终把基准放到正确位置。递归调用直到子数组长度为1。实际工程中,为了减少最坏情况,可以在分区前随机交换一个元素到high位置,或者对小数组改用插入排序。
归并排序采用分治策略,将数组不断二分直到每个子数组只有一个元素,然后逐步合并两个有序子数组。它的最大优势是稳定且时间复杂度始终为O(n log n),缺点是需要额外的数组存储空间。下面的代码演示了递归实现和合并过程。
#include <iostream>
#include <vector>
using namespace std;
void merge(vector<int>& arr, int l, int m, int r) {
int n1 = m - l + 1;
int n2 = r - m;
vector<int> L(n1), R(n2);
for (int i = 0; i < n1; ++i)
L[i] = arr[l + i];
for (int j = 0; j < n2; ++j)
R[j] = arr[m + 1 + j];
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
++i;
} else {
arr[k] = R[j];
++j;
}
++k;
}
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void mergeSort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int m = l + (r - l) / 2;
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
merge(arr, l, m, r);
}
合并过程创建了两个临时向量L和R,分别存放左右两部分,然后按顺序逐个比较并写回原数组。注意在while循环中使用了<=来判断,这保证了稳定性:当左半部分元素等于右半部分元素时,优先取左半部分,从而维持原相对顺序。这种实现方式在面试中经常被考查,理解它有助于掌握分治和双指针技巧。
堆排序利用最大堆的性质,先原地建立最大堆,然后反复将堆顶元素与末尾元素交换,再调整剩余元素为最大堆。它不需要额外存储空间,最坏时间复杂度也为O(n log n),但由于跳跃式访问内存,实际速度通常慢于快速排序。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void heapify(vector<int>& arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
void heapSort(vector<int>& arr) {
int n = arr.size();
for (int i = n / 2 - 1; i >= 0; --i)
heapify(arr, n, i);
for (int i = n - 1; i > 0; --i) {
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
建堆从最后一个非叶子节点开始向前调整,确保每个子树满足最大堆性质。排序阶段每次将当前最大元素移到数组末尾,同时缩短堆的有效长度。heapify函数通过递归或循环维护堆结构。堆排序实现相对简洁,但需要注意索引边界和递归深度。
三、用好C++标准库排序:std::sort与自定义比较器
C++标准库中的std::sort采用了introsort算法,它结合了快速排序、堆排序和插入排序的优点。具体来说,std::sort先使用快速排序进行划分,当递归深度超过某个阈值时自动切换为堆排序,以避免快速排序的最坏情况;对于小规模子数组,则使用插入排序提升效率。因此,std::sort的平均和最坏时间复杂度均为O(n log n),且实际性能非常优秀。
日常开发中,普通数组或std::vector的排序只需一行代码。需要自定义排序规则时,可以传入函数指针、函数对象或lambda表达式。下面的示例展示了如何按结构体成员排序,以及如何使用lambda实现多字段排序。
#include <algorithm>
#include <vector>
#include <iostream>
struct Point {
int x, y;
};
bool comparePoint(const Point& a, const Point& b) {
return a.x < b.x;
}
int main() {
std::vector<int> nums = {5, 2, 9, 1, 5, 6};
std::sort(nums.begin(), nums.end());
for (int n : nums) std::cout << n << " ";
std::vector<Point> pts = {{3,4}, {1,2}, {3,1}};
std::sort(pts.begin(), pts.end(), [](const Point& a, const Point& b) {
if (a.x != b.x) return a.x < b.x;
return a.y < b.y;
});
return 0;
}
在上面的代码中,lambda表达式先按x升序排列,若x相同再按y升序排列。需要特别注意的是,自定义比较器必须满足严格弱序关系:如果a小于b,则b不能小于a;同时必须保持传递性。违反这一规则会导致未定义行为,轻则排序结果错误,重则程序崩溃。另外,std::sort不保证稳定性,若需要稳定排序,应使用std::stable_sort,其实现通常基于归并排序。
四、性能实测与工程优化建议
理论分析只能提供大致方向,实际性能还会受到缓存命中率、分支预测和编译器优化等因素影响。为了直观对比,我们可以用同一个随机数组分别测试快速排序、归并排序、堆排序和std::sort的耗时。在一台普通PC上,对100万个int元素排序,std::sort通常最快,快速排序次之,归并排序和堆排序略慢。以下数据为多次运行取平均值的结果,仅作参考。
| 算法 | 100万随机整数平均耗时(毫秒) | 100万有序整数耗时(毫秒) |
|---|---|---|
| 快速排序(随机基准) | 85 | 62 |
| 归并排序 | 120 | 105 |
| 堆排序 | 140 | 130 |
| std::sort | 70 | 58 |
从数据可以看出,std::sort在两种数据分布下都保持了领先。这归功于它对小数组使用插入排序以及混合策略。手写快速排序如果加入随机基准和三数取中优化,性能可以接近std::sort,但代码复杂度明显上升。实际项目中,除非有非常特殊的需求,例如必须在C语言环境或对额外空间严格限制,否则建议直接使用std::sort。
工程优化方面,以下几点值得重视:第一,避免在循环内进行不必要的拷贝,排序对象尽量使用引用或移动语义;第二,对于已经基本有序的数据,可以先用std::is_sorted检查,如果有序则跳过排序;第三,小规模数据(通常小于16个元素)直接使用插入排序可能比std::sort更快,因为常数因子更小;第四,如果排序对象是大结构体,考虑排序索引数组或使用指针数组,以减少交换成本。总之,理解排序算法原理是为了更好地使用和调优标准库,而不是重复造轮子。