递归
问题描述
假设你在祖母的阁楼中翻箱倒柜,发现了一个上锁的神秘手提箱。祖母告诉你,钥匙很可能在下面这个盒子里,这个盒子里有盒子,而盒子里的盒子又有盒子。钥匙就在某个盒子中。为找到钥匙,你将使用什么算法?
方法一:
- 创建一个要查找的盒子堆。
- 从盒子堆取出一个盒子,在里面找。
- 如果找到的是盒子,就将其加入盒子堆中,以便以后再查找。
- 如果找到钥匙,则大功告成!
- 回到第二步。
方法二:
- 检查盒子中的每样东西。
- 如果是盒子,就回到第一步。
- 如果是钥匙,就大功告成!
第一种方法使用的是while循环:只要盒子堆不空,就从中取一个盒子,并在其中仔细查找。
def look_for_key(main_box):
pile = main_box.make_a_pile_to_look_through()
while pile is not empty:
box = pile.grab_a_box()
for item in box:
if item.is_a_box():
pile.append(item)
elif item.is_a_key():
print("found the key!")
第二种方法使用递归——函数调用自己,这种方法的伪代码如下。
def look_for_key(box):
for item in box:
if item.is_a_box():
look_for_key(item) #递归
elif item.is_a_key():
print("found the key!)
递归只是让解决方案更清晰,并没有性能上的优势。实际上,在有些情况下,使用循环的性能更好。
基线条件和递归条件
由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。例如,下面这样的倒计时函数。
def countdown(i):
print(i)
countdown(i-1)
print(countdown(3))
运行上述代码,将发现一个问题:这个函数运行起来没完没了!(要让脚本停止运行,可按Ctrl+C。)
编写递归函数时,必须告诉它何时停止递归。正因为如此,每个递归函数都有两部分:基线条件(base case)和递归条件(recursive case)。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。
def countdown(i)
print(i)
if i <= 0: #基线条件
return
else: #递归条件
countdown(i - 1)
栈
调用栈(call stack)只有两种操作:压入(插入)和弹出(删除并读取)。
调用栈
def greet(name):
print("hello, " + name + "!")
greet2(name)
print("getting ready to say bye...")
bye()
def greet2(name):
print("how are you, " + name + "?")
def bye():
print("ok bye!")
greet("maggie")
假设你调用greet(“maggie”),计算机将首先为该函数调用分配一块内存。
我们来使用这些内存。变量name被设置为maggie,这需要存储到内存中。
每当你调用函数时,计算机都像这样将函数调用涉及的所有变量的值存储到内存中。接下来,你打印hello, maggie!,再调用greet2(“maggie”)。同样,计算机也为这个函数调用分配一块内存。
计算机使用一个栈来表示这些内存块,其中第二个内存块位于第一个内存块上面。你打印how are you, maggie?,然后从函数调用返回。此时,栈顶的内存块被弹出。
现在,栈顶的内存块是函数greet的,这意味着你返回到了函数greet。
这是本节的一个重要概念:调用另一个函数时,当前函数暂停并处于未完成状态。该函数的所有变量的值都还在内存中。执行完函数greet2后,你回到函数greet,并从离开的地方开始接着往下执行:首先打印getting ready to say bye…,再调用函数bye。
在栈顶添加了函数bye的内存块。然后,你打印ok bye!,并从这个函数返回。
现在你又回到了函数greet。由于没有别的事情要做,你就从函数greet返回。这个栈用于存储多个函数的变量,被称为调用栈。
递归调用栈
递归函数也使用调用栈!如计算阶乘的递归函数factorial:
def fact(x):
if x == 1:
return 1
else:
return x * fact(x - 1)
print(fact(5))
注意,每个fact调用都有自己的x变量。在一个函数调用中不能访问另一个的x变量。
栈在递归中扮演着重要角色。在本章开头的示例中,有两种寻找钥匙的方法。对于第一种方法,你创建一个待查找的盒子堆,因此你始终知道还有哪些盒子需要查找。
但使用递归方法时,没有盒子堆。既然没有盒子堆,那算法怎么知道还有哪些盒子需要查找呢?
原来“盒子堆”存储在了栈中!这个栈包含未完成的函数调用,每个函数调用都包含还未检查完的盒子。使用栈很方便,因为你无需自己跟踪盒子堆——栈替你这样做了。
使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。在这种情况下,你有两种选择。
- 重新编写代码,转而使用循环。
- 使用尾递归。这是一个高级递归主题,不在本书的讨论范围内。另外,并非所有的语言都支持尾递归。
小结
- 递归指的是调用自己的函数。
- 每个递归函数都有两个条件:基线条件和递归条件。
- 栈有两种操作:压入和弹出。
- 所有函数调用都进入调用栈。
- 调用栈可能很长,这将占用大量的内存
快速排序
分而治之(divide and conquer,D&C)——一种著名的递归式问题解决方法。
只能解决一种问题的算法毕竟用处有限,而D&C提供了解决问题的思路,是另一个可供你使用的工具。面对新问题时,你不再束手无策,而是自问:“使用分而治之能解决吗?”
示例1
问题描述
讲一块长方形的土地,均匀地分成方块,且分出的方块要尽可能大。即找出长和宽的最大公约数
欧几里得算法
用于查找(A,B)最大公约数(GCD)的欧几里得算法如下:
- 如果A = 0,则GCD(A,B)= B,因为GCD(0,B)= B,我们可以停下来。
- 如果B = 0,则GCD(A,B)= A,因为GCD(A,0)= A,我们可以停下来。
- 用余数形式写A(A =B⋅Q+ R),其中Q是除数,R是余数。
- 因为GCD(A,B)= GCD(B,R),所以使用欧几里得算法找到GCD(B,R)
使用D&C解决问题的两个步骤:
- 找出基线条件,这种条件必须尽可能简单。
- 不断将问题分解(或者说缩小规模),直到符合基线条件。
D&C并非可用于解决问题的算法,而是一种解决问题的思路。
示例2
求数字数组之和:
- 循环方法
def sum(arr):
total = 0
for x in arr:
total += x
return total
print(sum([1, 2, 3, 4]))
- 递归函数
def sum(xlist):
if len(xlist) == 0: #找出基准条件
return 0
else:
return xlist.pop(len(xlist) - 1) + sum(xlist)
#缩小问题规模,每次递归调用都必须离空数组更进一步
print(sum([1, 2, 3, 4, 5]))
编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时,请检查基线条件是不是这样的。
相比较用循环解决问题,函数式编程语言没有循环,只能使用递归来编写。
快速排序
工作原理
- 从数组中随机选择一个元素,这个元素被称为基准值(pivot)。
- 找出比基准值小的元素以及比基准值大的元素,这被称为分区(partitioning)。
- 对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组。
代码
def quicksort(array):
if len(array) < 2:
return array #基线条件:为空或只包含一个元素的数组是“有序”的
else:
pivot = array[0]
less = [i for i in array[1:] if i < pivot]
greater = [i for i in array[1:] if i > pivot]
return quicksort(less) + [pivot] + quicksort(greater)
print(quicksort([10, 5, 2, 3]))
快速排序的时间复杂度为,这里同样也省去了系数,而且是平均运行时间。
快速排序是最快的排序算法之一,也是D&C典范。
小结
- D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元素的数组。
- 实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为。
- 大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
- 比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时比O(n)快得多。
来源:CSDN
作者:嘭嘭嘭飞
链接:https://blog.csdn.net/chupengfei_hust/article/details/103964293