上一章你手写了归并排序,重点是那个「合并」。
这一章的题目 —— 求逆序对 —— 表面上和排序毫无关系。 但它的正解,就是在合并那一步里加一句计数,其他一个字都不改。
这种「旧零件解新问题」的体验,是算法学习里最爽的时刻之一。 更重要的是,它会让你养成一个习惯: 看到一道新题,先想想手上已有的零件能不能改一改就用上。
1一句话问题
给一个数组,求有多少对下标 (i, j) 满足 i < j 且 a[i] > a[j]。
这样的一对叫做一个逆序对 —— 排在前面、但数值更大。
输入
6 3 1 4 1 5 2
输出
6
第一行是 n,第二行是那 n 个数。
数一数:(3,1) (3,1) (3,2) (4,1) (4,2) (5,2),一共 6 对。
注意数组里有两个 1,所以 3 和它们各算一对。
它衡量「这个序列有多乱」。
- 完全升序 → 0 对(最整齐)
- 完全降序 →
n(n-1)/2对(最乱,任意两个都是逆序)
还有个很实用的含义:逆序对的个数 = 用冒泡排序把它排好所需的交换次数。 因为冒泡每次只交换相邻两个,而交换一次相邻的逆序对,逆序对总数正好减一。
(想想为什么:交换相邻两个元素,只会改变它们这一对的相对顺序, 其他所有对的相对顺序都没变。)
2暴力:每一对都看一遍
// 逆序对 —— 暴力:每一对都数一遍//// 输入:第一行 n,第二行 n 个整数// 输出:逆序对的个数//// 逆序对的定义:一对下标 (i, j),满足 **i < j 且 a[i] > a[j]**。// 也就是「排在前面、但数值更大」的那种搭配。//// 例:3 1 2 —— 逆序对有 (3,1) 和 (3,2),一共 2 对。//// 它衡量的是「这个序列有多乱」:// 完全升序 → 0 对(最整齐)// 完全降序 → n(n-1)/2 对(最乱,任意两个都是逆序)//// 暴力就是把每一对都看一遍,O(n²)。n = 5 万时是 12.5 亿次比较。//// ⚠ 答案要开 long long!// n = 10⁵ 时最多有 n(n-1)/2 ≈ 50 亿对,早就爆 int 了。// 这是这道题的头号送命点 —— 而且用小数据对拍**根本查不出来**。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
vector<long long> a(n); for (int i = 0; i < n; i++) cin >> a[i];
long long cnt = 0; for (int i = 0; i < n; i++) for (int j = i + 1; j < n; j++) if (a[i] > a[j]) cnt++;
cout << cnt << "\n"; return 0;}点「运行 ▶」看结果
O(n²)。思路无可挑剔,就是慢。
n = 10⁵ 时最多有 n(n-1)/2 ≈ 50 亿对 —— int 装不下(上限约 21 亿)。
更阴险的是:用小数据对拍根本查不出这个错误。
小数据的答案才几十,int 完全够用,对拍会全过,然后你在正式提交时得到 0 分。
用小数据对拍查不出溢出。 这是它的第二个盲区(第一个是「两份程序错得一模一样」,见第 9 章)。
⚠ 主语不能省成「对拍查不出溢出」—— P1908 那一页把这句话验了一遍:
把生成器的 n 开到题面顶格,同一个 bug 100 轮抓到 84 次。
真正的死结是「对拍的参照物是暴力」,而暴力在顶格数据上一轮就要几分钟。
3实测:它有多慢
本机实测(1..n 的随机排列):
| n | 暴力 O(n²) | 归并 O(n log n) | 逆序对个数 |
|---|---|---|---|
| 20 000 | 0.42 秒 | 0.004 秒 | 约 1.0 亿 |
| 30 000 | 0.96 秒 | 0.005 秒 | 约 2.2 亿 |
| 50 000 | 2.67 秒 | 0.007 秒 | 约 6.2 亿 |
注意最右边那一列:n = 50000 时答案已经是 6.2 亿了。
n = 100000 时是 25 亿 —— 正好越过 int 的边界。
4★ 关键的一步
分治三步:分 → 治 → 合。
逆序对总数 = 左半边内部的 + 右半边内部的 + 一左一右的
↑递归解决 ↑递归解决 ↑这是唯一要动脑的部分前两项交给递归(第 1 章:信任那个还没写完的函数)。 关键是第三项:一个下标在左半边、一个在右半边的逆序对,怎么数?
如果两个半边都已经排好序了(而这正是归并排序帮我们做到的), 那么合并的时候:
左半边: [ 2 5 8 ] 右半边: [ 1 6 ]
↑i ↑j
当前要比较 2 和 1。2 > 1,所以取右边的 1。
但请注意 —— 左半边是有序的,2 后面的 5 和 8 也一定比 1 大!
于是 (2,1)、(5,1)、(8,1) 全都是逆序对:一口气 3 对。代码就一行:
cnt += mid - i + 1; // 左边从 i 到 mid 还剩这么多个,全都比 a[j] 大「左半边有序」是这一行成立的唯一理由。 而那正好是递归已经顺手保证了的 —— 排序成了数逆序对的脚手架。
相等的两个数不构成逆序对(要求是严格大于)。
写成 a[i] < a[j] 的话,相等时会走 else 分支,白白多数一批 —— 答案偏大。
这个错误只有在有重复元素的数据上才会暴露。 所以下面的生成器特意让取值只有 0~5,重复满地都是。 (这个套路第 8 章刚用过一次,它是通用的。)
5正解:上一章的代码加一行
// 逆序对 —— 归并排序顺手数出来//// 输入输出和 brute.cpp 完全一样,但是 O(n log n)。//// ============ 和第 10 章的归并排序比,只多了一行 ============//// 分治的三步(分 → 治 → 合)在这里是:// 逆序对总数 = 左半边内部的 + 右半边内部的 + 一左一右的// ↑递归解决 ↑递归解决 ↑合并时顺手数//// 前两项交给递归(信任它)。关键是第三项:**一个在左半边、一个在右半边**的逆序对。//// 合并的时候,两边都已经各自有序了。此刻如果发现 a[i] > a[j]// (左边当前这个 比 右边当前这个 大,要取右边的),那么://// **左半边从 i 到 mid 的所有元素都比 a[j] 大**(因为左半边是有序的!)// 它们都排在 a[j] 前面 —— 于是一口气产生了 (mid - i + 1) 个逆序对。//// 左半边: [ 2 5 8 ] 右半边: [ 1 6 ]// ↑i ↑j// 2 > 1,那么 5、8 也一定 > 1// 一次记 3 对,不用一个个数//// 这一行 `cnt += mid - i + 1;` 就是这道题的全部。// **左半边有序**这个条件是它成立的唯一理由 —— 而那正好是递归已经帮我们保证了的。//// 这就是分治最漂亮的地方:**排序这件事本身,成了数逆序对的脚手架。**
#include <bits/stdc++.h>using namespace std;
vector<long long> a, tmp;long long cnt = 0;
void mergeSort(int l, int r) { if (l >= r) return;
int mid = l + (r - l) / 2; mergeSort(l, mid); // 左半边内部的逆序对(顺便把左半边排好) mergeSort(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 { cnt += mid - i + 1; // ★ 就是这一行:左边剩下的全都比 a[j] 大 tmp[k++] = a[j++]; } } while (i <= mid) tmp[k++] = a[i++]; while (j <= r) tmp[k++] = a[j++]; for (int p = l; p <= r; p++) a[p] = tmp[p];}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
a.assign(n, 0); tmp.assign(n, 0); for (int i = 0; i < n; i++) cin >> a[i];
if (n > 0) mergeSort(0, n - 1);
cout << cnt << "\n"; return 0;}点「运行 ▶」看结果
把它和第 10 章的 merge.cpp 并排看,差别只有:
if (a[i] <= a[j]) {
tmp[k++] = a[i++];
} else {
cnt += mid - i + 1; // ← 新增的唯一一行
tmp[k++] = a[j++];
}
// 逆序对 —— 把「一口气数一批」的过程打印出来//// 输入:n / n 个整数(用小数据,n <= 16)// 输出:每一次合并时,哪一步一口气数出了多少对逆序对//// 跑一遍,重点看带 ★ 的行:// 每次从右半边取一个元素,就一次性记下「左半边还剩几个」——// 因为左半边是有序的,剩下的那些一定全都比它大。//// 数一数 ★ 出现的次数:它远远少于逆序对的总数。// **暴力要一对一对地数,分治是一批一批地数** —— 这就是它快的地方。
#include <bits/stdc++.h>using namespace std;
vector<long long> a, tmp;long long cnt = 0;int depth = 0;
void indent() { for (int i = 0; i < depth; i++) cout << "| "; }
void show(const char* label, int l, int r) { cout << label << "["; for (int i = l; i <= r; i++) cout << a[i] << (i == r ? "" : " "); cout << "]";}
void mergeSort(int l, int r) { if (l >= r) return;
int mid = l + (r - l) / 2; depth++; mergeSort(l, mid); mergeSort(mid + 1, r); depth--;
indent(); cout << "合并 [" << l << ", " << r << "]:左 "; show("", l, mid); cout << " 右 "; show("", mid + 1, r); cout << "\n";
int i = l, j = mid + 1, k = l; while (i <= mid && j <= r) { if (a[i] <= a[j]) { tmp[k++] = a[i++]; } else { long long add = mid - i + 1; indent(); cout << " ★ 取右边的 " << a[j] << ":左边还剩 " << add << " 个("; for (int p = i; p <= mid; p++) cout << a[p] << (p == mid ? "" : " "); cout << "),它们全都比 " << a[j] << " 大 → 一口气记 " << add << " 对\n"; cnt += add; tmp[k++] = a[j++]; } } while (i <= mid) tmp[k++] = a[i++]; while (j <= r) tmp[k++] = a[j++]; for (int p = l; p <= r; p++) a[p] = tmp[p];
indent(); cout << " 合并结果 "; show("", l, r); cout << " 累计逆序对 " << cnt << "\n";}
int main() { int n; if (!(cin >> n)) return 0; if (n <= 0 || n > 16) { cout << "这份是用来看过程的,请用 1 <= n <= 16\n"; return 0; }
a.assign(n, 0); tmp.assign(n, 0); for (int i = 0; i < n; i++) cin >> a[i];
cout << "原数组:"; for (int i = 0; i < n; i++) cout << a[i] << " "; cout << "\n\n";
mergeSort(0, n - 1);
cout << "\n逆序对总数 = " << cnt << "\n"; return 0;}点「运行 ▶」看结果
6单步看「一批一批地数」
盯住红色的那一批:
- 每次从右半边取走一个元素(绿色),左边剩下的那一整批(红色)同时被记账。
- 它们全都比那个绿色元素大 —— 因为左半边是有序的,一个都不用挨个检查。
- 底下的「这一步一口气数了 +k」就是那一行代码的可视化。
暴力是一对一对地数(n² 次),分治是一批一批地数(n log n 次)。
试试把数组改成 5 4 3 2 1(完全降序):答案是 10,
而 ★ 只会出现四五次 —— 一批就顶好几对。
7★ 对拍验证
把「归并」那一栏换成你自己默写的,再点开始。
// 逆序对 —— 归并排序顺手数出来//// 输入输出和 brute.cpp 完全一样,但是 O(n log n)。//// ============ 和第 10 章的归并排序比,只多了一行 ============//// 分治的三步(分 → 治 → 合)在这里是:// 逆序对总数 = 左半边内部的 + 右半边内部的 + 一左一右的// ↑递归解决 ↑递归解决 ↑合并时顺手数//// 前两项交给递归(信任它)。关键是第三项:**一个在左半边、一个在右半边**的逆序对。//// 合并的时候,两边都已经各自有序了。此刻如果发现 a[i] > a[j]// (左边当前这个 比 右边当前这个 大,要取右边的),那么://// **左半边从 i 到 mid 的所有元素都比 a[j] 大**(因为左半边是有序的!)// 它们都排在 a[j] 前面 —— 于是一口气产生了 (mid - i + 1) 个逆序对。//// 左半边: [ 2 5 8 ] 右半边: [ 1 6 ]// ↑i ↑j// 2 > 1,那么 5、8 也一定 > 1// 一次记 3 对,不用一个个数//// 这一行 `cnt += mid - i + 1;` 就是这道题的全部。// **左半边有序**这个条件是它成立的唯一理由 —— 而那正好是递归已经帮我们保证了的。//// 这就是分治最漂亮的地方:**排序这件事本身,成了数逆序对的脚手架。**
#include <bits/stdc++.h>using namespace std;
vector<long long> a, tmp;long long cnt = 0;
void mergeSort(int l, int r) { if (l >= r) return;
int mid = l + (r - l) / 2; mergeSort(l, mid); // 左半边内部的逆序对(顺便把左半边排好) mergeSort(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 { cnt += mid - i + 1; // ★ 就是这一行:左边剩下的全都比 a[j] 大 tmp[k++] = a[j++]; } } while (i <= mid) tmp[k++] = a[i++]; while (j <= r) tmp[k++] = a[j++]; for (int p = l; p <= r; p++) a[p] = tmp[p];}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0;
a.assign(n, 0); tmp.assign(n, 0); for (int i = 0; i < n; i++) cin >> a[i];
if (n > 0) mergeSort(0, n - 1);
cout << cnt << "\n"; return 0;}值得故意写错的:
cnt += mid - i + 1写成cnt += 1→ 退化成只数相邻的,答案偏小cnt += mid - i(少 1)→ 每批少数一对a[i] <= a[j]写成a[i] < a[j]→ 相等时多数,有重复元素就露馅cnt用int→ 小数据对拍全过,大数据溢出(对拍抓不住,只能靠脑子)
8分治的通用框架
1. 分:把问题切成两个(或多个)规模更小的同类问题
2. 治:递归解决它们(信任!)
3. 合:把子问题的答案拼成原问题的答案 —— 并处理「跨越边界」的那部分第 3 步的「跨越边界」才是每道分治题的真正内容。
| 题目 | 「跨越边界」的部分是什么 |
|---|---|
| 归并排序 | 把两个有序半边合并起来 |
| 逆序对(本章) | 左边一个、右边一个的那些对 |
| 最大子段和(分治版) | 跨过中点的那一段 |
| 平面最近点对 | 一个点在左、一个点在右的那些点对 |
分和治都是套路,合才是每道题不一样的地方。 拿到一道分治题,直接问自己:「跨越中点的那部分怎么算?」
第 38 章的树状数组也能求逆序对,而且代码更短: 从右往左扫,每次问「右边已经出现过多少个比我小的数」。
两种做法都是 O(n log n),各有各的用处:
- 归并版:不用离散化,思路自洽,适合初学
- 树状数组版:更容易改成「求某个区间里的逆序对」之类的变形
学完第 38 章可以回来用另一种方法再写一遍,然后拿这一章的代码和它对拍 —— 两种完全不同的思路互相验证,这是最踏实的验证方式(第 9 章讲过)。
9自测
- 洛谷 P1908 逆序对解析 → —— 本章原题,模板。注意开 long long
- 洛谷 P1966 火柴排队解析 → —— NOIP2013。要先想明白「答案等价于求某个排列的逆序对数」,转化是难点
- 洛谷 P1177 排序解析 → —— 上一章那道模板题,这次专门用手写归并交一遍
- 洛谷 P1115 最大子段和解析 → —— 先用 O(n) 的做法过掉,再试着用分治写一遍 —— 练「跨越中点的那部分怎么算」
第 12 章分治进阶:快速幂、二分查找的分治视角、以及「跨越中点」的更多花样。
顺便,你已经具备做第 13 章 DFS 的全部前置知识了 —— 如果想早点摸搜索,可以先跳过去,回头再补第 12 章。