一、前言
本节主要讲解内容如下:
1.典型的递归案例
2.分析递归算法
3.递归算法的不足
4.python中的最大递归深度
5.线性递归
6.二路递归
7.多重递归
二、递归案例
什么是递归?
递归是一种技术,这种技术通过一个函数在执行过程中一次或者多次调用其本身,或者通过一种数据结构在其表示中依赖相同类型的结构更小的实例。
注:当函数的一次调用需要进行递归调用时,该调用被挂起,直到递归调用完成。(函数调用时局部变量和执行位置压栈,调用结束后恢复。)
案例1:阶乘函数
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n-1)
print(factorial(4))
我们使用递归跟踪的形式来说明一个递归函数的执行过程。如下图:
跟踪的每个条目代表着一个递归调用,每一个新的递归函数调用用一个向下的箭头指向新的调用来表示。函数返回时,用一个弯曲的箭头表示,并将返回值标在箭头的旁边。
递归跟踪密切反映了编程语言对于递归的执行。在python中,每当一个函数(递归或其他方式)被调用时,都会创建一个被称为活动记录的结构来存储信息,这些信息是关于函数调用的过程的。这个活动记录包含一个用来存储函数调用的参数和局部变量的命名空间,以及关于在这个函数体中当前正在执行的命令的信息。
如果一个函数的执行导致嵌套函数的调用,那么前者调用的执行将被挂起,其活动记录将存储源代码中的位置,这个位置是被调用函数返回后将继续执行的控制流。
案例2:二分查找
二分查找算法的具体思想这里不再讲解,这里仅仅给出递归实现
data = [2,4,5,7,8,9,12,14,17,19,22,25,27,28,33,37]
def binary_search(data,target,low,high):
if low > high:
return False
else:
mid = (low+high) // 2
if target == data[mid]:
return True
elif target < data[mid]:
return binary_search(data,target,low,mid-1)
else:
return binary_search(data,target,mid+1,high)
print(binary_search(data,14,0,15))
顺序查找的时间复杂度是O(n),而二分查找的时间复杂度是O(log n),这是一个很大的提升。假设n是十亿,log n 仅仅是30
案例3:报告一个文件系统磁盘使用情况
为了计算出一个磁盘使用情况,我们需要借助python的系统模块,在程序执行的过程中,该模块提供了强大的与操作系统交互的工具。这是一个丰富的函数库,但我们只需要以下四个函数。
- os.path.getsize(path)
返回由字符串路劲标识的文件或目录使用的即时磁盘空间大小(单位是字节) - os.path.isdir(path)
判断字符串路径指定的条目是否是一个目录,返回布尔值 - os.listdir(path)
返回一个字符串列表。它是字符串路径指定的目录中所有条目的名称。 - os.path.join(path,filename)
生成路径字符串和文件名字符串,并使用一个适当的操作系统分隔符在两者之间分隔(例如:Unix/Linux系统中的’/‘字符和Windows系统中的’'字符)。返回表示文件完整路径的字符串。
报告一个文件系磁盘使用情况的递归函数
import os
def disk_usage(path):
total = os.path.getsize(path)
if os.path.isdir(path):
for filename in os.listdir(path):
childpath = os.path.join(path,filename)
total += disk_usage(childpath)
print('{0:<7}'.format(total),path)
return total
disk_usage('D:\Program Files\Analysis')
为了产生另一种格式的递归跟踪,我们在python实例加入了额外的print语句
打印结果如下:
三、分析递归算法
计算阶乘
在上面的递归跟踪阶乘算法中,我们可以看出,为了计算factorial(n),共执行了n+1次函数调用,阶乘的每个调用执行了一个常数级别的运算。
执行二分查找
我们可以观察到二分查找的每次递归调用中被执行的基本操作次数是恒定的。因此,运行时间和执行递归调用的数量成正比。
四、递归算法的不足
虽然递归是一种强大的工具,但它也很容易被误用。
- 错误使用的例子如下:
使用二分递归计算第n个斐波拉契数列
def bad_fibonacci(n):
if n<= 1:
return n
else:
return bad_fibonacci(n-2) + bad_fibonacci(n-1)
print(bad_fibonacci(10))
这样的斐波拉契数列的直接实现会导致函数的效率非常低。以这种方式计算第n个斐波拉契数需要对这个函数进行指数级别的调用
上图,用Cn表示在bad_fibonacci(n)执行中进行的调用次数。
从上图,我们可以看到,对于每两个连续的指标,后者调用的数量将是前者的2倍以上。也就是说,c4是c2的两倍以上,c5是c3的两倍以上,以此类推。因此,Cn>2^n/2意味着这个函数的调用总数是n的指数级。
为什么会出现效率这样低下的情况呢?
我们知道,因为第n个斐波拉契数取决于前两个值,即F(n-2) 和F(n-1).
计算出F(n-2) 之后,计算F(n-1) 的调用需要其自身递归调用以计算F(n-2) ,因为它不知道先前级别调用中被计算的F(n-2)的值。这是一个重复的操作。更严重的是,这两个调用都要重新计算F(n-3)的值。正是这种滚雪球效应,导致bad_fibonacci函数有指数倍的运行时间。
- 改进算法:
使用线性递归计算第n个斐波拉契数列
def good_fibonacci(n):
if n <= 1:
return (n,0)
else:
(a,b) = good_fibonacci(n-1)
return (a+b,a)
这种算法,返回的不再是一个数值,而是连续的一对斐波拉契数,它可以让我们避免再计算第二个值,这个值在递归是已知的。
五、python中的最大递归深度
-
为什么python中要有最大递归深度?
在递归的误用中,另一个危险就是所谓的无限递归。如果一个递归没有递归出口,那么就会导致无限递归。无限递归会快速耗尽计算资源,这不仅仅是因为CPU的快速使用,而且由于每个连续的调用会创建需要额外内存的活动记录。为了避免无限递归,python的设计者做了一个对递归层数的限制,就是python中的最大递归深度。典型的默认值是1000层。
-
最大递归深度的缺点:
python在递归深度上的人为限制可能会破坏一些其他的合法计算(1000层不一定够用)。
-
重设最大递归深度:
python解释器可以动态设置默认的递归限制。
import sys old = sys.getrecursionlimit()#获取默认的递归深度 print(old) sys.setrecursionlimit(1200) #设置深度为1200 old = sys.getrecursionlimit() print(old) #打印结果: #1000 #1200
六、线性递归
线性递归定义的一个结果是任何递归跟踪将表现为一个单一的调用序列。
像在上面的递归函数中,阶乘函数的实现和good_fibonacci函数是线性递归的鲜明的例子。而且上面的二分查找算法也是一个线性递归,二分查找的代码包括一个具有两个分支的情况分析,这两个分支产生递归调用,但在函数体的一个具体执行期间只有一个调用可以被执行。
七、二路递归
当一个函数执行两个递归调用时,我们就说它使用了二路递归。
例子:用二路递归计算一个序列的元素之和。
S = [1,2,3,4]
def binary_num(S,start,stop):
if start >=stop:
return 0
elif start == stop-1:
return S[start]
else:
mid = (start+stop)//2
return binary_num(S,start,mid) + binary_num(S,mid,stop)
print(binary_num(S,0,len(S)))
结构如下:
每次递归调用后,范围的大小减少一半,因此递归深度是1+log2^ n。提高了了效率。
八、多重递归
从二路递归可知,我们将多重递归定义为一个过程,在这个过程中,一个函数可能会执行多于两次的递归调用。对于一个文件系统磁盘空间使用状况分析的递归就是一个多重递归的例子。因为在一个调用期间,递归调用执行的次数等于在文件系统给定目录中条目的数量。
另一个多重递归的常见应用是通过枚举各种配置来解决组合谜题的情况,这里不在给出具体代码实现,有兴趣者可自行查找。
参考书籍:数据结构与算法