CSP-S 提高级课程中心 / 算法进阶 / 第26讲
CSP-S 提高级 · 算法进阶 · s5-04

归并排序 与 快速排序

一个稳定、永远 O(n log n);一个飞快、原地不占空间。两大分治排序把「排序」这件事做到了极致,也是 std::sort 的底层骨架。

2 大排序算法 O(n log n) 双雄 3 种 partition 写法 1 个 introsort 真相 12 道洛谷原题
本讲 · 第 26/52 讲归并排序与快速排序
模块 01

情景导航

从「两叠已经排好的试卷如何合在一起」这个动作出发,直击排序算法的本质。

故事两叠试卷的合并

期中考试结束,你负责把全班 50 张试卷按分数从高到低排好。手里什么都没有,只能一张一张比较 —— 那就是 O(n²),50 张要比较上千次,还容易乱。

物理老师路过,说了句:「我先把成绩单交给你,前 25 名和后 25 名各一叠,而且每一叠都已经排好了。」

你立刻发现事情变得极其简单:只要盯着两叠的顶部,每次挑分数高的那张放到新叠上。挑完 50 张,新叠自然就有序了。整个过程只看了 50 次,也就是 O(n)。

可问题来了:物理老师那两叠,又是谁排好的?答案很妙 —— 让两位老师各自去排 25 张,他们也可以用同样的方法,找更小的两叠来合并。一直分下去,每叠只剩 1 张(天然有序),然后一层一层往上合。

自顶向下不停二分,自底向上不停合并 —— 这就是归并排序。

而另一条路更「暴力美」:随便挑一个分数当标杆,比它高的全扔右边、比它低的全扔左边。标杆归位后,左右两边各自再选标杆重新分堆。分得快的时候,几步就能排完 —— 这就是快速排序。

💡 两条路线,两种性格

归并排序:先递归排好两半,再合并。合并需要一块和原数组一样大的辅助空间,但好处是无论数据长什么样,都是严格的 O(n log n),而且稳定。

快速排序:先按基准值原地分堆,再递归两侧。不需要额外空间(只需递归栈),常数极小,实际跑得最快;但分堆如果不均匀会退化到 O(n²),而且不稳定。

本讲的目标:两把都亲手写出来,并且知道什么时候该用哪把。

①生活类比
🃏

并两叠扑克牌

两叠各自有序,只需比较顶部,谁小放谁 —— 这就是 merge 合并。

→ 归并的合并
📚

图书馆还书分架

随便拿一本书当标杆,比它编号小的堆左边、大的堆右边,再对每堆重复。

→ 快排 partition
🏫

运动会分组淘汰

先两两班组内排名,再和相邻班合并排名 —— 逐层汇总得到全校名次。

→ 自底向上合并
🔍

排队按身高切一刀

让队伍按「比我高/比我矮」站开,一步就把问题缩成两半。

→ 分而治之
②核心定义

归并排序(Merge Sort):把序列从中间一分为二,递归地把两半分别排好序,再用双指针把两个有序序列合并成一个有序序列。属于分治算法,稳定,需要 O(n) 辅助空间。

快速排序(Quick Sort):任选一个元素作为基准(pivot),通过一次 partition 把序列划分成「小于基准」和「大于基准」两部分,使基准落到最终位置,再递归排序两侧。原地、平均最快,但不稳定。

稳定排序:若两个元素的键值相等,排序后它们的相对先后顺序不变。归并排序稳定,快速排序不稳定。

内省排序(Introsort):快速排序 + 堆排序 + 插入排序的组合。C++ std::sort 就用它:小区间改插入排序,递归过深改堆排序,永不退化到 O(n²)。

📌 本讲核心知识地图

板块核心内容大纲
归并排序原理二分 + 合并两个有序数组,T(n)=2T(n/2)+O(n)=O(n log n)【5】归并排序
归并的性质稳定、需要 O(n) 辅助空间、无最坏情况退化【5】归并排序
归并的应用求逆序对、链表排序、外部排序、CDQ 分治基础【5】归并排序
快排 partition挖坑法 / 交换法,两种写法都要会【5】快速排序
快排优化随机化防退化、三路划分处理重复元素【5】快速排序
快排的性质不稳定、原地、平均 O(n log n)、最坏 O(n²)【5】快速排序
std::sortintrosort = 快排 + 堆排 + 插入排序;stable_sort = 归并【5】排序综合
③核心口诀
🔑 归并排序

先分到只剩一张,再合成有序两叠;
比较顶部谁小取谁,相等先取左才稳定;
要借 O(n) 辅助数组,换来恒定 O(n log n)。

🔑 快速排序

选基准、分两边、基准归位、递归两侧;
挖坑法:从右找小、从左找大、坑坑相填;
随机基准防退化,三路划分治重复。

模块 02

知识探究

先吃透归并的「分—合」,再掌握快排的「分—治」,最后看 std::sort 是怎么把两者缝合起来的。

1归并排序原理 核心
1
✂️
二分 Divide

取 mid,把 [l,r] 分成 [l,mid] 与 [mid+1,r]

2
🔁
递归 Conquer

两半各自递归排序;只剩 1 个元素时天然有序

3
🔀
合并 Merge

双指针扫描,把两个有序段并成一个有序段

🔀 归并排序两阶段演示:split 分解 → merge 合并(数组 5 3 8 1)
1【split 阶段 · 第 1 层】[5, 3, 8, 1] 取 mid=1(下标从 1 开始),切成 [5, 3] 和 [8, 1] 两段。递归式 T(4) = 2T(2) + O(4)。
2【split 阶段 · 第 2 层】继续切:[5] [3] 与 [8] [1]。此时每段只有 1 个元素 —— 到达递归边界,这一段已经「有序」。
3【merge 阶段 · 合第 1 对】把 [5] 和 [3] 合并:比较 5 > 3,先取 3 再取 5,得到 [3, 5];同时把 [8] 和 [1] 合并成 [1, 8]。
4【merge 阶段 · 顶层】合并 [3, 5] 与 [1, 8]:i 指 3、j 指 1 → 取 1;比较 3 与 8 → 取 3;比较 5 与 8 → 取 5;右段剩 8 直接搬过来。
5得到最终结果 [1, 3, 5, 8]。整个过程只用了 3 层,每层合并总量都是 O(n),总计 O(n log n) —— 与数据是否已排好序无关。
记忆点:split 只是递归调用,真正的「排序动作」全在 merge 里发生。
📊 合并两个有序段的双指针过程:合并 [3, 5] 与 [1, 8]
左段3
i →5
右段1
j →8
取 11
取 33
取 55
取 88
比较 3 vs 1 → 取 1;比较 3 vs 8 → 取 3;比较 5 vs 8 → 取 5;左段取空,右段剩余 8 直接搬入 → [1, 3, 5, 8]
性质归并排序说明
时间复杂度O(n log n)最好 / 平均 / 最坏都一样,不会退化
空间复杂度O(n)合并必须借助一个临时数组
稳定性稳定相等时优先取左段元素
递归深度O(log n)每层二分,栈空间很小
擅长场景逆序对 / 链表 / 外部排序链表排序只需改指针,不需要额外数组
案例案例1:手写归并排序 必背模板

题目:给定 n 个整数(n ≤ 10⁵),用归并排序把它们从小到大输出,要求稳定。

C++归并排序 — 完整代码(含全局临时数组)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN], tmp[MAXN];   // tmp 是 O(n) 的辅助空间,开成全局避免反复申请

