《算法笔记》第四章——基础算法之递归与分治 学习记录
分治
分治(divide and conquer)的全称为“ 分而治之”,也就是说,分治法将原问题划分成若千个规模较小而结构与原问题相同或相似的子问题,然后分别解决这些子问题,最后合并子问题的解,即可得到为原问题的解。上面的定义体现出分治法的三个步骤:
- 分解:将原问题分解为若干和原问题拥有相同或相似结构的子问题。
- 解决:递归求解所有子问题。如果存在子问题的规模小到可以直接解决,就直接解决它。
- 合并:将子问题的解合并为原问题的解。
需要指出的是,分治法分解出的子问题应当是相互独立、没有交叉的。如果存在两个子问题有相交部分,那么不应当使用分治法解决。
从广义上来说,分治法分解成的子问题个数只要大于0即可。但是从严格的定义上讲,一般把子问题个数为1的情况称为减治(decrease and conquer),而把子问题个数大于1的情况称为分治,不过通常情况下不必在意这种区别。
另外,分治法作为一种算法思想, 既可以使用递归的手段去实现,也可以通过非递归的手段去实现,可以视具体情况而定,一般来说,使用递归实现较为容易。下面介绍递归的概念,其中对n的阶乘的求解过程体现了减治的思想,对Fibonacci数列的求解过程体现了分治的思想。
递归
有一个看似玩笑的对递归的定义:“要理解递归,你要先理解递归,直到你能理解递归”,但是这对递归的解释算是十分直观的。递归就在于反复调用自身函数,但是每次把问题范围缩小,直到范围缩小到可以直接得到边界数据的结果,然后再在返回的路上求出对应的解。从这点上看,递归很适合用来实现分治思想。
递归的逻辑中一般有两个重要概念:
- 递归边界。
- 递归式(或称递归调用)。
其中递归式是将原问题分解为若千个子问题的手段,而递归边界则是分解的尽头。
可以想象,如果使用递归式不断递归而不进行阻止,那么最后将会无法停止这个黑洞似的无穷尽的算法。可以指出,递归的代码结构中一定存在这两个概念,它们支撑起了整个递归最关键的逻辑。读者应当在后面的学习中不断思考递归边界和递归式这两个概念是如何运用的。
来看一个经典的例子:使用递归求解n的阶乘。
首先给出n!的计算式: n!=1 2...*n,这个式子写成递推的形式就是n!=(n-1)! *n,
于是就把规模为n的问题转换为求解规模为n-1的问题。如果用F(n)表示n!,就可以写成F(n)= F(n- 1)*n (即递归式),这样规模就变小了。
那么,如果把F(n)变为F(n-1),又把F(n-1)变为F(n-2),这样一直减小规模, 什么时候是尽头呢?由于0!=1,因此不妨以F(0)=1作为递归边界,即当规模减小至n=0的时候开始“回头”。
再来看另一个经典例子:求Fibonacci数列的第n项。
Fibonacci数列( 即斐波那契数列)是满足F(0)=1,F(1)=1,F(n)=F(n-1)+ F(n-2)(n≥2)的数列,数列的前几项为1,1,2,3,5,8,13,21。由于从定义中已经可以获知递归边界为F(0)=1和F(1)=1,且递归式为F(n)= F(n-1)+ F(n-2)(n≥2)。
回头来看求解Fibonacci数列的过程会发现,这实际上就是使用递归实现分治法的一一个简单例子。对给定的正整数n来说,把求解F(n)的问题分解为求解F(n- 1)与F(n - 2)这两个子问题,而F(0)=F(1)=1是当n很小时问题的直接解决,递归式F(n)= F(n-1)+ F(n-2)则是子问题的解的合并。
由上面两个例子可以知道,如果要实现一个递归函数,那么就要有两样东西:递归边界和递归式。其中递归边界用来返回最简单底层的结果,递归式用来减少数据规模并向下一层递归。初学者最容易陷入一层层数下去结果把自己绕晕了的怪圈,建议初学的时候多像上面那样画出递归图,因为这样可以把递归放到一-个平面上来思考,会容易很多。同时,初学递归时要格外强调递归边界和递归式,因为无论递归程序多么复杂,它们都是书写递归的两个关键。
最后来看全排列(Full Permutation)。一般把1~n这n个整数按某个顺序摆放的结果称为这n个整数的一个排列,而全排列指这n个整数能形成的所有排列。例如对1、2、3这三个整数来说,(1,2,3)、 (1,3,2)、 (2,1,3)、 (2,3,1)、 (3,1,2)、(3,2,1)就是1~3的全排列。现在需要实现按字典序从小到大的顺序输出1 ~ n的全排列,其中\((a_1,a_2,...,a_n)\)的字典序小于\((b_1,b_2,...,b_n)\)是指存在一个i,使得\(a_1 = b_1 、a_2=b_2、...、a_{i-1]<b_{i-1}、a_i<b_i\)成立。因此上面n=3的例子就是按字典序从小到大的顺序给出的。
从递归的角度去考虑,如果把问题描述成“输出1~n这n个整数的全排列”,那么它就可以被分为若干个子问题:“输出以1开头的全排列”“输出以2开头的全排列”...“输出以n开头的全排列”。于是不妨设定一个数组P,用来存放当前的排列;再设定一个散列数组hashTable,其中hashTable[x]当整数x已经在数组P中时为true。
现在按顺序往P的第1位到第n位中填入数字。不妨假设当前已经填好了P[1] ~ P[index-1], 正准备填P[index]。显然需要枚举1~n,如果当前枚举的数字x还没有在P[1] ~ P[index-1]中(即hashTable[x] = false),那么就把它填入P[index],同时将hashTable[x]置为true,然后去处理P的第index + 1位(即进行递归);而当递归完成时,再将hashTable[x]还原为false,以便让P[index]填下一个数字。
那么递归边界是什么呢?显然,当index达到n+1时,说明P的第1 ~ n位都已经填好了,此时可以把数组P输出,表示生成了一个排列,然后直接return即可。
最后来看n皇后问题。n皇后问题是指在一个n*n的国际象棋棋盘上放置n个皇后,使得这n个皇后两两均不在同一行、同一列、同一条对角线上,求合法的方案数。
对于这个问题,如果采用组合数的方式来枚举每一种情况(即从\(n^2\)个位置中选择n个位置),那么将需要\(C_{n \times n}^{n}\)的枚举量,当n=8时就是54502232次枚举,如果n更大,那么就会无法承受。
但是换个思路,考虑到每行只能放置一个皇后、每列也只能放置一个皇后,那么如果把n列皇后所在的行号依次写出,那么就会是1~n的一个排列。
例如对图4-4a来说对应的排列是24135,对图4-4b来说就是35142。于是就只需要枚举1~n的所有排列,查看每个排列对应的放置方案是否合法,统计其中合法的方案即可。由于总共有n!个排列,因此当n=8时只需要40320次枚举,比之前的做法优秀许多。

于是可以在全排列的代码基础上进行求解。由于当到达递归边界时表示生成了一个排列,所以需要在其内部判断是否为合法方案,即遍历每两个皇后,判断它们是否在同一条对角线上(不在同一行和同一列是显然的),若不是,则累计计数变量count即可。
这种枚举所有情况,然后判断每一种情况是否合法的做法是非常朴素的(因此一般把不使用优化算法、直接用朴素算法来解决问题的做法称为暴力法)。
事实上,通过思考可以发现,当已经放置了一部分皇后时(对应于生成了排列的一部分),可能剩余的皇后无论怎样放置都不可能合法,此时就没必要往下递归了,直接返回上层即可,这样可以减少很多计算量。例如图4-4b中,当放置了前三个皇后(对应生成了排列的一部分,即351),可以发现剩下两个皇后不管怎么放置都会产生冲突,就没必要继续递归了。
一般来说,如果在到达递归边界前的某层,由于一些事实导致已经不需要往任何一个子问题递归,就可以直接返回上一层。一般把这种做法称为回溯法。

浙公网安备 33010602011771号