醋醋百科网

Good Luck To You!

从一次数据排序的“事故”说起:浅谈排序算法的稳定性

引子:被破坏的原有顺序

在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,我们主要使用头文件中的两个排序函数:

  1. std::sort
    • 功能:对范围 [first, last) 内的元素进行排序。
    • 稳定性不稳定排序(Unstable Sort)
    • 原理:它通常被实现为一种内省排序(Introsort)。内省排序是快速排序、堆排序和插入排序的混合算法。
      • 开始时采用快速排序:递归划分数据集。
      • 当递归深度过深时(超过 log(n) 级别):转为堆排序,避免快速排序在最坏情况下O(n^2)的时间复杂度。
      • 当元素数量很少时(例如 <= 16):转为插入排序,因为对于小数据集,插入排序的常数因子很小,效率更高。
    • 特点:平均和最坏情况下的时间复杂度均为O(N·log(N)),是绝大多数场景下的首选,效率极高。
  1. 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




堆操作

通常用

最大堆

实现

不稳定

适配器,不是容器。它只保证堆顶是最大(或最小)元素,内部其他元素的顺序是不确定的,且不保证相等元素的任何顺序。


总结与建议:

  1. 默认选择std::sort:对于vector和deque,在不需要稳定性时,它是性能最好的选择。
  2. 需要稳定性时选择std::stable_sort:当需要保持相等元素的原始顺序时使用。
  3. 对链表排序用成员函数list::sort():不要对std::list和std::forward_list使用std::sort,必须使用其自带的成员函数sort()。
  4. 关联容器自身有序:它们通过内部的数据结构(红黑树)自动维护顺序,无需调用排序算法。
控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言