☰
逆序对计数:归并排序的O(n log n)解法与Go实现
2026/10/19 17:40:56 网站建设 项目流程

1. 逆序对的定义:为什么这一题能拦住很多人

我第一次认真接触“逆序对”是在处理一批排序统计相关的小工具时。当时我需要量化一组序列到底“有多乱”,排序本身只能给出结果顺序,却丢失了原始乱序程度。后来才意识到,逆序对数量正好是一个可以精确描述乱序程度的指标:对于一个数组nums,只要下标i < j并且nums[i] > nums[j],这一对元素就叫一个逆序对。完全升序的数组,逆序对数量是 0;完全降序的数组,逆序对数量是n*(n-1)/2,也就是任意两个元素都能构成逆序对。

这个定义说起来一句话的事,但真正做起来却很考验对分治算法的理解。很多人在这一题上卡住,并不是因为不知道规则,而是遇到大数组时,第一反应只会写两层循环。面试里也经常问这道题,它的本意不是考察你会不会枚举所有数对,而是考察你能不能把统计过程融进排序过程里,做到 O(n log n) 的时间复杂度。

1.1 一个例子把定义说清楚

拿[3, 1, 2]来举例。数组下标从 0 开始,三个元素能组成三对下标:

  • (0, 1):3和1,因为3 > 1,构成逆序对;
  • (0, 2):3和2,因为3 > 2,构成逆序对;
  • (1, 2):1和2,因为1 < 2,不构成逆序对。

所以这个数组的逆序对总数是 2。这个例子看起来过于简单,但里面藏着最容易被忽略的点:逆序对不是考察某个元素本身有多大,而是考察两个元素的相对位置和相对大小是否颠倒。一个很大的元素出现在数组靠前的位置,它可能和后面好几个元素构成逆序对;一个很小的元素出现在靠后的位置,它可能和前面好几个元素构成逆序对。

1.2 逆序对和数组乱序程度的关系

逆序对的数量在数学上可以直接反映乱序程度,但它又不是一个线性指标。升序数组是 0,降序数组是n*(n-1)/2,中间任意乱序数组的数量则落在两者之间。换句话说,它是衡量序列无序度的一种“距离”度量,和“把这个数组稳定排序需要移动多少元素”直接相关。

我在后来的很多场景里发现这个概念并不只是面试题:数据完整性校验、排序后相对次序变化分析、以及一些推荐排序里的扰动检测,都会用到逆序对或者它的变种。理解清楚它的数学性质,比单纯背答案有用得多。

2. 暴力解法:能跑,但跑不出规模

如果不知道优化思路,绝大多数人的第一反应都是直接枚举。两层循环,外层固定左侧元素,内层遍历右侧元素,一旦发现左侧大于右侧,计数加一。这个解法完全没错,而且在小数据量下还可以作为验证工具。

2.1 双层循环的直白版本

暴力版本在 Go 里面写起来很简洁:

func bruteForceCount(nums []int) int64 { var count int64 for i := 0; i < len(nums); i++ { for j := i + 1; j < len(nums); j++ { if nums[i] > nums[j] { count++ } } } return count }

注意我把返回值定义成了int64,原因是逆序对数量的上限是n*(n-1)/2,当数组长度为 100000 时,理论最大值已经接近 50 亿,普通 32 位整数很可能装不下。从第一版代码就使用 64 位整数,可以少踩一个非常隐蔽的坑。

2.2 规模大了以后会发生什么

暴力解法的时间复杂度是 O(n^2)。假设一台机器每秒能执行大约一亿次基本比较,当数组长度来到 10000,大约需要执行 5000 万次比较,肉眼可能还能接受;当长度来到 100000,需要执行 50 亿次比较,运行时间会从毫秒级直接跨到秒级甚至更久;再往上到 1000000,那就是灾难。

我在实际测试时用随机数组跑过两个版本的对比。长度 1 万以内,暴力版和归并版几乎看不出差距;长度 10 万时,暴力版已经明显卡顿,而归并版仍然在个位数毫秒级别。这里想说的并不是暴力版“一无是处”,相反,它非常适合做基准验证:我先用暴力版算出正确答案,再拿归并版去对照,能快速确定优化版本有没有写错。我到现在都习惯把暴力版留在测试代码里,作为交叉验证的基准。

