递推和递归
一、递推算法 Recursion method
递推算法是通过已知条件,利用特定关系得出中间推论,直至得到结果的算法。递推算法分为顺推和逆推两种。动态规划
1、顺推法
所谓顺推法是从已知条件出发,逐步推算出要解决的问题的方法叫顺推。
# n! 阶乘
def factorial(n):
t = 1
for i in range(1, n + 1):
t *= i
return t
print(factorial(4))
递推法,就是递增法,时间复杂度是 O(n)。
2、逆推法
逆推法是从已知问题的结果出发,用迭代表达式逐步推算出问题的开始的条件,即顺推法的逆过程,称为逆推。
年初准备一笔钱存入银行,保证每年年底取出 1000 元,到第 3 年刚好取完。假设银行一年整存零取月息为 0.31%,请问需存入银行多少钱?
假设第 3 年年初的银行存款为 x 元,则 x×(1+0.0031×12) = 1000,得 x = 1000/(1+0.0031×12)。同理可得前两年年初的银行存款,计算如下:
第 2 年年初的银行存款 = (第 3 年年初的银行存款+1000)/(1+0.0031×12)
第 1 年年初的银行存款 = (第2年年初的银行存款+1000)/(1+0.0031×12)
def foo():
total = 0.0 # 从第三年年底倒推
for _ in range(3):
total = (total + 1000) / (1 + 0.0031 * 12)
return total
print("第一次必须向银行存入%.2f元\n" % foo())
二、递归函数 recursion
1、递归算法
递归算法(recursion algorithm)是指一种通过重复将问题分解为同类的子问题而解决问题的方法。递归完全可以取代循环。
优点:结构层次很清晰,易于理解。
缺点:在调用的过程中,每一层调用都需要保存临时性变量和返回地址、传递参数,因此执行效率低,还有可能会造成栈溢出。
如果一个函数在内部调用了自身,这个函数就被称为递归函数。
注意,这里我加了Python的 @functools.lru_cache() ,他会缓存递归结果,避免重复计算从而加速递归实现。没有这个的话会超时。
# n! 阶乘函数:f(n) = n*f(n-1)
@functools.lru_cache()
def factorial(n):
return 1 if n == 0 else factorial(n - 1) * n
print(factorial(4))
执行过程:
factorial(4)
factorial(3) * 4
factorial(2) * 3 * 4
factorial(1) * 2 * 3 * 4
factorial(0) * 1 * 2 * 3 * 4
1 * 1 * 2 * 3 * 4
1 * 2 * 3 * 4
2 * 3 * 4
6 * 4
24
写法最简洁,但是效率最低,会出现大量的重复计算,时间复杂度O(1.618^n),而且最深度1000。
使用递归函数需要注意防止递归深度溢出,在Python中,通常情况下,这个深度是1000层,超过将抛出异常。函数递归调用是通过栈(stack)实现的,每当进入一个递归时,栈就会加一层,每当函数返回一次,栈就会减一层。由于栈的大小不是无限的,所以,递归调用的次数过多,会导致栈溢出。
可以试试 factorial(999):
RuntimeError: maximum recursion depth exceeded in comparison
2、尾递归
尾递归是指递归函数在调用自身时直接传递其状态值。在这些语言中尾部递归不会占用调用堆栈空间。
# 尾递归
def factorial(n, res=1):
if n < 2:
return res
return factorial(n - 1, n * res)
print(factorial(4))
执行过程:
factorial(4, 1)
factorial(3, 4)
factorial(2, 12)
factorial(1, 24)
factorial(0, 24)
24
factorial 函数在递归调用的时候是将状态保存在 res 这个变量中,不会产生一系列逐渐增多的中间变量。
尾递归就是把当前的运算结果(或路径)放在参数里传给下层函数,如果在递归函数中,递归调用返回的结果总被直接返回,则称为尾递归。
斐波那契数列(Fibonacci sequence),又称黄金分割数列、因数学家列昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列以如下被以递归的方法定义:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=2,n∈N*)
import time
def fib_recursion(num):
'''
直接使用递归法求解斐波那契数量的第 num 个数字
'''
if num <= 2:
return 1
return fib_recursion(num - 1) + fib_recursion(num - 2)
def fib_tail_recursion(num, res=0, temp=1):
'''
使用尾递归法求解斐波那契数量的第 num 个数字
'''
if num == 0:
return res
else:
return fib_tail_recursion(num - 1, temp, res + temp)
def fib_loop(num):
'''
直接使用循环来求解
'''
a, b = 0, 1
for i in range(1, num):
a, b = b, a + b
# return b
# 生成器
yield b
if __name__ == '__main__':
num_list = [5, 10, 20, 30, 40, 50]
for num in num_list:
start_time = time.time()
print(fib_recursion(num))
end_time = time.time()
print(fib_tail_recursion(num))
end_time2 = time.time()
print(fib_loop(num))
end_time3 = time.time()
print('正在求解的斐波那契数字下标为 %s' % num)
print('直接递归耗时为 :', end_time - start_time)
print('尾递归调用耗时为:', end_time2 - end_time)
print('直接使用循环耗时为:', end_time3 - end_time2)
尾递归调用时,如果做了优化,栈不会增长,因此,无论多少次调用也不会导致栈溢出。
遗憾的是,大多数编程语言没有针对尾递归做优化,Python 解释器也没有做优化,所以,即使把上面的 fib_recursion(n) 函数改成尾递归方式,也会导致栈溢出。
递推与递归的比较
使用递推写法的计算方式是自底向上,即从边界开始,不断向上解决问题,直到解决了目标问题;
而使用递归写法的计算方式是自顶向下, 即从目标问题开始,将它分解成子问题的组合,直到分解至边界为止。
相对于递归算法,递推算法免除了数据进出栈的过程,也就是说,不需要函数不断的向边界值靠拢,而直接从边界出发,直到求出函数值。递推的效率要高一些,在可能的情况下应尽量使用递推。
练习爬楼梯
题目:一次只能爬一阶或者两阶台阶,求爬到 n 阶有多少种走法。
假定 n=10,要想到达第十级台阶,最后一步一定是从第八级或者第九级台阶开始。假设从地面到第八级台阶一共有 X 种走法,从地面到第九级台阶一共有 Y 种走法,那么从地面到第十级台阶一共有 X+Y 种走法。即 F(10) = F(9)+F(8)
和斐波那契数列比较看看有什么不同?
def climbStairs(x):
if x <= 2:
return x
a, b = 1, 2
for i in range(3, x + 1):
a, b = b, a + b
return b
if __name__ == '__main__':
for i in range(20):
print(climbStairs(i))
题目:一栋楼有N阶楼梯,兔子每次可以跳1、2或3阶,问一共有多少种走法?
# 递归法
def climbStairs(stairs):
basic_num = {1:1,2:2,3:4} # 1、2、3 个台阶分别有 1、2、4 种方法
if stairs in basic_num.keys():
return basic_num[stairs]
else:
# 从第四台阶开始,下一阶是前三阶的和。
return climbStairs(stairs-1) + climbStairs(stairs-2) + climbStairs(stairs-3)
# 递推法
def climbStairs(stairs):
basic_num = {1: 1, 2: 2, 3: 4}
if stairs in basic_num.keys():
return basic_num[stairs]
else:
h1, h2, h3, i = 1, 2, 4, 3
while (i := i + 1) <= stairs:
h1, h2, h3 = h2, h3, h1 + h2 + h3 # 从第四台阶开始,下一阶是前三阶的和。
return h3
小结
遇到需要进行多层循环或者根本不清楚循环层数的场景,递归就很有用了,只要确定了终止条件和递归方程就可以实现遍历。
针对尾递归优化的语言可以通过尾递归防止栈溢出,尾递归事实上和循环是等价的。
Python标准的解释器没有针对尾递归做优化,任何递归函数都存在栈溢出的问题。
练习
汉诺塔(Hanoi)游戏相传出现在古印度圣庙中。游戏的规则是:
在一块铜板装置上,有三根编号分别为A、B、C 的杆,在A杆上按自下而上、由大到小按顺序放置着 64 个金盘,每次只能移动一个金盘,并且在移动过程中三根杆上都始终保持大盘在下、小盘在上,操作过程中盘子可以置于A、B、C任意一杆上,如何把A杆上的金盘全部移到C杆上?
请编写 move(n, a, b, c)函数,它接收参数n,表示3个柱子A、B、C中第1个柱子A的盘子数量,然后打印出把所有盘子从A借助B移动到C的方法,例如:
def move(n, a, b, c):
if n == 1:
print(a, '-->', c)
# 期待输出:
# A --> C
# A --> B
# C --> B
# A --> C
# B --> A
# B --> C
# A --> C
move(3, 'A', 'B', 'C')
分析三个金盘的情况:rohanTower(3, ‘A’, ‘B’, ‘C’)
首先:把前 2 个借助于 C 移到 B 即:A–>C A–>B C–>B # 注意 交换柱子 递归 rohanTower(2, x, z, y)
其次:最后一个移到 C 即:A–>C
再次:把 B 柱 2 个借助于 A 移到 C 即:B–>A B–>C A–>C # rohanTower(2, y, x, z)
def rohanTower(n, x, y, z):
if n == 1:
print(f'{x}-->{z}')
else:
rohanTower(n - 1, x,z,y) # 把前 n-1 个借助于 c 移到 b
print(f'{x}-->{z}') # 最后一个移到 c
rohanTower(n - 1, y,x,z) # 把 b 柱 n-1 个借助于 a 移到 c
rohanTower(3, 'A', 'B', 'C')
# 只考虑移动次数
def hanoi(n):
if n == 1:
return 1
else:
return 2 * hanoi(n - 1) + 1
hanoi(3)
三、函数总结
第一、函数的使用可以重用代码。
第二、函数能封装内部实现,保护内部数据。很多时候,我们把函数看做“黑盒子”,即对应一定的输入会产生特定的结果或返回某个对象。往往函数的使用者并不是函数的编写者,函数的使用者对黑盒子的内部行为并不需要考虑,可以把精力投入到自身业务逻辑的设计而不是函数的实现细节。只有函数的设计者或者说编写者,才需要考虑函数内部实现的细节,如何暴露对外的接口,返回什么样的数据,也就是API的设计。
第三、即使某种功能在程序中只使用一次,将其以函数的形式实现也是有必要的,因为函数使得程序模块化,从“一团散沙”变成“整齐方队”,从而有利于程序的阅读、调用、修改和完善。