Skip to content

第7章 排序算法 ​


🎯 本章学习目标 ​

通过本章的学习,你将能够:

  1. 理解排序概念:知道排序的含义,区分内部排序和外部排序
  2. 掌握稳定性定义:理解排序稳定性的含义,能判断算法是否稳定
  3. 手写冒泡排序:熟练编写冒泡排序代码,理解其优化方式
  4. 手写选择排序:熟练编写选择排序代码,理解为什么不稳定
  5. 手写插入排序:熟练编写插入排序代码,理解向前插入的过程
  6. 分析时间复杂度:能推导三种排序的最好、最坏、平均时间复杂度
  7. 理解空间复杂度:知道三种排序都是 O(1) 原地排序
  8. 掌握大O表示法:能分析代码片段的时间复杂度
  9. 根据数据规模选算法:根据 n 的范围判断哪种复杂度的算法能通过
  10. 使用STL sort:会用 sort 函数排序,知道其稳定性

🗺️ 本章知识图谱 ​

排序算法
├── 基础概念
│   ├── 排序的定义(升序/降序)
│   ├── 内部排序 vs 外部排序
│   └── 稳定性(相等元素相对顺序)
├── 三大基础排序
│   ├── 冒泡排序
│   │   ├── 相邻比较交换
│   │   ├── 每轮确定一个最大值
│   │   ├── 标志位优化
│   │   └── 稳定,O(n²)
│   ├── 选择排序
│   │   ├── 每轮选最小值
│   │   ├── 交换到已排序末尾
│   │   ├── 不稳定
│   │   └── 始终 O(n²)
│   └── 插入排序
│       ├── 从后向前插入
│       ├── 元素后移
│       ├── 稳定
│       └── 最好O(n),最坏O(n²)
├── 复杂度分析
│   ├── 大O表示法
│   ├── 时间复杂度(最好/最坏/平均)
│   ├── 空间复杂度
│   ├── 常见复杂度排序
│   └── 数据规模反推
├── STL排序
│   ├── sort 函数
│   ├── stable_sort
│   ├── 自定义比较函数
│   └── sort 不稳定
└── 应用场景
    ├── 排行榜
    ├── 查找预处理
    ├── 去重
    └── 数据展示

一、排序的基本概念 ​

1.1 什么是排序 ​

排序(Sorting)是将一组数据按照特定顺序(升序或降序)重新排列的过程。

  • 升序(从小到大) :如 [5, 2, 8, 1] → [1, 2, 5, 8]
  • 降序(从大到小) :如 [5, 2, 8, 1] → [8, 5, 2, 1]

排序后的数据满足:对任意 i < j,有 a[i] ≤ a[j](升序)或 a[i] ≥ a[j](降序)。

⚠️ 易错点: 排序不改变数据本身,只改变数据的排列顺序。排序前后元素集合完全相同,一个不多一个不少。

1.2 排序的应用场景 ​

应用场景说明示例
查找预处理排序后可使用二分查找,效率从 O(n) 提升到 O(log n)电话簿按姓氏排序便于查找
排行榜按分数、销量等指标排名游戏积分榜、考试成绩排名
数据展示用户更习惯查看有序数据商品按价格从低到高展示
去重排序后相同元素相邻,去重只需扫描一遍统计不重复数字的个数

1.3 内部排序 vs 外部排序 ​

分类定义特点举例
内部排序数据全部存放在内存中进行排序速度快,适合数据量不大冒泡、插入、选择、快排
外部排序数据量大,无法全部放入内存,需借助外存(硬盘)速度慢,涉及磁盘读写归并排序用于大文件排序

📌 GESP四级聚焦: 四级考纲只要求掌握比较类内部排序——即冒泡排序、插入排序、选择排序。非比较排序和外部排序在更高级别才会涉及。


二、算法稳定性 ​

2.1 稳定性的定义 ​

排序稳定性: 如果排序后,序列中值相等的元素保持了它们在排序前的相对先后顺序,则称该排序算法是稳定的;否则是不稳定的。

用数学语言描述:对于任意两个值相等的元素 a[i] 和 a[j](i < j),排序后若 a[i] 仍在 a[j] 前面,则该排序是稳定的。

2.2 举例说明 ​

假设有一组扑克牌,按花色+点数区分每张牌,但我们只按点数排序:

排序前(输入)排序后(稳定)排序后(不稳定)
牌点数牌点数牌点数
♠55♥33♥33
♥33♠55♣55
♣55♣55♠55
♥77♥77♥77
  • 稳定排序:两张 5 点牌中,♠5 原本在 ♣5 前面,排序后 ♠5 仍在 ♣5 前面 ✅
  • 不稳定排序:两张 5 点牌的先后顺序可能被颠倒 ❌

2.3 稳定性为什么重要? ​

稳定性在多关键字排序时尤为重要。

场景:

