《从问题到程序:用Python学编程和计算》——3.2 递归-阿里云开发者社区

开发者社区> 华章出版社> 正文
登录阅读全文

《从问题到程序:用Python学编程和计算》——3.2 递归

简介:

本节书摘来自华章计算机《从问题到程序:用Python学编程和计算》一书中的第3章,第3.2节,作者:裘宗燕 更多章节内容可以访问云栖社区“华章计算机”公众号查看。

3.2 递归

有一种函数定义比较特殊,就是在定义的函数体里调用被定义的函数自身。Python允许这种形式的函数定义,称为递归定义,这样定义出的函数也经常被称为递归函数。但是,这样做带来了一个数学里经常提到的问题:基于自己定义自己,这种形式的定义有意义吗?会不会做出了一个所谓的“循环定义”,完全没有意义?本节讨论这方面的一些情况,并说明递归定义是一种很有用的编程技术。
3.2.1 递归定义的函数
在一个函数的定义体里调用正在定义的函数自身,意味着要求用这个函数完成它自己的一部分工作。这样写出的定义称为函数的递归定义。实际上,数学中也经常见到递归定义的函数,例如,阶乘函数常用下面公式定义:


<a href=https://yqfile.alicdn.com/628f75f8e0bb13e9adf1639419cf7cad922e15ea.png" >

对一般情况,n的阶乘通过n - 1的阶乘定义,这就是递归定义。
不难想清楚,上面定义确实给出了所有自然数n的阶乘定义:自然数0的阶乘直接给出,对于非0自然数,其阶乘基于比它小1的自然数的阶乘算出。从最小的非0自然数开始考虑:由于已知0的阶乘,我们可以得到1的阶乘,然后就能得到2的阶乘,如此等等。知道了n的阶乘,就能知道n + 1的阶乘。根据数学归纳法,所有自然数的阶乘都有定义。这一论证就说明了上面定义的有效性(不是无意义的循环定义)。
阶乘的数学定义是一个分情况定义,而且是一个递归的定义。Python语言也允许在函数定义的函数体里调用被定义的函数(也就是说,允许以递归的方式定义函数),因此,阶乘的数学定义可以直接翻译为Python的函数定义:

def fact(n):
    if n == 0:
        return 1
    else:
        return n * fact(n - 1)

通过试验可以看到该函数确实能给出正确结果。这个函数定义是数学定义的直接翻译,前面有关该数学定义有效的讨论,也说明这个Python函数的定义有效。
借助条件表达式写出的定义更简短:

def fact(n):
    return 1 if n == 0 else n * fact(n - 1)

这样递归定义的阶乘函数,看起来是一个简单的分支型程序,但是它在一个分支里调用了自己,递归调用又要求执行函数里的一条路径。因此,在这种递归定义函数的执行中,同样可能多次重复执行函数里的语句,运行中执行的总路径长度由递归调用该函数的次数确定。对这个阶乘函数,实际上是由调用时给定的参数决定的。
3.2.2 乘幂的计算
作为递归函数的编程实例,本小节考虑定义乘幂函数的问题。Python语言有乘幂运算符,实际上并不需要定义这个函数。这里将其作为示例,只是想说明编程中的一些情况。下面只考虑以自然数为指数的乘幂问题。
乘幂有如下简单的递归定义:


496d9eb28973e8fba0d31fd43de0821b60214f4a

很容易把这个数学定义翻译成Python的递归函数定义:
def power(x, n):
    if n == 0:
        return 1
    else:
        return x * power(x, n-1)

与数学定义类似,本函数又是一个分情况定义:基本情况的结果直接给出,对于一般情况,把对于参数n的计算归结为同一函数对参数n-1的计算。由于在每次递归调用中参数的值减一,不断递归一定能达到基本情况,最终给出函数的结果。
实际上,任何递归函数的定义都应该是某种分情况处理:(一些)基本情况直接给出结果,一般情况采用递归的方式处理,把对较“复杂”参数的计算归结为对较“简单”参数的计算。这样的定义才能保证得到结果(保证定义有效)。
对于乘幂函数,前面的定义需要做较多的递归,如果实际参数为n,就需要递归n层。为防止出现无穷递归(递归定义不当,有可能出现无限递归下去而得不到结果的情况,称为无穷递归),Python对程序的最大函数调用深度有默认限制。例如:

>>> power(2, 1000)
Traceback (most recent call last):
  File "<pyshell#0>", line 1, in <module>
    power(2, 1000)
... ...

后面还有很长一段错误信息。这里出现的问题就是递归调用的深度超过了Python系统的默认限制,程序执行被Python解释器强制中断。
这里有一个数学问题:求xn最少需要做多少次乘法?上面函数采用了最简单的想法,完成这一计算需要做n次乘法运算,函数的递归调用也要做n次,比较低效。仔细思考不难看到一些情况,例如x8 = (x2)4 = ((x2)2)2,这个计算中只需要做3次乘法,而不是7或8次。指数是偶数可以采用这种做法,指数为奇数的乘幂也可以归结到指数为偶数的情况。把这种想法严格写出来,就得到下面的递归的数学定义:


<a href=https://yqfile.alicdn.com/c6dedcdb3d70e554d90011ea2a4219281729e1b4.png" >

很容易直接写出这个函数的递归定义:
def power(x, n):
    if n == 0:
        return 1
    elif n % 2 != 0:
        return x * power(x, n-1)
    else:
        return power(x*x, n//2)

表达式n % 2 != 0判断奇数。由于if语句的条件顺序检查,执行到elif时已知参数不为0。对于大的实参,这个函数的递归次数比前一个少得多。例如,计算power(2, 1024)时只需要递归10次。对于一般参数的情况,请读者自行分析。
3.2.3 循环和递归
前面讨论过循环程序与简单程序(直线型程序和分支程序)之间的不同。上面又提出了递归定义的函数,在其写法中并没有出现循环结构,但同样可能导致重复的计算。这里的情况是通过递归调用,多次重复执行同一个函数的体代码。由此我们可以想一个问题:递归和循环能做的事情类似,那么,两者可以相互替代吗?
人们对这方面的问题做过一些理论研究。一个结果是:如果一个语言中包含了基本语句、顺序组合和条件语句,而且允许以递归的方式定义函数,这样构成的语言就足够强大了,足以描述所有可能的计算过程。另一方面,一个包含了基本语句、顺序组合和循环结构的语言也足够强大,也可以写出所有可能的计算。
进一步说,不难设计出一种方法,采用这种方法,可以根据任何一个循环定义出一个递归函数,在程序里原来写循环的地方调用这个函数,就能得到同样的计算效果。而反过来的情况比较复杂,要把一个递归定义的函数改为不用递归方式定义的函数(称为原函数的非递归实现),有时会遇到一些困难,但也是可以完成的。
上面的理论结果说明递归和循环中有一个就足够了。但为了方便实际的程序开发,各种实际编程语言都引进了一些冗余的机制,包括冗余的控制机制(以及后面将会讨论的数据机制)。例如,Python语言有循环结构,同时也允许递归的函数定义。
作为例子,现在考虑前一小节的乘幂函数,看看怎样做出与之对应的循环实现。
可以看到,在前面power函数的递归实现里,递归调用之间传递两个参数x和n,还有一个是函数的返回值。虽然传递的参数名字不变,但参数的值一直在变化。其中变量n的值不断减小(或者整除2或者减一),x不断求平方。每次遇到指数为奇数时,就是当时x的值作为乘数。按说需要用一个变量记录这个变化的x。但由于Python中函数的参数就是变量,可以直接用参数x记录这个变化的值。另外,为了反映积累的乘积(递归函数的返回值),引进另一个变量p。由于这里用乘法累积信息,p的初值设为1。
根据这些考虑,写出的函数定义如下:

def power(x, n):
    p = 1
    while n > 0:
        if n % 2 != 0:
            p *= x
            n -= 1
        else:
            x *= x
            n //= 2
    return p

p是这里的累积变量,循环中把x的一些幂次累积到p上,最终得到x的n次幂。循环里区分了几种情况:指数n等于0时累积工作完成,循环结束,变量p的值即为所需。循环体就是一个if语句,两个分支分别处理指数为偶数和奇数的情况,其中用了几个扩展的赋值语句更新变量。注意,对p的初始化保证,即使这个循环的体一次也没有执行,得到的结果也是对的。
3.2.4 斐波那契数列的计算
斐波那契(Fibonacci)数列是数学中一个很重要的自然数数列,该数列在计算领域也有非常重要的意义。数列的定义如下:


96ec953e7be8c51b1e0d245c09b23d9b979b2997

可以看出这个数列中的值是递归定义的,但与前面的递归定义不同,这里有两个基础情况,此外,一般项的值被归结到两个下标较小的项之和,这种递归可以称为二路递归,因为在定义中出现了两个递归项。很容易写出一个采用递归定义技术的Python函数,计算斐波那契序列值,下面是一个简单定义:
def fib(n):
    if n < 1:
        return 0
    if n == 1:
        return 1
    return fib(n - 1) + fib(n - 2)

这个定义也处理了实参为负的情况,它把所有参数为负的情况硬性定义为1。这是一种合理处置,写程序常遇到这类情况,也经常采用这种处理方法。
通过简单的试验可以看到,Fibonacci数列中的项增大得非常快。下面是该数列中最前面的10项:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34

下面将基于这个问题,对计算的性质做一些探讨。
问题分析
现在想讨论一个问题:上面定义的这个函数好不好?从一个角度看,这个函数确实很好,其描述方式(递归定义的函数)直接对应于Fibonacci数列的数学定义,很容易确定函数定义的正确性。函数定义很简单,也容易阅读和理解。
但是,从另一个角度,这个定义却有一个本质性的缺陷。为了说明它的缺陷,现在看一个计算实例。图3.1展示了fib(5)的计算过程,描述了执行中的函数调用关系。图中从fib(5)向下到fib(4)和fib(3)有箭头,表示计算fib(5)时要调用fib(4)和fib(3),其他箭头的意义类似。从这个图中可以看到许多重复计算,除fib(4)以外,其他调用都出现了多次。还有一个情况也值得注意:参数越小重复调用次数也越多(除了fib(0)比fib(1)少),例如fib(2)计算了3次,fib(1)计算了5次。这种情况具有普遍性,用更大的参数启动计算,情况也是这样。而且,开始的实参值越大,重复计算越多。


94d451d06a3fc77ab7cad10291949c7790ac9550

读者可能认为,出现一些重复计算关系不大,今天计算机这么快,多做几次计算花不了多少时间,但问题不这么简单。随着参数增大,fib函数计算中的重复将飞速增长,在计算机上试试fib(30)、fib(31)、……,就能体会到这种情况。在作者的微机上计算fib(40)需要一分多钟,而40并不是一个很大的参数。
更严重的问题是,参数值每增加1,fib的计算时间将增长约1.6倍左右(理论估计,实际可能更多)。这样,在目前最快的微机上一个小时也算不出fib(50),而计算fib(100)可能需要一百万年(数量级估计)。这个结果确实令人吃惊!
上面情况反映了计算机的一个本质弱点:计算需要时间,大量的计算要很多时间。这是计算机的一个本质性特征,它也说明了存在着计算机不可能做好的事情。
实际的计算不是纯粹的抽象的数学理论,而是需要由真实世界中的设备(以前由人来做,目前是用电子计算机)完成的实际工作。无论采用什么设备,执行每步计算都有代价。即使计算设备非常快,每一步所需的时间极少 ,但也不为0。很多个动作的时间累积起来,就可能超过给定的任意大的数(这是数学)。
计算有代价,这是计算的一种本质特征。科学家对这个问题进行了一些研究,取得了许多非常重要的理论结论。计算的这一特征有很多推论,例如:

  • 可能存在一些(许多)从理论上看能用计算机解决的问题,但我们不可能等到计算机给出解的那个时间(人的寿命有限,宇宙的寿命也可能有限)。
  • 我们在编程时,需要考虑程序完成计算所需要的时间。计算机科学技术领域最重要的问题之一就是寻找解决各种问题的高效方法(算法)。
  • 有些实际问题对于计算完成的时间非常敏感。这方面的例子很多,例如:汽车的ABS/EBD(电子制动分配)系统,必须能在若干分之一秒完成计算;天气预报系统,必须在今天的某个时间完成对明天天气的计算。
    对于求Fibonacci数列值的问题,实际上可以找到更好的办法(更好的算法,下面介绍),但有些问题的情况不是这样。人们已经发现了许多非常重要的实际计算问题,从理论上说它们是可以用计算机解决的,不难写出解决它们的程序。但是,从实际角度看这些问题并没有解决,因为对规模稍微大一点的实际情况(“规模大”对应于上面“大一点的参数n”),我们的千万代子孙都无法等到程序给出结果。在这种情况下能说问题解决了吗?显然,说“能”是很牵强的。理解这一点,对于理解计算机也是非常重要的。

为计算过程计时
统计程序或程序片段的计算时间,有助于帮助我们理解程序的动态性质,在实际中人们经常需要做这件事。我们可以用普通的计时器为程序计时,例如用手表或者秒表,但如果实际地这样做,就会发现它既费事又不精确。程序语言或系统大都提供了内部计时功能,下面介绍Python标准库的计时功能。
Python标准库包time里提供了一个计时函数time,导入这个函数之后,调用它时可以得到一个浮点数,表示从1970年1月1日开始的秒数值。通过两次调用time,就可以为一段计算计时。例如,下面函数可以用于给fib计算计时:

from time import time

def test_fib(n):
    t = time()
    f = fib(n)
    print("Fib[" + str(n) + "] =", f,
          "Timing:", str(time() - t) + "s\n")

这个函数的输出是两次相继的time调用的结果之差,基本上等于计算一个fib值的时间。此外,由于Python系统可能有最小时间单位,计时的结果按单位增长,小于该单位的时间将无法分辨。虽然这些计时值可能有误差,但还是很能说明问题的。
现在定义一个函数,让它输出一些Fibonacci数列值以及计算所需的时间:

def test_fibs(m, n):
    for k in range(m, n):
        test_fib(k)

下面是在一台计算机上调用函数的输出,从中可以清晰看到时间在迅速增长:

>>> test_fibs(32, 40)
Fib[32] = 2178309 Timing: 1.4560840129852295s
Fib[33] = 3524578 Timing: 2.1551239490509033s
Fib[34] = 5702887 Timing: 3.5712039470672607s
Fib[35] = 9227465 Timing: 5.6563239097595215s
Fib[36] = 14930352 Timing: 9.236528873443604s
Fib[37] = 24157817 Timing: 14.828848123550415s
Fib[38] = 39088169 Timing: 24.034374952316284s
Fib[39] = 63245986 Timing: 39.42125487327576s

为程序计时,是人们在实现大系统、研究计算过程的性质时经常需要做的事情。如果发现一个程序很慢,不能满足实际需要,有可能做的一件事就是仔细检查程序,估计其中各个部分消耗的时间。还可以考虑对其中的某些部分计时,考察程序运行的实际情况,然后设法改进设计,或者改进计算的实现方法。
Fibonacci数列的迭代计算
看起来Fibonacci数列的计算规则很简单,需要花费那么多时间不太合理。前面已经分析了其中的原因,主要是没必要的重复计算太多。现在考虑能不能找到求Fibonacci数问题的“更好”(更快)的方法。还好,确实有这样的方法。考虑下面论述:
1)F0和F1已知;
2)由F0和F1可以算出F2;
3)一般而言,已知连续两个Fibonacci数,就可以算出序列中下一个数。
这几条规则给出了一种以递推方式计算第n个Fibonacci数的方式:从F0和F1出发逐个向前推算,直至算出所需要的Fn为止。显然,在这个计算里需要用一个计数变量,保存当时算出的Fibonacci数的下标。按这种想法写出的新函数定义如下:

def fib(n):
    if n < 0:
        return 0
    f1, f2 = 0, 1 # 开始时分别表示F_0和F_1
    k = 0
    while k < n:
        f1, f2 = f2, f2 + f1
        k += 1
    return f1

通过试验,可以看到本函数对一些具体参数能得到所需结果。
但也应该看到,这个函数里的循环比较复杂,怎么才能确信它总能完成所需工作呢?这也就是问,对任何参数,它都能算出相应的Fibonacci数吗?下面我们仔细讨论一下这个问题,其中将涉及一点程序理论,这里采用尽可能直观的讨论方式。
要写好一个循环,首先需要想清楚它应该做些什么。对于上面函数里的循环,其设计目的是:一旦变量k增加到等于n,变量f1的值就应当是所需要的(第n个)Fibonacci数。为了确认该循环能做到这一点,我们将说明它能保证:每次做循环条件判断时,f1的值总是第k个Fibonacci数,而且f2的值总是第k+1个Fibonacci数。如果这个断言正确,当k值等于n时循环结束,f1就是第n个Fibonacci数了。
通过下面的分析不难看到,函数fib里的循环确实能保证这种关系。循环里用了两个递推变量,其更新方式是逐步前推。我们有如下论证:

  • 首先,进入循环后,第一次判断循环条件时k值为0而f1的值等于0,也就是F0,上述断言关系成立。而且f2的值是F1,是下一个Fibonacci数。
  • 假设某次判断循环条件时f1的值是Fk,而且f2的值是Fk+1。循环体里的赋值使f1和f2分别变成Fk+1和Fk+2。随后变量k的值加1,使得f1和f2重新变成Fk和Fk+1。因此,在下次判断循环条件时上述断言仍然成立。
    根据上面的论证,我们可以断定函数fib里的循环是正确的,对任何参数n,这个循环都能在算出第n个Fibonacci数后结束。正因为如此,函数fib也是正确的。注意,这一结论具有一般性,与函数的具体参数值无关,因此是一个(数学意义上的)证明。前面说过,一次测试可以说明程序对这一测试实例正确,但多少次能给出百分之百肯定的结论呢?以函数fib为例,其参数可以是任何整数。我们测试了0,结果正确;测试了1,结果也正确;再做一批测试,就能断定对所有的正整数fib都能给出正确结果吗?

