4.1.目标
本章的目标如下:
- 要理解可能难以解决的复杂问题有一个简单的递归解决方案。
- 学习如何递归地写出程序。
- 理解和应用递归的三个定律。
- 将递归理解为一种迭代形式。
- 实现问题的递归公式化。
- 了解计算机系统如何实现递归。
4.2.什么是递归
递归是一种解决问题的方法,将问题分解为更小的子问题,直到得到一个足够小的问题可以被很简单的解决。通常递归涉及函数调用自身。递归允许我们编写优雅的解决方案,解决可能很难编程的问题。
4.3.计算整数列表和
我们将以一个简单的问题开始,你已经知道如何不使用递归解决。 假设你想计算整数列表的总和,例如:[1,3,5,7,9]。 计算总和的迭代函数见ActiveCode 1。函数使用累加器变量(theSum)来计算列表中所有整数的和,从 0 开始,加上列表中的每个数字。
def listsum(numlist):
theSum = 0
for i in numlist:
theSum = theSum + i
return theSum
print(listsum([x for x in range(1,11,2)]))
假设没有 while 循环或 for 循环。你将如何计算整数列表的总和?如果你是一个数学家,你可能开始回忆加法是一个函数,这个函数定义了两个整数类型的参数。故将列表和问题从加一个列表重新定义为加一对整数,我们可以把列表重写为一个完全括号表达式。如下所示:
我们也可以把表达式用另一种方式括起来
注意,最内层的括号(7 + 9)我们可以没有循环或任何特殊的结构来解决它。 事实上,我们可以使用以下的简化序列来计算最终的和。
我们如何能把这个想法变成一个 Python 程序? 首先,让我们以 Python 列表的形式重述求和问题。 我们可以说列表 numList 的和是列表的第一个元素numList[0] 和列表其余部分numList [1:] 之和的总和。 以函数形式表述:
在这个方程式中,first(numList) 返回列表的第一个元素,rest(numList) 返回除第一个元素之外的所有元素列表。这很容易在 Python 中表示,如 ActiveCode 2 中所示。
def listsum(numlist):
if len(numlist) == 1:
return numlist[0]
else:
return numlist[0] + listsum(numlist[1:])
print(listsum([x for x in range(1,11,2)]))
在这个清单中有几个关键地方。 首先,在第 2 行,我们检查列表是否为一个元素。这个检查是至关重要的,是我们的函数的转折子句。 长度为 1 的列表和是微不足道的; 它只是列表中的数字。 第二,在第 5 行函数调用自己! 这就是我们称 listum 算法递归的原因。递归函数是调用自身的函数。
Figure 1 展示了对列表[1,3,5,7,9] 求和所需的一系列递归调用。 你应该把这一系列的调用想象成一系列的简化。 每次我们进行递归调用时,我们都会解决一个较小的问题,直到达到问题不能减小的程度。
当我们到达简单问题的点,我们开始拼凑每个小问题的答案,直到初始问题解决。Figure 2 展示了在 listsum 通过一系列调用返回的过程中执行的 add 操作。当 listsum 从最顶层返回时,我们就有了整个问题的答案。
4.4.递归的三定律
像阿西莫夫机器人,所有递归算法必须服从三个重要的定律:
- 递归算法必须具有基本情况。
- 递归算法必须改变其状态并向基本情况靠近。
- 递归算法必须以递归方式调用自身。
让我们更详细地看看每一个定律,看看它如何在 listsum 算法中使用。首先,基本情况是算法停止递归的条件。基本情况通常是足够小以直接求解的问题。在listsum 算法中,基本情况是长度为 1 的列表。
为了遵守第二定律,我们必须将算法向基本情况的状态改变。状态的改变意味着该算法正在使用的一些数据被修改。通常,表示问题的数据在某种程度上变小。在 listsum 算法中,我们的主要数据结构是一个列表,因此我们必须将我们的状态转换工作集中在列表上。因为基本情况是长度 1 的列表,所以朝向基本情况的自然进展是缩短列表。在 Activecode 2 第五行,我们调用 listsum 生成一个较短的列表。
最后的法则是算法必须调用自身。这是递归的定义。递归对于许多新手程序员来说是一个混乱的概念。作为一个新手程序员,你已经知道函数是有益的,因为你可以将一个大问题分解成较小的问题。较小的问题可以通过编写一个函数来解决。我们用一个函数解决问题,但该函数通过调用自己解决问题!该逻辑不是循环;递归的逻辑是通过将问题分解成更小和更容易的问题来解决的优雅表达。
在本章的剩余部分,我们将讨论更多递归的例子。在每种情况下,我们将集中于使用递归的三个定律来设计问题的解决方案。
4.5.整数转换为任意进制字符串
假设你想将一个整数转换为一个二进制和十六进制字符串。例如,将整数 10 转换为十进制字符串表示为 10,或将其字符串表示为二进制 1010。虽然有很多算法来解决这个问题,包括在栈部分讨论的算法,但递归的解决方法非常优雅。
让我们看一个十进制数 769 的具体示例。假设我们有一个对应于前 10 位数的字符序列,例如 convString =“0123456789”。 通过在序列中查找,很容易将小于 10 的数字转换为其等效的字符串。例如,如果数字为 9 ,则字符串为 convString[9] 或 “9”。如果我们将数字 769 分成三个单个位数字,7,6 和 9,那么将其转换为字符串很简单。数字小于 10 听起来像一个好的基本情况。
知道我们的基本情况是什么意味着整个算法将分成三个部分:
- 将原始数字减少为一系列单个位数字。
- 使用查找将单个位数字数字转换为字符串。
- 将单个位字符串连接在一起以形成最终结果。
下一步是找到改变其状态的方法并向基本情况靠近。由于我们示例为整数,所以考虑什么数学运算可以减少一个数字。最可能的候选是除法和减法。虽然减法可能可以实现,但我们不清楚应该减去多少。使用余数的整数除法为我们提供了一个明确的方向。让我们看看如果我们将一个数字除以我们试图转换的基数,会发生什么。
使用整数除法将 769 除以 10 ,我们得到 76,余数为 9。这给了我们两个好的结果。首先,余数是小于我们的基数的数字,可以通过查找立即转换为字符串。第二,我们得到的商小于原始数字,并让我们靠近具有小于基数的单个数字的基本情况。现在我们的工作是将 76 转换为其字符串表示。再次,我们使用商和余数分别获得 7 和 6 的结果。最后,我们将问题减少到转换 7,我们可以很容易地做到,因为它满足 n < base 的基本条件,其中 base = 10。我们刚刚执行的一系列操作如 Figure 3 所示。请注意,余数位于图右侧框中。
Figure 4 显示我们得到的结果,但看起来数字是错误的顺序。该算法是正确的,因为我们首先在第 6 行进行递归调用,然后我们添加余数的字符串形式。 如果我们反向返回 convertString 查找并返回 toStr 调用,则生成的字符串将是反向的!通过延后连接操作直到递归调用返回,我们可以得到正确顺序的结果。这应该能使你想起你在上一章中讨论的栈。
4.6.栈帧:实现递归
假设不是将递归调用的结果与来自 convertString 的字符串拼接到 toStr,我们修改了算法,以便在进行递归调用之前将字符串入栈。此修改的算法的代码展示在 ActiveCode 1 中。
from pythonds.basic.stack import Stack
rStack = Stack()
def toStr(n,base):
convertString = "0123456789ABCDEF"
while n > 0:
if n < base:
rStack.push(convertString[n])
else:
rStack.push(convertString[n % base])
n = n // base
res = ""
while not rStack.isEmpty():
res = res + str(rStack.pop())
return res
print(toStr(1453,16))
每次我们调用 toStr,我们在栈上推入一个字符。回到前面的例子,我们可以看到在第四次调用 toStr 之后,栈看起来像 Figure 5 。注意,现在我们可以简单地将字符从栈中弹出,并将它们连接成最终结果 “1010”。
前面的例子让我们了解了 Python 如何实现一个递归函数调用。 当在 Python 中调用函数时,会分配一个栈来处理函数的局部变量。当函数返回时,返回值留在栈的顶部,以供调用函数访问。 Figure 6 说明了第 4 行返回语句后的调用栈。
4.7.介绍:可视化递归
在上一节中,我们讨论了一些使用递归很容易解决的问题; 然而,我们可能很难找到一个模型或一种可视化方法知道在递归函数中发生了什么。这使得递归难以让人掌握。在本节中,我们将看到几个使用递归绘制一些有趣图片的例子。当你看到这些图片的形状,你会对递归过程有新的认识,可能有助于巩固你对递归理解。
我们使用的插图的工具是 Python 的 turtle 模块称为 turtle。turtle 是 Python 所有版本的标准库,并且非常易于使用。比喻很简单。你可以创建一只乌龟,乌龟能前进,后退,左转,右转等。乌龟可以让它的尾巴或上或下。当乌龟的尾巴向下,它移动时会画一条线。为了增加乌龟的艺术价值,你可以改变尾巴的宽度以及尾巴浸入的墨水的颜色。
这里有一个简单的例子来说明龟图形基础。我们将使用 turtle 模块递归绘制螺旋。 见 ActiveCode 1。导入 turtle 模块后,我们创建一个乌龟。当乌龟被创建时,它也创建一个窗口来绘制。接下来我们定义 drawSpiral 函数。这个简单函数的基本情况是当我们想要绘制的线的长度(由 len 参数给出)减小到零或更小时。如果线的长度大于零,我们让乌龟以 len 单位前进,然后向右转 90 度。当我们再次调用 drawSpiral 并缩短长度时递归。在ActiveCode 1 结束时,你会注意到我们调用函数 myWin.exitonclick(),这是一个方便的缩小窗口的方法,使乌龟进入等待模式,直到你单击窗口,然后程序清理并退出。
import turtle
myTurtle = turtle.Turtle()
myWin = turtle.Screen()
def drawSpiral(myTurtle, lineLen):
if lineLen > 0:
myTurtle.forward(lineLen)
myTurtle.right(90)
drawSpiral(myTurtle,lineLen-5)
drawSpiral(myTurtle,100)
myWin.exitonclick()
这是关于你知道的所有龟图形,以制作一些令人印象深刻的涂鸦。我们的下一个程序,将绘制一个分形树。分形来自数学的一个分支,并且与递归有很多共同之处。分形的定义是,当你看着它时,无论你放大多少,分形有相同的基本形状。大自然的一些例子是大陆的海岸线,雪花,山脉,甚至树木或灌木。这些自然现象中的许多的分形性质使得程序员能够为计算机生成的电影生成非常逼真的风景。在我们的下一个例子中,将生成一个分形树。
要理解这如何工作,需要想一想如何使用分形词汇来描述树。记住,我们上面说过,分形是在所有不同的放大倍率下看起来是一样的。如果我们将它翻译成树木和灌木,我们可能会说,即使一个小树枝也有一个整体树的相同的形状和特征。有了这个想法,我们可以说一棵树是树干,一棵较小的树向右走,另一棵较小的树向左走。如果你用递归的思想考虑这个定义,这意味着我们将树的递归定义应用到较小的左树和右树。
让我们把这个想法转换成一些 Python 代码。Listing 1 展示了如何使用我们的乌龟来生成分形树。让我们更仔细地看一下代码。你会看到在第 5 行和第 7 行,我们正在进行递归调用。在第 5 行,我们在乌龟向右转 20 度之后立即进行递归调用;这是上面提到的右树。然后在第 7 行,乌龟进行另一个递归调用,但这一次后左转 40 度。乌龟必须向左转 40 度的原因是,它需要撤消原来的向右转 20 度,然后再向左转 20 度,以绘制左树。还要注意,每次我们对树进行递归调用时,我们从 branchLen 参数中减去一些量; 这是为了确保递归树越来越小。你还应该看到到第 2 行的初始 if 语句是检查 branchLen 的基本情况大小。
import turtle
'''
def tree(branchLen, t):
if branchLen > 5:
t.forward(branchLen)
t.right(20)
tree(branchLen - 15,t)
t.left(40)
tree(branchLen - 15,t)
t.left(20)
t.backward(branchLen)
if __name__ == "__main__":
t = turtle.Turtle()
myWin = turtle.Screen()
t.left(90)
t.up()
t.backward(100)
t.down()
t.color("green")
tree(75, t)
myWin.exitonclick()
'''
def tree(branchLen,t):
if branchLen > 5:
t.forward(branchLen)
t.right(30)
tree(branchLen-10,t)
t.left(60)
tree(branchLen-10,t)
t.right(30)
t.backward(branchLen)
def main():
t = turtle.Turtle()
myWin = turtle.Screen()
t.left(90)
t.up()
t.backward(100)
t.down()
t.color("green")
tree(75,t)
myWin.exitonclick()
main()
注意树上的每个分支点如何对应于递归调用,并注意树的右半部分如何一直绘制到它的最短的树枝。你可以在 Figure 1 中看到这一点。现在,注意程序如何工作,它的方式是直到树的整个右侧绘制完成回到树干。你可以在 Figure 2 中看到树的右半部分。然后绘制树的左侧,但不是尽可能远地向左移动。相反,直到我们进入到左树最小的枝干,左树的右半部分才开始绘制。
这个简单的树程序只是一个起点,你会注意到树看起来不是特别现实,因为自然不像计算机程序那样对称。
4.8.谢尔宾斯基三角形
另一个展现自相似性的分形是谢尔宾斯基三角形。 Figure 3 是一个示例。谢尔宾斯基三角形阐明了三路递归算法。用手绘制谢尔宾斯基三角形的过程很简单。 从一个大三角形开始。通过连接每一边的中点,将这个大三角形分成四个新的三角形。忽略刚刚创建的中间三角形,对三个小三角形中的每一个应用相同的过程。 每次创建一组新的三角形时,都会将此过程递归应用于三个较小的角三角形。 如果你有足够的铅笔,你可以无限重复这个过程。在继续阅读之前,你可以尝试运用所描述的方法自己绘制谢尔宾斯基三角形。
因为我们可以无限地应用算法,什么是基本情况? 我们将看到,基本情况被任意设置为我们想要将三角形划分成块的次数。有时我们把这个数字称为分形的“度”。 每次我们进行递归调用时,我们从度中减去 1,直到 0。当我们达到 0 度时,我们停止递归。在 Figure 3 中生成谢尔宾斯基三角形的代码见 ActiveCode 1。
import turtle
def drawTriangle(points,color,myTurtle):
myTurtle.fillcolor(color)
myTurtle.up()
myTurtle.goto(points[0][0],points[0][1])
myTurtle.down()
myTurtle.begin_fill()
myTurtle.goto(points[1][0],points[1][1])
myTurtle.goto(points[2][0],points[2][1])
myTurtle.goto(points[0][0],points[0][1])
myTurtle.end_fill()
def getMid(p1,p2):
return ( (p1[0]+p2[0]) / 2, (p1[1] + p2[1]) / 2)
def sierpinski(points,degree,myTurtle):
colormap = ['blue','red','green','white','yellow',
'violet','orange']
drawTriangle(points,colormap[degree],myTurtle)
if degree > 0:
sierpinski([points[0],
getMid(points[0], points[1]),
getMid(points[0], points[2])],
degree-1, myTurtle)
sierpinski([points[1],
getMid(points[0], points[1]),
getMid(points[1], points[2])],
degree-1, myTurtle)
sierpinski([points[2],
getMid(points[2], points[1]),
getMid(points[0], points[2])],
degree-1, myTurtle)
def main():
myTurtle = turtle.Turtle()
myWin = turtle.Screen()
myPoints = [[-100,-50],[0,100],[100,-50]]
sierpinski(myPoints,3,myTurtle)
myWin.exitonclick()
main()
ActiveCode 1 中的程序遵循上述概念。谢尔宾斯基的第一件事是绘制外三角形。接下来,有三个递归调用,每个使我们在连接中点获得新的三角形。我们再次使用 Python 附带的 turtle 模块。你可以通过使用 help(‘turtle’) 了解 turtle 可用方法的详细信息。
看下代码,想想绘制三角形的顺序。虽然三角的确切顺序取决于如何指定初始集,我们假设三角按左下,上,右下顺序。由于谢尔宾斯基函数调用自身,谢尔宾斯基以它的方式递归到左下角最小的三角形,然后开始填充其余的三角形。填充左下角顶角中的小三角形。最后,它填充在左下角中右下角的最小三角形。
有时,根据函数调用图来考虑递归算法是有帮助的。Figure 4 展示了递归调用总是向左移动。活动函数以黑色显示,非活动函数显示为灰色。向 Figure 4 底部越近,三角形越小。该功能一次完成一次绘制; 一旦它完成了绘制,它移动到左下方底部中间位置,然后继续这个过程。
谢尔宾斯基函数在很大程度上依赖于 getMid 函数。 getMid 接受两个端点作为参数,并返回它们之间的中点。 此外,ActiveCode 1 还有一个函数,使用 begin_fill 和 end_fill
方法绘制填充一个三角形。
4.10.河内塔游戏
河内塔是由法国数学家爱德华·卢卡斯在 1883 年发明的。他的灵感来自一个传说,有一个印度教寺庙,将谜题交给年轻的牧师。在开始的时候,牧师们被给予三根杆和一堆 64 个金碟,每个盘比它下面一个小一点。他们的任务是将所有 64 个盘子从三个杆中一个转移到另一个。有两个重要的约束,它们一次只能移动一个盘子,并且它们不能在较小的盘子顶部上放置更大的盘子。牧师日夜不停每秒钟移动一块盘子。当他们完成工作时,传说,寺庙会变成灰尘,世界将消失。
4.11.探索迷宫
在这一节中,我们将讨论一个与扩展机器人世界相关的问题:你如何找到自己的迷宫? 如果你在你的宿舍有一个扫地机器人(不是所有的大学生?)你希望你可以使用你在本节中学到的知识重新给它编程。 我们要解决的问题是帮助我们的乌龟在虚拟迷宫中找到出路。 迷宫问题的根源与希腊的神话有关,传说忒修斯被送入迷宫中以杀死人身牛头怪。忒修斯用了一卷线帮助他找到回去的退路,当他完成杀死野兽的任务。在我们的问题中,我们将假设我们的乌龟在迷宫中间的某处,必须找到出路。
4.12.动态规划
计算机科学中的许多程序是为了优化一些值而编写的; 例如,找到两个点之间的最短路径,找到最适合一组点的线,或找到满足某些标准的最小对象集。计算机科学家使用许多策略来解决这些问题。本书的目标之一是向你展示几种不同的解决问题的策略。动态规划 是这些类型的优化问题的一个策略。
4.13.总结
在本章中,我们讨论了几个递归算法的例子。 选择这些算法来揭示几个不同的问题,其中递归是一种有效的问题解决技术。 本章要记住的要点如下:
所有递归算法都必须具有基本情况。
递归算法必须改变其状态并朝基本情况发展。
递归算法必须调用自身(递归)。
递归在某些情况下可以代替迭代。
递归算法通常可以自然地映射到你尝试解决的问题的表达式。
递归并不总是答案。有时,递归解决方案可能比迭代算法在计算上更昂贵。