引子:被破坏的原有顺序
在C++项目开发中,我们经常使用STL的vector来存储数据,并使用库中的sort函数进行排序。但如果不了解排序算法“稳定性”的概念,就很容易掉入陷阱。
假设我们有一个vector,它已经按照学生的姓名进行了升序排序。现在,我们希望按照分数进行降序排序,但期望是:对于分数相同的学生,他们之间的原有顺序(即按姓名排好的顺序)能够得以保留。
让我们看一段示例代码:
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
using namespace std;
struct Student {
string name;
int score;
// 为了方便输出,重载 << 运算符
friend ostream& operator<<(ostream& os, const Student& s) {
os << "(" << s.name << ": " << s.score << ")";
return os;
}
};
int main() {
vector<Student> students = {
{"Alice", 85},
{"Bob", 91},
{"Charlie", 91}, // Bob和Charlie分数相同,但Bob的姓名在Charlie之前
{"David", 78}
};
cout << "原始顺序(按姓名排好): ";
for (const auto& s : students) cout << s << " ";
cout << endl << endl;
// 现在我们想按分数降序排列
sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
return a.score > b.score; // 降序
});
cout << "按分数降序排序后: ";
for (const auto& s : students) cout << s << " ";
cout << endl;
return 0;
}
运行结果可能如下:
原始顺序(按姓名排好): (Alice: 85) (Bob: 91) (Charlie: 91) (David: 78)
按分数降序排序后: (Bob: 91) (Charlie: 91) (Alice: 85) (David: 78)
这个结果看起来没问题,Bob和Charlie都是91分,并且Bob仍然在Charlie前面,符合我们的预期。但这是有保障的吗?
答案是否定的。 std::sort默认不保证稳定性。这意味着,在所有情况下,它都不承诺会保持相等元素的原始相对顺序。虽然在这个简单的例子中我们看到了“正确”的结果,但一旦数据量变大、比较函数变复杂或者使用的C++标准库实现不同,结果可能会变为:
按分数降序排序后: (Charlie: 91) (Bob: 91) (Alice: 85) (David: 78)
注意,Bob和Charlie的顺序发生了交换!原有按姓名排好的顺序被破坏了。这就是因为没有使用稳定排序(stable_sort) 导致的。
第一部分:vector的排序函数与稳定排序的概念
vector支持的排序函数
对于std::vector,我们主要使用头文件中的两个排序函数:
- std::sort
- 功能:对范围 [first, last) 内的元素进行排序。
- 稳定性:不稳定排序(Unstable Sort)。
- 原理:它通常被实现为一种内省排序(Introsort)。内省排序是快速排序、堆排序和插入排序的混合算法。
- 开始时采用快速排序:递归划分数据集。
- 当递归深度过深时(超过 log(n) 级别):转为堆排序,避免快速排序在最坏情况下O(n^2)的时间复杂度。
- 当元素数量很少时(例如 <= 16):转为插入排序,因为对于小数据集,插入排序的常数因子很小,效率更高。
- 特点:平均和最坏情况下的时间复杂度均为O(N·log(N)),是绝大多数场景下的首选,效率极高。
- std::stable_sort
- 功能:对范围 [first, last) 内的元素进行排序,并保证保留相等元素的顺序。
- 稳定性:稳定排序(Stable Sort)。
- 原理:它通常采用归并排序(Mergesort) 或它们的变种。
- 如果内存充足,通常使用外部归并排序。
- 如果内存不足,可能会使用一种原地归并排序的变种,或者分配临时内存,但其核心思想始终是“分治”与“合并”,而合并过程在遇到相等元素时会优先取前一子序列的元素,从而保证了稳定性。
- 特点:时间复杂度也是O(N·log(N))或O(N·log(N)^2),但常数因子通常比sort大,因为它需要更多的比较或移动操作来维持稳定性。
如何选择?
- 如果不关心相等元素的原始顺序,或者确定比较条件能唯一区分所有元素(即没有相等的元素),使用std::sort,因为它更快。
- 如果需要保持相等元素的原始相对顺序(就像我们上面的例子),则必须使用std::stable_sort。
修正上面的代码:
只需将sort替换为stable_sort,就能在任何情况下保证Bob一定在Charlie之前。
stable_sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
return a.score > b.score;
});
第二部分:常见排序算法的稳定性与原理
下表列出了常见排序算法的分类、原理和稳定性。
排序算法 | 分类 | 稳定性 | 时间复杂度(平均) | 原理简述 | 动画示意图 |
冒泡排序 (Bubble Sort) | 比较排序 | 稳定 | O(n^2) | 重复遍历列表,比较相邻元素,如果顺序错误就交换,直到没有需要交换的元素。 | https://upload.wikimedia.org/wikipedia/commons/c/c8/Bubble-sort-example-300px.gif |
选择排序 (Selection Sort) | 比较排序 | 不稳定 | O(n^2) | 每次从未排序部分中找到最小(或最大)元素,将其放到已排序序列的末尾。 | https://upload.wikimedia.org/wikipedia/commons/9/94/Selection-Sort-Animation.gif |
插入排序 (Insertion Sort) | 比较排序 | 稳定 | O(n^2) | 构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。 | https://upload.wikimedia.org/wikipedia/commons/0/0f/Insertion-sort-example-300px.gif |
归并排序 (Merge Sort) | 比较排序、分治 | 稳定 | O(n log n) | 采用分治法:1. 将列表递归地分成两半,直到每个子列表只有一个元素。2. 将两个有序子列表合并成一个新的有序列表。 合并时,遇到相等元素优先取前一子列表的,从而保证稳定性。 | https://upload.wikimedia.org/wikipedia/commons/c/cc/Merge-sort-example-300px.gif |
快速排序 (Quick Sort) | 比较排序、分治 | 不稳定 | O(n log n) | 选取一个“基准”元素,将数组分区,使得左边元素都小于等于基准,右边元素都大于等于基准。然后递归地对左右分区进行排序。 分区过程中元素的交换会打乱相等元素的原始顺序。 | https://upload.wikimedia.org/wikipedia/commons/6/6a/Sorting_quicksort_anim.gif |
堆排序 (Heap Sort) | 比较排序 | 不稳定 | O(n log n) | 利用堆这种数据结构:1. 构建一个最大堆(或最小堆)。2. 反复将堆顶元素(最大/最小值)与堆末尾元素交换,并缩小堆范围,重新调整堆结构。 堆的调整过程会破坏稳定性。 | https://upload.wikimedia.org/wikipedia/commons/1/1b/Sorting_heapsort_anim.gif |
希尔排序 (Shell Sort) | 比较排序、插入排序改进 | 不稳定 | 取决于间隔序列,优于O(n^2) | 是插入排序的改进版。它通过将原始列表分成多个子序列(由某个“间隔”决定)分别进行插入排序,然后逐步缩小间隔,最终进行一次完整的插入排序。 早期的长距离插入会破坏稳定性。 | https://upload.wikimedia.org/wikipedia/commons/d/d8/Sorting_shellsort_anim.gif |
计数排序 (Counting Sort) | 非比较排序 | 稳定 | O(n + k) | 不是通过比较,而是通过统计数组中每个值出现的次数来工作。然后计算每个元素的位置索引, 从后向前遍历原数组,根据计数数组放置元素以保证稳定性。 | https://upload.wikimedia.org/wikipedia/commons/c/cd/Counting_sort_animation.gif |
基数排序 (Radix Sort) | 非比较排序 | 稳定 (取决于子排序算法) | O(nk) | 按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。 通常使用稳定的计数排序作为子程序。 | https://upload.wikimedia.org/wikipedia/commons/4/45/Radix_sort_animation.gif |
桶排序 (Bucket Sort) | 非比较排序 | 稳定 (取决于子排序算法) | O(n + k) | 将数组分到有限数量的桶里,每个桶再分别排序(通常使用另一种稳定的排序算法,如插入排序)。 |
第三部分:C++ STL容器排序功能对比
C++ STL中的容器并非都支持排序,只有序列容器(如vector, deque, list) 和关联容器的相关操作涉及排序。
容器/操作 | 使用的算法/原理 | 稳定性 | 说明 |
std::vector | |||
std::sort(beg, end) | 内省排序(快排+堆排+插排) | 不稳定 | 通用排序,效率高。 |
std::stable_sort(beg, end) | 归并排序(或变种) | 稳定 | 保证相等元素顺序不变。 |
std::deque | |||
std::sort(beg, end) | 内省排序 | 不稳定 | 同 vector 。 |
std::stable_sort(beg, end) | 归并排序 | 稳定 | 同 vector 。 |
std::list | |||
member_function.sort() | 归并排序(通常是) | 稳定 | 成员函数 。因为 list 是链表,不支持随机访问迭代器, std::sort 算法无法使用(它需要随机访问迭代器)。其成员函数 sort() 专为链表优化,且 保证稳定 。 |
std::sort | 无法使用 | - | 编译错误,因为 std::sort 需要随机访问迭代器,而 list 提供的是双向迭代器。 |
std::forward_list | |||
member_function.sort() | 归并排序(通常是) | 稳定 | 同 list ,是成员函数,保证稳定。 |
关联容器 (set, map, multiset, multimap) | |||
始终有序 | 通常用 红黑树 实现 | 稳定 (指遍历顺序) | 关联容器本身自动根据键(key)保持元素有序。 插入/删除 操作后顺序自动维护。对于 multiset 和 multimap ,相等的键(key)的插入顺序会被保留(即按照插入的先后顺序遍历),这本身也是一种稳定性。 |
std::priority_queue | |||
堆操作 | 通常用 最大堆 实现 | 不稳定 | 适配器,不是容器。它只保证堆顶是最大(或最小)元素,内部其他元素的顺序是不确定的,且不保证相等元素的任何顺序。 |
总结与建议:
- 默认选择std::sort:对于vector和deque,在不需要稳定性时,它是性能最好的选择。
- 需要稳定性时选择std::stable_sort:当需要保持相等元素的原始顺序时使用。
- 对链表排序用成员函数list::sort():不要对std::list和std::forward_list使用std::sort,必须使用其自带的成员函数sort()。
- 关联容器自身有序:它们通过内部的数据结构(红黑树)自动维护顺序,无需调用排序算法。