学生成绩表,先按班级排序,再按成绩排序。

排序前:          先按班级排序后:    再按成绩排序(稳定):
张三 1班 90       张三 1班 90         李四 1班 95
李四 1班 95       李四 1班 95         张三 1班 90
王五 2班 85       赵六 1班 80         赵六 1班 80
赵六 1班 80       王五 2班 85         王五 2班 85

如果第二次排序(按成绩)是稳定的,那么成绩相同的学生会保持按班级排好的顺序。稳定排序可以逐关键字叠加排序效果。

🔥 高频考点: GESP判断题常考稳定性的定义。记住:稳定性只关注相等元素的相对顺序,和排序是否正确、数据是否丢失没有任何关系!

2.4 三种排序的稳定性归类 ​

稳定性排序算法说明
稳定 ✅冒泡排序相邻比较,相等元素不交换
稳定 ✅插入排序从后向前插入,相等元素不后移
不稳定 ❌选择排序跨位置交换可能打乱相等元素顺序

下面讲解每种算法时,会从代码层面详细分析为什么稳定或不稳定。


三、冒泡排序(Bubble Sort) ​

3.1 核心原理 ​

冒泡排序的思想非常直观:重复遍历数组,比较相邻两个元素,如果顺序错误就交换它们。每一轮遍历结束后,当前未排序部分的最大元素会像气泡一样"浮"到末尾。

具体过程:

  1. 从数组开头开始,依次比较相邻元素 a[j] 和 a[j+1]
  2. 如果 a[j] > a[j+1](升序),则交换它们
  3. 一轮结束后,最大元素被放到数组末尾
  4. 对剩余未排序部分重复以上过程,直到没有任何交换发生

3.2 图示过程 ​

以数组 [5, 3, 8, 1, 2] 升序排序为例:

初始:  [5, 3, 8, 1, 2]

第1轮(比较4次,最大8冒到末尾):
  5>3 交换 → [3, 5, 8, 1, 2]
  5<8 不换 → [3, 5, 8, 1, 2]
  8>1 交换 → [3, 5, 1, 8, 2]
  8>2 交换 → [3, 5, 1, 2, 8]  ← 8已就位

第2轮(比较3次,次大5冒到倒数第二):
  3<5 不换 → [3, 5, 1, 2, 8]
  5>1 交换 → [3, 1, 5, 2, 8]
  5>2 交换 → [3, 1, 2, 5, 8]  ← 5已就位

第3轮(比较2次):
  3>1 交换 → [1, 3, 2, 5, 8]
  3>2 交换 → [1, 2, 3, 5, 8]  ← 3已就位

第4轮(比较1次):
  1<2 不换 → [1, 2, 3, 5, 8]  ← 无交换,排序完成

记忆口诀: 相邻比较,大的后移;每轮少比一个,n-1轮搞定。

3.3 完整代码(含 flag 优化) ​

cpp
#include <iostream>
using namespace std;

// 冒泡排序(升序),含flag优化
void bubbleSort(int a[], int n) {
    for (int i = 0; i < n - 1; i++) {
        bool swapped = false;  // 标记本轮是否发生交换
        // 每轮比较到 n-1-i,因为末尾i个元素已有序
        for (int j = 0; j < n - 1 - i; j++) {
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                swapped = true;
            }
        }
        if (!swapped) break;  // 本轮无交换,已有序,提前结束
    }
}

int main() {
    int a[] = {5, 3, 8, 1, 2};
    int n = 5;

    bubbleSort(a, n);

    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
    // 输出: 1 2 3 5 8
    return 0;
}

⚠️ 易错点: 内层循环边界是 j < n - 1 - i,不是 j < n - 1。因为每轮结束后末尾已有 i 个元素排好序,不需要再比较。写成 j < n - 1 虽然结果正确,但会做多余比较。

3.4 复杂度与稳定性分析 ​

指标值说明
最好时间复杂度O(n)数组已有序 + flag优化,一轮即可结束
最坏时间复杂度O(n²)数组完全逆序,需 n-1 轮,比较 n(n-1)/2 次
平均时间复杂度O(n²)随机数据
空间复杂度O(1)仅用了几个辅助变量,原地排序
稳定性稳定 ✅相等元素不交换(a[j] > a[j+1] 才交换)

🔥 高频考点: 冒泡排序的稳定性取决于比较条件。使用 >(严格大于)时,相等元素不会交换,排序稳定;如果写成 >=,相等元素也会交换,就变成不稳定排序了。


四、选择排序(Selection Sort) ​

4.1 核心原理 ​

选择排序的思想:每一轮从未排序部分中找到最小(升序)元素,将其与未排序部分的第一个元素交换,放到已排序部分的末尾。

具体过程:

  1. 在未排序区间 [i, n-1] 中找到最小值的下标 minIdx
  2. 将 a[i] 与 a[minIdx] 交换
  3. 已排序区间扩展为 [0, i]
  4. 重复以上过程 n-1 轮

