递归和快速排序

感情迁移 提交于 2020-01-22 05:48:59

递归

问题描述

假设你在祖母的阁楼中翻箱倒柜,发现了一个上锁的神秘手提箱。祖母告诉你,钥匙很可能在下面这个盒子里,这个盒子里有盒子,而盒子里的盒子又有盒子。钥匙就在某个盒子中。为找到钥匙,你将使用什么算法?
方法一:

  1. 创建一个要查找的盒子堆。
  2. 从盒子堆取出一个盒子,在里面找。
  3. 如果找到的是盒子,就将其加入盒子堆中,以便以后再查找。
  4. 如果找到钥匙,则大功告成!
  5. 回到第二步。

方法二:

  1. 检查盒子中的每样东西。
  2. 如果是盒子,就回到第一步。
  3. 如果是钥匙,就大功告成!

第一种方法使用的是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)的欧几里得算法如下:

  1. 如果A = 0,则GCD(A,B)= B,因为GCD(0,B)= B,我们可以停下来。
  2. 如果B = 0,则GCD(A,B)= A,因为GCD(A,0)= A,我们可以停下来。
  3. 用余数形式写A(A =B⋅Q+ R),其中Q是除数,R是余数。
  4. 因为GCD(A,B)= GCD(B,R),所以使用欧几里得算法找到GCD(B,R)

使用D&C解决问题的两个步骤:

  1. 找出基线条件,这种条件必须尽可能简单。
  2. 不断将问题分解(或者说缩小规模),直到符合基线条件。

D&C并非可用于解决问题的算法,而是一种解决问题的思路

示例2

求数字数组之和:

  1. 循环方法
def sum(arr): 
    total = 0 
    for x in arr: 
        total += x 
        return total 
        
print(sum([1, 2, 3, 4]))
  1. 递归函数
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]))

编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时,请检查基线条件是不是这样的。

相比较用循环解决问题,函数式编程语言没有循环,只能使用递归来编写。

快速排序

工作原理

  1. 从数组中随机选择一个元素,这个元素被称为基准值(pivot)。
  2. 找出比基准值小的元素以及比基准值大的元素,这被称为分区(partitioning)。
  3. 对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组。

代码

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]))

快速排序的时间复杂度为O(nlogn)O(n*logn),这里同样也省去了系数,而且是平均运行时间。

快速排序是最快的排序算法之一,也是D&C典范。

小结

  • D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元素的数组。
  • 实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(nlogn)O(n*log n)
  • 大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
  • 比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时O(logn)O(log n)比O(n)快得多。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!