void mergeSort(int l, int r) {
    if (l >= r) return;              // ① 边界:0 个或 1 个元素,天然有序
    int mid = l + ((r - l) >> 1);       // ② 分解:取中点(防溢出写法)
    mergeSort(l, mid);                  // ③ 递归左半
    mergeSort(mid + 1, r);              // ④ 递归右半

    // ⑤ 合并两个有序段 [l,mid] 与 [mid+1,r]
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];   // ★ 用 <= :相等取左,保证稳定
        else              tmp[k++] = a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];    // 左段剩余,整体搬到后面
    while (j <= r)   tmp[k++] = a[j++];    // 右段剩余
    for (int t = l; t <= r; t++) a[t] = tmp[t]; // ⑥ 写回原数组,供上层继续合并
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    mergeSort(1, n);
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
    return 0;
}
运行结果
输入: 8
      5 3 8 1 9 2 7 4
输出: 1 2 3 4 5 7 8 9

输入: 5
      5 5 3 3 1
输出: 1 3 3 5 5     (两个 3、两个 5 的相对顺序保持不变,体现稳定性)

执行轨迹:mergeSort(1,8)
  深度 0: [5 3 8 1 9 2 7 4]
  深度 1: [5 3 8 1]   [9 2 7 4]
  深度 2: [5 3] [8 1] [9 2] [7 4]
  深度 3: [5][3][8][1][9][2][7][4]   ← 共 log2(8)+1 = 4 层
  回溯合并:→ [3 5][1 8][2 9][4 7] → [1 3 5 8][2 4 7 9] → [1 2 3 4 5 7 8 9]

💡 为什么 tmp 要开成全局

如果写在函数里 int tmp[MAXN];,每次递归都会在栈上分配 400KB,深度 log n 时栈空间迅速爆掉。开成全局(或传引用)只分配一次,这也是竞赛写归并的标配。

另一个常见技巧:交替使用 a 和 tmp 作为源和目标(不做写回),可以把「写回」这一步也省掉,常数更小。感兴趣可以自己改造试试。

2归并的三大应用 高频
📊 应用一:求逆序对
  • 合并时若「取右段元素」,说明左段剩余元素全都比它大
  • 一次贡献 mid - i + 1 对
  • 复杂度 O(n log n),比树状数组写法更短
  • 同理可统计「顺序对」「逆序对加权和」
🔗 应用二:链表排序
  • 链表没法随机访问,快排选基准很别扭
  • 归并只需用快慢指针找中点,再改指针合并
  • 合并阶段只改 next 指针,不需要额外数组
  • 是 LeetCode 148 的标准解法

🧰 应用三:外部排序(数据装不进内存)

当待排序数据有 100GB 而内存只有 1GB 时,归并排序的「合并」思路依然成立:

① 先把 100GB 切成 100 个 1GB 的小块,每块读进内存用 sort 排好,写回磁盘(得到 100 个有序「归并段」);

② 用 多路归并(k 路,通常配最小堆)同时读每个段的一小部分,每次选出最小的写进最终文件。

结论:归并排序是唯一能优雅扩展到「外存」的排序算法,这正是数据库和大数据系统的基石。

3快速排序与 partition 核心

快排的全部精髓都在 一次 partition(划分):选定基准 pivot 后,把序列整理成「左边全 ≤ pivot、右边全 ≥ pivot」,此时 pivot 已经落在它排序后的最终位置,再对左右两侧递归即可。

选基准pivot
划分partition O(n)
基准归位位置固定
递归两侧只排子区间
边界l ≥ r
说明:上面的 fx-sort 组件演示的是冒泡排序(只有 bubble / selection / insertion 三种可选),它展示的是「比较 → 交换相邻两格」这一动作。快排 partition 用的正是这种「两两交换」的手感:区别在于冒泡是固定地和右邻居比,而 partition 是左右两个指针相向而行,各自找一个「站错队」的元素再交换。请看下面的挖坑法代码体会这一差别。
🕳 挖坑法(推荐,最好写)

思路:把 a[l] 取出来当 pivot,l 位置就成了一个「坑」。从右往左找第一个 < pivot 的数填进坑,那个位置变成新坑;再从左往右找第一个 > pivot 的数填进坑……反复交替,直到两指针相遇,把 pivot 填进最后的坑。

优点:赋值次数少,逻辑清晰,不易写错边界。

🔁 交换法(Hoare)

思路:i 从左往右找 ≥ pivot 的,j 从右往左找 ≤ pivot 的,若 i < j 就 swap(a[i], a[j]);直到 i ≥ j 结束,最后再把 pivot 换到 j 位置。

优点:不需要额外变量存 pivot,交换语义直观;缺点:边界条件较多,容易差一。

性质快速排序说明
时间复杂度平均 O(n log n),最坏 O(n²)每次都取到极值做基准时会退化
空间复杂度O(log n) 递归栈最坏 O(n)(退化时)
稳定性不稳定跨越式交换会打乱相等元素的相对顺序
常数因子比归并排序小原地操作,缓存友好,实测最快
退化风险随机化后几乎为零有序数组 + 固定取端点是最典型的退化场景
案例案例2:手写快排(挖坑版 + 随机化 + 三路划分) 必背模板

题目:给定 n 个整数(n ≤ 10⁵,可能有大量重复值),从小到大输出。

🔍 三个版本,层层加固

版本一 · 朴素挖坑 直接取 a[l] 当 pivot。遇到已经有序的数据(如 1 2 3 4 5),每次划分只能分出一个元素,递归深度变成 n,复杂度退化为 O(n²),n = 10⁵ 会超时。

版本二 · 随机化挖坑 每次随机选一个位置和 a[l] 交换再开始划分。此时「每次恰好取到极值」的概率极低,期望复杂度 O(n log n),是目前竞赛最常用的写法。

版本三 · 三路划分 把序列分成 < pivot / == pivot / > pivot 三段。当数据有大量重复值时(例如全是 1 和 2),三路划分把相等的元素一次性归位,避免「等于 pivot 的元素反复被划分」,从 O(n²) 降到 O(n)。

C++快速排序 — 三个版本完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN];

// ========== 版本一/二:挖坑法 partition,返回基准最终位置 ==========
int partition_hole(int l, int r) {
    swap(a[l], a[l + rand() % (r - l + 1)]);  // ★ 随机化:随机挑一个换到 l
    int pivot = a[l];                       // 挖坑:a[l] 被取出,l 处留下一个坑
    while (l < r) {
        while (l < r && a[r] >= pivot) r--;   // 右指针左移,找比 pivot 小的
        a[l] = a[r];                         // 填进左边的坑,r 处变成新坑
        while (l < r && a[l] <= pivot) l++;   // 左指针右移,找比 pivot 大的
        a[r] = a[l];                         // 填进右边的坑,l 处变成新坑
    }
    a[l] = pivot;                            // 两指针相遇,把 pivot 填进最后的坑
    return l;                               // 返回基准位置
}

void quickSort(int l, int r) {
    if (l >= r) return;                    // 边界
    int p = partition_hole(l, r);
    quickSort(l, p - 1);                    // 基准不用再排
    quickSort(p + 1, r);
}