4.2 图示过程 ​

以数组 [5, 3, 8, 1, 2] 升序排序为例:

初始:  [5, 3, 8, 1, 2]

第1轮(在[0,4]找最小):
  最小值1在位置3,交换a[0]和a[3]
  → [1, 3, 8, 5, 2]  ← 1已就位

第2轮(在[1,4]找最小):
  最小值2在位置4,交换a[1]和a[4]
  → [1, 2, 8, 5, 3]  ← 2已就位

第3轮(在[2,4]找最小):
  最小值3在位置4,交换a[2]和a[4]
  → [1, 2, 3, 5, 8]  ← 3已就位

第4轮(在[3,4]找最小):
  最小值5在位置3,无需交换
  → [1, 2, 3, 5, 8]  ← 排序完成

对比记忆: 冒泡排序是"边比边换",选择排序是"先找后换"——每轮只在最后做一次交换。

4.3 完整代码 ​

cpp
#include <iostream>
using namespace std;

// 选择排序(升序)
void selectionSort(int a[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int minIdx = i;  // 假设未排序部分第一个元素最小
        // 在未排序区间 [i+1, n-1] 中找最小值下标
        for (int j = i + 1; j < n; j++) {
            if (a[j] < a[minIdx]) {
                minIdx = j;  // 更新最小值下标
            }
        }
        // 将最小值交换到未排序部分的起始位置
        if (minIdx != i) {
            swap(a[i], a[minIdx]);
        }
    }
}

int main() {
    int a[] = {5, 3, 8, 1, 2};
    int n = 5;

    selectionSort(a, n);

    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
    // 输出: 1 2 3 5 8
    return 0;
}

⚠️ 易错点: minIdx 的初始值必须是 i(未排序部分的起始位置),不是 0!每轮开始时,假设 a[i] 是最小的,然后从 i+1 开始比较更新。

4.4 复杂度与稳定性分析 ​

指标值说明
最好时间复杂度O(n²)即使已有序,仍需逐轮比较找最小值
最坏时间复杂度O(n²)始终比较 n(n-1)/2 次
平均时间复杂度O(n²)与数据分布无关
空间复杂度O(1)原地排序
稳定性不稳定 ❌跨位置交换可能改变相等元素的相对顺序

🔥 高频考点: 选择排序的时间复杂度始终是 O(n²),无论数据是否有序,因为它每轮都必须遍历未排序部分来找最小值,无法像冒泡那样用 flag 提前终止。

4.5 为什么选择排序不稳定? ​

举一个反例。数组 [5(a), 5(b), 2],其中 5(a) 和 5(b) 值相等:

第1轮:在[0,2]找最小值2(位置2),交换a[0]和a[2]
  → [2, 5(b), 5(a)]

排序后 5(b) 在 5(a) 前面,原来 5(a) 在 5(b) 前面,
相对顺序发生了改变,所以选择排序不稳定!

根本原因: 选择排序的交换是"跨越式"的,最小值可能隔着多个元素与前面交换,这一跃迁就可能越过相等元素,打乱它们的相对顺序。


五、插入排序(Insertion Sort) ​

5.1 核心原理 ​

插入排序的思想类似整理手中的扑克牌:将数组分为"已排序"和"未排序"两部分,每次取出未排序部分的第一个元素,在已排序部分中从后向前中找到合适位置插入。

具体过程:

  1. 将 a[0] 视为已排序部分(只有一个元素)
  2. 取出未排序部分的第一个元素 a[i](即 key)
  3. 从已排序部分末尾 a[i-1] 开始向前比较
  4. 如果 a[j] > key,将 a[j] 后移一位
  5. 重复直到找到 a[j] <= key 或到达数组开头
  6. 将 key 插入到 j+1 的位置

5.2 图示过程 ​

以数组 [5, 3, 8, 1, 2] 升序排序为例:

初始:  [5 | 3, 8, 1, 2]   (|左边已排序,右边未排序)

第1轮:取key=3,与5比较,5>3后移
  [_, 5, 8, 1, 2] → 3插入位置0
  → [3, 5 | 8, 1, 2]

第2轮:取key=8,与5比较,5<8,不移动
  → [3, 5, 8 | 1, 2]

第3轮:取key=1,与8比较,8>1后移
  [3, 5, _, 8, 2] → 与5比较,5>1后移
  [3, _, 5, 8, 2] → 与3比较,3>1后移
  [_, 3, 5, 8, 2] → 到达开头,1插入位置0
  → [1, 3, 5, 8 | 2]

第4轮:取key=2,与8比较,8>2后移
  [1, 3, 5, _, 8] → 与5比较,5>2后移
  [1, 3, _, 5, 8] → 与3比较,3>2后移
  [1, _, 3, 5, 8] → 与1比较,1<2,停止,2插入位置1
  → [1, 2, 3, 5, 8]  ← 排序完成

