从信息学奥赛到面试刷题:用归并排序搞定逆序对问题(附洛谷P1908 AC代码) 从算法竞赛到技术面试归并排序在逆序对问题中的高阶应用第一次参加信息学奥赛时我盯着那道逆序对统计的题目整整半小时无从下手。直到赛后复盘才明白原来归并排序不仅能排序还是解决这类分治统计问题的瑞士军刀。如今在技术面试中类似的题目依然频繁出现——只不过换了个马甲变成了数组小和或翻转对问题。本文将带你穿透题目表象掌握归并排序解决逆序对类问题的通用思维模型。1. 逆序对问题的本质与应用场景逆序对Inversion Pair的严格定义是在一个序列中如果前面的数字大于后面的数字这两个数字就组成一个逆序对。这个概念看似简单却在多个领域有着重要应用排序算法分析衡量数据的有序程度逆序对数量直接影响插入排序等算法的性能金融风险建模股票价格序列的逆序对数量可以反映市场波动性基因组测序通过比较基因序列的逆序对数量分析生物相似性推荐系统评估用户偏好排序的逆序对数量可衡量推荐列表的质量在技术面试中逆序对的变体问题层出不穷。比如字节跳动常考的数组小和问题本质就是统计所有元素前面比它小的元素之和——这与逆序对统计有异曲同工之妙。美团2022年校招笔试中的翻转对问题则是要求统计满足i j且nums[i] 2*nums[j]的元组数量这需要我们对标准逆序对算法进行巧妙改造。提示当面试官要求时间复杂度优于O(n²)时90%的情况下都指向基于分治思想的解决方案2. 归并排序解决逆序对的数学原理为什么归并排序能高效统计逆序对关键在于分治过程中天然具备的有序性保证和区间划分特性。让我们用数学归纳法严格证明这个方法的正确性基础情况当数组长度为1时逆序对数量显然为0算法正确。归纳假设假设对于所有长度小于n的数组算法能正确计算逆序对数量。归纳步骤对于长度为n的数组我们将其分为两个子数组L和R根据假设递归过程能正确计算L和R内部的逆序对。在合并阶段当且仅当R中的元素y被选中放入合并数组时它会与L中所有未被放入的元素构成逆序对。由于L已排序这些元素的数量可以立即确定为mid - i 1。这个过程的正确性依赖于一个关键观察跨区间的逆序对不会被重复计算也不会遗漏。具体来说对于L中的元素x所有与x构成逆序对的R中元素y都会在合并时被精确统计对于R中的元素y所有与y构成逆序对的L中元素x都会在y被处理时被统计L或R内部的逆序对已在递归过程中统计完成def count_inversions(arr): def merge_sort(arr): if len(arr) 1: return arr, 0 mid len(arr) // 2 left, inv_left merge_sort(arr[:mid]) right, inv_right merge_sort(arr[mid:]) merged, inv_merge merge(left, right) total inv_left inv_right inv_merge return merged, total def merge(left, right): result [] i j inv_count 0 while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) i 1 else: result.append(right[j]) j 1 inv_count len(left) - i result.extend(left[i:]) result.extend(right[j:]) return result, inv_count _, count merge_sort(arr) return count3. 从竞赛到面试问题变体与应对策略技术面试往往不会直接考查标准逆序对问题而是会设计各种变体来考察候选人的算法迁移能力。以下是三种典型变体及其解决方案3.1 数组小和问题问题描述计算数组中每个数前面比它小的数的和再求所有这些和的总和。解法将统计逆序对数量改为累加左侧剩余元素的和。修改merge步骤def merge(left, right): result [] i j small_sum 0 while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) small_sum left[i] * (len(right) - j) i 1 else: result.append(right[j]) j 1 result.extend(left[i:]) result.extend(right[j:]) return result, small_sum3.2 翻转对问题问题描述统计满足i j且nums[i] 2*nums[j]的元组数量。解法在归并前先进行一次单独的统计def count_reverse_pairs(nums): def merge_sort(nums): if len(nums) 1: return nums, 0 mid len(nums) // 2 left, cnt_left merge_sort(nums[:mid]) right, cnt_right merge_sort(nums[mid:]) # 统计翻转对 j 0 cnt 0 for i in range(len(left)): while j len(right) and left[i] 2 * right[j]: j 1 cnt j merged, cnt_merge merge(left, right) total cnt_left cnt_right cnt cnt_merge return merged, total _, count merge_sort(nums) return count3.3 区间逆序对问题问题描述给定多个查询区间统计每个区间内的逆序对数量。解法使用莫队算法结合树状数组或者预处理所有可能的区间方法预处理时间查询时间空间复杂度适用场景归并排序O(n² log n)O(1)O(n²)查询次数极多莫队算法O(1)O(n√n log n)O(n)查询次数中等分块处理O(n√n log n)O(√n log n)O(n√n)平衡场景4. 工程实践中的优化技巧在实际编码实现时有多个细节会影响算法的正确性和效率数据类型选择逆序对数量可能非常大最大为n(n-1)/2必须使用64位整数边界条件处理空数组、单元素数组、全等数组等特殊情况稳定性保持当元素相等时应该先处理左侧元素以保证排序稳定性内存优化避免频繁创建临时数组可以预分配全局临时空间以下是经过优化的C实现#include vector using namespace std; long long mergeSort(vectorint nums, vectorint temp, int left, int right) { if (left right) return 0; int mid left (right - left) / 2; long long inv_count mergeSort(nums, temp, left, mid) mergeSort(nums, temp, mid 1, right); int i left, j mid 1, k left; while (i mid j right) { if (nums[i] nums[j]) { temp[k] nums[i]; } else { temp[k] nums[j]; inv_count mid - i 1; } } while (i mid) temp[k] nums[i]; while (j right) temp[k] nums[j]; for (i left; i right; i) { nums[i] temp[i]; } return inv_count; } long long countInversions(vectorint nums) { vectorint temp(nums.size()); return mergeSort(nums, temp, 0, nums.size() - 1); }在解决洛谷P1908这类问题时还需要注意输入规模可能达到5×10^5必须使用高效的IO方法考虑使用指针操作代替数组下标访问可能带来的性能提升对于Java等语言要注意避免自动装箱带来的性能损耗5. 算法思维的系统化训练掌握归并排序解决逆序对问题只是起点更重要的是培养识别问题模式的眼光。当遇到新问题时可以问自己这个问题是否涉及区间统计统计过程是否可以利用分治后有序性优化统计量是否具有可加性即整体统计量等于子问题统计量之和加上合并时的统计量以下是一些可以尝试的扩展练习练习1统计数组中满足i j k且nums[i] nums[j] nums[k]的三元组数量练习2给定两个排列计算它们的逆序对之和减去交集的逆序对数量练习3动态逆序对问题支持插入删除操作并实时统计逆序对数量在准备技术面试时建议建立自己的算法模式库将归并排序解决逆序对这类经典模式归档并记录各种变体的解决思路。例如模式名称关键特征时间复杂度典型例题分治统计区间查询、可加性统计O(n log n)逆序对、数组小和双指针统计有序区间、单调性O(n)两数之和、盛水容器前缀和区间聚合查询O(n)预处理O(1)查询子数组和、矩阵块求和