循环不变式
一个循环表示了一个反复进行的计算过程,根据实际执行中变量的状态,这个循环的体可能执行很多次。在这种情况下,我们怎样保证对任何可能的数据,该循环都能正确完成所需要的计算呢?显然,总存在一些在循环体的每次执行中都可能改变的变量。人们仔细研究后发现,在写循环的时候,最需要关注的是一些变量之间的关系。虽然通过一次次迭代,循环中各个变量的值不断变化,但有些关系却是不变的,反映了这个循环的本质。正确的循环就应该要保证这些关系在一次次迭代中不变。这种关系称为循环不变式。
在前面写简单的循环时,我们更多地依靠直觉。实际上,那里的循环中也存在不变关系。例如,对于下面这个简单循环:

ssum = 0
k = 1
while k < 101:
    ssum += k * k
    k += 1

它维持的不变关系就是:每次判断循环条件时“ssum总是前k-1个正整数的平方和”。进入循环时k为1,ssum为0,上述关系显然成立;每次迭代把k的平方加入ssum,同时k值加1,循环不变式得到维持。当循环结束时k < 101不成立了,也就是说当时k是101。根据这些分析,我们可以确信,这时ssum的值就是前100个数的平方和。这些说明,循环不变式并不是高妙的难以理解的东西,它不过是人们直观认识的提升。
实际上,要写好一个循环,最重要就是弄清在循环中应当维持什么东西不变,想清楚循环中需要维持哪些变量之间的什么关系,才能保证循环结束时关键变量都处于所需要的状态。写完循环后应该回头去仔细检查,看提出的要求是否满足。对于简单循环,这些问题的回答可能非常自然,不需要特别费脑筋。但在描述复杂的循环时,有意识地思考这个问题则非常重要。在上面计算Fibonacci数的循环里,需要保持的就是变量f1和f2与k之间的关系,保证每次循环判断时f1和f2的值正好是第k个和第k+1个Fibonacci数。确保这一点,我们就可以毫无保留地断定,该函数总能计算出正确结果。
下面的问题是:这个函数真的比前一个好吗?
首先应当看到,这个函数定义比前面的那个复杂很多,其中不但要区分各种情况,还出现了一个较复杂的循环。弄清这个程序能否完成所需计算也不再简单,要经过仔细思考,还要借助循环不变式的概念。从这方面看,这个新函数远不如前面递归定义的函数。
但是,新函数在计算时间上却有很大的优越性。易见,该函数的主体就是一个循环,执行时间基本上由循环次数确定,而循环体的执行次数基本上等于n的大小(n大于0时),计算fib(100)只需做大约100次循环,在计算机上根本察觉不到运行时间。前面的函数完成同样计算需要的时间以万年计,与之相比,新函数有绝对的优势。
总结一下,我们在前面定义了一个清晰但非常低效的函数,现在又定义了一个不太清晰但却非常高效的函数。在这里两者不能兼得。能不能有一种方法,并基于这种方法实现一个翻译器(一个程序),使人只需要针对要解决的问题写出一个最清晰简单的程序,该翻译器就能把它翻译为一个高效程序?这是一个非常有趣的研究课题。
还要提醒读者:计算Fibonacci数的问题可以找到快速计算方法,但这个情况并不具有普遍性。有很多问题的计算确实需要很长时间。人们已经发现了一大批非常重要,很有实际价值的问题,但对这些问题,已经找到的解决方法都非常耗时,完全不实用。但另一方面,我们还不知道这些问题究竟有没有更快的算法。有关计算代价的理论研究形成了一个称为“计算复杂性”的研究领域,该领域已取得许多极其重要的成果。例如,目前被数学家们排在第一位的“最重要数学问题”就源自这一领域。
有读者可能想,为了完成同一个计算,前面递归定义的函数比这个用循环实现的函数慢得多,这是不是一种普遍规律?其实不一定。对于求Fibonacci数的问题,下面是另一个递归定义的函数,其计算速度与前面用循环定义的函数差不多:

def fib0(f1, f2, k, n):
    if  k > n:
        return f1
    else:
        return fib0(f2, f1 + f2, k + 1, n)

def fib(n):
    return fib0(0, 1, 1, n)

这里先定义了一个辅助函数fib0,函数体里只有一个递归调用。主函数fib启动计算。当然,想说明这个函数确实能计算Fibonacci数又不那么容易了,请读者自己考虑。另外,还请将这个函数与前面循环定义的函数做一个对比。
利用Python的功能,前面的循环程序还可以简化。例如:

def fib(n):
    f1, f2 = 0, 1
    for k in range(n):
        f1, f2 = f2, f2 + f1
    return f1

函数的正确性请读者自己分析。
3.2.5 最大公约数
最大公约数(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,这个语句应该是:

if m % k == 0 and n % k == 0:
    d = k;  # k是新的公约数,而且更大

可以看到,引入d及其初值后,k的取值可以从2开始。函数定义已经很清楚:

def gcd (m, n):
    d = 1
    k = 2
    while k <= m and k <= n:
        if m % k == 0 and n % k == 0:
            d = k
        k += 1
    return d

这里假定了m和n的值都不小于0。如果m和n没有除去1之外的其他公约数,变量d的初值1就会留下来,也能得到正确结果。
如果考虑任意整数参数,还需要仔细考虑各种情况:

  • 当m和n都是0时不存在最大公约数。上面函数定义没考虑这种情况,给出结果1并不合适。对这种特殊情况需要选一种合理的处理方式,例如可以令函数返回0(0不是任一对整数的最大公约数),表明遇到了特殊情况。
  • 如果m、n之一为0,最大公约数就是另一个数。在遇到这种情况时,上面函数返回的值也不对,可以加入直接的判断和处理。
  • m、n可能是负数,此时上面函数返回1,一般而言是错的,应该处理。
    综合起来,在函数的循环之前应该加上几个语句:
if m == 0:
        return n
    if n == 0:
        return m
    if m < 0:
        m = -m
    if n < 0:
        n = -n

两个参数都是0的情况也自然地处理了,无须专门检查。
在这个示例程序里用了一个辅助变量d,用于记录计算过程中得到的中间结果。根据需要引入辅助变量是程序设计中的一种常用技术。另一个问题也值得注意:许多计算问题都有一些需要处理的特殊情况,应当仔细分析,分别给以处理。其中一件事情就是分析参数的各种可能情况,看看程序是否都能给出正确结果,必要时修改程序,经常需要在主要代码前面加一些条件判断,也可能需要加入另外的处理语句。
方法2:令k从某个大数开始递减取值,这样,找到的第一个公约数就是最大公约数。可以令k的初值为m和n中较小的一个,在没有遇到公约数的情况下递减。结束条件是:或者k值达到1,或者已经找到了公约数。由于1总是两个数的公约数,因此前一条件被后一条件覆盖。有关程序留作读者的练习。
上面这两种求解方法的共同点是采用重复测试的方法,用每一个数去检查条件是否成立。这里采用的是通用的生成与检查方法,很多问题都能这样求解。为应用这种方法,需要设计并生成需要检查的数据集,实现适当检查的方法。这种方法的缺点是效率通常比较低,如果需要检查的数据集合很大,程序中的循环就要反复执行很多次。一般而言,如果能找到解决具体问题的特殊方法,就不应该选用这种“通用”方法。
辗转相除法
对于求最大公约数的问题,古代科学家已经提出了更有效的计算方法,这就是著名的欧几里得算法(欧氏算法),即辗转相除法。下面介绍欧几里得算法的程序实现。首先,不难用递归形式给出辗转相除法求最大公约数的定义:


1bc7dfe864b74db61f26a4c6174c0abf6d9e1a6e

这里假定m和n都是自然数,而且m不为0。
函数定义1(递归定义):先假定函数的第一个参数非0,而且两个参数都不小于0。下面的函数定义直接对应于辗转相除法的数学定义:

def gcd(m, n):
    if n % m == 0:
        return m
    return gcd(n % m, m)

这个函数很简单,对欧几里得算法的研究保证该函数的计算一定能结束。这个函数的计算速度很快,快于前面的顺序检查,参数较大时优势更明显。
为处理各种特殊情况,可以另外写一个函数,其中调用上面的函数。这也是程序设计中常见的技术。下面是求最大公约数的主函数:

def gcd_main(m, n):
    if m < 0:
        m = -m
    if n < 0:
        n = -n
    if m == 0:
        return n
    return gcd(m, n)

函数定义2(循环方式):辗转相除也是一种重复性工作,其中反复求余数。前面用递归的方式定义函数,也可以通过循环实现。在写程序前,先分析一下应当如何写循环:
1)循环开始时,m和n是求最大公约数的出发点。
2)每次循环判断n % m是否为0。如果为0,m就是最大公约数,结束。
3)否则做变量更新:更新后的n取原来m的值,而m取原来n % m的值。这两个变量更新可以用一个平行赋值实现:

m, n = n % m, m

下面是这个函数的一种定义,其中假定两个参数的值均不小于0。把这一函数补充完整的工作留给读者考虑。

def gcd(m, n):
    if m == 0:
        return n
    while n % m != 0:
        m, n = n % m, m
    return m

n的值为0以及m和n的值都为0的情况没有特殊处理,因为这些处理都已经包含在定义里了。读者很容易弄清楚这一点。上面函数里也有一个较复杂的循环。按照前面说法,一个循环中总要维持某种不变关系,以保证循环的正确性。那么,上面函数里的这个循环维持的是变量之间的什么关系呢?这一点也请读者考虑。
这个例子也说明了平行赋值的作用。在上面的函数里采用了平行赋值,既简单又清晰,意义也正确。如果将其分开写为两个单独的赋值语句,无论怎样排序都不能得到正确结果。当然,这并不说明用简单赋值不能解决这个问题,但要完成同样功能,需要另外引进一个辅助变量。请读者自己验证这些论断。
3.2.6 不容易用循环求解的递归问题
前面说过所有的递归程序都可能用循环的方式求解,但也确实存在一些问题,用递归的方式求解自然而且简单,但却不容易写出解决它们的循环程序。这里看两个例子,它们都是非常著名的计算和编程问题。
河内塔(梵塔)问题
我们讨论的第一个问题称为河内塔问题(hanoi塔问题),或梵塔问题。据说问题来自古印度,另一说是来自西藏。用递归求解很简单,用循环就不太容易了。
该问题的描述如下:某神庙里有三根细长的玉石圆柱,另外有64个大小不等、中心有圆孔的金盘,按下大上小的方式套在这几根圆柱上,这就是梵塔。神庙里的僧侣们日夜不息地把圆盘从一根圆柱搬到另一根圆柱,搬移的规则是每次只能搬一个圆盘,而且不允许把大盘放到小盘之上。开始时所有圆盘按下大上小顺序地套在一根圆柱上,据说当所有圆盘都被搬到另一根圆柱的时候,世界就将毁灭。图3.2中的一个细柱上套着5片圆盘。


