大家好,这里是「力扣」视频题解。今天要和大家分享的是面试题第 51 题:数组中的逆序对。
这道题虽然是标注为 hard 的一个问题,但其实它一个非常经典的问题,如果一开始没有思路的话,可以把它当做一道例题来学习。
这道题要求我们计算一个数组中「逆序对」的个数。
什么是「逆序对」呢? 我们看示例数组里的 和 , 排在 的前面, 大于 ,因此它们构成了一个逆序对。
「逆序对」的个数反映了一个数组的有序程度,有两个很特殊的例子:
1、对于「顺序数组」来说,任意抽出的两个数字都不存在逆序关系,所以任意顺序数组的逆序对的个数就为 ;
例如:[1, 2, 3, 4, 5]
2、而对于「逆序数组」来说,任意抽出的两个数字都构成了逆序关系,所以逆序数组的逆序对的个数就达到了最大。
这个数值就等于:在每个元素之后的「所有元素的」个数之和。
例子:[5, 4, 3, 2, 1]
(下一页)
对于这个问题:一个非常容易想到的方法是,使用两个 for
循环,枚举所有不同下标的数对,只要发现一对逆序关系,就给计数器加 。
这个方法我们称之为暴力解法或者是依据定义的解法,时间复杂度是 ,空间复杂度是 。
这个方法的缺点是显而易见的:我们在每一次比较的过程中,不管 比较的结果构成了顺序对还是逆序对,都没有有被记录下来。也就是说:之前比较的结果不能为以后的比较提供有用的信息。
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;
}
}
怎么加速这个过程呢?
其实隐含在我们刚刚向大家介绍,计算逆序数组的「逆序对」的那个例子里。正是因为我们知道了这个数组里的元素的大小关系,我们就可以一下子计算出:
- 与数字 构成逆序对的元素的个数;
- 与数字 构成逆序对的元素的个数;
依次加下去,而不用一个一个地去比较。
以上两点事实告诉我们:在计算逆序对的过程中,掌握元素的大小关系可以加快计算。
而掌握元素的大小关系就需要在计算逆序对的过程中,对已经看到的数字做一些顺序上的调整。
在高级的排序算法(归并排序、快速排序)里,能够看到非常明显的「阶段排序效果 」的算法就是归并排序。
说到这里,大家不妨暂停一下视频。想一想如何利用「归并排序」算法,在给数组排序的过程中,计算出逆序对的个数。
我们来看「归并排序」的一个关键步骤:「合并两个有序数组」。
在这里我们单独看这样一个过程:考察的是两个紧挨着的有序子区间,然后将它们合并成为一个更长的有序区间。
我们以
[2, 3, 5, 7, 1, 4, 6, 8]
为例。
这个数组的前半部分与后半部分,均是有序的。
-
我们在合并之前,首先需要把它们拷贝到一个新的空间;
-
然后使用两个指针变量
i
和j
分别指向两个有序子区间的第 1 个位置,再使用一个指针变量k
指向合并回去的那个数组的第 1 个位置; -
我们的规则是:
i
和j
指向的元素谁小,谁就先合并回去。
(具体)
- 先看 和 的大小, 比 大, 先合并回去。由于两个子区间都是有序的,我们就知道了, 比它之前的所有元素都小,也就是 与它之前的所有的元素都构成了逆序关系,我们就可以一下子给计数器加上 。
这就是在合并的过程中,能够加速计算逆序数的原因。我们利用了数组的部分有序性,而不用一个一个地去比较。
合并回去以后,比较 和 。 比 小,在 合并回去的同时,我们可以观察到 点:
-
第 1 : 与之前合并回去的数 构成了逆序对,只不过我们在刚才,把 合并回去的时候,已经把 和 的逆序关系计算了一次,因此在这一步我们没有必要再计算一遍;
-
第 2 : 与 之后的所有元素都不构成逆序关系,因此在 合并回去这一步,我们什么都不用做。
-
接下来比较 和 , 比 小,和上一步一样, 合并回去以后,什么都不用做;
-
接下来比较 和 , 比 大,这个时候 合并回去。与此同时,我们就知道了, 与第 1 个数组里还没有被合并回去的所有元素: 和 构成了逆序关系。把 合并回去以后,我们就需要给计数器直接加上 ;
相信说到这里,大家也就看出来了:我们只需要在第 2 个有序数组的元素归并回去以后,把第 1 个数组里还没有被归并回去的元素个数加到计数变量里。而计算第 1 个数组里当前还没有归并回去的 元素的个数 可以通过下标 i
的数值,以 的时间复杂度计算出来。
接下来比较 和 , 比 小, 合并回去,什么都不做;
接下来比较 和 的大小, 比 大,这个时候第 1 个有序数组里只有 1 个 ,我们把 合并回去以后,给计数器加上 ;
接下来比较 和 , 比 小, 合并回去以后,什么都不做;
最后我们把 合并回去,由于第 1 个有序数组的所有元素都合并回去了,也就是 在这个区间里不与任何元素构成逆序关系,我们可以认为是计数器加上 。
总结一下这个算法,我们只需要在第 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) $,感兴趣的朋友可以在互联网上搜索一下这个结论是如何得到的,需要借助一个叫做「主定理」的理论进行具体的推导;
- 由于我们在「合并两个有序数组」的时候,需要使用和原始数组同等大小的空间,空间复杂度就是 ,这里递归调用,使用的方法栈的大小是 ,由于它比 小,因此在计算复杂度的时候被忽略。
这就是这道题的视频题解,感谢大家的收看。