什么是递归
递归是解决问题的一种方法,它会使用一个不停调用自己的函数,它可以帮助我们写出非常优雅的解决方案。
计算 - 列数之和
计算[1, 3, 5, 7, 9]的和。 普通求和方法:
def listSum(numList):
theSum = 0
for i in numList:
theSum = theSum + i
return theSum
而用递归求和可以想象成,数字列表numList的总和等于列表的第一个元素(numList[0])加上其他元素(numList[1:])的和。
用函数表达式写成 listSum(numList) = first(numberList[0]) + listSum(rest(numList)),其中rest(numList)表示返回其余元素。
那么递归求和方法为:
def listSum(numList):
if len(numList) == 1: // 此函数的退出语句
return numList[0]
else:
return numList[0] + listSum(numList[1:]) // 自己调用自己
下面的图展示了listSum函数在返回一系列调用结果时进行的加法操作,当他返回顶层时,就有了最终答案。
递归三原则
- 递归算法必须有基本情况(指的是使算法停止递归的条件)。
- 递归算法必须改变其状态并向基本情况靠近。
- 递归算法必须递归地调用自己。
那么在listSum中是怎样遵循三原则的呢?
(1) listSum算法的基本情况就是列表的长度为 1。
(2) listSum算法的主数据结构是一个列表,所以是要改变该列表的状态。因此向基本情况开进的做法就是缩短列表,正如第5行代码所做的那样,即在一个更短的列表上调用listSum。
(3) 最后一个就是调用自身,listSum(numList[1:])就是调用自身的表现。
将整数转换成任意进制的字符串
假设要将一个整数转换成以 2 ~ 16 为基数的字符串,例如,将10转化成十进制字符串" 10 ",或者二进制字符串 "1010" 。
以十进制整数 769 为例,假设有一个字符序列对应前 10 个数,比如convString = '0123456789'。若要将一个小于 10 的数字转换成其对应的字符串,只需要在字符序列中查找到对应的数字即可。例如, 9 对应的字符串是convString[9]或者" 9 "。
整个算法包含三个组成部分:
(1) 将原来的整数分为一系列仅有单位数的数。
(2) 通过查表将单位数的数转换成字符串。
(3) 连接得到的字符串,从而形成结果。
将 769 除以 10 ,商为 76 ,余数是 9 。我们将得到的 76 再次除以 10 ,得到了 商 7 和余数 6 。所以最终输出的字符串值应该为" 769 "。
def toStr(n, base):
convertString = '0123456789ABCDEF'
if n < base:
return convertString[n]
else:
return toStr(n // base, base) + convertString[n % base]
toStr(769, 10) // 769
// 第4行是停止递归的条件,而第7行通过调用自身以及除法来分析问题,满足了第二和第三条原则。
栈帧:实现递归
如果我们使用 toStr 函数,将10转换为二进制字符串,我们会发现输入的结果是反的。
假设我们不使用 toStr 函数,而是在进行递归调用之前把字符串压入栈中,生成一个新的 toStr 函数:
rStack = Stack() def toStr(n, base): convertString = '0123456789ABCDEF' if n < base: rStack.push(convertString[n]) else: rStack.push(convertString[n % base]) toStr(n // base, base)当我们每一次调用toStr,都会将一个字符压入栈中。Python分配了一个栈帧来处理该函数的局部变量。当函数返回时,返回值就在栈的顶端。栈帧限定了函数所用变量的作用域,尽管反复调用相同的函数,但每一次调用都会为函数的局部变量创建一个新的作用域。
递归可视化
谢尔平斯基三角形
谢尔平斯基三角形展示了三路递归算法,它是从一个大三角形开始,通过连接每条边的中点将它们分隔成新的四个三角形,忽略中间的三角形,利用同样的方法分隔其余的三个三角形。即每创建一个新三角形集合,都递归地分隔三个三角形。
首先我们思考它的停止递归条件,应该是根据我们想要分隔的次数来设定的,每进行一次递归,这个次数应该减1,直到最终为0为止。
from turtle import *
def drawTriangle(points, color, myTurtle):
myTurtle.fillcolor(color)
myTurtle.up()
myTurtle.goto(points[0])
myTurtle.down()
myTurtle.begin_fill() // turtle模块绘制带颜色的三角形
myTurtle.goto(points[1])
myTurtle.goto(points[2])
myTurtle.goto(points[0])
myTurtle.end_fill() // turtle模块绘制带颜色的三角形
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]),
getMod(points[1], points[2])],
degree - 1, myTurtle)
sierpinski([points[1],
getMid(points[0], points[1]),
getMod(points[1], points[2])],
degree - 1, myTurtle)
sierpinski([points[2],
getMid(points[2], points[1]),
getMod(points[0], points[2])],
degree - 1, myTurtle)
myTurtle = Turtle()
myPoints = [(-500, -250), (0, 500), (500, -250)]
myWin = myTurtle.getscreen()
sierpinski(myPoints, 5, myTurtle)
myWin.exitonclick()
// 我们通过sierpinski绘制出最外部的三角形,接着进行3个递归调用,每一次的调用都会生成一个新的三角形。
// 我们假设三个角的顺序是左下角、顶角和右下角,那么sierpinski会一直在左下角绘制三角形,直到绘制完最后的三角形,然后绘制顶部,绘制完顶部最小三角形,再去绘制右下角三角形,直到绘制完成。
// sierpinski函数非常依赖getMid函数,getMid接收两个点作为参数,并返回他们的中点。
下面是谢尔平斯基三角形的函数调用图,它有助于理解递归算法。在这个图中,黑色表示正在执行的函数,灰色表示没有被执行的函数,越深入到该图的底部,三角形就越小。函数一次完成一层的绘制;一旦它绘制好左下角的三角形,就会接着绘制顶角的三角形。
复杂的递归问题
汉诺塔问题
我们不难看出,问题的核心在于如何借助一根中间柱子,将高度为height的一叠盘子从起点柱子移至终点柱子:
(1) 借助终点柱子,将高度为 height - 1 的一叠盘子移到中间柱子。
(2) 将最后一个盘子移到终点柱子。
(3) 借助起点柱子,降高度为 height - 1 的一叠盘子从中间柱子移至终点柱子。
它的停止递归条件应该是高度为 0 。
def moveDisk(fp, tp):
print("moving disk from %d to %d\n" % (fp, tp)) // 打印一条消息,说明盘子从一根柱子移动到了另一根柱子
def moveTower(height, fromPole, toPole, withPole):
if height >= 1:
moveTower(height - 1, fromPole, withPole, toPole) // 将除了最后一个盘子以外的其他盘子从起始柱子移动到中间柱子
moveDisk(formPole, toPole) // 将最后一个盘子移到终点柱子
moveTower(height - 1, withPole, toPole, fromPole) // 将之前的塔从中间柱子移动到终点柱子,并将其放置在最大的盘子上
// 若要显示地保存柱子的状态,就需要用到3个Stack对象,一根柱子对应一个栈。
总结
- 所有递归算法都必须有基本情况,也就是使算法停止递归的条件。
- 递归算法必须改变其状态并向基本情况靠近。
- 递归算法必须递归地调用自己。
- 递归在某些情况下可以代替循环。
- 递归算法往往与问题的形式化表达相对应。
- 递归并非总是最佳方案,有时递归算法比其他算法的计算复杂度更高。