SICP 第一章 使用函数抽象概念 1.7 递归函数

文档:1.7 Recursive Functions

参考:cs61a.org/spring2018


1.7 递归函数

如果函数的主体直接或间接地调用函数本身,则函数被称为recursive递归。 也就是说,执行递归函数体的过程可能需要再次应用该函数。 递归函数在Python中不使用任何特殊的语法,但它们需要一些努力才容易理解和创建。

我们将从一个示例问题开始:编写将一个自然数的每位数字相加的函数。 在设计递归函数时,我们希望能找到一种将问题简单化的方法。 在这个示例里,运算符//可用于将数字分成两部分:其最后一位数字和除最后一位数字之外的所有数字。

>>> 18117 % 10
7
>>> 18117 // 10
1811

18117的每位数字之和为1 + 8 + 1 + 1 + 7 = 18。正如我们可以分开数字一样,我们可以将这个数值分解为最后一个数字7,以及除最后一个数字之外的所有数字之和, 1 + 8 + 1 + 1 = 11。这样我们给出了一个算法:想要把将数字n的每位数字相加,等价于将其最后一个数字n%10加到n // 10的每位数字之和上。这里有一个特殊情况: 如果这个数是单位数字,那么它的数字的总和本身就是本身。 该算法可以用递归函数来实现。

>>> def sum_digits(n):
        """Return the sum of the digits of positive integer n."""
        if n < 10:
            return n
        else:
            all_but_last, last = n // 10, n % 10
            return sum_digits(all_but_last) + last

sum_digits的这个定义是完整和正确的,即使sum_digits函数在其自身的内部被调用。 将一个数的每位数字相加的问题分为两个步骤:将除最后一位之外的所有数字相加,然后添加最后一位数字。 这两个步骤都比原问题简单。 该函数是递归的,因为第一步处理是与原问题相同的问题。 也就是说,sum_digits正是我们为了实现sum_digits而需要的函数。

>>> sum_digits(9)
9
>>> sum_digits(18117)
18
>>> sum_digits(9437184)
36
>>> sum_digits(11408855402054064613470328848384)
126

当执行def语句时,sum_digits的名称被绑定到一个新的函数,但函数体尚未执行。因此,sum_digits的循环性并不是问题。然后,sum_digits被在738上调用:

  1. 创建一个n = 738sum_digits的局部帧,sum_digits的主体在以该帧开始的环境中执行。
  2. 由于738不小于10,执行第4行的赋值语句时,将738分解为738
  3. 在以下return语句中,sum_digits在73上调用,这是当前环境中的all_but_last的值。
  4. 创建sum_digits的另一个局部帧,此时n被绑定到73上。 sum_digits的主体再次在以此帧开始的新环境中执行。
  5. 由于73也不小于1073被分为73,并且sum_digits7上被调用,所以在此帧中计算all_but_last的值。
  6. 创建sum_digits的第三个局部帧,其中n7
  7. 在从这个帧开始的环境中,确定符合n <10,因此返回7
  8. 在第二个局部帧中,此返回值73相加,最后的值返回10
  9. 在第一个局部帧中,返回值10与最后一个值8相加,返回18
    该函数的循环体,虽然被应用了两次,但每次都有一个不同的参数,所有这个递归函数调用正确。此外,第二次调用的数字求和问题是比第一次更简单的问题。我们生成调用sum_digits(18117)的环境图示,看到对sum_digits的每个连续调用都比上一个参数更小,直到最终达到单位数输入。

这个例子还说明了简单函数体如何通过使用递归来演化复杂的计算过程。

1.7.1 解剖递归函数

常见的模式可以在许多递归函数的体中找到。函数体以base case基线条件开始,它是一个定义了最简单处理输入函数的行为条件的语句。在sum_digits例子里面,base case基线条件是任何的单位数的参数,处理方法是返回该参数。一些递归函数可能会有多个base case基线条件。

