递归是解决问题的一种方法,它将问题不断地分成更小的子问题,直到子问题可以用普通的方法解决。通常情况下,递归会使用一个不停调用自己的函数来进行。
引例
现存在一个数字列表(numlist),计算数字列表各数字之和。
第一种方法,通过循环实现:
def listsum(numlist):
nSum = 0
for i in numlist:
nSum = nSum + i
return nSum
第二种方法,通过递归实现: 递归的逻辑不是循环,而是将问题分解成更小、更容易解决的子问题。
def listsum(numlist):
if len(numlist) == 1:
return numlist[0]
else:
return numlist[0] + listsum(numlist[1:])
- 其中,数字列表 numList 的各元素总和等于列表中的第一个元素( numList[0] )加上其余元素之和,而其余元素则是 numList[1:] 列表的各元素。可以说,numList[1:] 是将第一个元素剔除后得到的新列表。
- 代码中第 2 行检查列表是否只包含一个元素。这个检查非常重要,同时也是该函数的退出语句。对于长度为 1 的列表,其元素之和就是列表中的数。这同时也是算法停止递归的条件,停止条件通常是小到足以直接解决的问题。
- 代码中第 5 行 listsum 函数通过调用自己来进行下一步操作,所以将 listsum 函数称为递归函数,每一次递归都改变了问题的状态并向停止条件靠近。
总结递归:一系列递归调用其实就是一系列的简化操作。每一次递归调用都是在解决一个更小的问题,如此进行下去,直到问题本身不能再简化为止。所以当问题无法再简化时,我们开始拼接所有子问题的答案,以此解决最初的问题。
实例
给定一个整数,将整数转换成任意进制(2~16进制)的字符串。 例如,将十进制整数10转换成十进制字符串"10" ,或者转换成二进制字符串"1010" 。
(1)首先考虑问题的最简情况:即数字小于10的情况。假设进制为八,一个小于十的数字是7,那么在八进制下十进制的7转换后为字符“7”。用代码表示则为:
convertString = "0123456789ABCDEF" #一个最大能表示十六进制的字符映射表
return convertString[7]
(2)若一个小于十的数字是9,那么在八进制下十进制的9转换后为字符串“11”。用代码表示为:
convertString = "0123456789ABCDEF"
return convertString[9//8] + convertString[9%8]
以上述两个分析步骤,可以总结出代码:
def toStr(n, base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n//base, base) + convertString[n%base]
其中 base 表示转换后的进制,n 表示为初始整数。当 整数n 小于进制时,参考情况(1)直接得到转换后的字符。
当 整数n 大于进制时,参考情况(2),将整数拆分为 单数位 后分别转换成对应字符再拼接。其中大于 个位数 的部分,如 十位、百位数 则利用了递归函数进行进制转换。就是将拆分成单个数字的十位、百位数进行单独进制转换,再把每个数位转换后得到的字符串再拼接起来。
在这个过程中,n<base 所代表的就是问题的最简单情况,这种情况下问题本身不能再进行简化。
所以整个算法包含三个组成部分:
- 将原来的整数分成一系列仅有单数位的数;
- 将单数位的数字转换成字符(串);
- 连接每一步得到的字符(串),从而得出整个字符串。
递归的实质——栈
引例
上个实例中,已经得到的代码如下:
def toStr(n, base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n//base, base) + convertString[n%base]
假设不拼接递归调用 toStr 的结果和 convertString 的查找结果,而是在进行递归之前把字符压入 栈 中,那么所有递归调用结束后,只需执行出栈操作和拼接操作,最终得到字符串便是转换后的结果。
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)
class Stack:
# 定义一个列表/构造一个栈
def __init__(self):
self.items = []
print("你创造了一个栈!")
def isEmpty(self):
return self.items == []
def push(self, item):
self.items.append(item)
print("你给栈顶加了个%s" % item)
def pop(self):
return self.items.pop()
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(19, 2)
while rStack.isEmpty() == False:
print(rStack.pop(), end=' ')
程序运行结果如下:
上面这个过程,我们使用栈替换了最初的拼接操作,目的是便于理解递归的整个过程。我们发现,将整个程序运行过程想象成一个栈,则每次递归的过程都像是“入栈”,递归到无可递归,则是到达了“栈顶”。然后返回每一次递归的结果,这个过程则像“出栈”。
有了这种思想,再来看最开始的引例:
def listsum(numlist):
if len(numlist) == 1:
return numlist[0]
else:
return numlist[0] + listsum(numlist[1:])
程序的目的是计算数字列表中各元素之和。
假设给定列表为[1, 2, 3],则程序分析过程如下:
numlist = [1, 2, 3]
第一次递归发生于:return 1 + listsum([2, 3])
假定 递归=入栈 则此步意味着执行listsum([2, 3])并“入栈”
numlist = [2, 3]
第二次递归发生于:return 2 + listsum([3])
listsum([3])执行并入栈
listnum([3])的执行结果为3。
出栈:
第一次出栈:"return 2+3"
第二次出栈:"return 1+(return 2+3)"
则总过程结束后得到6。
函数调用栈、栈帧
事实上在所有程序语言进行的编程中,当一个函数被调用时,计算机使用的正是“栈”来存储函数的所有数据,称为 “函数调用栈”。在程序运行过程中, 不管是函数执行还是函数调用, 这个栈都非常关键, 它的主要作用:
- 保存函数的局部变量
- 向被调用函数传递参数
- 返回函数的返回值
- 保存函数的返回地址(返回地址是指函数调用结束后,程序应该继续执行的指令地址)
每个函数在执行过程中都需要使用一块栈内存用来保存上述这些值,我们称这块“栈内存”为函数的“栈帧(stack frame)”。
当发生函数调用时,因为调用者还没有执行完成,其栈内存中保存的数据还有用,所以被调用函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧“push”到栈上,等被调用函数执行完成后再将其栈帧从栈上 “pop” 出去。这样,函数调用栈的大小会随着函数调用的层级增加而生长,也会随着函数的返回而缩小。
在函数递归调用时,首次函数因为进行了递归调用自己,所以算处在一种未执行完成的状态,故而后面一次次递归调用所产生的“栈桢”会一次次“push”到函数调用栈上。栈桢限定了函数所用变量的作用域,尽管反复调用相同的函数,但是每一次调用都会为函数的局部变量创建新的作用域。
总结栈帧:
- 栈帧是一块因函数运行而临时开辟的空间
- 每调用一次函数便会创建一个独立栈帧
- 栈帧中存放的是函数中的必要信息,如局部变量、函数传参、返回值等
- 当函数运行完毕栈帧将会销毁
知道了递归的实质之后,再来分析最开始的程序:
def toStr(n, base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n//base, base) + convertString[n%base]
以 toStr(10, 2) 为例:
toStr(10, 2)
n=10
base=2
return toStr(10//2,2)+convertString[10%2]
toStr(5, 2)
n=5
base=2
return toStr(5//2,2)+convertString[5%2]
toStr(2, 2)
n=2
base=2
return toStr(2//2,2)+convertString[2%2]
调用 toStr(2//2, 2) 将返回值“1”放在栈的顶端。之后,这个返回值被用来替换对应的函数调用 toStr(2//2, 2) 并生成表达式“1 + convertString[2%2]”。这一表达式会作为 toStr(5//2, 2) 的返回值,并将字符串“10”留在栈顶。后面的过程则以此类推。所以我们是利用了函数调用栈来取代了引例中显式使用的栈,还可以认为利用栈中的返回值特性取代了累加过程。