阶段 2 · 排序与分治 · 第 11 章普及组 J

分治:归并排序顺手把逆序对数出来

上一章学的「合并」,这一章一行都不用改 —— 只在里面加一句计数,一道新题就白捡了。

需要先学:第 10 章 排序:冒泡 → 归并 → 快排例题:逆序对建议用时:90 分钟
这一章是「学会一个零件,白捡一道题」的典型

上一章你手写了归并排序,重点是那个「合并」。

这一章的题目 —— 求逆序对 —— 表面上和排序毫无关系。 但它的正解,就是在合并那一步里加一句计数,其他一个字都不改。

这种「旧零件解新问题」的体验,是算法学习里最爽的时刻之一。 更重要的是,它会让你养成一个习惯: 看到一道新题,先想想手上已有的零件能不能改一改就用上。

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暴力:每一对都看一遍

brute.cpp暴力
// 逆序对 —— 暴力:每一对都数一遍
//
// 输入:第一行 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

O(n²)。思路无可挑剔,就是慢。

⚠ 这道题的头号送命点:答案要开 long long

n = 10⁵ 时最多有 n(n-1)/2 ≈ 50 亿对 —— int 装不下(上限约 21 亿)。

更阴险的是:用小数据对拍根本查不出这个错误。 小数据的答案才几十,int 完全够用,对拍会全过,然后你在正式提交时得到 0 分。

用小数据对拍查不出溢出。 这是它的第二个盲区(第一个是「两份程序错得一模一样」,见第 9 章)。

⚠ 主语不能省成「对拍查不出溢出」—— P1908 那一页把这句话验了一遍: 把生成器的 n 开到题面顶格,同一个 bug 100 轮抓到 84 次。 真正的死结是「对拍的参照物是暴力」,而暴力在顶格数据上一轮就要几分钟。

3实测:它有多慢

同题对比:每一对都看 vs 归并顺手数
数据是 1..n 的随机排列。跑完把 n 改成 5 万、8 万 —— 暴力是 O(n²)。
每一对都看
归并顺手数

本机实测(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] 而不是 <

相等的两个数不构成逆序对(要求是严格大于)。

写成 a[i] < a[j] 的话,相等时会走 else 分支,白白多数一批 —— 答案偏大。

这个错误只有在有重复元素的数据上才会暴露。 所以下面的生成器特意让取值只有 0~5,重复满地都是。 (这个套路第 8 章刚用过一次,它是通用的。)

5正解:上一章的代码加一行

fast.cpp正解
// 逆序对 —— 归并排序顺手数出来
//
// 输入输出和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

把它和第 10 章的 merge.cpp 并排看,差别只有:

if (a[i] <= a[j]) {
    tmp[k++] = a[i++];
} else {
    cnt += mid - i + 1;      // ← 新增的唯一一行
    tmp[k++] = a[j++];
}
trace.cpp过程演示
每次「一口气数一批」都会打印一行 ★。数一数 ★ 的个数 —— 它远远少于逆序对总数。
// 逆序对 —— 把「一口气数一批」的过程打印出来
//
// 输入: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

6单步看「一批一批地数」

归并求逆序对:一批一批地数
答案 13 对 · 54 帧
第 1 / 54 步
3
1
4
1
5
2
6
0
已数出的逆序对
0
这一步一口气数了
—
正确答案(暴力数的)
13
蓝 = 左半边,橙 = 右半边,绿 = 已经合并好的一段。 红色那一批就是「一口气数出来」的逆序对左端 —— 它们全都比刚取走的那个绿色元素大。
切开 [0, 7]:逆序对 = 左半边内部的 + 右半边内部的 + 一左一右的。前两项交给递归。

盯住红色的那一批:

  • 每次从右半边取走一个元素(绿色),左边剩下的那一整批(红色)同时被记账。
  • 它们全都比那个绿色元素大 —— 因为左半边是有序的,一个都不用挨个检查。
  • 底下的「这一步一口气数了 +k」就是那一行代码的可视化。

暴力是一对一对地数(n² 次),分治是一批一批地数(n log n 次)。

试试把数组改成 5 4 3 2 1(完全降序):答案是 10, 而 ★ 只会出现四五次 —— 一批就顶好几对。

7★ 对拍验证

★ 正确的用法

把「归并」那一栏换成你自己默写的,再点开始。

对拍器
生成器专门造:完全升序(答案 0)、完全降序(答案最大)、大量重复元素(相等不算逆序对)、n=0 和 n=1。重复元素那一类最关键 —— 它专门用来抓 <= 写成 < 的错误。
// 逆序对 —— 归并排序顺手数出来
//
// 输入输出和 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自测

自测清单0 / 8
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 12 章分治进阶:快速幂、二分查找的分治视角、以及「跨越中点」的更多花样。

顺便,你已经具备做第 13 章 DFS 的全部前置知识了 —— 如果想早点摸搜索,可以先跳过去,回头再补第 12 章。