递归(下)
前言
在上一篇中,我介绍了如何使用递归,来处理迭代法中比较复杂的数值计算。但是我们知道,有些迭代法并不是简单的数值计算,而是要通过迭代的过程进行一定的操作,过程更加复杂,需要考虑很多中间数据的分配或者保存。比如我在迭代法中提到的使用二分查找进行数据匹配,或者这篇文章里将要讲解的归并排序中的数据排序等等。在这种情况下,要怎么使用递归法呢?
【程序员必修数学课】->基础思想篇->递归(上)->泛化数学归纳
我们可以先分析一下这些复杂的问题是否可以简化成更小的、更简单的子问题来解决,这是一般思路。如果可以,那就意味着我们可以使用递归的核心思想,将复杂的问题逐步简化成最基本的情况来求解。在这篇文章里,我将从归并排序开始,延伸到多台机器的并行处理,详细介绍递归思想在“分而治之”这个领域的应用。
归并排序中的分治思想
首先,我们先考虑如何使用递归编程解决数字的排序问题。
对一堆杂乱无序的数字,按照从小到大或者从大到小的规则进行排序,这是计算机领域非常经典,也非常流行的问题。小到Excel电子表格,大到搜索引擎,都需要对一堆数字进行排序。因此,计算机领域的前辈们研究排序问题已经很多年了,也提出了许多优秀的算法,比如归并排序、快速排序、堆排序等等。其中,归并排序和快速排序都很好地体现了分治的思想,这篇文章主要就说一说归并排序(merge sort)。
很显然,归并排序算法的核心就是“归并”,也就是把两个有序的数列合并起来,形成一个更大的有序数列。
假设我们需要按照从小到大的顺序,合并两个有序数列 A 和 B。我们需要开辟一个新的存储空间 C,用于保存合并后的结果。
我们首先比较两个数列的第一个数,如果 A 数列的第一个数小于 B 数列的第一个数,那么就先取出 A 数列的第一个数放入 C,并把这个数从 A 数列中删除。如果是 B 的第一个数更小,那么就先取出 B 数列的第一个数放入 C,并把它从 B 数列里删除。
以此类推,直到 A 和 B 里所有的数据都被取出来放入 C。如果到某一步, A 或 B 数列为空,那直接将另一个数列的数据依次取出放入 C 就可以了。这种操作,可以保证两个有序的数列 A 和 B 合并到 C 之后,C 数列仍然是有序的。
比如说合并有序数组 {6, 11, 13, 17} 和 {8, 10, 16}的过程👇
为了保证得到有序的 C 数列,我们必须保证参与合并的 A 和 B 也是有序的。但是,等待排序的数组一开始都是乱序的,如果无法保证这点,那归并又有什么意义呢?
这就需要用到递归了。我们可以利用递归的思想,把问题不断简化,也就是把数列不断简化,一直简化到最后只有一个数,那它本身就是有序的了。那么如何进行每一次的简化呢?
最简单的想法就是把长度为 n 的数列,每次简化为长度为 n - 1 的数列,直至长度为 1。不过,这样的处理没有并行性,要进行 n - 1 次的归并操作,效率就会很低。
所以,我们可以在归并排序中引入了分而治之(Divide and Conquer) 的思想。分而治之,我们通常简称为分治。它的思想就是,将一个复杂的问题,分解成两个甚至多个规模相同或类似的子问题,然后对这些子问题再进一步细分,直到最后的子问题变得简单,很容易就能被求解出来,这样这个复杂的问题就求解出来了。
归并排序通过分治的思想,把长度为 n 的数列,每次简化为两个长度为 n / 2 的数列。这样更有利于计算机的并行处理,只需要 log2n 次归并。
我们把归并和分治的思想结合起来,这其实就是归并排序算法。这种算法每次把数列进行二等分,直到唯一的数字,也就是最基本的有序数列。然后从这些最基本的有序数列开始,两两合并有序的数列,直到所有的数字都参与了归并排序。
我用一个包含 0~9 这 10 个数字的数组,分析一下归并排序的过程。
- 假设初始的数组为 {7, 6, 2, 4, 1, 9, 3, 8, 0, 5},我们要对它进行从小到大的排序。
- 第一次分解后,变成两个数组 {7, 6, 2, 4, 1} 和 {9, 3, 8, 0, 5}。
- 然后,我们将 {7, 6, 2, 4, 1} 分解成 {7, 6} 和 {2, 4, 1},将 {9, 3, 8, 0, 5} 分解成 {9, 3} 和 {8, 0, 5}。
- 按照这个规律继续细分下去,直到每个组只包含一个数字。到这里,都是递归的嵌套调用过程。
- 接下来,就要开始进行合并了。我们可以将 {4, 1} 分解为 {4} 和 {1}。现在无法再细分了,我们开始合并,在合并的过程中进行排序,所以合并的结果为 {1, 4}。合并后的结果将返回当前函数的调用者,这就是函数返回的过程。
- 重复上述合并的过程,直到完成整个数组的排序,得到 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}。
这个过程可以画一张图来理解👇
可以看到 归并排序使用了分治的思想,而这个过程需要使用递归来实现。
归并排序算法用分治的思想把数列不断地简化,直到每个数列仅剩下一个单独的数,然后再使用归并逐步合并有序的数列,从而达到将整个数列进行排序的目的。而这个归并排序,正好可以使用递归的方式来实现。我们可以看看下面这张图,可以发现,分治的过程和递归的过程是一致的。
分治的过程可以通过递归来表达,因此,归并排序最直观的实现方式就是递归。所以,我们从递归的步骤出发,来看归并排序如何实现。
我们假设 n = k - 1 的时候,我们已经对较小的两组数进行了排序。那我们只要在 n = k 的时候,将这两组数合并起来,并且保证合并后的数组仍然是有序的就行了。
所以,在递归的每次嵌套调用中,代码都将一组数分解成更小的两组,然后将这两个小组的排序交给下一次的嵌套调用。而本次调用只需要关心,如何将排好序的两个小组进行合并。
在初始状态,也就是 n = 1 的时候,对于排序的案例而言,只包含单个数字的分组。由于分组里只有一个数字,所以它已经是排好序的了,之后就可以开始递归调用的返回阶段。