「剑指Offer」第 51 题:数组中的逆序数(分治思想,草稿)

大家好,这里是「力扣」视频题解。今天要和大家分享的是面试题第 51 题:数组中的逆序对。

这道题虽然是标注为 hard 的一个问题,但其实它一个非常经典的问题,如果一开始没有思路的话,可以把它当做一道例题来学习。

这道题要求我们计算一个数组中「逆序对」的个数。

什么是「逆序对」呢? 我们看示例数组里的 554455 排在 44 的前面,55 大于 44 ,因此它们构成了一个逆序对。

「逆序对」的个数反映了一个数组的有序程度,有两个很特殊的例子:

1、对于「顺序数组」来说,任意抽出的两个数字都不存在逆序关系,所以任意顺序数组的逆序对的个数就为 00

例如:[1, 2, 3, 4, 5]

2、而对于「逆序数组」来说,任意抽出的两个数字都构成了逆序关系,所以逆序数组的逆序对的个数就达到了最大。

这个数值就等于:在每个元素之后的「所有元素的」个数之和。

例子:[5, 4, 3, 2, 1]


(下一页)

对于这个问题:一个非常容易想到的方法是,使用两个 for 循环,枚举所有不同下标的数对,只要发现一对逆序关系,就给计数器加 11

这个方法我们称之为暴力解法或者是依据定义的解法,时间复杂度是 O(N2)O(N^2),空间复杂度是 O(1)O(1)

这个方法的缺点是显而易见的:我们在每一次比较的过程中,不管 比较的结果构成了顺序对还是逆序对,都没有有被记录下来。也就是说:之前比较的结果不能为以后的比较提供有用的信息。

Java 代码:

public class Solution {

    public int reversePairs(int[] nums) {
        int len = nums.length;

        int res = 0;
        for (int i = 0; i < len - 1; i++) {
            for (int j = i + 1; j < len; j++) {
                if (nums[i] > nums[j]) {
                    res++;
                }
            }
        }
        return res;
    }
}

怎么加速这个过程呢?

其实隐含在我们刚刚向大家介绍,计算逆序数组的「逆序对」的那个例子里。正是因为我们知道了这个数组里的元素的大小关系,我们就可以一下子计算出:

  • 与数字 55 构成逆序对的元素的个数;
  • 与数字 44 构成逆序对的元素的个数;

依次加下去,而不用一个一个地去比较。


以上两点事实告诉我们:在计算逆序对的过程中,掌握元素的大小关系可以加快计算

而掌握元素的大小关系就需要在计算逆序对的过程中,对已经看到的数字做一些顺序上的调整

在高级的排序算法(归并排序、快速排序)里,能够看到非常明显的「阶段排序效果 」的算法就是归并排序。

说到这里,大家不妨暂停一下视频。想一想如何利用「归并排序」算法,在给数组排序的过程中,计算出逆序对的个数。


我们来看「归并排序」的一个关键步骤:「合并两个有序数组」。

在这里我们单独看这样一个过程:考察的是两个紧挨着的有序子区间,然后将它们合并成为一个更长的有序区间。

我们以

[2, 3, 5, 7, 1, 4, 6, 8] 

为例。

这个数组的前半部分与后半部分,均是有序的。

  • 我们在合并之前,首先需要把它们拷贝到一个新的空间;

  • 然后使用两个指针变量 ij 分别指向两个有序子区间的第 1 个位置,再使用一个指针变量 k 指向合并回去的那个数组的第 1 个位置;

  • 我们的规则是:ij 指向的元素谁小,谁就先合并回去。

(具体)

  • 先看 2211 的大小,2211 大,11 先合并回去。由于两个子区间都是有序的,我们就知道了,11 比它之前的所有元素都小,也就是 11 与它之前的所有的元素都构成了逆序关系,我们就可以一下子给计数器加上 44