5.3 完整代码 ​

cpp
#include <iostream>
using namespace std;

// 插入排序(升序)
void insertionSort(int a[], int n) {
    for (int i = 1; i < n; i++) {
        int key = a[i];  // 取出未排序部分的第一个元素
        int j = i - 1;   // 已排序部分的末尾下标

        // 从后向前扫描已排序部分,将大于key的元素后移
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];  // 元素后移
            j--;
        }
        a[j + 1] = key;  // 插入key到正确位置
    }
}

int main() {
    int a[] = {5, 3, 8, 1, 2};
    int n = 5;

    insertionSort(a, n);

    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
    // 输出: 1 2 3 5 8
    return 0;
}

⚠️ 易错点: 后移操作是 a[j+1] = a[j],方向是从后往前逐个覆盖。注意最后插入位置是 j+1,因为 while 循环结束时 j 已经多减了一次(或者到达了 -1)。

🔥 高频考点: while 循环条件 j >= 0 && a[j] > key 的顺序不能写反!必须先判断 j >= 0,否则当 j = -1 时访问 a[-1] 会越界。这是 C++ 逻辑与 && 的"短路求值"特性。

5.4 复杂度与稳定性分析 ​

指标值说明
最好时间复杂度O(n)数组已有序,每轮只需比较1次,不移动
最坏时间复杂度O(n²)数组完全逆序,每轮需比较并移动 i 次
平均时间复杂度O(n²)随机数据
空间复杂度O(1)仅用 key 和 j 等辅助变量
稳定性稳定 ✅从后向前比较,a[j] > key(严格大于)才后移

对比冒泡: 插入排序和冒泡排序最好情况都是 O(n),但插入排序在实际应用中通常比冒泡排序更快,因为它的移动操作更高效(冒泡需要三次赋值的交换,插入只需一次赋值的后移)。


六、三种排序对比总结 ​

对比项冒泡排序选择排序插入排序
核心思想相邻比较,大的后移找最小值,交换到前面取出元素,插入正确位置
最好时间O(n)O(n²)O(n)
最坏时间O(n²)O(n²)O(n²)
平均时间O(n²)O(n²)O(n²)
空间复杂度O(1)O(1)O(1)
稳定性✅ 稳定❌ 不稳定✅ 稳定
交换次数较多(每次逆序都交换)较少(每轮最多1次)无交换(后移覆盖)
适用场景教学演示、小数据量交换代价高的场景小数据量、基本有序的数据

⚡ 考试速记:

  • 最好 O(n) 的:冒泡(带 flag)、插入
  • 始终 O(n²) 的:选择
  • 稳定的:冒泡、插入
  • 不稳定的:选择

七、C++ 标准库 sort 函数 ​

📚 拓展 · 超纲:四级考纲「排序算法」只要求手写冒泡、插入、选择三种排序,并未把 sort/stable_sort 等 STL 函数列入考点(STL 容器与算法属更高级别)。本节作为"拓展"介绍——实际竞赛与工程中常用,但在四级考试中手写三种排序才是得分点,请务必分清主次。

GESP四级允许直接使用 C++ 标准库的 sort 函数,实际编程中最常用。

7.1 基本用法 ​

cpp
#include <iostream>
#include <algorithm>   // sort 函数所在头文件
using namespace std;

int main() {
    int a[6] = {5, 2, 8, 1, 9, 3};
    int n = 6;

    // sort(起始地址, 结束地址的下一位)
    // 默认升序排列
    sort(a, a + n);

    cout << "升序: ";
    for (int i = 0; i < n; i++) cout << a[i] << " ";
    cout << endl;
    // 输出: 升序: 1 2 3 5 8 9

    // 降序排列:使用 greater<int>()
    sort(a, a + n, greater<int>());

    cout << "降序: ";
    for (int i = 0; i < n; i++) cout << a[i] << " ";
    cout << endl;
    // 输出: 降序: 9 8 5 3 2 1

    return 0;
}

⚠️ 易错点: sort 的第二个参数是结束地址的下一位,不是最后一个元素的地址。对长度为 n 的数组,正确写法是 sort(a, a + n),不是 sort(a, a + n - 1)。

7.2 sort 与 stable_sort ​

函数底层实现稳定性速度
sort快速排序(优化版)不稳定 ❌快
stable_sort归并排序稳定 ✅略慢

🔥 高频考点: sort 是不稳定的!如果题目要求排序后相等元素保持原顺序,必须使用 stable_sort。


八、算法复杂度估算 ​

前面分析三种排序时,我们反复提到 O(n)、O(n²) 这些符号。它们到底是什么意思?怎么算出来的?这一节系统讲解。

8.1 为什么要分析复杂度? ​