还有一个容易被忽略的点:暴力版会真实地枚举所有数对,所以它对任何规模的数据都能给出确定结果,不存在递归深度、边界处理之类的问题。因此在小数组的单元测试中用暴力版做标准答案,是非常稳妥的策略。

3. 归并排序解法:把计数融进排序过程的三个关键点

既然暴力解法在数据规模变大以后不现实,那就要找一种只排序、不枚举所有数对的方法。这里核心思路来自归并排序:在合并两个有序子数组的过程中,顺带统计跨子数组的逆序对。

3.1 从“比较所有数对”到“只在合并时数”

归并排序的本质是分治:先把数组从中间拆成左右两半,分别排序,再合并。一个逆序对中的两个元素,只可能有两种位置关系:都在左半部分,都在右半部分,或者一个在左、一个在右。前两种情况会被递归子问题覆盖,第三种情况只在合并这一步发生。

所以我可以把问题拆成三部分统计:左半部分内部的逆序对,加上右半部分内部的逆序对,再加上左右两半之间形成的逆序对。递归能解决前两部分,合并时只需要重点处理第三部分。这个“分治”视角和暴力枚举完全不同,暴力枚举把每一对都看作独立个体,而归并解法把大量比较压缩到了合并阶段。

因此整个算法的时间复杂度从 O(n^2) 下降到 O(n log n)。这也是归并排序本身的能力:每一层所有子区间合并的总工作量是 O(n),递归深度是 O(log n),所以总工作量是 O(n log n)。

3.2 (mid - i + 1) 是怎么算出来的

这是全题最关键的一行代码,也是我在初次实现时最容易写错的地方。先看合并过程本身:两个子数组都已经有序,左边是arr[left:mid+1],右边是arr[mid+1:right+1],合并时用两个指针i和j分别指向当前左右元素,谁小先放入临时数组。

关键判断发生在arr[i] > arr[j]时。由于左边子数组内部是有序递增的,从i到mid的所有元素都大于等于arr[i],而arr[i]已经大于arr[j],那么从i到mid的全部元素都一定大于arr[j]。也就是说,arr[j]和左边剩余的这一批元素,每一对都构成逆序对,数量正好是mid - i + 1。

一句让很多人困惑的话是:“为什么不是只加 1?”因为当前这一步取了右边的arr[j],说明右边的这个元素小于左边当前指针指向的元素。可左边指针后面的元素更大,它们自然也都大于这个右边的元素。这几个逆序对在这一步被批量统计了,等指针再往后走,这些元素就再也不会和同一个右边的arr[j]组成任何新对,因此不会漏计,也不会重复计。

等号的处理也要特别留意:如果arr[i] <= arr[j],说明左边元素不大于右边元素,此时不构成逆序对。如果误写成<,会把相等的两个元素也当成逆序对。这个问题在重复元素很多的数组中会直接导致答案错误。

3.3 用手算子数组推演一遍 [7,5,6,4]

我每次学这一类算法,都习惯拿一个小数组完整推演,光看代码容易云里雾里。这里用[7, 5, 6, 4]来验证上面的逻辑。

第一步,从中间拆开,左半部分[7, 5],右半部分[6, 4]。

先递归处理左半部分[7, 5]:再拆成[7]和[5],合并时7 > 5,加 1,得到排序后的[5, 7]。

再递归处理右半部分[6, 4]:拆成[6]和[4],合并时6 > 4,加 1,得到排序后的[4, 6]。

现在进入最后的合并,左边是[5, 7],右边是[4, 6]:

  • 比较5和4,因为5 > 4,加mid - i + 1,此时左边从5到末尾有两个元素,所以加 2,同时取走右边的4;
  • 比较5和6,5 <= 6,取走左边的5;
  • 比较7和6,7 > 6,此时左边剩余元素只有7,加 1,取走右边的6;
  • 左边剩余7直接放入临时数组。

总计数是1 + 1 + 2 + 1 = 5。再暴力枚举验证一下:[7,5]、[7,6]、[7,4]、[5,4]、[6,4],正好 5 个,没有误计。这个推演过程理解透了,代码就很难写错。

