阶段 2 · 排序与分治 · 第 11 章

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

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

例题:逆序对 建议用时:90 分钟
这一章是「学会一个零件,白捡一道题」的典型

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

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

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

1 一句话问题

给一个数组,求有多少对下标 (i, j) 满足 i < ja[i] > a[j]。 这样的一对叫做一个逆序对 —— 排在前面、但数值更大。

输入 6
     3 1 4 1 5 2
输出 6

     数一数:(3,1) (3,1) (3,2) (4,1) (4,2) (5,2)
     注意有两个 1,所以 3 和两个 1 各算一对
✓ 逆序对到底在衡量什么

它衡量「这个序列有多乱」。

  • 完全升序 → 0 对(最整齐)
  • 完全降序 → n(n-1)/2 对(最乱,任意两个都是逆序)

还有个很实用的含义:逆序对的个数 = 用冒泡排序把它排好所需的交换次数。 因为冒泡每次只交换相邻两个,而交换一次相邻的逆序对,逆序对总数正好减一。

(想想为什么:交换相邻两个元素,只会改变它们这一对的相对顺序, 其他所有对的相对顺序都没变。)

2 暴力:每一对都看一遍

brute.cpp暴力
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

3 实测:它有多慢

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

本机实测(1..n 的随机排列):

n暴力 O(n²)归并 O(n log n)逆序对个数
20 0000.42 秒0.004 秒约 1.0 亿
30 0000.96 秒0.005 秒约 2.2 亿
50 0002.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正解
输入(stdin)
输出
点「运行 ▶」看结果

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

if (a[i] <= a[j]) {
    tmp[k++] = a[i++];
} else {
    cnt += mid - i + 1;      // ← 新增的唯一一行
    tmp[k++] = a[j++];
}
trace.cpp过程演示
每次「一口气数一批」都会打印一行 ★。数一数 ★ 的个数 —— 它远远少于逆序对总数。
输入(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。重复元素那一类最关键 —— 它专门用来抓 <= 写成 < 的错误。

值得故意写错的:

  • cnt += mid - i + 1 写成 cnt += 1 → 退化成只数相邻的,答案偏小
  • cnt += mid - i(少 1)→ 每批少数一对
  • a[i] <= a[j] 写成 a[i] < a[j] → 相等时多数,有重复元素就露馅
  • cntint → 小数据对拍全过,大数据溢出(对拍抓不住,只能靠脑子)

8 分治的通用框架

★ 三步,认准这个形状
1. 分:把问题切成两个(或多个)规模更小的同类问题
2. 治:递归解决它们(信任!)
3. 合:把子问题的答案拼成原问题的答案 —— 并处理「跨越边界」的那部分

第 3 步的「跨越边界」才是每道分治题的真正内容。

题目「跨越边界」的部分是什么
归并排序把两个有序半边合并起来
逆序对(本章)左边一个、右边一个的那些对
最大子段和(分治版)跨过中点的那一段
平面最近点对一个点在左、一个点在右的那些点对

分和治都是套路,合才是每道题不一样的地方。 拿到一道分治题,直接问自己:「跨越中点的那部分怎么算?」

逆序对还有别的做法

第 38 章的树状数组也能求逆序对,而且代码更短: 从右往左扫,每次问「右边已经出现过多少个比我小的数」。

两种做法都是 O(n log n),各有各的用处:

  • 归并版:不用离散化,思路自洽,适合初学
  • 树状数组版:更容易改成「求某个区间里的逆序对」之类的变形

学完第 38 章可以回来用另一种方法再写一遍,然后拿这一章的代码和它对拍 —— 两种完全不同的思路互相验证,这是最踏实的验证方式(第 9 章讲过)。

9 自测

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

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

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