优化逆序对计数算法:从暴力解法到归并排序
问题背景
在数组中,逆序对是指满足 i < j
且 record[i] > record[j]
的下标对 (i, j)
。给定一个整数数组 record
,我们需要计算其逆序对的数量。例如,对于数组 [3, 1, 2]
,逆序对有 (3, 1)
和 (3, 2)
,总数为 2。然而,当数组规模较大时,简单的暴力解法会导致“时间超限”(Time Limit Exceeded)。本文将探讨如何从 O(n²) 的暴力解法优化到 O(n log n) 的归并排序解法。
暴力解法及其瓶颈
最初的解法使用两层循环,检查每一对 (i, j)
(其中 j < i
),如果 record[j] > record[i]
,则计数加 1。代码如下:
for (int j = 0; j < record.length-1; j++) {
for (int i = j+1; i < record.length; i++) {
if (record[j] > record[i]) {
result += 1;
}
}
}
这种方法的优点是简单直观,但时间复杂度为 O(n²),其中 n 是数组长度。对于小型数组(例如 n < 100),这种方法尚可接受。但当数组规模达到 10^4 或更高时,计算量将变得巨大,导致运行时间过长,无法通过严格的时间限制。
优化的思路:利用归并排序
为了将时间复杂度降低到 O(n log n),我们可以使用归并排序(Merge Sort)来计数逆序对。归并排序是一种分治算法,将数组分成两半,递归排序子数组,并在合并时处理逆序对的计数。其关键在于合并步骤,我们可以在合并两个已排序的子数组时高效地统计逆序对。
为什么选择归并排序?
归并排序的核心是将数组分成较小的子问题,解决后再合并。在合并过程中,两个子数组已经是排序好的,因此可以通过比较左右子数组的元素来快速计算逆序对。具体来说:
- 假设我们合并两个子数组:左子数组
[left, mid]
和右子数组[mid+1, right]
。 - 在合并时,如果左子数组的元素
record[i]
大于右子数组的元素record[j]
,那么从i
到mid
的所有元素都大于record[j]
(因为左子数组是排序好的)。因此,逆序对的数量可以直接加上mid - i + 1
。
这样,我们在合并的同时就能统计逆序对,避免了暴力解法中逐一比较的低效。
算法步骤
- 分治:将数组递归分成两半,直到子数组长度为 1(此时无逆序对)。
- 合并与计数:
- 在合并两个已排序的子数组时,使用两个指针
i
(指向左子数组)和j
(指向右子数组)。 - 如果
record[i] <= record[j]
,将record[i]
放入临时数组,继续处理下一个左子数组元素。 - 如果
record[i] > record[j]
,则发现逆序对,计数增加mid - i + 1
,并将record[j]
放入临时数组。 - 继续处理剩余元素,并将临时数组复制回原数组。
- 在合并两个已排序的子数组时,使用两个指针
- 递归累加:总逆序对数是左子数组、右子数组及合并过程中的逆序对数之和。
优化后的代码
以下是基于归并排序的优化代码:
class Solution {
// 主函数,用于计算数组中的逆序对数量
// 逆序对是指满足 i < j 且 record[i] > record[j] 的下标对 (i, j)
public int reversePairs(int[] record) {
// 处理边界情况:如果数组为空或长度小于2,则没有逆序对,返回0
if (record == null || record.length < 2) return 0;
// 调用归并排序函数,处理整个数组(从索引0到length-1)
return mergeSort(record, 0, record.length - 1);
}
// 递归的归并排序函数,通过分治法分割数组并统计逆序对
// 参数:record(数组),left(子数组起始索引),right(子数组结束索引)
private int mergeSort(int[] record, int left, int right) {
// 基本情况:如果子数组长度为0或1(left >= right),没有逆序对,返回0
if (left >= right) return 0;
// 计算中间索引,将数组分成两半
// 使用 left + (right - left) / 2 避免整数溢出,而不是 (left + right) / 2
int mid = left + (right - left) / 2;
// 递归计算左半部分(left到mid)和右半部分(mid+1到right)的逆序对
// 总逆序对数 = 左半部分逆序对 + 右半部分逆序对 + 合并过程中的逆序对
int count = mergeSort(record, left, mid) + mergeSort(record, mid + 1, right);
// 在合并两个子数组时统计逆序对并完成合并
count += merge(record, left, mid, right);
// 返回当前子数组的逆序对总数
return count;
}
// 合并函数,用于合并两个已排序的子数组并统计逆序对
// 参数:record(数组),left(左子数组起始索引),mid(左子数组结束索引),right(右子数组结束索引)
private int merge(int[] record, int left, int mid, int right) {
// 创建临时数组存储合并结果,大小为 right - left + 1,包含整个子数组范围
int[] temp = new int[right - left + 1];
// 初始化逆序对计数器
int count = 0;
// 初始化指针:
// i:指向左子数组 [left, mid] 的当前元素
// j:指向右子数组 [mid+1, right] 的当前元素
// k:指向临时数组的当前填充位置
int i = left, j = mid + 1, k = 0;
// 合并两个子数组并同时统计逆序对
while (i <= mid && j <= right) {
// 如果左子数组当前元素小于或等于右子数组当前元素
// 没有逆序对,将左子数组元素放入临时数组,移动i指针
if (record[i] <= record[j]) {
temp[k++] = record[i++];
} else {
// 如果 record[i] > record[j],发现逆序对
// 由于左子数组已排序,从i到mid的所有元素都大于record[j]
// 因此逆序对数量为 mid - i + 1
count += mid - i + 1;
// 将右子数组元素放入临时数组,移动j指针
temp[k++] = record[j++];
}
}
// 将左子数组剩余元素复制到临时数组
// 这些元素已经按顺序,无需额外处理
while (i <= mid) {
temp[k++] = record[i++];
}
// 将右子数组剩余元素复制到临时数组
// 这些元素也已按顺序
while (j <= right) {
temp[k++] = record[j++];
}
// 将临时数组中的合并结果复制回原数组,从left开始
// 确保原数组在[left, right]范围内保持排序
System.arraycopy(temp, 0, record, left, temp.length);
// 返回本次合并过程中发现的逆序对数量
return count;
}
}
优化效果分析
- 时间复杂度:归并排序的每次分割将问题规模减半,合并过程需要 O(n) 时间,总时间复杂度为 O(n log n)。相比暴力解法的 O(n²),这对大数组更加高效。
- 空间复杂度:合并时需要一个长度为 n 的临时数组,空间复杂度为 O(n)。
- 稳定性:该算法不仅解决了时间超限问题,还保持了较高的可读性和稳定性,适用于各种输入规模。
示例
以数组 [3, 1, 2]
为例:
- 第一次分割:左子数组
[3]
,右子数组[1, 2]
。 - 合并
[3]
和[1, 2]
:- 比较
3
和1
,发现3 > 1
,计数加 1(mid - i + 1 = 1
)。 - 比较
3
和2
,发现3 > 2
,计数加 1。 - 合并结果为
[1, 2, 3]
。
- 比较
- 总逆序对数:2。
总结
通过将暴力解法替换为基于归并排序的优化算法,我们成功将时间复杂度从 O(n²) 降低到 O(n log n),有效避免了“时间超限”问题。这种方法利用了归并排序的特性,在排序的同时高效统计逆序对,兼顾了性能和代码简洁性。对于需要处理大规模数据的场景,这种优化思路具有广泛的适用性。