4. Go 源码实现与逐段注释

语言本身也会影响这类算法的写法。Go 的切片、递归和内存分配机制,和 C++ 或 Java 主流的写法有一些微妙差异。这里给出一个我能直接放到项目里用的完整实现。

4.1 完整源码

package inversion // InversePairs 统计数组中的逆序对数量。 // 返回值使用 int64,避免数组较大时计数溢出。 func InversePairs(nums []int) int64 { // 拷贝一份输入数据,保证原数组不被排序过程改动 arr := make([]int, len(nums)) copy(arr, nums) // 临时数组只在最外层分配一次 tmp := make([]int, len(arr)) return mergeSortAndCount(arr, tmp, 0, len(arr)-1) } func mergeSortAndCount(arr, tmp []int, left, right int) int64 { if left >= right { return 0 } mid := left + (right-left)/2 var count int64 // 左半部分内部的逆序对 count += mergeSortAndCount(arr, tmp, left, mid) // 右半部分内部的逆序对 count += mergeSortAndCount(arr, tmp, mid+1, right) // 跨越左右两部分的逆序对 count += mergeAndCount(arr, tmp, left, mid, right) return count } func mergeAndCount(arr, tmp []int, left, mid, right int) int64 { i := left // 左子数组指针 j := mid + 1 // 右子数组指针 k := left // 临时数组指针 var count int64 for i <= mid && j <= right { if arr[i] <= arr[j] { tmp[k] = arr[i] i++ } else { tmp[k] = arr[j] // 关键计数:左边从 i 到 mid 的所有元素都大于 arr[j] count += int64(mid - i + 1) j++ } k++ } // 剩余元素直接拷贝 for i <= mid { tmp[k] = arr[i] i++ k++ } for j <= right { tmp[k] = arr[j] j++ k++ } // 把排序结果写回原切片,保证递归上层能看到有序结果 copy(arr[left:right+1], tmp[left:right+1]) return count }

4.2 三个值得多花时间看的设计点

第一个设计点是临时数组的分配策略。很多初学版本会在每次递归合并时新建make([]int, ...),结果会产生 O(n log n) 次分配,虽然算法复杂度没变,但内存分配带来的开销非常明显。我在上面的实现里只在最外层创建一次tmp,递归全程复用同一块切片。这样可以把额外空间控制在 O(n)。

第二个设计点是left + (right-left)/2而不是(left+right)/2。在左右边界都很大的情况下,后一种写法可能因为整数加法溢出导致 mid 算错。Go 的 int 在 64 位平台是 64 位,能容纳很大的数组长度,但用前一种写法是更稳妥的习惯,也能避免边界计算中的细小问题。

第三个设计点是递归边界left >= right。这个条件看似简单,却在数组为空时特别关键。当InversePairs接收到空数组时,初始调用right = -1,此时left(0) >= right(-1),函数直接返回 0,不会出现切片越界。很多初学者会写成left == right,遇到空数组就会直接 panic。

4.3 时间空间的实际代价

这个实现的时间复杂度是 O(n log n),空间复杂度 O(n)。但实际运行时的常数开销比我想象中要小不少,因为切片拷贝使用 Go 底层的内存复制,合并过程是线性扫描,没有额外复杂操作。

我在测试数据规模 100 万乱序整数时,单次调用大约在几百毫秒级别,具体取决于机器。这个规模下,暴力版本基本已经等不到结果了。如果数据量继续增大到几千万,我会考虑换成迭代式归并排序,规避递归函数调用的开销,不过那是另一个优化话题,普通业务场景里用上面的递归版本已经足够稳定。

5. 测试用例与边界条件:写这类函数最容易翻车的地方

算法写完不等于万事大吉。逆序对这类函数,边界条件多,重复数据多,稍微不注意就会出现“小数据全对,大数据全错”的诡异现象。

5.1 用 Go 测试函数把正确性锁住

我用 Go 内置的testing包写一组固定测试用例,配合暴力版交叉验证。