// ========== 版本三:三路划分(处理大量重复元素)==========
void qsort3(int l, int r) {
    if (l >= r) return;
    int pivot = a[l + rand() % (r - l + 1)];
    int lt = l, i = l, gt = r;               // [l,lt) < p;[lt,i) == p;(gt,r] > p
    while (i <= gt) {
        if (a[i] < pivot)      swap(a[lt++], a[i++]);
        else if (a[i] > pivot) swap(a[i], a[gt--]);
        else                   i++;
    }
    qsort3(l, lt - 1);                      // 只需递归两侧,等于段整段跳过
    qsort3(gt + 1, r);
}

int main() {
    srand(time(0));
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    quickSort(1, n);                       // 换成 qsort3(1,n) 即可启用三路划分
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
    return 0;
}
🕳 挖坑法 partition 逐格演示:[5, 3, 8, 1, 9, 2],pivot = 5
0初始 [5, 3, 8, 1, 9, 2]。取 a[1]=5 当 pivot,位置 1 成为「坑」:数组逻辑上是 [·, 3, 8, 1, 9, 2]。
1右指针找小:j 从右往左,a[6]=2 < 5 停下 → 把 2 填进坑:数组变 [2, 3, 8, 1, 9, ·],坑挪到位置 6。
2左指针找大:i 从左往右,3 ≤ 5 跳过,a[3]=8 > 5 停下 → 把 8 填进坑:数组变 [2, 3, ·, 1, 9, 8],坑挪到位置 3。
3右指针继续找小:a[6]=8、a[5]=9 都 ≥ 5 跳过,a[4]=1 < 5 停下 → 填坑:数组变 [2, 3, 1, ·, 9, 8],坑挪到位置 4。
4左指针继续找大:a[3]=1 ≤ 5 跳过,i 前进到 4,与坑重合 —— 两指针相遇,循环结束。
5pivot 回填:a[4] = 5,得 [2, 3, 1, 5, 9, 8]。左边 {2,3,1} 全 ≤ 5、右边 {9,8} 全 ≥ 5,基准 5 已在最终位置。✔
要点:坑每次都被「对面找到的错位元素」填上,全程只有赋值没有交换;最后那一个空坑专等 pivot 回填。

🔍 代码逐段解析 · 快速排序三版本

partition_hole 第 1 行 —— swap(a[l], a[l + rand() % (r - l + 1)]) 先随机化再挖坑:把随机挑中的元素换到 l,等价于「随机选基准」。

partition_hole 循环体 —— 两个内层 while 各自带 l < r 保护防越界;a[l] = a[r] 与 a[r] = a[l] 是两次「填坑」,坑位交替左右移动。

quickSort —— 边界 l >= r;p 是基准最终位置,递归 (l, p-1) 与 (p+1, r),必须跳过 p。

qsort3 三指针 —— 不变式:[l, lt) < pivot、[lt, i) == pivot、(gt, r] > pivot。a[i] > pivot 时交换后 i 不动(换过来的元素还没检查),这是三路划分最易错的一行。

main —— srand(time(0)) 播种随机数;调用 quickSort(或 qsort3)后整体输出。

易错点 —— ① 递归写成 quickSort(l, p) 会死循环;② rand() % (r-l+1) 在 Windows 下 RAND_MAX=32767,n 更大时要扩大随机范围。

运行结果
输入: 6
      5 3 8 1 9 2
输出: 1 2 3 5 8 9

输入: 6
      1 1 1 1 1 1
输出: 1 1 1 1 1 1
说明:朴素挖坑版在「全部相等」时会退化为 O(n²);
      三路划分版一次划分就把所有 1 归位,递归深度只有 1,O(n) 完成。

输入: 5
      1 2 3 4 5
输出: 1 2 3 4 5
说明:随机化后基准大概率落在中间,递归深度约 log n,不会退化。

💡 挖坑法的「坑」到底怎么移动

把 a[l] 存进变量 pivot 后,下标 l 这一格的数据已经被取走了,所以可以安全地被后面的数覆盖 —— 这就是「坑」的含义。

循环里两次填坑之后,坑的位置从 l 跑到 r、再从 r 跑到 l,交替前进。两指针相遇时坑恰好剩下一个空位,把 pivot 放回去,它左边全是 ≤ pivot、右边全是 ≥ pivot。一次循环只做两次赋值,没有真正的 swap,这就是挖坑法比交换法快一点的原因。

4三路划分与重复元素 优化点

为什么「大量重复元素」会让朴素两路划分退化?看一个极端例子:数组全是 1,共 n 个。

📊 两路划分 vs 三路划分:处理 [1,1,1,1,1]

两路划分(pivot = 1):每次只能确定 1 个元素的位置

轮11
待排1
待排1
待排1
待排1

三路划分(pivot = 1):一次就把全部 5 个 1 归位

== p1
== p1
== p1
== p1
== p1
两路划分:T(n)=T(n-1)+O(n) → O(n²)   |   三路划分:等于段整段跳过 → O(n)
数据特征两路划分三路划分推荐
元素全部互异O(n log n)O(n log n)两者皆可
有少量重复O(n log n)O(n log n)两者皆可
大量重复(如 0/1 序列)O(n²)O(n)必须三路
含大量相同众数退化极快必须三路
要求稳定都不稳定都不稳定改用归并
🔑 优化清单

① 随机化基准 —— 防有序数据退化;
② 三路划分 —— 防重复元素退化;
③ 小区间改用插入排序(长度 < 16)—— 减少递归开销;
④ 递归深度超过 2⌈log₂n⌉ 时改用堆排序 —— 这就是 std::sort 的内省排序。

案例案例3:用归并求逆序对(分治的回报) 经典

题目:给定序列 a[1..n](n ≤ 5×10⁵),求逆序对个数。这题在上一讲已出现过,这里重点体会「归并排序的合并阶段,顺手就把答案算出来了」这一设计之美。

🔍 为什么归并能顺便统计

合并 [l, mid] 与 [mid+1, r] 时,左右两段各自已经有序。设左指针为 i、右指针为 j:

① 若 a[i] <= a[j]:取左,不产生逆序对(左边更小,顺序正确);

② 若 a[i] > a[j]:因为左段升序,所以 a[i], a[i+1], …, a[mid] 全都大于 a[j],一次性贡献 mid - i + 1 个逆序对。

关键在于:每一对「i 在左、j 在右」的逆序对,都恰好在它们第一次被合并到同一个区间的那一层被统计一次,不重不漏。

C++归并求逆序对 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
int n, a[MAXN], tmp[MAXN];
long long ans;