这就是在合并的过程中,能够加速计算逆序数的原因。我们利用了数组的部分有序性,而不用一个一个地去比较。

11 合并回去以后,比较 22442244 小,在 22 合并回去的同时,我们可以观察到 22 点:

  • 第 1 :22 与之前合并回去的数 11 构成了逆序对,只不过我们在刚才,把 11 合并回去的时候,已经把 2211 的逆序关系计算了一次,因此在这一步我们没有必要再计算一遍;

  • 第 2 :2211 之后的所有元素都不构成逆序关系,因此在 22 合并回去这一步,我们什么都不用做。

  • 接下来比较 33443344 小,和上一步一样,33 合并回去以后,什么都不用做;

  • 接下来比较 55445544 大,这个时候 44 合并回去。与此同时,我们就知道了,44 与第 1 个数组里还没有被合并回去的所有元素: 5577 构成了逆序关系。把 44 合并回去以后,我们就需要给计数器直接加上 22

相信说到这里,大家也就看出来了:我们只需要在第 2 个有序数组的元素归并回去以后,把第 1 个数组里还没有被归并回去的元素个数加到计数变量里。而计算第 1 个数组里当前还没有归并回去的 元素的个数 可以通过下标 i 的数值,以 O(1)O(1) 的时间复杂度计算出来。

接下来比较 55665566 小,55 合并回去,什么都不做;
接下来比较 7766 的大小,7766 大,这个时候第 1 个有序数组里只有 1 个 77,我们把 66 合并回去以后,给计数器加上 11
接下来比较 77887788 小,77 合并回去以后,什么都不做;
最后我们把 88 合并回去,由于第 1 个有序数组的所有元素都合并回去了,也就是 88 在这个区间里不与任何元素构成逆序关系,我们可以认为是计数器加上 00


总结一下这个算法,我们只需要在第 2 个有序数组的元素合并回去的时候,把当前第 1 个数组里还有几个元素还没有合并回去的个数,加到计数变量里就好了。

依然要和大家强调的是:可以这样计算逆序对个数的前提是:两个子区间分别有序。


下面,我们再从整体向大家介绍一下优化算法的思路:

  • 这个算法在开始的时候对输入数组一直对半拆分,直到区间里只剩下一个元素的时候;
  • 1 个元素的数组,肯定是有序数组;
  • 然后我们再依次合并两个有序数组,而计算逆序对的个数,就发生在合并的过程中;
  • 每一次合并完成以后,得到了一个更长的有序子区间,同时也为 下一次的合并做好了准备;
  • 直至整个数组有序,我们就计算出了整个数组里逆序对的个数;
  • 这个过程是非常典型的「分治算法」的思想。

我们把一个规模较大的问题划分成同等结构的子问题,将子问题逐一解决以后,原问题就得到了解决。

这个算法里计算逆序对的结构是:

  • 先计算两个子区间内部的逆序对的个数;
  • 然后在合并的过程中计算跨越两个子区间的逆序对的个数。

下面我们来看一下代码:

Java 代码:

public class Solution {

    public int reversePairs(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return 0;
        }

        int[] copy = new int[len];
        for (int i = 0; i < len; i++) {
            copy[i] = nums[i];
        }

