剑指 Offer 51. 数组中的逆序对
难度困难
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
示例 1:
输入: [7,5,6,4]
输出: 5
限制:
0 <= 数组长度 <= 50000
题解来自:https://leetcode.cn/problems/shu-zu-zhong-de-ni-xu-dui-lcof/solution/bao-li-jie-fa-fen-zhi-si-xiang-shu-zhuang-shu-zu-b/
解法一:暴力(超时)
使用两层 for
循环枚举所有的数对,逐一判断是否构成逆序关系。
public class Solution {
public int reversePairs(int[] nums) {
int cnt = 0;
int len = nums.length;
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
if (nums[i] > nums[j]) {
cnt++;
}
}
}
return cnt;
}
}
解法二:分治思想(借助归并排序统计逆序数)
说明:理解这个算法需要对「归并排序」比较熟悉。掌握如果编写递归函数,每一次都一分为二拆分数组的子区间,然后在方法栈弹出的时候,一步一步合并两个有序数组,最后完成排序工作。
而计算逆序数就发生在排序的过程中,利用了「排序」以后数组的有序性。
- 利用「归并排序」计算逆序对,是非常经典的做法;
- 关键在于「合并两个有序数组」的步骤,利用数组的部分有序性,一下子计算出一个数之前或者之后元素的逆序的个数;
- 前面「分」的时候什么都不做,「合」的过程中计算「逆序对」的个数;
- 「排序」的工作是必要的,正是因为「排序」才能在下一轮利用顺序关系加快逆序数的计算,也能避免重复计算;
- 在代码实现上,只需要在「归并排序」代码的基础上,加上「逆序对」个数的计算,计算公式需要自己在草稿纸上推导。
思想是「分治算法」,所有的「逆序对」来源于 3 个部分:
- 左边区间的逆序对;
- 右边区间的逆序对;
- 横跨两个区间的逆序对。
下面提供两种写法:
1、在第 2 个子区间元素归并回去的时候,计算逆序对的个数(参考代码 2);
2、在第 1 个子区间元素归并回去的时候,计算逆序对的个数(参考代码 3)。
注意:两者不能同时计算,否则会计算重复。
参考代码 2:在第 2 个子区间元素归并回去的时候,计算逆序对的个数。
即在 j
指向的元素赋值回去的时候,给计数器加上 mid - i + 1
。
class Solution {
//利用归并排序解答,在合并的时候,当左边的大于右边,就计算逆序数。
//计算公式; mid-left+1
//定义一个全局的计数器变量
int count = 0;
public int reversePairs(int[] nums) {
this.count = 0;
mergeSort(nums, 0, nums.length-1);
return count;
}
public void mergeSort(int[] nums,int left,int right){
//当只有一个节点的时候,直接返回,退出递归
if(left >= right){
return;
}
int mid = (left+right)/2;
//左拆分
mergeSort(nums,left,mid);
//右拆分
mergeSort(nums,mid+1,right);
//合并
merge(nums,left,mid,right);
}
public void merge(int[] nums,int left,int mid,int right){
//定义一个临时数组
int[] temp = new int[right-left+1];
//定义一个指针,指向第一个数组的第一个元素
int i = left;
//定义一个指针,指向第二个数组的第一个元素
int j = mid+1;
//定义一个指针,指向临时数组的第一个元素
int t = 0;
//当两个数组都有元素的时候,遍历比较每个元素大小
while(i <= mid && j <= right){
//比较两个数组的元素,取较小的元素加入到,临时数组中
//并将两个指针指向下一个元素
if(nums[i] <= nums[j]){
temp[t++] = nums[i++];
}else{
//当左边数组的大与右边数组的元素时,就对当前元素以及后面的元素的个数进行统计,
//此时这个数就是,逆序数
//定义一个计数器,记下每次合并中存在的逆序数。
count += mid-i+1;
temp[t++] = nums[j++];
}
}
//当左边的数组没有遍历完成后,直接将剩余元素加入到临时数组中
while(i <= mid){
temp[t++] = nums[i++];
}
//当右边的数组没有遍历完成后,直接将剩余元素加入到临时数组中
while(j <= right){
temp[t++] =nums[j++];
}
//将新数组中的元素,覆盖nums旧数组中的元素。
//此时数组的元素已经是有序的
for(int k =0; k< temp.length;k++){
nums[left+k] = temp[k];
}
}
}
Python版本:
class Solution:
def reversePairs(self, nums: List[int]) -> int:
def mergesort(nums, left, right):
if left >= right:
return 0
mid = left + (right - left) // 2
cnt_l = mergesort(nums, left, mid)
cnt_r = mergesort(nums, mid + 1, right)
cnt_c = merge(nums, left, mid, right)
return cnt_l + cnt_r + cnt_c
def merge(nums, left, mid, right):
tmp, cnt = [], 0
l1, l2 = left, mid + 1
while l1 <= mid and l2 <= right:
if nums[l1] <= nums[l2]:
tmp.append(nums[l1])
l1 += 1
else:
tmp.append(nums[l2])
l2 += 1
cnt += (mid - l1 + 1)
if l1 <= mid:
tmp.extend(nums[l1 : mid + 1])
else:
tmp.extend(nums[l2 : right + 1])
nums[left : right + 1] = tmp[:]
return cnt
return mergesort(nums, 0, len(nums) - 1)
解法三:树状数组
-
用树状数组解决逆序数问题,也是一个经典的做法。
-
树状数组是一种实现了高效查询「前缀和」与「单点更新」操作的数据结构,在「力扣」第 315 题:计算右侧小于当前元素的个数 的 题解 里有介绍,这两题的解法可以说是一模一样。
具体的做法是:
- 先离散化,将所有的数组元素映射到
0、1、2、3...
,这是为了节约树状数组的空间; - 从后向前扫描,边统计边往树状数组里面添加元素,这个过程是「动态的」,需要动手计算才能明白思想。
public class Solution {
public int reversePairs(int[] nums) {
int len = nums.length;
if (len < 2) return 0;
// 离散化:使得数字更紧凑,节约树状数组的空间
// 1、使用二分搜索树是为了去掉重复元素
Set<Integer> treeSet = new TreeSet<>();
for (int i = 0; i < len; i++) {
treeSet.add(nums[i]);
}
// 2、把排名存在哈希表里方便查询
Map<Integer, Integer> rankMap = new HashMap<>();
int rankIndex = 1;
for (Integer num : treeSet) {
rankMap.put(num, rankIndex);
rankIndex++;
}
int count = 0;
// 在树状数组内部完成前缀和的计算
// 规则是:从后向前,先给对应的排名 + 1,再查询前缀和
FenwickTree fenwickTree = new FenwickTree(rankMap.size());
for (int i = len - 1; i >= 0; i--) {
int rank = rankMap.get(nums[i]);
fenwickTree.update(rank, 1);
count += fenwickTree.query(rank - 1);
}
return count;
}
private class FenwickTree {
private int[] tree;
private int len;
public FenwickTree(int n) {
this.len = n;
tree = new int[n + 1];
}
//(add) 单点更新:将 index 这个位置 + delta
public void update(int i, int delta) {
// 从下到上,最多到 size,可以等于 size
while (i <= this.len) {
tree[i] += delta;
i += lowbit(i);
}
}
//(sum) 区间查询:查询小于等于 tree[index] 的元素个数
// 查询的语义是「前缀和」
public int query(int i) {
// 从右到左查询
int sum = 0;
while (i > 0) {
sum += tree[i];
i -= lowbit(i);
}
return sum;
}
public int lowbit(int x) {
return x & (-x);
}
}
}
补充理解:
前缀和的意义是:正序遍历是求自己前面比自己大的数,倒序遍历是求自己后面比自己小的数
倒序处理:在自己之前出现,说明这个数在自己后面,求比自己小1的前缀和sum(n-1)
<==求在自己后面比自己小的数。
倒序。【用树状数组倒序处理数列,当前数字的前一个数的前缀和即为以该数为较大数的逆序对的个数】。例如 {5, 4, 2, 6, 3, 1},倒序处理数字:
-
- 数字 1。把 a[1] 加一,计算 a[1] 前面的前缀和 sum(0),逆序对数量
ans = ans + sum(0) = 0;
- 数字 3。把 a[3] 加一,计算 a[3] 前面的前缀和 sum(2),逆序对数量
ans = ans + sum(2) = 1;
- 数字 6。把 a[6] 加一,计算 a[6]前面的前缀和 sum(5),逆序对数量
ans = ans + sum(5) = 1+2
- ⋯
- 数字 1。把 a[1] 加一,计算 a[1] 前面的前缀和 sum(0),逆序对数量
正序处理:在自己之前出现,说明这个数在自己前面,求前缀和sum(n)
,算在自己前面比自己小的数加上自己,用总的个数减去这个数,就是在自己前面比自己大的数。
- 正序。当前已经处理的数字个数减掉当前数字的前缀和即为以该数为较小数的逆序对个数。例如 {5, 4, 2, 6, 3, 1},正序处理数字:
- 数字 5。把 a[5] 加一,当前处理了 1 个数,
ans = ans + (1-sum(5)) = 0;
- 数字 4。把a[4] 加一,当前处理了 2 个数,
ans = ans+(2-sum(4)) = 0+1 = 1;
- 数字 2。把 a[2] 加一,
ans = ans + (3-sum(2)) = 1+2 = 3;
- 数字 6。把 a[6] 加一,
ans = ans + (4-sum(6)) = 3+0 = 3;
- ⋯
- 数字 5。把 a[5] 加一,当前处理了 1 个数,