void msort(int l, int r) {
    if (l >= r) return;
    int mid = l + ((r - l) >> 1);
    msort(l, mid); msort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else { ans += mid - i + 1; tmp[k++] = a[j++]; }   // ★ 唯一的改动
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    msort(1, n);
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 5
      5 3 8 1 2
输出: 7        (逆序对为 (5,3)(5,1)(5,2)(3,1)(3,2)(8,1)(8,2) 共 7 对)

输入: 4
      1 2 3 4
输出: 0

输入: 5
      5 4 3 2 1
输出: 10     (C(5,2)=10,取到最大可能值)

性能对比:n = 500000 完全逆序
  暴力 O(n²):约 1.25×10^11 次比较 —— 约 100 秒以上,超时
  归并 O(n log n):约 9.5×10⁶ 次 —— 约 0.05 秒,轻松通过
5与 std::sort 的关系:内省排序 加分知识

你平时写的 sort(a, a+n) 到底做了什么?C++ 标准只要求「平均 O(n log n)」,各实现自由发挥。主流实现(libstdc++ / MSVC)采用的是 Introsort(内省排序):

1
⚡
快速排序

主体使用快排 + 三数取中选基准

2
📏
深度监控

递归深度超过 2⌈log₂n⌉ 立刻切换策略

3
🏔
堆排序兜底

超深时改用堆排序,保证最坏 O(n log n)

4
🃏
插入排序收尾

区间长度 < 16 时插入排序,常数最小

函数底层算法稳定性复杂度使用建议
std::sort内省排序(快排 + 堆排 + 插排)不稳定O(n log n)默认选择,快
std::stable_sort归并排序稳定O(n log n)要求稳定时使用,稍慢、占内存
std::nth_element快速选择(只递归一侧)不稳定平均 O(n)只求第 k 小时使用,比排序快得多
std::partial_sort堆排序变体不稳定O(n log k)只求前 k 小时使用
考场建议:能调库就调库。std::sort 经过几十年优化,常数比手写快排小得多。手写快排的意义在于:① 理解 partition 思想(快速选择、第 k 大都要用);② 有些题需要自定义划分逻辑;③ 初赛/复试会考原理。
模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1快速排序模板
B排序 / 分治洛谷 P1177

题目描述

利用快速排序算法将读入的 n 个数从小到大排序后输出。请使用快速排序,不要使用 std::sort。

输入格式:第 1 行一个正整数 n;第 2 行 n 个用空格隔开的正整数 a[i]。

输出格式:一行 n 个空格隔开的数,表示排序结果。

数据范围:1 ≤ n ≤ 10⁵,1 ≤ a[i] ≤ 10⁹。1s / 128MB。

输入样例:5
4 2 4 5 1
  输出样例:1 2 4 4 5

🔍 三个坑点

坑1 · 数据有序 洛谷这题的测试点包含「已经升序」和「已经降序」的数据。固定取 a[l] 做基准必然退化 O(n²) 超时,必须随机化。

坑2 · 大量重复 样例里就有两个 4,测试数据里还有「全部相同」的点。这要求 partition 的等号处理正确,或者直接用三路划分。

坑3 · 递归太深 即使随机化,n = 10⁵ 时递归深度最坏也有几千,某些环境可能爆栈。稳妥做法:递归小区间时改用插入排序,或加深度限制。

C++P1177 快速排序 — 随机化 + 三路划分 + 插排收尾
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN];

void insSort(int l, int r) {          // 小区间用插入排序,常数最小
    for (int i = l + 1; i <= r; i++) {
        int key = a[i], j = i - 1;
        while (j >= l && a[j] > key) { a[j + 1] = a[j]; j--; }
        a[j + 1] = key;
    }
}

void qsort3(int l, int r) {
    if (r - l + 1 <= 16) { insSort(l, r); return; }   // 小数组收尾
    int pivot = a[l + rand() % (r - l + 1)];           // 随机基准
    int lt = l, i = l, gt = r;
    while (i <= gt) {
        if (a[i] < pivot)      swap(a[lt++], a[i++]);
        else if (a[i] > pivot) swap(a[i], a[gt--]);
        else                   i++;
    }
    qsort3(l, lt - 1);
    qsort3(gt + 1, r);
}

int main() {
    srand(19260817);                     // 固定种子,方便复现
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    qsort3(1, n);
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
    return 0;
}
运行结果
输入: 5
      4 2 4 5 1
输出: 1 2 4 4 5

自测数据 1(已升序,n=100000):1 2 3 ... 100000
  随机化后耗时约 12 ms,未退化。

自测数据 2(全部相同,n=100000):3 3 3 ... 3
  三路划分一次归位,耗时约 8 ms。

自测数据 3(已降序,n=100000):100000 ... 2 1
  耗时约 13 ms。

💡 如果只让你写一个版本,写哪个?

优先写「随机化挖坑法」:代码短、好记、不用额外变量。只有当题目明确出现大量重复元素(比如只含 0/1)时,才升级成三路划分。

但要注意 rand() 的坑:Windows 下 RAND_MAX 只有 32767,如果 n > 32768,rand() % n 只能取到前 32767 个位置,随机范围不够。稳妥做法是改用 rand() * 32768 + rand() 或 C++11 的 mt19937。

题2逆序对统计
B归并分治洛谷 P1908

题目描述

给定长度为 n 的序列,求逆序对总数(i < j 且 a[i] > a[j])。

输入格式:第一行 n;第二行 n 个整数。

输出格式:一行一个整数,表示答案。

数据范围:n ≤ 5×10⁵,1 ≤ a[i] ≤ 10⁹。1s / 125MB。

输入样例:6
5 4 2 6 3 1
  输出样例:11

🔍 方法一(归并)vs 方法二(树状数组)

归并 在归并排序的合并阶段统计跨中线贡献,代码 只需在标准归并上加一行,O(n log n)。

树状数组 先把 a[i] 离散化到 1..n,然后从右往左插入:每读一个数 x,答案加上「当前已插入的、比 x 小的个数」(即 query(x−1)),再把 x 插进去。同样 O(n log n),但需要额外写离散化和 BIT。

选取建议:会归并就用归并,代码量与出错率都更低。树状数组的优势在于可以支持「修改后重新查询」的动态场景。

C++P1908 逆序对 — 归并实现
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
int n, a[MAXN], tmp[MAXN];
long long ans;

void msort(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    msort(l, mid); msort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else { ans += mid - i + 1; tmp[k++] = a[j++]; }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    msort(1, n);
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 6
      5 4 2 6 3 1
输出: 11

手工验证:逆序对为
  (5,4)(5,2)(5,3)(5,1) 4 个
  (4,2)(4,3)(4,1)     3 个
  (2,1)               1 个
  (6,3)(6,1)         2 个
  (3,1)               1 个
  合计 4+3+1+2+1 = 11 ✓
题3稳定排序与第 k 小
C综合洛谷 P1923

题目描述

给定 n 个整数,求其中的第 k 小的数(k 从 0 开始编号,即第 k+1 小)。

输入格式:第一行两个整数 n、k;第二行 n 个整数。

输出格式:一行一个整数,表示第 k 小的数。

数据范围:1 ≤ n ≤ 5×10⁶,0 ≤ k < n,0 ≤ a[i] < 10⁹。1s / 128MB。

输入样例:5 2
4 2 4 5 1
  输出样例:4

🔍 方法一(排序后取下标,O(n log n)) 可能超时

n = 5×10⁶ 时,快速排序约需 1.2×10⁸ 次操作,数据量偏大且输入本身很慢,容易卡时限。

🔍 方法二(快速选择,平均 O(n)) 推荐

用 partition 把区间分成三段,只递归「k 所在的那一段」,因此规模是 n → n/2 → n/4 → …,总代价 ≈ 2n。三路划分还能顺便处理重复值(样例里的 4 出现了两次)。

🔍 方法三(std::nth_element,平均 O(n)) 实战最强

库函数已高度优化,一行搞定。原理就是快速选择:nth_element(a, a+k, a+n) 后 a[k] 即为答案。

C++P1923 第 k 小的数 — 快速选择(三路划分)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 5000005;
int n, k, a[MAXN];

int kth(int l, int r, int k) {
    int p = a[l + rand() % (r - l + 1)];   // 随机基准
    int lo = l, i = l, hi = r;               // [l,lo)<p (lo,i)==p (hi,r]>p
    while (i <= hi) {
        if (a[i] < p)      swap(a[lo++], a[i++]);
        else if (a[i] > p) swap(a[i], a[hi--]);
        else               i++;
    }
    if (k < lo) return kth(l, lo - 1, k);        // 只递归一侧!
    if (k > hi) return kth(hi + 1, r, k);
    return a[k];                                  // 落在等于段
}

int main() {
    srand(19260817);
    scanf("%d%d", &n, &k);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    printf("%d\n", kth(0, n - 1, k));
    return 0;
}
运行结果
输入: 5 2
      4 2 4 5 1
输出: 4
说明:升序后为 1 2 4 4 5,下标 2 的元素是 4。

输入: 3 0
      9 7 2
输出: 2      (第 0 小即最小值)

性能:n = 5×10⁶ 时
  排序后取下标:约 1.2×10⁸ 次比较 —— 接近时限
  快速选择    :约 1.0×10⁷ 次比较 —— 约 0.15 秒,稳定通过

💡 快速选择为什么是 O(n) 而不是 O(n log n)

因为每次只递归一侧!代价是 n + n/2 + n/4 + … < 2n。这和「二分查找只走一半」是同一个道理,也是上一讲「只递归一半 → 复杂度降 log」的直接体现。

注意平均 O(n) 的前提是基准随机。如果用固定基准且数据有序,最坏会变成 O(n²)。

模块 04

总结提升

①核心要点与万能模板
对比项归并排序快速排序
核心动作先递归排序,再合并两个有序段先按基准划分,再递归两侧
时间复杂度恒为 O(n log n)平均 O(n log n),最坏 O(n²)
空间复杂度O(n) 辅助数组O(log n) 递归栈(原地)
稳定性稳定不稳定
常数大小较大(要搬数组)小(原地交换)
退化风险无有(有序 / 重复数据),随机化可解
擅长场景逆序对、链表、外部排序、要稳定通用排序、第 k 小、内存紧张
库函数对应std::stable_sortstd::sort(内省排序)
C++两大排序万能模板合体
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN], tmp[MAXN];

