LCR170-优化逆序对计数算法:从暴力解法到归并排序

 

优化逆序对计数算法:从暴力解法到归并排序

问题背景

在数组中,逆序对是指满足 i < jrecord[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],那么从 imid 的所有元素都大于 record[j](因为左子数组是排序好的)。因此,逆序对的数量可以直接加上 mid - i + 1

这样,我们在合并的同时就能统计逆序对,避免了暴力解法中逐一比较的低效。

算法步骤

  1. 分治:将数组递归分成两半,直到子数组长度为 1(此时无逆序对)。
  2. 合并与计数
    • 在合并两个已排序的子数组时,使用两个指针 i(指向左子数组)和 j(指向右子数组)。
    • 如果 record[i] <= record[j],将 record[i] 放入临时数组,继续处理下一个左子数组元素。
    • 如果 record[i] > record[j],则发现逆序对,计数增加 mid - i + 1,并将 record[j] 放入临时数组。
    • 继续处理剩余元素,并将临时数组复制回原数组。
  3. 递归累加:总逆序对数是左子数组、右子数组及合并过程中的逆序对数之和。

优化后的代码

以下是基于归并排序的优化代码:

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] 为例:

  1. 第一次分割:左子数组 [3],右子数组 [1, 2]
  2. 合并 [3][1, 2]
    • 比较 31,发现 3 > 1,计数加 1(mid - i + 1 = 1)。
    • 比较 32,发现 3 > 2,计数加 1。
    • 合并结果为 [1, 2, 3]
  3. 总逆序对数:2。

总结

通过将暴力解法替换为基于归并排序的优化算法,我们成功将时间复杂度从 O(n²) 降低到 O(n log n),有效避免了“时间超限”问题。这种方法利用了归并排序的特性,在排序的同时高效统计逆序对,兼顾了性能和代码简洁性。对于需要处理大规模数据的场景,这种优化思路具有广泛的适用性。

本文遵守 Attribution-NonCommercial 4.0 International 许可协议。 Attribution-NonCommercial 4.0 International