大师兄

09 | 归并排序:如何解决逆序数问题?

你好,我是胡光,欢迎回来。

通过前面几节课,我们完成了线程池的封装,经历了从算法到工程开发的全过程。今天,我们继续学习排序类算法,开始学习归并排序。说到归并排序,我们就会想到一类非常经典的算法面试题,求逆序数的问题。

接下来,我们就一起来看看今天要解决的问题:给你一个任意的序列,你怎么求出序列的逆序数?

首先,我们要知道什么是逆序数。在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就是一个逆序。一个排列中逆序的总数就是这个排列的逆序数。

我们看下面这个序列,根据逆序数的定义,序列中逆序的个数有 5 个,分别是(7,2)、(9,2)、(7,6)、(9,6)和(14,12)。

那要求序列的逆序数,一种最简单的做法就是,我们从前向后遍历序列的每个位置,每到一个位置,我们就记录一下有多少个元素大于当前位置值。这样,遍历完这个序列之后,我们就得到了它的逆序数。这种做法的时间复杂度是 $O(n^2)$,也是逆序数问题的一个时间上限。接下来,我们就一起来讨论一下,怎么利用归并排序算法来优化这个时间复杂度。

简述归并排序

归并排序的算法过程,我们可以基于递归的思想来学习和理解。递归是什么呢?递归是一种编程技巧,“递归”中的每一步过程都类似,可是问题规模不同。

假设,我们要用归并排序对一组无序数组排序。根据递归的思想,我们可以将当前序列的左半部分和右半部分,分别排成两个有序数组,然后我们再将左右两个有序数组,合并成一个有序序列,这样就完成了当前序列的整体排序。整个过程如图所示:

因此,对于一个无序序列,归并排序算法会先把序列的左右两半部分看成是两个待排序的子问题,先解决这两子问题,再把得到的两个有序子序列合并成一个完整的有序序列。

这当中涉及一个非常重要的算法思想,分治算法。分治算法的过程,就是将当前问题先分成若干个规模更小的子问题,通过先解决小规模的子问题,最终得到原问题的解。正如这个算法名字一样,就是分而治之的意思。

使用递归实现归并排序的算法写成代码的话,大致如下:

void merge_sort(int *arr, int l, int r) {
if (l == r) return ;
int mid = (l + r) >> 1;
merge_sort(arr, l, mid);
merge_sort(arr, mid + 1, r);
merge(arr, l, mid, r);
return ;
}

如代码所示,我们先给merge_sort 函数传入三个参数,分别是数据数组 arr,待排序区间的起始位置 l 以及 待排序区间的终止位置 r。程序的第 2 行,是递归的终止条件,也就是当待排序区间中只剩下 1 个元素的时候,就可以直接返回结果。代码的第 3 行定义了一个整型变量 mid,代表待排序区间 l 到 r 的中间位置。之后我们对左区间 l 到 mid,以及右区间 mid + 1 到 r 分别排序。最后,我们通过调用 merge 方法,将左右两个有序区间合并成一个有序区间。

从代码中,我们可以看到,实现归并排序的重点就是 merge 方法,也就是将两个有序数组合并成一个有序数组的过程。下面,我们就来重点讲解一下这个合并过程。

归并排序中的 merge 过程

将两个有序数组合并成一个有序数组的过程中,我们可以每次选出两个有序数组中最小的元素,把它放到结果数组中,当两个有序数组中的元素都被取干净以后,merge 过程就结束了。

举个例子,我们模拟2个有序数组的合并过程,这2个数组一共有6个元素。从下图中可知,在合并的过程中,我们需要一个额外的存储空间,用来存放合并以后的结果。当然,在归并排序的过程中,当我们完成了合并以后,还需要把合并以后的结果,拷贝回原数组所对应的空间中。

上述过程所对应的代码如下:

void merge(int *arr, int l, int mid, int r) {
int n = r - l + 1;
int *temp = (int *)malloc(sizeof(int) * n);
int p1 = l, p2 = mid + 1, k = 0;
while (p1 <= mid || p2 <= r) {
if (p2 > r || (p1 <= mid && arr[p1] <= arr[p2])) {
temp[k++] = arr[p1++];
} else {
temp[k++] = arr[p2++];
}
}
for (int i = 0, j = l; i < n; i++, j++) {
arr[j] = temp[i];
}
free(temp);
return ;
}

我来说说其中的重点代码。