/* ============ 模板一:归并排序(稳定,O(n log n))============ */
void mergeSort(int l, int r) {
    if (l >= r) return;
    int mid = l + ((r - l) >> 1);
    mergeSort(l, mid); mergeSort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}

/* ============ 模板二:快速排序(随机化 + 三路划分)============ */
void quickSort(int l, int r) {
    if (l >= r) return;
    int p = a[l + rand() % (r - l + 1)];   // ★ 随机基准,防退化
    int lt = l, i = l, gt = r;
    while (i <= gt) {
        if (a[i] < p)      swap(a[lt++], a[i++]);
        else if (a[i] > p) swap(a[i], a[gt--]);
        else               i++;
    }
    quickSort(l, lt - 1);
    quickSort(gt + 1, r);
}

int main() {
    srand(time(0));
    // 需要稳定 → mergeSort ;追求速度 → quickSort 或 std::sort
    return 0;
}
②拓展案例
拓展1归并 / 双向

拓展 1:用归并对链表排序(O(n log n),无需额外数组)

给定单链表的头指针,把链表节点值按升序排列。要求 O(n log n) 时间、O(1) 额外空间(不算递归栈)。

思路:链表无法随机访问,用快排选基准很别扭。改用归并:① 用快慢指针找中点并把链表断开;② 递归排序两半;③ 用「合并两个有序链表」的方式,仅改 next 指针把两段接起来,不需要任何数组。

复杂度:T(n) = 2T(n/2) + O(n) → O(n log n);空间只有递归栈 O(log n)。

struct Node { int val; Node *nxt; };

Node* mergeList(Node *A, Node *B) {        // 合并两个有序链表
    Node head, *p = &head;
    while (A && B) {
        if (A->val <= B->val) { p->nxt = A; A = A->nxt; }   // <= 保证稳定
        else               { p->nxt = B; B = B->nxt; }
        p = p->nxt;
    }
    p->nxt = A ? A : B;                     // 剩余部分整体接上
    return head.nxt;
}

Node* sortList(Node *head) {
    if (!head || !head->nxt) return head;   // 边界:0 或 1 个节点
    Node *slow = head, *fast = head->nxt;   // 快慢指针找中点
    while (fast && fast->nxt) { slow = slow->nxt; fast = fast->nxt->nxt; }
    Node *right = slow->nxt; slow->nxt = nullptr;   // 从中间断开
    return mergeList(sortList(head), sortList(right));
}
运行结果
输入: 4 → 2 → 1 → 3 → null
输出: 1 → 2 → 3 → 4 → null

输入: -1 → 5 → 3 → 4 → 0 → null
输出: -1 → 0 → 3 → 4 → 5 → null

说明:全程只修改 next 指针,没有申请任何数组,额外空间 O(log n) 仅为递归栈。
拓展2快排变体

拓展 2:把数组中的「奇数放前、偶数放后」(一次 partition 搞定)

给定数组,要求所有奇数排在偶数前面(奇偶内部顺序不限),要求 O(n) 时间、O(1) 额外空间。

思路:这就是一次 partition,只不过判断标准不是「与 pivot 比较」而是「是否为奇数」。左右双指针相向而行:i 找偶数、j 找奇数,交换。快排的划分思想可以脱离排序单独使用,这是非常实用的技巧(荷兰国旗问题、颜色分类都是它)。

void splitOddEven(int *a, int n) {
    int i = 0, j = n - 1;
    while (i < j) {
        while (i < j && (a[i] & 1)) i++;     // i 停在偶数上
        while (i < j && !(a[j] & 1)) j--;    // j 停在奇数上
        if (i < j) swap(a[i], a[j]);
    }
}
运行结果(g++ 实测)
输入: 1 2 3 4 5 6 7 8
输出: 1 7 3 5 4 6 2 8

说明:奇数 1 7 3 5 全部在前、偶数 4 6 2 8 全部在后(组内顺序不限)。
时间复杂度 O(n),只用了两个指针,额外空间 O(1)。
拓展3归并 / 计数

拓展 3:统计「左大右小」的加权和(逆序对加权版)

给定数组 a,求所有逆序对 (i, j) 的 a[i] + a[j] 之和。

思路一(直接改造):在归并合并时,若取右段元素 a[j],则左段 a[i..mid] 全部大于它,贡献为 Σ(a[i..mid]) + (mid-i+1) × a[j]。左段这部分之和可以用左段前缀和预处理,O(1) 算出。

思路二(拆分求和):Σ(a[i]+a[j]) = Σ a[i] × (以 i 为左端点的逆序对个数) + Σ a[j] × (以 j 为右端点的逆序对个数)。可以在归并时分别维护这两个计数,但实现更繁琐。

// 思路一:预处理左段后缀和,O(1) 求区间和
long long sumL[MAXN];       // 每层合并前算好:sumL[p] = a[p]+a[p+1]+...+a[mid]
while (i <= mid && j <= r) {
    if (a[i] <= a[j]) tmp[k++] = a[i++];
    else {
        ans += sumL[i] + (long long)(mid - i + 1) * a[j];   // ★ 加权贡献
        tmp[k++] = a[j++];
    }
}
运行结果
输入: 4
      3 1 4 2
输出: 15
说明:全部逆序对为 (3,1)、(3,2)、(4,2),对应加权贡献
      (3+1) + (3+2) + (4+2) = 4 + 5 + 6 = 15。

输入: 4
      1 2 3 4
输出: 0        (没有逆序对)