<a href=https://yqfile.alicdn.com/d10d577c7fb2085f2f5074380cf3ad122673591d.png" >

现在考虑如何写一个程序模拟搬圆盘的过程。当然这个程序不能真正搬动圆盘,我们希望它能打印出一系列搬动指令,说明应该把哪根圆柱上的圆盘(只能是当时最上面的圆盘)搬到另一圆柱。为方便起见,把三根圆柱分别命名为A、B和C,假定开始时所有圆盘都在圆柱A,目标是把它们都搬到圆柱B,而且任何时候都“不能把大盘放到小盘上面”。
初看起来,这个问题好像没什么规律。第一步只能把柱A上最小的圆盘搬到另一圆柱,例如柱B。显然这时已经不能再向柱B搬圆盘了,因为任何圆盘搬过去,都将违反“不能把大盘放到小盘上”的规则。这时只能考虑把柱A上的次小圆盘搬到柱C。下一步只能将柱B上最小的圆盘搬到柱C的次小圆盘上面,或者把它搬回柱A。这样考虑问题,事情好像完全杂乱无章,看不出任何能写出程序的线索。
对这个问题,取得突破的要点在于换一种看法,反过来看问题:假设我们能把上面63个圆盘从柱A搬到柱C(其间可利用柱B作为辅助),那么下一步就可以把柱A上最后一个圆盘(最大的圆盘)直接搬到B,剩下的问题就是把C上的63个圆盘搬到B,这个问题与前面把63个圆盘从柱A搬到柱C类似。这样分析,就抓住了解决问题的过程的基本结构:实际上,我们可以把(借助于另一圆柱)搬64个圆盘的问题分解为三个子问题:
1)借助于柱B把63个圆盘从A搬到C;
2)从A搬一个圆盘到B;
3)借助于柱A把63个圆盘从C搬到B。
很显然,搬63个圆盘的问题与搬64个圆盘类似,只是问题的规模减小了一点。这个分析已经展现出采用递归方法解决问题的所有特征。这里有一种基本情况:如果某个圆柱上只有一个需要搬的圆盘,那么就直接搬它;如果需要搬的圆盘多于一个,那就是递归情况,需要借助于另一根圆柱,将这一问题分解为三个部分,……。
现在考虑如何写出解决这一问题的递归函数。把函数命名为henoi,先考虑它应该有哪些参数。显然,该函数应该有一个记录需要搬动的圆盘数(问题规模)的整数参数,递归调用时这个参数减1,参数为1时可以直接搬(函数直接返回),否则就需要递归。这个函数还需要知道应该从哪个圆柱往哪个圆柱搬,以哪个圆柱作为辅助。假设我们用字符串值表示圆柱,这个递归函数的头部应该是:
def henoi(n, sfrom, sto, sby):

这里参数名开头的s表示是圆柱,我们希望函数做的是:从圆柱sfrom搬n个圆盘到圆柱sto,以圆柱sby为辅助位置。
至此,写出henoi函数的递归定义已经不困难了。下面是一种写法:

def moveone(sfrom, sto):
    print("Move a disk from", sfrom, "to", sto)

def henoi(n, sfrom, sto, sby):
    if n == 1:
        moveone(sfrom, sto)
        return
    henoi(n - 1, sfrom, sby, sto)
    moveone(sfrom, sto)
    henoi(n - 1, sby, sto, sfrom)

这里单独定义了一个moveone函数,是想把与输出有关的功能集中到一个地方。如果需要修改输出的形式,我们只需修改这个表示一次操作的函数。举例说,如果存在适当的图形显示系统,可以考虑把搬圆盘的过程用更形象的方式显示出来。如果需要用这个程序去控制一个机械手,真正搬动物理世界中的圆盘,则只需要把moveone改为控制机械手的程序。在做这些改动时,核心求解部分,即函数henoi,完全不必修改。
下面的调用输出了把3个圆盘从圆柱A移动到圆柱B的各移动步骤:

>>> henoi(3, "A", "B", "C")
Move a disk from A to B
Move a disk from A to C
Move a disk from B to C
Move a disk from A to B
Move a disk from C to A
Move a disk from C to B
Move a disk from A to B