base case基线条件之后是一个或多个递归调用。递归调用始终具有某种特性:它们能简化原始问题。递归函数通过逐步简化问题来表达计算过程。例如,将7的每位数字相加比把73的每位数字相加简单得多,而这又比对`738的每位数字求和更简单。对于每个后续调用,剩下的工作会越来越少。

递归函数解决问题的方式与之前的迭代方法完全不同。比如用函数fact计算n次阶乘,其中fact(4)计算结果为:4!= 4⋅3⋅2⋅1 = 24。

使用while语句的编程思路是通过将小于等于n的每个正整数相乘来累积出结果。

>>> def fact_iter(n):
        total, k = 1, 1
        while k <= n:
            total, k = total * k, k + 1
        return total
>>> fact_iter(4)
24

另一方面,阶乘的递归实现可以用fact(n - 1)来表达fact(n),这样就转化成了一个更简单的问题。 递归的基线条件是问题的最简形式:fact(1) = 1


这两个阶乘函数在思路上有本质不同。 迭代函数通过对每项进行连续乘法将基线条件1的结果构造到最终数值。 但是,递归函数直接从最后一项n构造结果,并且将问题转化成fact(n - 1)

由于递归通过将fact函数连续应用到更简单的问题实例中”展开“,结果最终从基线条件开始构建。整个递归体在将参数1传递给fact后结束; 每次调用的结果取决于下一次调用,直到符合基线条件。

这个递归函数的正确性很容易从递归的数学标准定义中进行验证:


虽然我们可以使用我们的计算模型把递归展开,但是将递归调用视为函数抽象往往更为清晰。 也就是说,我们不需要太关心fact(n - 1)是如何实现的; 我们只需要知道它可以来计算n-1的阶乘。 将递归调用视为函数抽象可以说是“信念的递归飞跃”。 我们自己定义一个函数,只要相信在验证函数的正确性时,更简单的情况能正常工作就可以了。 在这个例子中,我们相信fact(n - 1)能正确地计算(n-1)! 。我们只需要检查一下n! , 如果这个假设成立,那它就能正确计算。 以这种方式,验证递归函数的正确性实际上是一种归纳证明。

1.7.2 相互递归

比如有一个函数f,里面调用了函数g,而函数g里面又调用了函数f。像这种函数f跟函数g,相互调用,那么我们就称这样的递归为相互递归 。例如,判断一个数是偶数,还是奇数就是一个最简单的相互递归。通过数学知识,我们很容易知道有以下的定义:

  1. 如果一个数是奇数,那它的后一位数一定是偶数。
  2. 如果一个数是偶数,那它的后一位数一定是奇数。
  3. 0是偶数

使用这个定义,我们可以用相互递归的函数来判断数字是偶数还是奇数:


通过打破两个函数之间的抽象边界,我们可以将相互递归函数转化为单个递归函数。 在以上例子中is_odd的函数体可以被合并到is_even的内容中。参数传递时,要确保用n-1来代替n

>>> def is_even(n):
        if n == 0:
            return True
        else:
            if (n-1) == 0:
                return False
            else:
                return is_even((n-1)-1)

因此,相互递归并不比简单递归更强大,它提供了在复杂的递归程序中维护抽象的机制。

1.7.3 递归函数中的打印

通过递归函数演化的计算过程通常可以使用打印调用进行可视化。 例如,我们将实现一个函数cascade,它将数字的所有前缀从最大到最小,又从最小到最大打印出来。

>>> def cascade(n):
        """Print a cascade of prefixes of n."""
        if n < 10:
            print(n)
        else:
            print(n)
            cascade(n//10)
            print(n)
>>> cascade(2013)
2013
201
20
2
20
201
2013

cascade的另一种写法:

>>> def cascade(n):
        """Print a cascade of prefixes of n."""
        print(n)
        if n >= 10:
            cascade(n//10)
            print(n)

相互递归的另一个例子是一个双人游戏:最初在桌子上有n个鹅卵石;玩家轮流从桌子上移除一个或两个鹅卵石;移除最后一个鹅卵石的玩家获胜。 假设Alice和Bob玩这个游戏,他们每次都使用以下策略:

1.Alice每次都拿掉一个鹅卵石
2.如果桌子上的石头数量为偶数,Bob就会拿掉两个;不然,Bob只拿一个

如果鹅卵石初始值为n并且游戏由Alice开始,谁能赢比赛呢?

我们将每个策略封装在自己的函数中,这样能让我们在修改一个策略的同时不会影响另一个策略,维护了两者之间的抽象界限。 这个游戏是轮流进行的,这两个函数在每一回合结束时需要相互调用。

>>> def play_alice(n):
        if n == 0:
            print("Bob wins!")
        else:
            play_bob(n-1)
>>> def play_bob(n):
        if n == 0:
            print("Alice wins!")
        elif is_even(n):
            play_alice(n-2)
        else:
            play_alice(n-1)
>>> play_alice(20)
Bob wins!

play_bob函数中,我们可以发现函数体中可能出现多个递归调用。 但是,在这个例子中,每次调用play_bob最多只会调用一次play_alice。 在下一节中,我们会讨论当单个函数调用导致多个递归调用时会发生什么。

1.7.4 树形递归

另一种常见的计算模式称为树形递归,这种情况下函数自己调用不止一次。 例如,计算斐波那契数列,其中每个数字是前两个数的和。



相对于我们以前的尝试,这种递归定义更有吸引力:它完全反映了斐波纳契数列的定义。 具有多个递归调用的函数被称为树形递归,因为每个调用由多个较小的调用的分支组成,每个调用的分支又可以由更小的调用分支组成,正如树的分支一样。

我们之前已经能够定义一个函数来计算没有树形递归的斐波那契数列。 事实上,我们以前的方法更有效率,这是本文后面将讨论的主题。 接下来,我们将讨论为什么树形递归比任何替代的迭代方案简单得多。

1.7.5 示例:拆分

把正整数n拆分成几个正整数相加的和的形式,其中任意一个正整数都不能大于m。 例如,使用4作为6的拆分数的话,可以有9种情况。

1.  6 = 2 + 4
2.  6 = 1 + 1 + 4
3.  6 = 3 + 3
4.  6 = 1 + 2 + 3
5.  6 = 1 + 1 + 1 + 3
6.  6 = 2 + 2 + 2
7.  6 = 1 + 1 + 2 + 2
8.  6 = 1 + 1 + 1 + 1 + 2
9.  6 = 1 + 1 + 1 + 1 + 1 + 1

我们将定义一个函数count_partitions(n,m),返回值是正整数n的不同拆分方法的数量,其中每个拆分数不能大于m,即最大拆分数为m。 如下,该函数有树形递归的简单解决方案:

用m作为最大拆分数的n的拆分方法有:

  1. 使用最大到m的正整数来拆分n-m的方式的数量,加上
  2. 使用最大到m-1的正整数来拆分n的方式的数量。

来看看为什么这是真的,通过观察可以发现拆分n的方法可以分为两组:那些至少包括一个m的和那些完全不包含m的。 此外,第一组中的每个拆分方法是n-m的拆分,之后再加上m。 在上面的例子中,前两个拆分法包含4,其余的不包含。

因此,我们把问题简化成两步:(1)拆分更小的数n-m,(2)使用最大为m-1的数来进行拆分。

要完成函数实现,我们需要指定以下基线条件:
1.拆分0的方式只有一种:不包括任何部分
2.拆分负数n的方式有0种。
3.拆分任何大于0的n但是用小于等于0作为组成部分的分割方式只有0种。

>>> def count_partitions(n, m):
        """Count the ways to partition n using parts up to m."""
        if n == 0:
            return 1
        elif n < 0:
            return 0
        elif m == 0:
            return 0
        else:
            return count_partitions(n-m, m) + count_partitions(n, m-1)
>>> count_partitions(6, 4)
9
>>> count_partitions(5, 5)
7
>>> count_partitions(10, 10)
42
>>> count_partitions(15, 15)
176
>>> count_partitions(20, 20)
627

我们可以把树形递归函数理解为在进行不同可能性的探索。在这个例子中,我们探索可以用作拆分的m的大小的可能性以及不可用作拆分的可能性。第一以及第二个递归调用对应这些可能性。

用非递归方式实现这个函数需要更多的投入,我们鼓励感兴趣的读者多多尝试。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容