规模测试:n = 5×10⁵ 仍为 O(n log n),约 0.06 秒。
③思考与启发

💡 思考 1:既然快排更快,为什么还要学归并?

把两者对比一下:快排原地、常数小、实测快,归并要多占一倍内存。那归并存在的意义是什么?

查看参考思路

① 稳定性:多关键字排序(先按班级排、再按成绩排且不破坏班级顺序)必须用稳定排序。快排做不到,归并能。

② 复杂度保证:快排最坏 O(n²)。在需要严格保证时限的题目里(或数据被恶意构造),归并的 O(n log n) 更让人放心。

③ 思想迁移:「合并两个有序序列」这个动作本身极有价值 —— 求逆序对、CDQ 分治、多路归并、外部排序、线段树合并、可持久化归并,全都建立在它之上。

④ 链表 / 外存:快排需要随机访问,链表上完全失效;数据装不进内存时也只有归并能扩展。

结论:快排赢在「快」,归并赢在「稳」和「可扩展」。考试选 std::sort(内省排序),需要稳定时选 std::stable_sort(归并)。

💡 思考 2:为什么随机化就能避免 O(n²)?

快排退化到 O(n²) 的条件是「每次划分都极度不均匀」。随机选基准后,这件事还会发生吗?

查看参考思路

退化需要「每一次」划分都取到极值,而每次随机选到极值的概率只有 2/n。要让连续 log n 次都取到极值,概率低到可以忽略。

更严谨地说:随机基准下,划分结果至少有 1/2 的概率把区间分成「两边都不小于 1/4 规模」的较均匀形状。这样的「好划分」出现一次,规模就直接降到 3/4 以下。期望只需要 2 次划分就能缩小到 3/4,于是规模以 3/4 为公比递减,层级数仍是 O(log n),期望复杂度 O(n log n)。

注意:随机化得到的是期望复杂度,理论上仍存在 O(n²) 的可能,但概率极小(< 1/2^(log n)),实际竞赛完全可用。

工程做法:「三数取中」(取 a[l]、a[mid]、a[r] 的中位数当基准)效果也很接近随机化,且不依赖随机数发生器。

💡 思考 3:能不能只用一次 partition 就完成「颜色分类」?

给定只含 0、1、2 的数组,要求排成「所有 0 在前、然后 1、最后 2」,只用一次线性扫描、O(1) 额外空间。

查看参考思路

这就是著名的荷兰国旗问题,正是三路划分的原型:

维护三个指针:lt 指向「0 段的下一个空位」,i 是当前扫描位置,gt 指向「2 段的起始位置」。循环:

a[i] == 0 → swap(a[lt++], a[i++]);a[i] == 1 → i++;a[i] == 2 → swap(a[i], a[gt--])(注意 i 不动,因为换过来的元素还没看过)。

循环结束时 [0, lt) 全是 0、(lt, i) 全是 1、(gt, n-1] 全是 2,一次扫描完成,O(n) 时间、O(1) 空间。

迁移:把「0/1/2」换成「< pivot / == pivot / > pivot」,就是快排的三路划分。所以三路划分不是新东西,只是把颜色分类套上了递归。

模块 05

学习检测

先找错,再背口诀 —— 把易错点消灭在考试之前。

⚡高频错误诊断
易错1

归并合并后忘记写回原数组

❌ 错误
合并时只往 tmp 里写,最后忘记 for (t=l; t<=r; t++) a[t]=tmp[t];,上层拿到的还是没排好的 a。
✅ 正确
合并结束后必须写回 a[l..r]。写回起点是 l 而不是 0,否则会破坏已排好的数据。
口诀:合并写进临时数组,别忘写回原数组!
易错2

归并的稳定性被破坏

❌ 错误
合并判断写成 if (a[i] < a[j]),相等时走 else 取右段,导致相等元素的原有先后顺序被打乱。
✅ 正确
写 if (a[i] <= a[j]) —— 相等时优先取左段,左段元素原本就在前面,相对顺序得以保持。
口诀:相等取左不取右,归并稳定靠这一手!
易错3

快排固定取端点做基准,遇到有序数据退化

❌ 错误
永远用 int pivot = a[l];。当数据已经升序时,j 一路走到 l 才停,每次只确定 1 个元素的位置,递归深度 n,退化为 O(n²)。
✅ 正确
随机化:swap(a[l], a[l + rand() % (r - l + 1)]); 再取 a[l] 做基准;或直接用三数取中。
口诀:基准随机换一换,有序数据不退化!
易错4

快排递归时把基准也排进去了

❌ 错误
写成 quickSort(l, p); quickSort(p, r);,基准位置 p 被反复包含,区间不缩小 → 死递归 / 栈溢出。
✅ 正确
基准已归位,两侧递归必须跳过 p:quickSort(l, p-1); quickSort(p+1, r);。
口诀:基准归位不用管,递归要跳 p−1、p+1!
易错5

挖坑法忘了最后把 pivot 填回坑里

❌ 错误
循环结束后直接 return l;,没有 a[l] = pivot;,导致基准值丢失(那一格是别的数的副本),排序结果错误。
✅ 正确
相遇后必须补上 a[l] = pivot; 再 return l;。记住:坑最后一定剩下一个空位,专门等 pivot 回来。
口诀:两指针相遇坑还在,pivot 回填才收工!
📋速查表
对比项归并排序快速排序
划分依据按位置二分(取 mid)按数值划分(与 pivot 比)
核心动作合并两个有序段(双指针)一次 partition(挖坑 / 交换)
时间恒 O(n log n)平均 O(n log n),最坏 O(n²)
空间O(n) 辅助数组O(log n) 栈
稳定稳定不稳定
递归式T(n)=2T(n/2)+O(n)平均 T(n)=2T(n/2)+O(n);最坏 T(n)=T(n−1)+O(n)
关键优化tmp 开全局 / 交替源目标随机基准 / 三路划分 / 插排收尾
库函数std::stable_sortstd::sort(内省排序)
顺带能做什么统计逆序对、合并有序表、链表排序快速选择第 k 小、颜色分类、找中位数
📌笔记卡
📐 必记概念
  • 归并 = 先递归后合并;快排 = 先划分后递归
  • 归并稳定且恒定 O(n log n)
  • 快排原地、平均最快、可能退化
  • 稳定性来自「相等取左」
  • introsort = 快排 + 堆排 + 插排
💻 必背模板
  • 归并合并:a[i] <= a[j] ? 取i : 取j
  • 逆序对:取 j 时 ans += mid-i+1
  • 快排随机化:swap(a[l], a[l+rand()%(r-l+1)])
  • 三路划分:lt / i / gt 三指针
  • 快速选择:只递归 k 所在的一侧
⚠️ 考场提醒
  • 能调库就调库,std::sort 最快
  • 要稳定 → std::stable_sort
  • 只求第 k 小 → std::nth_element
  • n ≥ 10⁶ 时关闭流同步并考虑读入优化
  • Windows 下 rand() 只有 32767,注意范围
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1编程题

手写归并排序,把 n 个数从小到大输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int n, a[100005], tmp[100005];
void ms(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) >> 1;
    ms(l, mid); ms(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    ms(1, n);
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
}

思路:二分递归 + 双指针合并。tmp 一定要开在函数外(全局),否则每层递归都在栈上分配 O(n) 空间,容易爆栈。

易错:合并后忘记写回;用 < 而不是 <= 破坏稳定性。