package inversion import ( "math/rand" "testing" ) func bruteForceCount(nums []int) int64 { var count int64 for i := 0; i < len(nums); i++ { for j := i + 1; j < len(nums); j++ { if nums[i] > nums[j] { count++ } } } return count } func TestInversePairs(t *testing.T) { cases := []struct { name string data []int want int64 }{ {"空数组", []int{}, 0}, {"单个元素", []int{1}, 0}, {"完全升序", []int{1, 2, 3, 4, 5}, 0}, {"完全降序", []int{5, 4, 3, 2, 1}, 10}, {"经典例子", []int{3, 1, 2}, 2}, {"重复元素", []int{2, 2, 1, 1}, 4}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := InversePairs(tc.data); got != tc.want { t.Fatalf("got %d, want %d", got, tc.want) } }) } }

固定用例之外,更重要的是随机交叉验证。我会生成长度从 0 到 50 的随机数组,分别用暴力版和归并版计算,只要结果不一致,就立刻定位问题。

func TestRandomCrossCheck(t *testing.T) { rng := rand.New(rand.NewSource(20250401)) for n := 0; n < 50; n++ { data := make([]int, n) for i := range data { data[i] = rng.Intn(100) } original := make([]int, n) copy(original, data) want := bruteForceCount(data) got := InversePairs(data) if got != want { t.Fatalf("n=%d data=%v got=%d want=%d", n, data, got, want) } for i := range data { if data[i] != original[i] { t.Fatalf("input modified: got %v want %v", data, original) } } } }

这个随机测试里我还顺带校验了一件事:调用完InversePairs后,原数组没有被排序过程改掉。因为我先拷贝了输入,所以这个检查一定通过;如果实现里直接拿外部切片排序,这个测试就会立刻报警。

5.2 重复元素导致的最大坑:相等到底算不算

很多人的第一版实现会把arr[i] <= arr[j]写成arr[i] < arr[j],导致相等的两个元素被当成逆序对。比如[2, 2],规范答案是 0,因为2并不大于2;但如果比较条件写错,会统计成 1。

这个问题在包含大量重复值的业务数据中非常致命。我一开始也踩过这个坑,测试数据全是随机整数,很少在意重复值是否统计正确。后来加了一组包含大量重复元素的压测用例,才暴露出来。所以我在固定用例里特意写了{"重复元素", []int{2, 2, 1, 1}, 4},就是要保证相等元素不会被误计。

5.3 别改动入参,除非你有明确理由

归并排序天然会改变原数组顺序。在纯粹刷题场景里,改掉传入数组可能无所谓;但在工程项目中,调用方往往还持有这份数据做后续计算,一旦被排序,后续逻辑全乱。

我建议的默认做法是:在入口处拷贝一次输入。虽然多出 O(n) 的拷贝时间,但能换来非常可贵的确定性。上面的源码也是这么做的。如果对性能极其敏感,可以在文档里明确标注“此函数会修改输入数组”,再把拷贝去掉,但这应该是一个显式决策,而不是顺手写出来的副作用。

6. 这道题的后续延伸:它不只是面试题

逆序对的归并解法掌握以后,能迁移到不少看起来不相干的题目上。比如“计算数组中有多少对元素满足某种偏序关系”“求两个排列之间的相似度”,甚至某些推荐排序场景里的乱序程度评估,背后都是同一套分治计数思想。每次遇到新的“成对计数”问题,我都会先问一句:能不能在排序的过程中顺便统计?如果能,那大概率就是归并排序变种。

6.1 从逆序对到偏序对问题的想象力

如果把“逆序对”的判定条件从nums[i] > nums[j]换成其他比较规则,比如nums[i] > 2 * nums[j],或者附带一些额外结构体字段比较,算法框架仍然成立。真正需要修改的只是合并时的判断条件和计数公式。理解到这一层,题目就不再是背模板,而是变成一种“排序时批量处理相对关系”的思维工具。

6.2 我检查这类代码的经验

我现在写这类函数,一定会先跑三件事:

  • 用空数组、长度为 1 的数组验证越界问题;
  • 用完全升序和完全降序的数组验证计数的上下边界;
  • 用包含重复元素的数组验证等号条件。

这三个都过了,再拿暴力版做随机交叉验证。很多边界问题,比如 Go 切片索引、递归终止条件、计数溢出,都能在这一套流程里暴露出来。这套流程看起来很简单,但能在很长时间里帮我少走弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询