首先是代码中的第 3 行。我们申请了一个额外的存储空间 temp,用来存放合并的结果。下一行的 p1、p2分别指向两个有序数组的第一个元素。

然后在第 5 行,我们设置了 while 循环条件,当两个数组中某一个还有元素的时候,合并过程继续。

为什么要这么设置呢?因为在合并过程中,我们首先要考虑,什么情况下会将第一个数组中的元素放入结果数组 temp 中。其实很简单,只有在两种情况下,我们会把第一个数组中的元素放入到结果数组中。

第一种情况,就是当第二个数组为空时。第二种情况,就是两个数组不为空,并且第一个数组中的元素小于等于第二个数组中的元素。这分别对应了代码中你所看到或语句中的两个条件。否则,我们就将第二个数组中的元素放入结果数组中。

最后是代码的第 12 行到第 15 行,我们将最后合并的结果放置回原数组对应的位置中。至此,归并排序中最重要的 merge 过程,我们就学习完了。

解决逆序数问题

理解了归并排序之后,我们再来优化逆序数问题的解决方案。这里,我们可以参考分治的思想。具体的优化方案是,如果我们能先求左半边的逆序数 a,再求右半边的逆序数 b,最后求出跨越左右两边的逆序数 c,三个逆序数相加的和,不就是全体数组的逆序数了吗?

这个方法可行吗?我们通过课程开始的数组来验证一下。

现在,这个序列有 7、9、2、6、14、12这6个数字,我们把它们按顺序平均分成两部分。这样,左半部分逆序数是2个,右半部分逆序数是 1个,跨越中间的逆序数是 2 个,所以整个序列的逆序数就是 5 个。

如果把求逆序数的这个过程写成代码,如下所示:

int inversion_number(int *arr, int l, int r) {
if (l == r) return 0;
int mid = (l + r) >> 1;
int a = inversion_number(arr, l, mid);
int b = inversion_number(arr, mid + 1, r);
int c = across_number(arr, l, mid, r);
return a + b + c;
}

代码中的 inversion_number 函数就是求解序列逆序数的方法,传入数组 arr,以及求解区间 l 到 r,返回值是一个整型,代表在数组 arr 的 l 到 r 区间内的逆序数个数。函数中,mid 代表 l 到 r 的中间位置,我们从中间位置将数组分成左右两部分,分别调用 inversion_number 函数进行递归求解,并分别得到左右两部分的逆序数 a 和 b,然后通过 across_number 方法求解跨越中间的逆序数 c,最后返回 a + b + c,就是数组 arr 从 l 到 r 位置的逆序数了。

观察上面的代码过程,你发现了什么?如果不看返回值的话,是不是就是改了名字的归并排序代码过程?在归并排序的代码实现中,关键部分是 merge 合并左右两部分的过程,而在求解逆序数的这个代码中,最关键的也是 across_number 求跨越左右两部分逆序数个数的这个过程。

实际上,我们可以参考归并排序中 merge 合并的过程,来实现 across_number 过程。你可以先想一想逆序是怎样产生的:我们把一个较小的值放到一个较大值的后面,就产生了一个逆序。而在 merge 合并过程中,我们将左右两部分合并成一个有序数组,当右半部分数组中的元素被放置到合并数组中,我们就可以知道,当前的这个数字与左半部分数组中剩余的数字分别产生了一个逆序。

结合上图你会看到,当右侧有序数组中的 5 被放入到合并数组中,左侧还剩下 6 和 14 两个元素,此时,我们所统计的逆序数量就增加了 2 个。通过这个过程,当完成 merge 合并操作以后,跨越中间的逆序数量我们也就统计完了。这个过程的实现,只需要我们在 merge 函数中增加两、三行代码就可以了。是不是很简单呢?

课程小结

今天,我们学习了一种算法思想叫做分治,就是把大问题分成很多个小问题,依次解决之后得到大问题的解。基于分治思想,我们学习了最基本的归并排序的算法过程。最后,我们利用归并排序的代码框架解决了逆序数的问题。

今天的重点内容用一句话总结,就是分而治之,你一定要记住。

课后练习

最后,你能试着修改一下 merge 函数,求出序列7、9、2、6、14、12的逆序数吗?欢迎你把答案写在留言区,我们一起讨论。

你会用归并排序解决逆序数问题了吗?快把这节课也转发给你的朋友吧!今天就讲到这里了,我是胡光,我们下节课见。