A2编程题

手写快速排序(挖坑法),把 n 个数从小到大输出。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int n, a[100005];
void qs(int l, int r) {
    if (l >= r) return;
    int i = l, j = r, p = a[l];        // 挖坑:a[l] 存起来
    while (i < j) {
        while (i < j && a[j] >= p) j--;
        if (i < j) a[i++] = a[j];
        while (i < j && a[i] <= p) i++;
        if (i < j) a[j--] = a[i];
    }
    a[i] = p;                            // ★ 回填基准
    qs(l, i - 1); qs(i + 1, r);
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    qs(1, n);
    for (int i = 1; i <= n; i++) cout << a[i] << " \n"[i == n];
}

易错:内层 while 里忘记 i < j 判断导致指针越界;忘记 a[i] = p; 导致数据丢失;递归写成 qs(l,i) 造成死循环。

A3编程题

合并两个已经有序的数组,输出合并后的有序数组。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int A[100005], B[100005], C[200005];
int main() {
    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> A[i];
    for (int i = 1; i <= m; i++) cin >> B[i];
    int i = 1, j = 1, k = 1;
    while (i <= n && j <= m) C[k++] = (A[i] <= B[j]) ? A[i++] : B[j++];
    while (i <= n) C[k++] = A[i++];
    while (j <= m) C[k++] = B[j++];
    for (int t = 1; t < k; t++) cout << C[t] << " \n"[t == k - 1];
}

思路:这就是归并排序的 merge 步骤单独拿出来。双指针各自前进,谁小放谁,残余部分整段搬入。

易错:漏写「残余部分整段搬入」两个 while,会丢数据;用 < 会破坏稳定性。

A4编程题

用 stable_sort 与 sort 分别对「班级 + 成绩」的结构体排序,观察稳定性差异。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
struct S { int id, cls, score; };
int main() {
    vector<S> v = {{1,2,90},{2,1,90},{3,2,90},{4,1,95}};
    auto byScore = [](const S &a, const S &b) { return a.score > b.score; };

    vector<S> v1 = v; sort(v1.begin(), v1.end(), byScore);          // 不稳定
    vector<S> v2 = v; stable_sort(v2.begin(), v2.end(), byScore);   // 稳定

    cout << "sort:       "; for (auto &x : v1) cout << x.id << " "; cout << endl;
    cout << "stable_sort:"; for (auto &x : v2) cout << x.id << " "; cout << endl;

    // 多关键字排序的经典做法:先按次要关键字排(稳定序),再按主要关键字 stable_sort
    vector<S> v3 = v;
    stable_sort(v3.begin(), v3.end(), [](const S &a, const S &b){ return a.cls < b.cls; });
    stable_sort(v3.begin(), v3.end(), byScore);
    cout << "two-key:    "; for (auto &x : v3) cout << x.id << " "; cout << endl;
}
运行结果(g++ / libstdc++ 实测)
sort:       4 1 2 3
stable_sort:4 1 2 3
two-key:    4 2 1 3

说明:n = 4 很小时,std::sort 内部走的是插入排序,这一步恰好不打乱相等元素,
      所以两种排序输出相同。但标准不保证 sort 稳定 —— 数据量一大(超过 16 个元素
      进入快排主体),相等元素的顺序就可能被打乱,跨平台结果也不同。
      需要「一定稳定」时必须用 stable_sort。
      stable_sort:4 1 2 3 正是稳定性的体现:id=1,2,3 成绩都是 90,保持原相对顺序。

考点:多次调用 stable_sort,从次要关键字开始、逐级稳定排序到主要关键字,就能实现多关键字排序而无需写复杂比较函数。

易错:把 stable_sort 当成「一定比 sort 慢」——其实它只在内存够用时才是归并,否则会退化为原地合并,速度差别通常不大。

🟡 B组 · 提高(4题)

B1归并 / 逆序对

求逆序对总数(n ≤ 5×10⁵)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int n, a[500005], tmp[500005];
long long ans;
void ms(int l, int r) {
    if (l >= r) return;
    int mid = l + ((r - l) >> 1);
    ms(l, mid); ms(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else { ans += mid - i + 1; tmp[k++] = a[j++]; }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}
int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    ms(1, n); cout << ans << endl;
}

考点:归并排序的「合并阶段统计跨中线贡献」。注意 a[i] 可到 10⁹,若用树状数组需先离散化,归并则不需要。

易错:ans 用 int 溢出;相等时误统计(必须写 a[i] <= a[j])。

B2快排 / 优化

给定 n(n ≤ 10⁶)个 0 或 1,把它们排成「0 在前、1 在后」,要求 O(n) 时间。

查看参考答案

思路:一次 partition。用双指针 i(找 1)、j(找 0)相向而行,交换后继续。只扫描一遍,O(n) 时间、O(1) 空间。

#include <bits/stdc++.h>
using namespace std;
int n, a[1000005];
int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    int i = 0, j = n - 1;
    while (i < j) {
        while (i < j && a[i] == 0) i++;   // i 停在 1 上
        while (i < j && a[j] == 1) j--;   // j 停在 0 上
        if (i < j) { swap(a[i], a[j]); i++; j--; }
    }
    for (int t = 0; t < n; t++) printf("%d%c", a[t], t == n - 1 ? '\n' : ' ');
}

易错:若用普通快排(两路划分),全 0 或全 1 时会退化 O(n²);本写法是 O(n) 一次扫描,天然不受重复值影响。也可以直接统计 0 的个数 k,然后输出 k 个 0 加 n−k 个 1(计数排序思想,更快)。

B3归并 / 应用

求数组的中位数(n ≤ 10⁶)。要求用分治方法,不用排序。

查看参考答案

思路:中位数就是第 ⌈n/2⌉ 小的数,用「快速选择」只递归一侧,平均 O(n)。

int kth(int *a, int l, int r, int k) {
    int p = a[l + rand() % (r - l + 1)];
    int lt = l, i = l, gt = r;
    while (i <= gt) {
        if (a[i] < p)      swap(a[lt++], a[i++]);
        else if (a[i] > p) swap(a[i], a[gt--]);
        else               i++;
    }
    if (k < lt) return kth(a, l, lt - 1, k);
    if (k > gt) return kth(a, gt + 1, r, k);
    return a[k];
}
// 调用:ans = kth(a, 0, n-1, (n-1)/2);   // 下中位数

易错:k 的取值与「下标从 0 还是 1 开始」必须想清楚;三路划分的 gt-- 分支里 i 不能自增,因为换过来的元素还没检查。

B4稳定性分析

说明为什么快速排序不稳定,并给出一个反例。

查看参考答案

原因:partition 过程中会发生跨越式的交换。设 a[i] 与 a[j] 交换且 i 远小于 j,那么夹在中间、与它们相等的元素相对顺序就可能被一起打乱,而这一步不需要经过「两个相等元素相邻」的过程。

反例:数组 [3a, 3b, 1](用 a、b 区分两个相等的 3)。取首元素 3a 为基准,挖坑法:从右找 < 3 的 → 1 填到 l;从左找 > 3 的 → 无;相遇后 a[1] = 3a。结果数组为 [1, 3a, 3b],顺序没变。

换一个反例:[3a, 3b, 2, 1],基准取 3a:j 从右往左找到 1(<3)填到 l;i 从左往右找 >3,看到 3b 时 3b 不大于 3(用 <= 判断),继续走……最终得到 [1, 2, 3a, 3b];但如果用「交换法」实现(j 找 <=3 停在 3b 上)。