同一个问题,往往有多种解法。比如对 10000 个数排序:

  • 冒泡排序大约需要比较 1 亿次
  • 快速排序大约需要比较 14 万次

差距高达 700 多倍 !复杂度分析帮我们:

  1. 衡量算法效率 ——不实际运行代码,也能比较算法快慢
  2. 预测运行时间 ——根据数据规模 n,估算程序会不会超时
  3. 选择更优算法 ——在多种方案中做出合理选择

核心思想: 我们不关心具体跑了几毫秒,而是关心当输入规模 n 增大时,运行时间增长的趋势——这叫"渐近分析"。

8.2 大O表示法 ​

大O表示法(Big-O Notation)用于描述算法运行时间随输入规模 n 增长的变化趋势。

例如,某算法的精确操作次数为 3n² + 5n + 2,我们记为 O(n²) 。

两条简化规则:

规则说明示例
忽略常数系数常数不影响增长趋势O(3n) → O(n),O(2n²) → O(n²)
忽略低阶项n 很大时,高阶项主导增长O(n² + n) → O(n²)
3n² + 5n + 2
  │       │
  │       └─ 低阶项和常数,去掉
  └─ 最高阶项,保留系数变1

结果:O(n²)

⚠️ 易错点: 写 O(2n)、O(3n²) 在考试中算错误!大O表示法必须忽略常数系数。

8.3 常见复杂度直观理解 ​

复杂度名称n=10n=100n=1000增长速度
O(1)常数111不增长
O(log n)对数~3~7~10极慢
O(n)线性101001000慢
O(n log n)线性对数~33~664~9966中等
O(n²)平方100100001000000快
O(n³)立方1000100000010⁹很快
O(2ⁿ)指数1024~10³⁰~10³⁰¹爆炸

记忆口诀:常数 < 对数 < 线性 < 线性对数 < 平方 < 立方 < 指数

🔥 高频考点: 这个排序必须倒背如流!考试常考"以下复杂度从小到大排列正确的是"。

O(1):常数复杂度 ​

执行时间与输入规模 n 完全无关 。

cpp
// 无论 n 多大,以下操作都只执行固定次数
int a = 10, b = 20;
int c = a + b;          // 算术运算 O(1)
cout << arr[500] << endl;  // 数组随机访问也是 O(1)

O(log n):对数复杂度 ​

每次操作将问题规模减半,效率极高。

cpp
// 二分查找 —— O(log n)
int binarySearch(int arr[], int n, int target) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) return mid;
        else if (arr[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return -1;
}
// n=1000 时只需约 10 次比较!

O(n):线性复杂度 ​

操作次数与 n 成正比 。

cpp
// 遍历数组求最大值 —— O(n)
int maxVal = arr[0];
for (int i = 1; i < n; i++) {
    if (arr[i] > maxVal) maxVal = arr[i];
}

O(n²):平方复杂度 ​

操作次数与 n² 成正比 ,三种基础排序都是 O(n²)。

cpp
// 两层嵌套循环 —— O(n²)
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        cout << i + j << " ";
    }
}

8.4 多项式复杂度 vs 指数复杂度 ​

多项式复杂度

形如 O(nᵏ)(k 为常数),如 O(n)、O(n²)、O(n³)。n 增大时,增长速度相对可控。

指数复杂度

形如 O(2ⁿ)、O(n!),增长速度极其惊人:

n2ⁿ大约值
101024~1千
201,048,576~1百万
301,073,741,824~10亿
40~1万亿计算机需要数秒到数分钟
50~1千万亿计算机需要数千年

关键结论: 指数复杂度的算法,n 每增加 1,运行时间就翻倍。n=30 时 2³⁰ ≈ 10 亿次操作已接近极限;n=40 以上基本无法运行。

cpp
#include <iostream>
using namespace std;

// 朴素斐波那契递归 —— O(2ⁿ)
long long fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    cout << fib(10) << endl;  // 55,瞬间出结果
    cout << fib(30) << endl;  // 832040,约0.1秒
    // fib(40) ≈ 1亿次调用,需要好几秒!
    // fib(50) 就需要数千年了!
    return 0;
}

📌 GESP四级要求: 不需要你写出指数算法的优化方案,但必须能识别出哪些代码是指数复杂度,并知道它在数据规模稍大时会超时。

8.5 空间复杂度 ​

空间复杂度衡量算法运行过程中额外占用的内存空间中随 n 增长的趋势。

空间复杂度含义典型示例
O(1)只用几个辅助变量冒泡排序、选择排序、插入排序
O(n)使用了与 n 等大的额外空间归并排序(需要辅助数组)
O(n²)使用了二维数组动态规划表格
cpp
// 空间复杂度 O(1):只用了几个变量
int sum(int arr[], int n) {
    int s = 0;
    for (int i = 0; i < n; i++) s += arr[i];
    return s;
}