        int[] temp = new int[len];
        return reversePairs(copy, 0, len - 1, temp);
    }

    private int reversePairs(int[] nums, int left, int right, int[] temp) {
        if (left == right) {
            return 0;
        }

        int mid = left + (right - left) / 2;
        // 先计算 nums[left..mid] 逆序对的个数,再计算 nums[mid + 1..right] 逆序对的个数
        int leftPairs = reversePairs(nums, left, mid, temp);
        int rightPairs = reversePairs(nums, mid + 1, right, temp);

        if (nums[mid] <= nums[mid + 1]) {
            return leftPairs + rightPairs;
        }

        int crossPairs = mergeAndCount(nums, left, mid, right, temp);
        return leftPairs + rightPairs + crossPairs;
    }

    /**
     * nums[left..mid] 有序,nums[mid + 1..right] 有序
     *
     * @param nums
     * @param left
     * @param mid
     * @param right
     * @param temp
     * @return
     */
    private int mergeAndCount(int[] nums, int left, int mid, int right, int[] temp) {
        for (int i = left; i <= right; i++) {
            temp[i] = nums[i];
        }

        int i = left;
        int j = mid + 1;
        int count = 0;

        for (int k = left; k <= right; k++) {
            if (i == mid + 1) {
                nums[k] = temp[j];
                j++;
            } else if (j == right + 1) {
                nums[k] = temp[i];
                i++;
            } else if (temp[i] <= temp[j]) {
                nums[k] = temp[i];
                i++;
            } else {
                nums[k] = temp[j];
                j++;
                count += (mid - i + 1);
            }
        }
        return count;
    }
}

Java 代码:

public class Solution {

    // 后有序数组中元素出列的时候,计算逆序个数

    public int reversePairs(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return 0;
        }
        int[] temp = new int[len];
        return reversePairs(nums, 0, len - 1, temp);
    }

    /**
     * 计算在数组 nums 的索引区间 [left, right] 内统计逆序对
     *
     * @param nums  待统计的数组
     * @param left  待统计数组的左边界,可以取到
     * @param right 待统计数组的右边界,可以取到
     * @return
     */
    private int reversePairs(int[] nums, int left, int right, int[] temp) {
        // 极端情况下,就是只有 1 个元素的时候,这里只要写 == 就可以了,不必写大于
        if (left == right) {
            return 0;
        }

        int mid = (left + right) >>> 1;

        int leftPairs = reversePairs(nums, left, mid, temp);
        int rightPairs = reversePairs(nums, mid + 1, right, temp);

        int reversePairs = leftPairs + rightPairs;
        if (nums[mid] <= nums[mid + 1]) {
            return reversePairs;
        }

        int reverseCrossPairs = mergeAndCount(nums, left, mid, right, temp);
        return reversePairs + reverseCrossPairs;

    }

    /**
     * [left, mid] 有序,[mid + 1, right] 有序
     * @param nums
     * @param left
     * @param mid
     * @param right
     * @param temp
     * @return
     */
    private int mergeAndCount(int[] nums, int left, int mid, int right, int[] temp) {
        // 复制到辅助数组里,帮助我们完成统计
        for (int i = left; i <= right; i++) {
            temp[i] = nums[i];
        }

        int i = left;
        int j = mid + 1;
        int res = 0;
        for (int k = left; k <= right; k++) {
            if (i > mid) {
                // i 用完了,只能用 j
                nums[k] = temp[j];
                j++;
            } else if (j > right) {
                // j 用完了,只能用 i
                nums[k] = temp[i];
                i++;
            } else if (temp[i] <= temp[j]) {
                // 此时前数组元素出列,不统计逆序对
                nums[k] = temp[i];
                i++;
            } else {
                // 此时后数组元素出列,统计逆序对,快就快在这里,一次可以统计出一个区间的个数的逆序对
                nums[k] = temp[j];
                j++;
                res += (mid - i + 1);
            }
        }
        return res;
    }
}

最后我们来看一下复杂度分析:

  • 这个算法时间复杂度其实就是「归并排序」的时间复杂度, $O(\N \logN) $,感兴趣的朋友可以在互联网上搜索一下这个结论是如何得到的,需要借助一个叫做「主定理」的理论进行具体的推导;
  • 由于我们在「合并两个有序数组」的时候,需要使用和原始数组同等大小的空间,空间复杂度就是 O(N)O(N),这里递归调用,使用的方法栈的大小是 O(logN)O(\log N),由于它比 O(N)O(N) 小,因此在计算复杂度的时候被忽略。

这就是这道题的视频题解,感谢大家的收看。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章