虽然这个函数不长,但是其执行时间随num值增大的速度却非常快。对num的值应用数学归纳法,很容易证明,函数moveone的调用次数等于2n - 1,其中的n = num,是圆盘的个数。请读者考虑如何证明,再通过为程序运行计时等方式验证这一理论结论。当然,由于输出的速度(与程序内部计算相比)非常慢,所有产生大量输出信息的程序(如函数henoi)在运行中很大比例的时间都用在生成输出上。
完全可以采用循环实现这个程序,人们提出了一些方法,但都不太简单。具体情况这里不介绍了,读者可以自己考虑,或者查查有关资料。
兑换硬币
现在考虑另一个计算问题,硬币兑换。同样,用递归的方式求解这个问题比较简单,用循环描述,程序会比较复杂,也不那么清晰。
人民币的硬币共有1分、2分、5分、10分、50分和100分6种面值。现在考虑这样一个问题:给了一定人民币款额,问将其换成硬币有多少种不同的兑换方法。
用一定的具体实例试一试,就会发现这个问题不那么简单,人工做很容易弄错,需要找到系统化的列举方式,才能保证给出正确结果。
现在考虑一种递归的看法:币值n的兑换方式数等于:

  • 用了一个硬币a之后n - a的兑换方式数,加上
  • 不用硬币a时n的兑换方式数
    这是递归的情况。有几种(基础)情况可以直接得到结果:
  • n等于0时,应该认为是找到了一种兑换方式(计1种兑换方式);
  • n小于0,说明没找到兑换方式(计0种兑换方式);
  • 硬币的种类数等于0,说明没找到兑换方式(0种兑换方式)。
    由于上面递归的一种方式是去掉一种硬币,因此可能出现最后这种情况。

有了上面对问题的递归看法(和相应的算法)之后,就可以进一步考虑算法的实现了,但这里还有几个需要解决的问题。
人民币硬币的币值没有简单的规律,在计算中不太好用。这里考虑定义一种映射,给硬币排一个顺序编号,以便能方便地取得硬币的币值。我们将整数1到6分别对应到1分到100分的硬币,以方便地通过编号取得相应币值。
定义一个函数实现这种对应关系。开始时硬币的范围编号是1到6,缩小编号的范围,就能实现减一种硬币的操作。函数定义虽然长一点,但很简单:

def amount(k):
    if k == 1:
        return 1
    if k == 2:
        return 2
    if k == 3:
        return 5
    if k == 4:
        return 10
    if k == 5:
        return 50
    if k == 6:
        return 100

前面所做的分析可以直接翻译为一个递归定义的函数,ccoins(k, n)返回用k种硬币去兑换币值n时,不同兑换方式的总数:

def ccoins(k, n):
    if n == 0:
        return 1
    if k == 0 or n < 0:
        return 0
    return ccoins(k, n - amount(k)) + ccoins(k - 1, n)

最后定义一个主函数,它调用ccoins(6, n)完成对具体币值的计算:

def num_coins(n):
    return ccoins(6, n)

这个问题的解由3个函数构成,其中amount是个辅助函数,只是为了使核心函数的实现更加方便。核心函数ccoins采用递归的方式定义,实现前面通过分析得到的算法,其定义很简单。主函数只是一种包装,为了让使用更方便,也不容易出错(保证不给错参数,用足6种硬币,也防止用户无意中写出错误的调用,如ccoins(8, 100))。
上述解法中隐含着先试验币值较大的硬币(请仔细看定义),在我们的分析中并没有对硬币的顺序提出任何要求。请读者修改程序的实现,改为先考虑币值最小的硬币,看看能不能得到结果,得到的结果是否相同。最后用比较大的币值做些试验,看看两种不同处理顺序是否造成计算效率上的差异。如果有,也请想想为什么。
完全有可能用循环的方式写出程序求解这个问题,但可能比较复杂,求解的脉络也不那么清晰。有编程经验的读者可以自己试试。
3.2.7 更复杂的递归情况
前面讨论中出现的递归都是一个函数在体中调用自身,这种递归可以称为自递归。有时也会出现需要两个或多个函数相互调用,形成复杂的递归情况。其中最简单的情况就是两个函数相互递归调用,例如在f里调用g,在g里调用f。
如果需要定义两个相互调用的函数,就会遇到一个问题:无论先定义哪个,其函数体里总需要调用另一个尚未定义的函数。还好,在Python语言里,怎么排列两个函数定义都没问题。在处理函数的定义时,Python解释器并不检查其中调用的函数有无定义,只要在实际调用时被调函数有定义,能找到它,就万事大吉了。如果执行到一个函数调用时被调用函数还没有定义,Python解释器自然要报错。
下面是两个相互递归的函数,它们分别判断参数的奇偶:

def even(n):
    if n == 0:
        return True
    else:
        return odd(n-1)

even(4) # 在这里调用将会报销

def odd(n):
    if n == 0:
        return False
    else:
        return even(n-1)

如果在函数even的定义之后调用even(4),系统就会报错。因为当解释器处理到这一点,函数odd还没有定义。在odd有定义后,写even(4)就没问题了。
这个例子本身并没有实际意义,只是用来说明情况。在实际编程中,可以出现更复杂的递归情况,这里就不举例了。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享: