第一次在面试官面前写这道题,我交出的版本是两层for循环。代码在纸上倒也工整,只是对面那位看了一眼就放下笔问了一句:“如果数组是十万个元素,你的程序要跑多久?”那一瞬间我突然意识到,剑指offer里的这道逆序对,看起来是个“数数”的题,实际上考的是你对排序算法底层机制的理解深度。
这道题的完整表述很简单:给定一个数组,统计其中满足i < j且nums[i] > nums[j]的数对数量。比如[7,5,6,4],逆序对一共有(7,5)、(7,6)、(7,4)、(5,4)、(6,4)五对。它之所以是面试高频题,是因为它同时覆盖了分治思想、归并排序实现、稳定性理解、数据范围判断,甚至还能扩展到树状数组和离散化。无论你是刚开始刷题准备校招,还是工作几年想补算法短板,这道题都值得花一个晚上彻底吃透。
下文就按我自己的理解路径来写:先讲清楚暴力解为什么不行,再拆归并排序的统计原理,接着把所有容易踩的细节摆出来,然后给出树状数组的另一种解法,最后聊聊面试现场和工程中的延展。整个过程也是我当初从“能跑”到“能讲清楚”的真实过程。
1. 为什么这道题容易在细节上翻车:从暴力解法的局限说起
1.1 先搞清楚逆序对到底在数什么
很多人在第一眼看到逆序对时,会觉得它不过是一个“双重循环判断大小”的送分题。暴力思路确实很直白:枚举所有满足i < j的下标组合,数一下有多少个nums[i] > nums[j],累加输出。
def count_inversions_brute(nums): n = len(nums) inv = 0 for i in range(n): for j in range(i + 1, n): if nums[i] > nums[j]: inv += 1 return inv这段代码本身没有任何错误,在小数组上甚至能秒出答案。但它在面试里只配当“热身运动”,因为它完全没有触及这道题真正考察的算法思维。你可以把n代入10^5试着感受一下:此时内层循环要执行约5 x 10^9次比较,即便以每秒一亿次运算的理想速度估算,也需要几十秒才能出结果。真正面试时不可能存在这种规模的数组让你慢慢等。
1.2 双重循环在千万级数据面前根本不现实
这里的关键不是“暴力能不能做”,而是“暴力为什么不能做”。两层循环的时间复杂度是O(n^2),当数据规模从几千涨到几十万时,计算量会以平方级别爆炸式增长。实际笔试里逆序对题目给出的数组长度往往能达到10^5甚至10^6,这时候任何O(n^2)的算法都无法在规定时间内跑完。
我见过不少人在这一步就卡住了:他们知道暴力不行,但不知道下一步该往哪个方向想。其实面试官真正想看到的是一层简单的推导——“比较两个元素大小并统计顺序关系,这本质上和排序过程中对元素进行‘跨区间比较’是同一件事”。排序算法的时间复杂度下界是O(n log n),这说明我们完全有可能在排序的同时把逆序对数量顺带算出来,而不是傻傻地把所有数对枚举一遍。
1.3 一个关键结论:逆序对数等于冒泡排序的交换次数
在引入归并排序之前,值得先记住一个有趣的等价关系:一个数组的逆序对数量,恰好等于用冒泡排序对它排好序所需的交换次数。原因很简单——冒泡排序每次交换一对相邻的逆序元素,只消除这一对逆序关系,不会影响其他元素之间的相对顺序,所以总交换次数就是初始逆序对总数。
这个结论的价值不在于让你真的去写冒泡排序,而在于它揭示了“排序过程天然携带逆序信息”。归并排序之所以能统计逆序对,正是因为它的每一次跨区间合并,本质上就是在做一种“相邻元素比较并消除逆序”的操作。理解了这一点,后面看归并解法的统计逻辑就会顺很多。
2. 归并排序解法:排序动作里数出答案
2.1 为什么偏偏是归并排序而不是快排或堆排
很多初学者会问:既然要排序,我用快速排序行不行?答案是,归并排序有一个其他排序不具备的结构特性——它在合并两个已经有序的子数组时,需要逐对比较左右两边的元素,而这种“跨子数组的比较”恰恰是逆序对产生的全部来源。
回想快速排序:每次划分确实把元素分到了两侧,但它只保证左侧整体小于等于右侧整体,不会逐个比较两个分区之间的元素对,所以无法在排序过程中自然地累计逆序信息。堆排序更不用说,它只关心最小值的弹出,对全序关系的处理是隐式的。而归并排序是稳定排序,遇到相等元素时不会颠倒它们的相对位置,这一点也和“只有严格大于才算逆序对”的定义严丝合缝。
再往深一层想:逆序对只会在“左边某个元素大于右边某个元素”时出现。而归并排序在合并左右两个有序区间时,只要发现右边的一个元素被放进结果数组,就意味着它比左边区间所有尚未放入的元素都小,这些“尚未放入的左边元素”每一个都和它构成一个逆序对。这个逻辑只需要看一眼合并过程就能写出来。
2.2 核心统计逻辑:右边落位时,左边剩余多少就加多少
归并排序的合并函数本身就不复杂。假设我们正在合并[left, mid]和[mid+1, right]两个有序子数组,使用三个指针i、j、k分别指向左子数组当前位置、右子数组当前位置、临时数组写入位置:
- 当
nums[i] <= nums[j]时,左边的元素先落位,不产生逆序对; - 当
nums[i] > nums[j]时,右边的元素先落位,此时左子数组中从i到mid的所有元素都比nums[j]大,因此新增mid - i + 1个逆序对。
def merge_sort(arr, tmp, left, right): if left >= right: return 0 mid = (left + right) // 2 inv = merge_sort(arr, tmp, left, mid) inv += merge_sort(arr, tmp, mid + 1, right) i, j, k = left, mid + 1, left while i <= mid and j <= right: if arr[i] <= arr[j]: tmp[k] = arr[i] i += 1 else: tmp[k] = arr[j] inv += mid - i + 1 j += 1 k += 1 while i <= mid: tmp[k] = arr[i] i += 1 k += 1 while j <= right: tmp[k] = arr[j] j += 1 k += 1 arr[left:right + 1] = tmp[left:right + 1] return inv def count_inversions(nums): tmp = [0] * len(nums) return merge_sort(nums, tmp, 0, len(nums) - 1)我把这段代码写成了“递归返回逆序对数量”的形式,而不是用全局变量,目的就是为了让函数签名自解释——每个递归调用返回它管辖区间内的逆序对总数,父级调用再把左右两边的数加上合并时新产生的数。这样无论是读代码还是口述思路,都很顺畅。
2.3 从切片写法到索引写法的升级
在网上很多题解里,你看到的归并统计代码是这样的:
def merge_sort_count(arr): n = len(arr) if n < 2: return arr, 0 mid = n // 2 left, inv_left = merge_sort_count(arr[:mid]) right, inv_right = merge_sort_count(arr[mid:]) merged = [] inv = inv_left + inv_right i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: merged.append(left[i]) i += 1 else: merged.append(right[j]) inv += len(left) - i j += 1 merged.extend(left[i:]) merged.extend(right[j:]) return merged, inv这种写法在逻辑上更好懂,适合初学者理解算法思路。但如果你把它直接交到面试官手里,对方大概率会追问一句:“数组切片会不会有额外开销?”,因为这个版本每次递归都要创建新的子数组和临时数组,空间复杂度实际是O(n log n)级别的。面试场上更稳妥的做法是使用“索引 + 辅助数组”的版本,所有操作都在原数组的索引区间上进行,辅助数组全程只有一个,总空间复杂度是标准的O(n)。
刚才那段索引版本,就是面试推荐写法。如果你面试的是C++岗位,还可以把返回类型写成long long,因为逆序对数量可能远超int的表示范围,这点我在下一节会专门展开。
3. 归并写法里最容易踩的四个细节
3.1 等号到底该放在哪一边
这是归并排序统计逆序对最经典的坑。合并时判断条件如果写成了if arr[i] < arr[j],而不是if arr[i] <= arr[j],程序还能跑通,结果却是错的。
用一个极小的例子验证:[2, 2],正确逆序对数为 0,因为相等元素不构成逆序对。但如果比较条件漏了等号,合并时第一个2会先落位(因为arr[i] < arr[j]为 False),而右子数组的2落位时,左子数组剩余元素数量为 1,就会被错误地累加一次逆序对。
再举一个更长一点的例子:[1, 2, 2, 3],正确的逆序数也是 0。错误写法在第二次合并时,会把第二个元素2与右侧区间合并时,错误地认为左侧剩余一个元素比它大,最终多出逆序对。这个 bug 不会导致程序崩溃,而且只在含有相等元素时才出错,所以非常隐蔽。
提示:判断条件里等号放在左边一侧——
arr[i] <= arr[j]时先放左边的元素。这不仅是为了维持稳定排序,更是为了避免把相等元素误算成逆序对。
3.2 递归里返回值和全局变量的取舍
统计逆序对有三种常见的传递方式:全局变量、返回值、引用参数。我个人在答案里更推荐返回值,或者引用参数。理由很简单:全局变量会在函数调用之间共享状态,如果同一个函数被并发调用或者连续调用多次,容易残留上一次的值;虽然面试场景不会真出这种并发问题,但“全局变量有副作用”这个点本身就是一个可以聊的技术话题,能体现工程意识。
引用参数版本在支持引用传递的语言里也很干净。比如面试官用 C++ 提问时,你可以这样写:
long long mergeCount(vector<int>& a, vector<int>& tmp, int l, int r) { if (l >= r) return 0; int mid = (l + r) / 2; long long cnt = mergeCount(a, tmp, l, mid) + mergeCount(a, tmp, 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; tmp[k++] = a[j++]; } } while (i <= mid) tmp[k++] = a[i++]; while (j <= r) tmp[k++] = a[j++]; for (int t = l; t <= r; ++t) a[t] = tmp[t]; return cnt; }这个版本把逆序对数量作为返回结构带上来,调用方不用关心全局状态,阅读起来结构清晰,也不容易写错。
3.3 逆序对数量的上限:int 真的会爆
这个细节在笔试里能刷掉一大半人。数组长度为n时,最大逆序对数出现在数组完全逆序的情况下,数量是n * (n - 1) / 2。当n = 200000时,这个数大约是2 x 10^10,远超 32 位int的约2.1 x 10^9上限。
因此,在 C++ 或 Java 里,计数器必须使用long long或等价的 64 位类型;Python 因为原生整数不设上限,反而没有这个问题。我见过有人面试时用int计数,小数据测试全通过,提交后在大规模用例上直接溢出的情况。虽然题目一般不会明说“答案可能超过 int 范围”,但你在设计返回类型时应该主动意识到这一点。在面试现场多问一句“数组长度有没有上限”,既显得严谨,也能帮你确定计数器类型。
3.4 数据规模极大时,递归深度也需要考虑
归并排序的标准版本是递归实现的,当数组长度达到10^6甚至10^7时,递归深度约为log n,通常不会触发栈溢出。但一些极端场景(比如某些语言栈空间受限,或者数组规模达到上亿)可能会让人担心递归开销。此时可以用迭代式归并替代递归。
迭代式归并的核心思路是:用width表示当前合并的子数组长度,从 1 开始,每次翻倍,直到width >= n。每一轮对数组按width分段,两两合并相邻区间。
def count_inversions_iterative(arr): n = len(arr) arr = arr[:] tmp = [0] * n inv = 0 width = 1 while width < n: for left in range(0, n, 2 * width): mid = min(left + width, n) right = min(left + 2 * width, n) i, j, k = left, mid, left while i < mid and j < right: if arr[i] <= arr[j]: tmp[k] = arr[i] i += 1 else: tmp[k] = arr[j] inv += mid - i j += 1 k += 1 while i < mid: tmp[k] = arr[i] i += 1 k += 1 while j < right: tmp[k] = arr[j] j += 1 k += 1 for t in range(left, right): arr[t] = tmp[t] width *= 2 return inv这段代码在逻辑上和递归版完全等价,只是把递归展开成了循环。面试时如果不是特别强调不许用递归,我不建议现场临时写这个版本,因为它对边界条件的理解要求更高,容易写着写着就漏了mid和right的取最小值逻辑。但它值得你私下推演一遍,因为迭代式归并本身就是不少框架底层排序的实现思路,理解了它对“分治到底在干什么”会有更深的体感。
4. 树状数组解法:换个角度数逆序对
4.1 从“右侧更小”到“频次前缀和”
归并排序虽然简洁,但它不是唯一解法。另一个非常经典的方向是:把逆序对问题看成一个“在值域上做动态频次统计”的问题。
换个视角:从左往右扫描数组,对于当前位置i,已经扫描过的左侧元素中,有多少个比nums[i]大,这些元素就与nums[i]构成了逆序对。于是问题变成了:我们需要维护一个数据结构,支持两种操作——向里面添加一个数(单点频次加一),以及查询“当前已经添加的元素里,大于某个值的个数是多少”。
朴素数组也能做:用一个长度等于值域范围的数组freq[idx]记录频次,添加是O(1),但查询“比 x 大的个数”需要从头扫到尾,是O(n)。如果对每个元素都这样做一次,总复杂度又退化成了O(n^2)。树状数组的优势在于,它把添加和查询都优化到了O(log n),整体复杂度是O(n log n)。
4.2 为什么用树状数组而不是线段树或普通数组
树状数组本质上是一个支持“单点更新 + 前缀和查询”的轻量级数据结构。它比线段树代码短、常数小,实现起来也不容易出错,所以在处理“频次累计”这类问题上性价比很高。而线段树功能更全,还能做区间最大值、区间合并统计等,但为了数一个逆序对去写线段树,有点杀鸡用牛刀。
这里还有一个容易忽略的步骤——离散化。数组元素的实际值域可能非常大,比如nums[i]的取值在[-10^9, 10^9]之间,这时不可能直接开一个这么大的频次数组。离散化的做法是:先取出数组中的所有不同值,排序,再把每个值映射成一个从 1 开始的排名。比较数值大小变为比较排名大小,逆序对的数量完全不变,但频次数组的大小被压缩到了n的规模。
4.3 树状数组的完整实现与归并解法对比
树状数组模板本身很短,核心就是两个函数:add(idx, delta)做单点更新,query(idx)做前缀和查询。低频次的实现细节是lowbit运算,也就是idx & -idx,它决定了一次更新或查询要经过哪些下标。
class BIT: def __init__(self, n): self.n = n self.tree = [0] * (n + 1) def add(self, idx, delta): while idx <= self.n: self.tree[idx] += delta idx += idx & -idx def query(self, idx): s = 0 while idx > 0: s += self.tree[idx] idx -= idx & -idx return s def count_inversions(nums): sorted_vals = sorted(set(nums)) rank = {v: i + 1 for i, v in enumerate(sorted_vals)} bit = BIT(len(sorted_vals)) inv = 0 for x in nums: idx = rank[x] # 左侧已经出现的元素中,大于 x 的个数 inv += bit.query(len(sorted_vals)) - bit.query(idx) bit.add(idx, 1) return inv这个版本从左往右扫,每次查询“目前的频次前缀和中,总频次减去小于等于当前排名的频次”,得到“已经出现过的、比当前元素大”的数量,累加到答案里。等价的写法还有从右往左扫,查询“比当前元素小的数量”,然后把它加入答案,逻辑也是一样的。
两种解法各有利弊,我用一个表格总结:
| 对比维度 | 归并排序解法 | 树状数组解法 |
|---|---|---|
| 时间复杂度 | O(n log n) | O(n log n) |
| 空间复杂度 | O(n),需辅助数组 | O(n),需离散化数组与频次数组 |
| 代码量 | 中等,约40行 | 较少,约35行 |
| 是否依赖排序稳定性 | 是 | 否 |
| 思维难度 | 偏分治,直观易讲 | 偏数据结构,需要额外概念 |
| 面试推荐度 | 强烈推荐,先讲这个 | 作为加分项展示 |
如果面试时间有限,我建议先把归并解法讲透;如果时间宽裕,可以再用“其实还能用树状数组优化到同样复杂度”作为加分点补一句,面试官通常会因此对你的知识广度有更好的印象。
5. 从刷题到实战:这题真正值钱的地方
5.1 面试现场如何组织表达
在面试里写这道题,我建议按下面这个顺序一步步展开,每一步都尽量用一两句话说清“为什么”:
- 先说题意:统计满足
i < j且nums[i] > nums[j]的数量; - 给出暴力解
O(n^2),并直接指出它在10^5数据量下不可行; - 引出“排序过程天然包含逆序信息”;
- 选归并排序,说明选择理由是它的稳定性和跨区间比较结构;
- 手写代码,写出
<=判断,计数器用long long; - 补一句正确性依据:每次合并右半元素落位时,左半剩余元素均大于该元素。
这套讲法能让面试官清楚地看到你的思考链条。实际上,他在这个过程中可能追问的点也无外乎三个:相等元素怎么处理、复杂度怎么算、有没有更优解法。前两个在前面都覆盖了,第三个答案就是树状数组版本。
5.2 几个常见变体题提前摸底
逆序对这个知识点不会只出现一种问法。我统计了一下,常见的变体至少有这么几类:
- 要求输出具体逆序对:此时归并排序同样可以收集,但需要额外注意,逆序对数量本身可能达到
O(n^2),全部输出的话复杂度下限就是O(n^2),题目一般不会真让这么做; - 单链表的逆序对:链表不能用随机访问索引,但可以写一个链表版归并排序,每次找中点再合并,思路完全一致;
- 二维数组或点对的逆序关系:比如平面点集按横坐标排序后,统计纵坐标之间的逆序关系,这就把题目从一维拓展到了二维偏序问题,也是很多高级数据结构的入门台阶。
这些东西不需要一次性全掌握,但如果你在面试中能主动补充一句“这个思路其实还能延伸到二维偏序”,会比只会默写模板强很多。
5.3 工程里真的会用逆序对吗
说实话,日常业务开发几乎不会让你去手写一个归并排序统计逆序对。但这个问题的底层思维在几个方向上有真实延伸。比如数据库的查询优化器在估算排序代价时,需要知道数据排列的无序程度,逆序对就是一个很好的度量指标;推荐系统里衡量用户兴趣序列的稳定性,也会用到类似“序列对之间的顺序一致性”的概念;量化分析里常用的 Kendall 系数,本质上就是对“两个序列之间逆序对数量”做归一化处理。
这不是说你需要把这些场景背下来,而是说“在有序与无序之间找到量化指标”这个能力,在工程中是通用的。当你确实遇到相关问题时,你会有足够敏锐度去想到“这个问题其实和逆序对不同,但核心思想是相通的”。
最后分享一个我自己的调试教训。早期我写归并解法时,曾经在<=和<之间摇摆,导致某些数组答案比实际多出一截。后来我摸索出一个非常有效的验证方法:生成一个长度为n的完全逆序数组和一个完全有序数组,前者答案必须精确等于n * (n - 1) / 2,后者必须精确等于 0,再用一个随机数组和暴力解法交叉比对。这三组测试一旦全部通过,代码基本就稳了。
这道题刷透之后你会明显感觉到,后面遇到很多“排序过程中顺便统计信息”的题目,比如逆序对的近亲“计算每个元素右侧比自己小的数量”,都会顺手很多。归并排序不再只是一个排序模板,而是一种能同时承载“比较、交换、统计”的思维工具。