本节书摘来自华章计算机《从问题到程序:用python学编程和计算》一书中的第3章,第3.2节,作者 裘宗燕,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
有一种函数定义比较特殊,就是在定义的函数体里调用被定义的函数自身。python允许这种形式的函数定义,称为递归定义,这样定义出的函数也经常被称为递归函数。但是,这样做带来了一个数学里经常提到的问题:基于自己定义自己,这种形式的定义有意义吗?会不会做出了一个所谓的“循环定义”,完全没有意义?本节讨论这方面的一些情况,并说明递归定义是一种很有用的编程技术。
前面讨论过循环程序与简单程序(直线型程序和分支程序)之间的不同。上面又提出了递归定义的函数,在其写法中并没有出现循环结构,但同样可能导致重复的计算。这里的情况是通过递归调用,多次重复执行同一个函数的体代码。由此我们可以想一个问题:递归和循环能做的事情类似,那么,两者可以相互替代吗?
人们对这方面的问题做过一些理论研究。一个结果是:如果一个语言中包含了基本语句、顺序组合和条件语句,而且允许以递归的方式定义函数,这样构成的语言就足够强大了,足以描述所有可能的计算过程。另一方面,一个包含了基本语句、顺序组合和循环结构的语言也足够强大,也可以写出所有可能的计算。
进一步说,不难设计出一种方法,采用这种方法,可以根据任何一个循环定义出一个递归函数,在程序里原来写循环的地方调用这个函数,就能得到同样的计算效果。而反过来的情况比较复杂,要把一个递归定义的函数改为不用递归方式定义的函数(称为原函数的非递归实现),有时会遇到一些困难,但也是可以完成的。
上面的理论结果说明递归和循环中有一个就足够了。但为了方便实际的程序开发,各种实际编程语言都引进了一些冗余的机制,包括冗余的控制机制(以及后面将会讨论的数据机制)。例如,python语言有循环结构,同时也允许递归的函数定义。
作为例子,现在考虑前一小节的乘幂函数,看看怎样做出与之对应的循环实现。
可以看到,在前面power函数的递归实现里,递归调用之间传递两个参数x和n,还有一个是函数的返回值。虽然传递的参数名字不变,但参数的值一直在变化。其中变量n的值不断减小(或者整除2或者减一),x不断求平方。每次遇到指数为奇数时,就是当时x的值作为乘数。按说需要用一个变量记录这个变化的x。但由于python中函数的参数就是变量,可以直接用参数x记录这个变化的值。另外,为了反映积累的乘积(递归函数的返回值),引进另一个变量p。由于这里用乘法累积信息,p的初值设为1。
根据这些考虑,写出的函数定义如下:
p是这里的累积变量,循环中把x的一些幂次累积到p上,最终得到x的n次幂。循环里区分了几种情况:指数n等于0时累积工作完成,循环结束,变量p的值即为所需。循环体就是一个if语句,两个分支分别处理指数为偶数和奇数的情况,其中用了几个扩展的赋值语句更新变量。注意,对p的初始化保证,即使这个循环的体一次也没有执行,得到的结果也是对的。
最大公约数(greatest common divisor,简写为gcd或gcd)是一个重要的数学概念。整数n的约数就是能整除n的整数,k整除n即为n除以k的余数为0。显然,对任意的n,1和n本身都是其约数。两个整数m和n的最大公约数,就是能同时整除两者的整数中绝对值最大的那个数。由于1是任何整数的约数,两个整数的最大公约数一定存在。本节考虑最大公约数的计算问题,定义完成它的python函数。这个问题也存在许多求解方法。
最直接的方法是系统化地检查一些整数,直至找到能同时整除两个数的最大数。设函数的参数分别是m和n,为做顺序检查,需要用一个辅助变量k,用它的值做检查。下面的问题是k如何取值。根据前面的经验,最简单的系统化检查方法就是顺序检查,从某个值开始逐一试验,直至到达我们的目标。为此需要设计一个循环,实现这种重复检查。这样,k的取值问题就变成如何取初值,如何更新,如何判断结束。
方法1:令k从1开始递增取值,结束条件是k大于m或n中的某一个。这样就能保证得到结果,因为最大公约数绝不会大于两个数中的任何一个。
下一个问题是如何得到所需结果。m和n可能有许多公约数,变量k最后的值也不会是m和n的公约数,因为它已大于两个数之一。这些情况说明,需要在循环中记录遇到的公约数,为此需要引进新变量。对这个具体问题,只需要引进一个变量,记录已经找到的最大的公约数。例如用变量d,其初值可以设为1,因为1是任何整数的约数。遇到m和n的新公约数时把它记入d,这个语句应该是:
可以看到,引入d及其初值后,k的取值可以从2开始。函数定义已经很清楚:
这里假定了m和n的值都不小于0。如果m和n没有除去1之外的其他公约数,变量d的初值1就会留下来,也能得到正确结果。
如果考虑任意整数参数,还需要仔细考虑各种情况:
综合起来,在函数的循环之前应该加上几个语句:
两个参数都是0的情况也自然地处理了,无须专门检查。
在这个示例程序里用了一个辅助变量d,用于记录计算过程中得到的中间结果。根据需要引入辅助变量是程序设计中的一种常用技术。另一个问题也值得注意:许多计算问题都有一些需要处理的特殊情况,应当仔细分析,分别给以处理。其中一件事情就是分析参数的各种可能情况,看看程序是否都能给出正确结果,必要时修改程序,经常需要在主要代码前面加一些条件判断,也可能需要加入另外的处理语句。
方法2:令k从某个大数开始递减取值,这样,找到的第一个公约数就是最大公约数。可以令k的初值为m和n中较小的一个,在没有遇到公约数的情况下递减。结束条件是:或者k值达到1,或者已经找到了公约数。由于1总是两个数的公约数,因此前一条件被后一条件覆盖。有关程序留作读者的练习。
上面这两种求解方法的共同点是采用重复测试的方法,用每一个数去检查条件是否成立。这里采用的是通用的生成与检查方法,很多问题都能这样求解。为应用这种方法,需要设计并生成需要检查的数据集,实现适当检查的方法。这种方法的缺点是效率通常比较低,如果需要检查的数据集合很大,程序中的循环就要反复执行很多次。一般而言,如果能找到解决具体问题的特殊方法,就不应该选用这种“通用”方法。
对于求最大公约数的问题,古代科学家已经提出了更有效的计算方法,这就是著名的欧几里得算法(欧氏算法),即辗转相除法。下面介绍欧几里得算法的程序实现。首先,不难用递归形式给出辗转相除法求最大公约数的定义:
这里假定m和n都是自然数,而且m不为0。
函数定义1(递归定义):先假定函数的第一个参数非0,而且两个参数都不小于0。下面的函数定义直接对应于辗转相除法的数学定义:
这个函数很简单,对欧几里得算法的研究保证该函数的计算一定能结束。这个函数的计算速度很快,快于前面的顺序检查,参数较大时优势更明显。
为处理各种特殊情况,可以另外写一个函数,其中调用上面的函数。这也是程序设计中常见的技术。下面是求最大公约数的主函数:
函数定义2(循环方式):辗转相除也是一种重复性工作,其中反复求余数。前面用递归的方式定义函数,也可以通过循环实现。在写程序前,先分析一下应当如何写循环:
1)循环开始时,m和n是求最大公约数的出发点。
2)每次循环判断n % m是否为0。如果为0,m就是最大公约数,结束。
3)否则做变量更新:更新后的n取原来m的值,而m取原来n % m的值。这两个变量更新可以用一个平行赋值实现:
下面是这个函数的一种定义,其中假定两个参数的值均不小于0。把这一函数补充完整的工作留给读者考虑。
n的值为0以及m和n的值都为0的情况没有特殊处理,因为这些处理都已经包含在定义里了。读者很容易弄清楚这一点。上面函数里也有一个较复杂的循环。按照前面说法,一个循环中总要维持某种不变关系,以保证循环的正确性。那么,上面函数里的这个循环维持的是变量之间的什么关系呢?这一点也请读者考虑。
这个例子也说明了平行赋值的作用。在上面的函数里采用了平行赋值,既简单又清晰,意义也正确。如果将其分开写为两个单独的赋值语句,无论怎样排序都不能得到正确结果。当然,这并不说明用简单赋值不能解决这个问题,但要完成同样功能,需要另外引进一个辅助变量。请读者自己验证这些论断。
前面讨论中出现的递归都是一个函数在体中调用自身,这种递归可以称为自递归。有时也会出现需要两个或多个函数相互调用,形成复杂的递归情况。其中最简单的情况就是两个函数相互递归调用,例如在f里调用g,在g里调用f。
如果需要定义两个相互调用的函数,就会遇到一个问题:无论先定义哪个,其函数体里总需要调用另一个尚未定义的函数。还好,在python语言里,怎么排列两个函数定义都没问题。在处理函数的定义时,python解释器并不检查其中调用的函数有无定义,只要在实际调用时被调函数有定义,能找到它,就万事大吉了。如果执行到一个函数调用时被调用函数还没有定义,python解释器自然要报错。
下面是两个相互递归的函数,它们分别判断参数的奇偶:
如果在函数even的定义之后调用even(4),系统就会报错。因为当解释器处理到这一点,函数odd还没有定义。在odd有定义后,写even(4)就没问题了。
这个例子本身并没有实际意义,只是用来说明情况。在实际编程中,可以出现更复杂的递归情况,这里就不举例了。