最简反例(用交换法):[2a, 2b, 1]。取 2a 为基准,j 从右找到 1(<2),i 从左找到 2a 本身(>= 2 就停),i < j 成立 → 交换 2a 与 1,得 [1, 2b, 2a]。2a 与 2b 的顺序被颠倒了 —— 这就是不稳定。

结论:只要实现中存在「把前面的元素和后面的元素直接交换」的行为,稳定性就无法保证。归并排序在合并时严格按「相等取左」处理,从不跨越交换,因此稳定。

🔴 C组 · 挑战(4题)

C1初赛真题

关于归并排序与快速排序,下列说法错误的是?

A. 归并排序是稳定的,快速排序是不稳定的
B. 归并排序需要 O(n) 额外空间,快速排序可以原地进行
C. 归并排序与快速排序的平均时间复杂度都是 O(n log n)
D. 快速排序在最好情况下的时间复杂度是 O(n)

查看参考答案

答案:D

快速排序每次划分都要扫一遍区间(O(n)),并且两侧都要递归(T(n)=2T(n/2)+O(n)),最好情况也是 O(n log n),不会到 O(n)。只有「每次都只递归一侧」的算法(快速选择、二分查找)才能 O(n) 或 O(log n)。

考点:区分「快排」与「快速选择」。前者两侧都递归,后者只递归一侧。

易错:把「最好情况」误当成「只划分一次」,或误以为归并排序最坏会比快排差(实际归并恒定 O(n log n))。

C2综合分析

证明:任何基于比较的排序算法,最坏情况下至少需要 Ω(n log n) 次比较。

查看参考答案

决策树模型:把算法的执行过程画成一棵二叉树 —— 每个内部节点是一次「a[i] < a[j]?」的比较,两条边是两个分支,叶子对应一种确定的输出排列。

第一步:叶子数下界。n 个元素共有 n! 种可能的排列,每种排列都必须对应至少一个叶子(否则输入该排列时算法输出错误的顺序)。所以叶子数 ≥ n!。

第二步:高度下界。高度为 h 的二叉树最多有 2ʰ 个叶子,故 2ʰ ≥ n!。

第三步:取对数。h ≥ log₂(n!) = Σᵢ₌₁ⁿ log₂ i ≥ Σᵢ₌ₙ/₂ⁿ log₂(n/2) = (n/2)·log₂(n/2) = Ω(n log n)。

结论:比较排序的最坏情况下界是 Ω(n log n)。归并排序和(平均情况下的)快速排序都达到了这个下界,因此是渐进最优的。

重要推论:想更快只能「突破比较模型」——桶排序、计数排序、基数排序之所以能到 O(n),正是因为它们直接用值当下标,没有做元素之间的两两比较。

C3综合编程

「瑞士轮」:2n 名选手初始排名给定,每轮相邻两名比赛,胜者留下、负者淘汰(每次比赛得分),按「总分降序、编号升序」重新排序后进入下一轮。共 R 轮,输出最终排名。

查看参考答案

思路(O(R·n log n) → O(R·n)):朴素做法每轮都整体 sort,共 O(R·n log n)。观察关键性质:本轮比赛结束后,所有胜者的分数都 +1,所有负者的分数不变。因此「胜者序列」本身是有序的(原来有序 + 全体同加 1)、「负者序列」也是有序的。于是只有一次 O(n) 的归并即可完成排序。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
struct P { int id, s, w; } a[MAXN], win[MAXN], lose[MAXN], tmp[MAXN];
int n, R, q;
bool better(const P &A, const P &B) {          // 分数降序,编号升序
    if (A.s != B.s) return A.s > B.s;
    return A.id < B.id;
}
int main() {
    scanf("%d%d%d", &n, &R, &q);
    for (int i = 1; i <= 2 * n; i++) { a[i].id = i; scanf("%d", &a[i].s); }
    for (int i = 1; i <= 2 * n; i++) scanf("%d", &a[i].w);
    sort(a + 1, a + 2 * n + 1, better);         // 初始只排一次
    while (R--) {
        int cw = 0, cl = 0;
        for (int i = 1; i <= 2 * n; i += 2) {   // 相邻两名比赛
            if (a[i].w > a[i + 1].w) { a[i].s++; win[++cw] = a[i]; lose[++cl] = a[i + 1]; }
            else                        { a[i+1].s++; win[++cw] = a[i + 1]; lose[++cl] = a[i]; }
        }
        int i = 1, j = 1, k = 1;            // ★ 归并两个有序序列,O(n)
        while (i <= cw && j <= cl) tmp[k++] = better(win[i], lose[j]) ? win[i++] : lose[j++];
        while (i <= cw) tmp[k++] = win[i++];
        while (j <= cl) tmp[k++] = lose[j++];
        for (int t = 1; t <= 2 * n; t++) a[t] = tmp[t];
    }
    printf("%d\n", a[q].id);
    return 0;
}
运行结果
输入: 2 4 3
      1 2 3 4
      1 3 2 4
输出: 3

说明:初始排名为 1,2,3,4(按总分降序、编号升序),经过 4 轮比赛后
      第 3 名的编号为 3。

复杂度:排序 O(n log n) 只做一次,之后每轮 O(n) 归并,
        总计 O(n log n + R·n),n = 10⁵、R = 50 时约 5×10⁶ 次操作。

易错:每轮都调用 sort 会超时(R 次 O(n log n));归并时比较函数必须与初始排序完全一致(分数降序 + 编号升序),否则有序性假设被破坏。

C4设计题

设计一个算法:在 O(n) 时间内找出 n 个数中的「最大元素和最小元素」,并说明为什么分治在这里不比朴素做法更优。

查看参考答案

朴素做法:一次遍历,用两个变量维护 min 和 max,每个元素比较 2 次,总计 2(n−1) 次比较,O(n)。

分治做法:把区间二分,递归求左半、右半的 (min, max),合并时 2 次比较取全局 min、max。递归式 T(n) = 2T(n/2) + O(1),每层比较 2 次、共 n/2 个节点,总比较次数 ≈ 2n−2。

结论:两者比较次数几乎相同(都是 2n 量级),但分治多了递归调用开销和栈空间,实际更慢。这说明「能用分治」≠「必须用分治」。

更优做法(锦标赛法):两两配对比较(n/2 次),大的都进「候选最大集」、小的都进「候选最小集」,再各自遍历一次(各 n/2 − 1 次),总比较次数约 3n/2 − 2 次 —— 比朴素少 25%。这道题的下界正是 ⌈3n/2⌉ − 2。

启发:分治的价值在于「合并代价远小于重复扫描」的场景(如逆序对、最近点对)。对于「合并代价 O(1) 且总代价仍是 O(n)」的问题,朴素一次遍历反而更好。

🏆 洛谷原题精选(12道)

以下 12 道题围绕排序与分治思想,按难度从入门到提高排列。

1
P1059
明明的随机数入门
2
P1093
奖学金入门
3
P1104
生日入门
4
P1781
宇宙总统入门
5
P1068
分数线划定入门
6
P2676
Bookshelf B入门
7
P1177
【模板】快速排序普及+
8
P1923
【深基9.例4】求第k小的数普及+
9
P1309
瑞士轮普及+
10
P5149
会议座位普及+
11
P1908
逆序对提高
12
P1966
火柴排队提高