⚠️ 易错点: 时间和空间复杂度是两个不同维度,不要混淆。一个算法可以时间 O(n²)、空间 O(1)(如冒泡排序)。递归函数还要考虑递归调用栈的空间。


九、复杂度分析实战 ​

9.1 三种排序的复杂度推导 ​

冒泡排序复杂度推导 ​

外层循环 i 从 0 到 n-2,共 n-1 轮
内层循环第 i 轮比较 n-1-i 次

总比较次数 = (n-1) + (n-2) + ... + 1
          = n(n-1)/2
          ≈ n²/2
          → O(n²)(最坏/平均)

最好情况:

数组已有序,第1轮无交换(flag优化),直接退出,只需 n-1 次比较 → O(n) 。

选择排序复杂度推导 ​

外层循环 i 从 0 到 n-2,共 n-1 轮
内层循环第 i 轮比较 n-1-i 次(无论数据如何)

总比较次数 = (n-1) + (n-2) + ... + 1
          = n(n-1)/2
          → O(n²)(最好/最坏/平均,始终如此)

选择排序无法提前终止——即使数组已有序,仍需逐轮扫描找最小值,因此三种情况都是 O(n²)。

插入排序复杂度推导 ​

外层循环 i 从 1 到 n-1,共 n-1 轮
第 i 轮最多比较 i 次、移动 i 次

最坏情况(完全逆序):
总次数 = 1 + 2 + 3 + ... + (n-1)
      = n(n-1)/2
      → O(n²)

最好情况(已有序):
每轮只比较1次,不移动
总次数 = n-1 → O(n)

9.2 代码片段复杂度判断 ​

例1:两层嵌套 ​

cpp
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        cout << i * j << " ";
    }
}

答:O(n²)

。两层嵌套,每层 n 次。

例2:内层循环次数与外层相关 ​

cpp
for (int i = 0; i < n; i++) {
    for (int j = 0; j < i; j++) {
        cout << i + j << " ";
    }
}

答:O(n²)

。总次数 = 0+1+2+...+(n-1) = n(n-1)/2 ≈ n²/2。

例3:对数级循环 ​

cpp
for (int i = 1; i < n; i *= 2) {
    cout << i << " ";
}

答:O(log n)

。i 每次翻倍(1, 2, 4, 8, ...),执行 log₂n 次。

例4:线性 × 对数 ​

cpp
for (int i = 0; i < n; i++) {
    for (int j = 1; j < n; j *= 2) {
        cout << i + j << " ";
    }
}

答:O(n log n)

。外层 n 次,内层 log₂n 次,相乘得 n log n。

技巧: 串联循环是相加取高阶(O(n)+O(n)=O(n)),嵌套循环是相乘(O(n)×O(n)=O(n²))。

9.3 根据数据规模反推复杂度 ​

每秒大约能执行 10⁸ (1亿)次简单操作:

数据规模 n可接受的复杂度说明
n ≤ 20O(2ⁿ)刚好 100 万左右
n ≤ 500O(n³)1.25×10⁸
n ≤ 5000O(n²)2.5×10⁷
n ≤ 10⁶O(n log n)~2×10⁷
n ≤ 10⁷O(n)10⁷

🔥 高频考点: 考试常给 n 的范围问"哪个算法能通过"。记住:n ≤ 1000 → O(n²) 没问题;n ≤ 100000 → 必须 O(n log n) 或更快。


十、常见易错点汇总 ​

易错点错误理解正确理解
稳定性 = 数据不丢失"排序后数据不丢失就是稳定"稳定性专指相等元素的相对顺序不变
所有排序都稳定"排序算法都是稳定的"选择排序就是不稳定的
冒泡内层边界j < n - 1应为 j < n - 1 - i
选择排序最小值索引minIdx = 0应为 minIdx = i(每轮重置)
插入排序后移方向从前向后移必须从后向前移,否则覆盖未读数据
sort 参数sort(a, a+n-1)应为 sort(a, a+n)(尾后地址)
sort 稳定性"sort是稳定的"sort 不稳定,需稳定用 stable_sort
大O保留系数O(2n)、O(3n²)必须简化为 O(n)、O(n²)
递归复杂度看到递归就以为 O(n)朴素斐波那契递归是 O(2ⁿ)
选择排序最好情况"已有序时选择排序是O(n)"选择排序始终 O(n²),无法提前终止

十一、📝 实战练习 ​

练习题 1(单选题) ​

题目: 对序列 (5, 3, 5, 2) 进行升序排序,排序结果为 (2, 3, 5, 5)。已知排序前第一个5记为5a,第三个5记为5b,排序后5a在5b前面。关于该排序算法的稳定性,以下说法正确的是( ) A. 该排序算法一定是稳定的
B. 该排序算法一定是不稳定的
C. 仅凭这一次排序结果,无法确定该算法是否稳定
D. 该排序算法有时稳定有时不稳定

