前言
假设你手里有一副打乱的扑克牌,想快速找到最小的那张。如果牌是乱序的,你得一张一张翻——最坏翻 52 张。但如果先把牌从小到大排好,最小的一张就在最上面,一眼看到。
排序就是这么一个”先花时间建立秩序,后面所有操作都变快”的过程。找中位数?排好序直接取中间。去重?排好序相邻比较。找”差最小的数对”?排好序相邻的差就是候选。可以说排序是算法工具箱里的万能钥匙——先把数据排好序,很多问题自然就解决了。
问题的本质
排序的核心价值不是”得到一个有序的数组”本身,而是有序性带来的后续便利。排序后,数据有了单调性——从小到大递增(或从大到小递减)。单调性可以大幅降低搜索、匹配、统计的代价。
举个例子:给你 1000 个学生的成绩,问”成绩第 10 名是多少分”。乱序时要排序才知道(),排序后直接看第 10 个位置()。
理论 + 代码
C++ 中的排序
比赛中不需要手写排序算法,直接用 std::sort:
#include <algorithm>
using namespace std;
int a[100];
sort(a, a + n); // 升序(从小到大)
sort(a, a + n, greater<int>()); // 降序(从大到小)
// 自定义规则:先按成绩降序,成绩相同按学号升序
struct Student { int score, id; };
Student stu[100];
sort(stu, stu + n, [](const Student& l, const Student& r) {
// ① lambda 表达式:定义"小于"的比较规则
// 返回 true 表示 l 应该排在 r 前面
if (l.score != r.score) return l.score > r.score; // 成绩高的在前
return l.id < r.id; // 成绩相同,学号小的在前
});
- ① Lambda 表达式
[](const Student& l, const Student& r) { ... }就是一个匿名的比较函数。sort会反复调用它来决定两个元素谁该排在前面。返回true表示l排在r前面。
std::sort 的实现是 Introsort(快排 + 堆排的混合体),最坏 。你只需要记住一件事:排序 个数,时间 。 时 ,1 秒内能跑完。
排序的应用:逆序对
逆序对是排序的一个重要应用。什么是逆序对?在一个数组中,如果 但 ,那 就是一个逆序对。比如数组 [3, 1, 2]: 和 都是逆序对,共 2 个。
为什么逆序对重要?它衡量了数组”有多乱”——完全有序的数组逆序对为 0,完全逆序的数组逆序对最多。很多问题可以转化为”统计逆序对”。
怎么求?朴素做法是双重循环 , 时太慢。聪明的做法是在归并排序的过程中统计。
归并排序的思想:把数组分成两半,分别排序,再合并。合并时,如果左半的当前元素 > 右半的当前元素,说明左半剩余的所有元素都和右半当前元素构成逆序对——因为左半已经有序了,剩余的元素只会更大。
long long merge_count(int a[], int l, int r) {
// 对 a[l..r) 排序并统计逆序对
if (r - l <= 1) return 0; // ① 只有一个元素,没有逆序对
int m = (l + r) / 2;
long long cnt = merge_count(a, l, m) // ② 左半的逆序对
+ merge_count(a, m, r); // ③ 右半的逆序对
// ④ 合并两个有序半段,统计跨左右的逆序对
int buf[200010];
int i = l, j = m, k = 0;
while (i < m && j < r) {
if (a[i] <= a[j]) {
buf[k++] = a[i++]; // ⑤ 左半元素更小,直接复制
} else {
// ⑥ 关键!a[i] > a[j],而且左半已经有序
// 所以 a[i], a[i+1], ..., a[m-1] 全都 > a[j]
buf[k++] = a[j++];
cnt += m - i; // ⑦ 一次性加上 m-i 个逆序对
}
}
while (i < m) buf[k++] = a[i++]; // ⑧ 左半剩余
while (j < r) buf[k++] = a[j++]; // ⑨ 右半剩余
for (int t = 0; t < k; t++)
a[l + t] = buf[t]; // ⑩ 写回原数组
return cnt;
}
走一遍具体过程:数组 [3, 1, 2]。
- 分成
[3]和[1, 2]。 [3]只有一个元素,逆序对 = 0。[1, 2]:分成[1]和[2],合并时 ,逆序对 = 0。- 合并
[3]和[1, 2]:- 比较 和 :,所以 和 构成逆序对。左半剩余 个元素,
cnt += 1。复制 。 - 比较 和 :,所以 和 构成逆序对。
cnt += 1。复制 。 - 左半还剩 ,复制 。
- 比较 和 :,所以 和 构成逆序对。左半剩余 个元素,
- 最终
cnt = 0 + 0 + 2 = 2。和手动数的一致。✓
例题
例题 1:M&A 027 — Sorting(★1)
题目:给定长度为 的数组 ,将其升序排列后输出。
数据范围:,
思路:裸排序。直接 std::sort,时间 。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
int N;
scanf("%d", &N);
long long a[200010];
for (int i = 0; i < N; i++)
scanf("%lld", &a[i]);
sort(a, a + N); // ① 排序。[a, a+N) 是待排序的范围
for (int i = 0; i < N; i++) {
if (i) printf(" "); // ② 第一个数前面不加空格
printf("%lld", a[i]);
}
printf("\n");
}
逐行解析:
- ①
sort(a, a + N):a是数组首地址,a + N是末尾之后的位置。sort会原地排好序,不返回新数组。时间 , 时约 ,瞬间完成。 - ② 输出格式控制:第一个数前面不加空格,后面的数前面加空格。这是 AtCoder 常见的输出格式。
例题 2:M&A 013 — Divisor Enumeration(★1)
题目:给定整数 ,枚举 的所有约数,按升序输出。
数据范围:
思路:约数的枚举方法:从 到 试除,每找到一个约数 ,就同时得到了一对约数 。最后把所有约数排序输出。
为什么只需要试到 ?和02-02 素数判定同理:如果 且 ,则 。
代码:
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
long long N;
scanf("%lld", &N);
vector<long long> divs;
for (long long i = 1; i * i <= N; i++) { // ① 枚举到 √N
if (N % i == 0) {
divs.push_back(i); // ② i 是约数
if (i != N / i)
divs.push_back(N / i); // ③ N/i 也是约数(避免重复)
}
}
sort(divs.begin(), divs.end()); // ④ 排序
for (int i = 0; i < (int)divs.size(); i++) {
printf("%lld\n", divs[i]); // ⑤ 逐行输出
}
}
逐行解析:
- ①
i * i <= N:等价于i <= sqrt(N),但避免浮点运算。i是long long—— 最大 ,,i * i最大约 ,用int会溢出。 - ③ 如果 (即 不是完全平方数, 不是 ),则 是另一个不同的约数。比如 时, 也要加入。
- ④ 约数是按 从小到大找到的,但 可能比 大也可能已经乱序。所以全部加入 vector 后排序一次。
走一遍过程:。
| 约数 | vector | ||
|---|---|---|---|
| 1 | 0 | 1, 12 | {1, 12} |
| 2 | 0 | 2, 6 | {1, 12, 2, 6} |
| 3 | 0 | 3, 4 | {1, 12, 2, 6, 3, 4} |
| 4 | ,循环结束 |
排序后:{1, 2, 3, 4, 6, 12}。✓
例题 3:Tessoku Book — A13 Close Pairs
题目:黑板上写着 个整数,已按从小到大排列为 。从这 个数中选两个不同的数,有多少种选法使得它们的差不超过 (即 )?
数据范围:,,
思路:数据已经有序。对每个 ,找最大的 使得 。因为数组有序,满足条件的 是连续的一段,可以用二分搜索(upper_bound)或双指针。
用双指针:右指针 r 从 开始,只要 就右移。最终 就是以 为左端点、满足条件的数对数。
但注意这里 ,双重循环+双指针的总时间是 (每个指针只往右走),绰绰有余。不过更简洁的写法是对每个 用 upper_bound。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
int N;
long long K;
scanf("%d%lld", &N, &K);
long long A[100010];
for (int i = 0; i < N; i++)
scanf("%lld", &A[i]);
long long ans = 0;
for (int i = 0; i < N; i++) {
// ① 找第一个 > A[i] + K 的位置
long long *p = upper_bound(A + i + 1, A + N, A[i] + K);
// ② p - (A + i + 1) = 满足 A[j] - A[i] <= K 的 j 的个数
ans += p - (A + i + 1);
}
printf("%lld\n", ans);
}
逐行解析:
- ①
upper_bound(A + i + 1, A + N, A[i] + K):在 到 中找第一个大于 的位置。因为数据有序, 等价于 。 - ②
p - (A + i + 1):从 到 之前的位置都满足 。个数 = 指针之差。
走一遍过程:,。
- :找 的。12, 16 满足。贡献 2。
- :找 的。16, 22 满足。贡献 2。
- :找 的。22 满足。贡献 1。
- :找 的。27, 28, 31 满足。贡献 3。
- :找 的。28, 31 满足。贡献 2。
- :找 的。31 满足。贡献 1。
总计 。✓
例题 4:M&A 074 — Sum of difference Easy(★2)
题目:给你 个整数 (已排序)。求 的值。
数据范围:,
思路:数据已经有序,所以 (),绝对值可以直接去掉。对每个 ,它对总和的贡献是 ,其中 是前 个数的前缀和。
代码:
#include <cstdio>
using namespace std;
int main() {
int N;
scanf("%d", &N);
long long a[200010];
for (int i = 0; i < N; i++)
scanf("%lld", &a[i]);
// ① 前缀和
long long S[200010];
S[0] = 0;
for (int i = 1; i <= N; i++)
S[i] = S[i - 1] + a[i - 1];
// ② 对每个 j,计算它和左边所有数的差之和
long long ans = 0;
for (int j = 1; j <= N; j++) {
ans += (long long)(j - 1) * a[j - 1] - S[j - 1];
}
printf("%lld\n", ans);
}
逐行解析:
- ① 前缀和 。
- ②
(j-1) * a[j-1] - S[j-1]: 和左边 个数的差之和。左边有 个数,每个和 的差是 ,差之和 = 。
验证:。
- :左边 0 个,贡献 。
- :()✓
- :()✓
总和 。手动验证:。✓
例题 5:M&A 076 — Sum of difference(★3)
题目:给你 个整数 (不一定有序)。求所有数对的差的绝对值之和:。
数据范围:,
思路:和例题 4 几乎一样,但数据不一定有序,还有负数。解法:先排序,排序后绝对值直接去掉,变成和例题 4 一样的前缀和问题。
,双重循环 绝对 TLE。排序 + 前缀和是 ,绰绰有余。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
int N;
scanf("%d", &N);
long long a[200010];
for (int i = 0; i < N; i++)
scanf("%lld", &a[i]);
sort(a, a + N); // ① 先排序——这是关键一步
long long S[200010];
S[0] = 0;
for (int i = 1; i <= N; i++)
S[i] = S[i - 1] + a[i - 1];
long long ans = 0;
for (int j = 1; j <= N; j++)
ans += (long long)(j - 1) * a[j - 1] - S[j - 1];
// ② (j-1) 最大 ~2×10^5,a[j-1] 最大 10^8
// 乘积最大 ~2×10^13,必须用 long long
printf("%lld\n", ans);
}
逐行解析:
- ① 排序是这道题的灵魂。排序前 里有绝对值,没法化简。排序后 (当 ),,绝对值消失。这就把问题转化成了例题 4。
- ②
(long long)(j - 1) * a[j - 1]:隐式类型转换。(j-1)最大约 ,a[j-1]最大 ,乘积约 ,远超int的 。不加强转就会溢出,答案错误。
这道题展示了排序的核心价值:数据有序后,绝对值可以去掉,问题立刻变简单。类似地,00-03 累积和与差分 中前缀和的应用也需要先排序作为前提。
参考文献
教材讲解 — 競技プログラミングの鉄則
- 第 3 章 3.1 B11 Binary Search 2(sort + lower_bound 解说)
- 排序在教材中作为工具使用(如二分前先 sort),非独立章节
基础练习 — アルゴリズムと数学 演習問題集
- 027 Sorting(归并排序实现)【例题】
- 013 Divisor Enumeration(约数排序输出)【例题】
- 074 Sum of difference Easy(排序+前缀和)【例题】
- 076 Sum of difference(排序+前缀和)【例题】
- 077 Distance Sum(排序坐标+前缀和)
- 094 Maximal Value(贪心+取min)
系统练习 — 競技プログラミングの鉄則
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