🔑 点击查看答案与解析

答案: C

解析: 稳定性是排序算法的固有属性,需要从算法原理上证明,不能仅凭一次排序结果判断。即使这次5a在5b前面,换一组数据可能顺序就颠倒了。只有从算法逻辑上保证"相等元素永远不交换",才能称为稳定排序。


练习题 2(单选题) ​

题目: 对数组 [6, 3, 8, 3, 1] 进行选择排序(升序),第2轮结束后,(i=1执行完后)数组是什么?( ) A. [1, 3, 8, 3, 6]
B. [1, 3, 3, 6, 8]
C. [1, 3, 8, 6, 3]
D. [1, 3, 3, 8, 6]

🔑 点击查看答案与解析

答案: A

解析:

初始: [6, 3, 8, 3, 1]
第1轮(i=0): 在[0,4]找最小值1在位置4,交换a[0]和a[4]
  → [1, 3, 8, 3, 6]
第2轮(i=1): 在[1,4]找最小值3(a[1]=3,无需交换)
  → [1, 3, 8, 3, 6]

选 A。同时注意:两个值为3的元素最终相对顺序可能改变,说明选择排序不稳定。


练习题 3(单选题) ​

题目: 以下代码的时间复杂度是( )

cpp
for (int i = 1; i < n; i *= 2) {
    for (int j = 0; j < n; j++) {
        cout << i + j << " ";
    }
}

A. O(n)
B. O(n²)
C. O(n log n)
D. O(log n)

🔑 点击查看答案与解析

答案: C

解析: 外层循环 i 每次翻倍(1, 2, 4, 8, ...),执行 log₂n 次;内层循环每次执行 n 次。总操作次数 = n × log₂n,因此复杂度为 O(n log n) 。


练习题 4(单选题) ​

题目: 某题数据范围为 n ≤ 100000,以下哪个复杂度的算法最有可能在 1 秒内通过?( ) A. O(n²)
B. O(n³)
C. O(n log n)
D. O(2ⁿ)

🔑 点击查看答案与解析

答案: C

解析:

  • O(n²) = (10⁵)² = 10¹⁰ = 100 亿次,超时 ❌
  • O(n³) = 10¹⁵ 次,严重超时 ❌
  • O(n log n) = 10⁵ × 17 ≈ 170 万次,轻松通过 ✅
  • O(2ⁿ) 完全不可行 ❌

选 C。


练习题 5(单选题) ​

题目: 以下关于排序的说法,正确的有( )

① 冒泡排序和插入排序都是稳定排序 ② 选择排序的最好、最坏、平均时间复杂度都是 O(n²) ③ sort 函数默认升序,且是稳定排序 ④ 插入排序在数组已有序时,时间复杂度为 O(n) ⑤ 对规模 n≤1000 的数据,O(n²) 的排序算法通常可以在1秒内完成 A. ①②④
B. ①②④⑤
C. ①②③④
D. ①②③④⑤

🔑 点击查看答案与解析

答案: B

解析:

  • ① 正确 ✅:冒泡(>比较不交换相等元素)和插入(>比较不后移相等元素)都是稳定的。
  • ② 正确 ✅:选择排序每轮必须遍历找最小值,三种情况均为 O(n²)。
  • ③ 错误 ❌:sort 默认升序没错,但底层是快速排序,不稳定,需要稳定应用 stable_sort。
  • ④ 正确 ✅:已有序时每轮只比较1次不移动,共 n-1 次比较 → O(n)。
  • ⑤ 正确 ✅:n=1000 时 n² = 100万 < 1亿,通常可以在1秒内完成。

选 B(①②④⑤)。


练习题 6(单选题) ​

题目: 对数组 [8, 3, 6, 2, 5] 进行升序插入排序,第 3 轮(i=3 处理完后)数组的状态是?( ) A. [2, 3, 6, 8, 5]
B. [2, 3, 5, 6, 8]
C. [3, 6, 8, 2, 5]
D. [2, 3, 8, 6, 5]

🔑 点击查看答案与解析

答案: A

解析:

插入排序第 i 轮处理第 i 个元素(i 从 1 开始),将其插入到前面已排序序列的正确位置。

初始:[8, 3, 6, 2, 5]

  • 第 1 轮(i=1,处理 3):3 插入到 8 前面 → [3, 8, 6, 2, 5]
  • 第 2 轮(i=2,处理 6):6 插入到 3 和 8 之间 → [3, 6, 8, 2, 5]
  • 第 3 轮(i=3,处理 2):2 插入到最前面 → [2, 3, 6, 8, 5]

选 A。

💡 插入排序的特点:前 i 个元素始终是有序的。每轮把一个新元素"插入"到正确的位置,就像打扑克牌时整理手牌。


练习题 7(判断题) ​

题目: 如果一个排序算法是稳定的,那么排序后数据不会丢失。( )

🔑 点击查看答案与解析

答案: 错误 ❌

解析: 稳定性的定义是:相等元素的相对顺序在排序后保持不变,和"数据是否丢失"没有关系。任何正确的排序算法都不会丢失数据(元素个数不变),但稳定和不稳定的区别在于相等元素的先后顺序是否保持。

🍎 生活比喻:就像排队——按身高排队,如果两个人一样高,稳定排序会让原来站前面的人还站前面;不稳定排序可能让他们交换位置。但人一个都不会少。


练习题 8(编程题) ​

题目: 输入 n 个学生的姓名和成绩,按成绩从高到低排序输出。成绩相同的按姓名字典序从小到大排序。

输入:第一行 n,接下来 n 行每行姓名和成绩
输出:排序后的 n 行,每行姓名和成绩

🔑 点击查看答案与解析

参考答案:

cpp
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

struct Student {
    string name;
    int score;
};

// 比较函数:成绩降序,成绩相同则姓名升序
bool cmp(const Student& a, const Student& b) {
    if (a.score != b.score)
        return a.score > b.score;   // 成绩高的在前
    return a.name < b.name;        // 姓名字典序小的在前
}

int main() {
    int n;
    cin >> n;
    Student stu[105];
    for (int i = 0; i < n; i++) {
        cin >> stu[i].name >> stu[i].score;
    }

    sort(stu, stu + n, cmp);

    for (int i = 0; i < n; i++) {
        cout << stu[i].name << " " << stu[i].score << endl;
    }
    return 0;
}

解析:

这是排序的经典题型——结构体多关键字排序。核心是自定义比较函数 cmp:

  1. 先比较成绩,成绩高的排前面(降序)
  2. 成绩相同时,比较姓名,字典序小的排前面(升序)

sort 函数的第三个参数是比较函数,返回 true 表示第一个参数应该排在第二个参数前面。

💡 记忆技巧:cmp(a, b) 返回 true → a 排在 b 前面。想要降序就 a > b,想要升序就 a < b。


十二、📊 考点总结表 ​

考点重要程度出题形式易错点
排序定义与内/外排序⭐⭐选择/判断混淆内部排序和外部排序的适用场景
稳定性的概念⭐⭐⭐⭐⭐判断/选择(高频)把稳定性和数据是否丢失混淆
冒泡排序原理与代码⭐⭐⭐⭐⭐代码填空/程序阅读内层循环边界 j < n-1-i
选择排序原理与代码⭐⭐⭐⭐⭐代码填空/程序阅读minIdx 每轮要重置为 i
插入排序原理与代码⭐⭐⭐⭐⭐代码填空/程序阅读从后向前插入,元素后移方向
三种排序稳定性⭐⭐⭐⭐⭐选择/判断题选择排序不稳定,冒泡和插入稳定
三种排序复杂度对比⭐⭐⭐⭐⭐选择/判断题选择排序最好也是O(n²),冒泡插入最好O(n)
大O表示法与简化⭐⭐⭐⭐选择/判断要去掉系数和低阶项
循环代码复杂度分析⭐⭐⭐⭐⭐选择/程序阅读嵌套乘,串联取最高阶
数据规模反推复杂度⭐⭐⭐⭐选择n≤1000→O(n²),n≤10⁵→O(nlogn)
sort 与 stable_sort⭐⭐⭐判断/选择sort不稳定,stable_sort稳定

⭐ 必背速记卡 ​

┌─────────────────────────────────────────────┐
│          排序算法 · 必背速记卡               │
├─────────────────────────────────────────────┤
│  1. 稳定性:相等元素排序后相对顺序不变       │
│  2. 冒泡排序:相邻比较交换,稳定,O(n²)     │
│  3. 冒泡优化:已有序时O(n),用标志位        │
│  4. 选择排序:每轮选最小交换,不稳定,O(n²) │
│  5. 插入排序:从后向前插入,稳定,最好O(n)  │
│  6. 选择排序始终O(n²),无法提前终止         │
│  7. 三种排序空间复杂度都是 O(1)(原地排序)  │
│  8. 稳定:冒泡+插入;不稳定:选择+sort      │
│  9. 大O表示法:去掉系数和低阶项             │
│ 10. 嵌套循环相乘,串联循环取最高阶           │
│ 11. O(1) < O(logn) < O(n) < O(nlogn) < O(n²)│
│ 12. 每秒约1亿次操作,据此估算能通过的n       │
│ 13. n≤1000 → O(n²)没问题;n≤10⁵ → O(nlogn) │
│ 14. sort函数:头文件algorithm,参数为首尾地址│
│ 15. sort默认升序,自定义cmp返回true=排前面  │
└─────────────────────────────────────────────┘

百炼成钢,融会贯通