3.4 递归函数(调用自身的函数)

3.4.1 递归函数的使用方法

1.什么是递归函数

由上述讲解可知,一个函数可以在其内部调用其他函数。如果一个函数在其内部存在调用该函数自身的语句,就称为递归函数

递归是一种将任务分解的解决问题的方法,一个大的问题如果能够分解成和它类似的子问题,且子问题的解决方法和大问题一样,只不过问题的规模有所区别而已,则这种情况就可以采用递归的方法来解决这个问题。

例如,求一个n的阶乘问题,可以通过n和n-1的阶乘相乘而得到,即问题规模为n的阶乘问题分解成了规模更小的n-1的阶乘问题,即n!=n×(n-1)!

因此,可以编写下面的代码来求n阶乘:

def fact(n):
    if n==1:                   #如果n等于1,就直接返回值1
      return 1
    return n * fact(n -1)    #如果n大于1,就是n和fact(n-1)的乘积
fact(4)                      #输出: 24

输出:

24

当n=1时,就不需要再分解了,即不需要再递归为更小的子问题了。这种不需要再分解的问题,即“规模是n=1的阶乘问题”称为“基问题”。

递归是一个嵌套的过程。例如,fact(4)的递归计算过程如下:

===> fact(4)
===> 4 * fact(3)
===> 4 *(3 * fact(2))
===> 4 *(3 *(2 * fact(1)))
===> 4 *(3 *(2 * 1))
===> 4 *(3 * 2)
===> 4 * 6
===> 24

2.斐波那契数列

斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家列昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称“兔子数列”,指的是数列{f(n)|n=0,1,2, …}。例如:

f(0)=1, f(1)=1,当n>=2时,f(n)=f(n-1)+f(n-2)

可编写如下的递归函数:

def fib(n):
    if n<=2 :
      return 1
    else:
      return fib(n-1)+fib(n-2)

for i in range(8):
    print(fib(i), end=', ')

输出:

1,1,1,2,3,5,8,13,

3.最大公约数

最大公约数也是一个递归问题:

可编写如下的递归函数:

def gcd(m, n):
    if n==0 :
      return m
    else:
      return gcd(n, m%n)

print(gcd(72,27))
print(gcd(24,36))

输出:

9
12

3.4.2 实战:二分查找的递归实现

本书第2章中的二分查找问题可以看作一个递归问题,在非空的原序列上的查找问题,被分解为三个子问题:(1)和中间的元素的直接比较问题;(2)左区间上的查找问题;(3)右区间上的查找问题。可以编写基于递归的二分查找程序:

def binarySearch(alist, value):
    if len(alist)==0:                                  #(0)空序列
      return -1
    else:
      Middle=len(alist)//2
      if alist[Middle]==value:                         #(1)中间元素直接比较
          return Middle
      else:
          if value<alist[Middle]:
              return binarySearch(alist[:Middle], value)#(2)左区间查找
          else:
              return binarySearch(alist[Middle+1:], value)#(3)右区间查找

可以用下列代码测试这个递归二分查找函数:

testlist=[5,7,12,25,34,37,43,46,58,80,82,105]
print(binarySearch(testlist, 25))
print(binarySearch(testlist, 13))

输出:3-1

3.4.3 实战:汉诺塔问题

汉诺塔问题(如图3-2所示)是由法国数学家爱德华·卢卡斯在1883年发明的(他的灵感来自一个印度教传说)。假设有三根柱子a、b、c,其中a柱子上有n个(n > 1)盘子,盘的尺寸从下到上依次变小。现在要求将盘子全部移到c柱子,每次只能移动一个盘子,且小盘必须在大盘之上,当然,盘子只能放在三个柱子之一上。

图3-2 汉诺塔问题

假设采用蛮力尝试法,处于一个柱子上的盘子最多有两个选择(移动到另外两个柱子之一), n个盘子都这样尝试,至少需要n次尝试,因此至少需要移动2n-1次,但由于每个盘子不可能只尝试一次,所以总的次数会远远大于这个数字。

如果采用“分而治之”的解决问题方法,就可以将“n个盘子的移动(从a柱子借助b柱子移到c柱子)”分解为如下子问题(如图3-3所示):

图3-3 汉诺塔的递归分解

●(上面的)n-1个盘子的移动(从a柱子借助c柱子移到b柱子)。

● 最大盘子的移动:直接从a柱子移到c柱子。

● n-1个盘子的移动(从b柱子借助a柱子移到c柱子)。

当然,对于n=1的“基问题”,不需要分解,直接移动即可。因此,可以编写下列代码:

#一个盘子:直接移动
def moveDisk(i, x, y):
    print("moving disk", i, " from", x, "to", y)

#盘子数,起始柱,中转柱,目标柱
def move(n, a, b, c):
    if n>=1:
      move(n-1, a, c , b)   #n-1个盘子从a柱子借助c柱子移到b柱子
      moveDisk(n, a, c)       #第n号盘子直接从a柱子移到c柱子
      move(n-1, b, a, c)    #n-1个盘子从b柱子借助a柱子移到c柱子

下面的代码对n=3调用这个move()函数:

move(3, 'A', 'B', 'C')

输出直接移动的步骤:

moving disk 1  from a to c
moving disk 2  from a to b
moving disk 1  from c to b
moving disk 3  from a to c
moving disk 1  from b to a
moving disk 2  from b to c
moving disk 1  from a to c

3.4.4 实战:快速排序算法

对一组数进行排序的快速排序是一个递归过程。首先在这组数中任意选取一个数作为“基准”,将这组数分为两部分,其中一部分的所有数不大于基准元素,而另外一部分的所有数不小于基准元素。例如,有一组数:

34,2,89,47,29,13

假设任意选取一个数34作为基准元素,然后将这组数按照34分为两部分:

2,29,13, [34],89,47

将一个序列按基准元素34一分为二,其中,左半部分的数都小于等于34,右边部分的都大于等于34,这个过程称为“一次划分”,对分割好的两部分重复上述过程,如此进行下去,直到每个部分的长度不超过1。

因此,整个快速排序算法(假设叫作qsort)分为三个步骤:首先一次划分;再对左、右部分分别调用这个快速排序算法(qsort)。可以编写如下代码:

#对[start, end]区间的元素进行快速排序
def qsort(arr, start , end):
    if start<end:
      pivot=partition(arr, start, end)#将[start, end]之间的序列一次划分为两部分
                                  #pivot是基准的位置
      qsort(arr, start, pivot-1)  #对[start, pivot-1]之间的序列调用qsort快速排序过程
      qsort(arr, pivot+1, end)     #对[pivot+1, end]之间的序列调用qsort快速排序过程

递归函数qsort()里调用了对一个区间完成一次划分的函数partition(),其过程如图3-4所示。假设第一个元素是基准元素,则可以使用首尾两个指示器,当右指示器指向的元素大于等于基准元素时,则该指示器向左移动,否则就停止;当左指示器指向的元素小于等于基准元素时,则该指示器向右移动,否则就停止。当两个指示器都停止时,左、右指示器指向的元素都分别大于或小于基准元素,这时就交换它们指向的元素值,再继续这个“两个指示器向内靠拢”的过程。直到两个指示器相遇,这个位置就是基准元素的目标位置。

图3-4 “一次划分”过程

函数partition()代码如下:

def partition(alist, start, end):
  pivotvalue=alist[start]  #假设选择start的元素为基准元素,并暂存到pivotvalue中
  L=start+1                #左指示器指向区间左侧
  R=end                     #右指示器指向区间右侧

  done=False
  while not done:
      while L <=R and alist[L] <=pivotvalue:
        L=L + 1

      while alist[R] >=pivotvalue and R >=L:
        R=R -1

      if R < L:
        done=True
      else:
        alist[L], alist[R]=alist[R], alist[L]
        #temp=alist[L]
        #alist[L]=alist[R]
        #alist[R]=temp
#R<L时的位置R就是基准元素的目标位置
  alist[R], alist[start]=alist[start], alist[R]   #交换基准元素和R位置的元素
  return R               #返回基准元素的位置

调用对一个区间的快速排序递归函数qsort(),可对一个序列进行快速排序。例如:

def quickSort(alist):
    qsort(alist,0, len(alist)-1) #调用递归函数qsort对[0, len-1]区间进行快速排序

alist=[49,38,27,97,76,13,27,49]
quickSort(alist)
print(alist)

输出:

[13, 27, 27, 38, 49, 49, 76, 97]

如果不追求效率,还可以采用一种取巧的简单方法:遍历这个数组列表,将其中小于基准元素的数放入一个新的列表,而大于等于基准元素的数则放入另外一个新的列表,然后将这两个新列表的快速排序的结果合并起来。例如:

def quicksort(arr):
    if len(arr)<=1:                       #如果输入序列长度小于等于1,则直接返回
      return arr
    pivot=arr[len(arr)// 2]             #任意选择一个元素作为基准元素
                                            #将原序列一分为二
    left=[x for x in arr if x < pivot]   #left是小于基准元素组成的子序列
    middle=[x for x in arr if x==pivot] #middle是等于基准元素组成的子序列
    right=[x for x in arr if x > pivot]  #right是大于基准元素组成的子序列
    return quicksort(left)+ middle + quicksort(right) #对left、right重复上述过程

print(quicksort([49,38,27,97,76,13,27,49]))

输出:

[13, 27, 27, 38, 49, 49, 76, 97]

因为这个算法需要移动函数,所以这个算法的效率比较低,并且还要消耗更多的空间。

3.4.5 实战:迷宫问题

给定一个迷宫,指明迷宫的起点和终点,找出从起点出发到终点的有效路径,这就是迷宫问题(maze problem),如图3-5所示。

图3-5 迷宫问题

迷宫可以用二维数组来存储表示。例如,0表示通路,1表示障碍,2表示终点。坐标以行和列表示,均从0开始。假设给定起点(0, 0)和终点(5, 5),迷宫表示如下:

maze=[[0, 0, 0, 0, 0, 1],
        [1, 1, 0, 0, 0, 1],
        [0, 0, 0, 1, 0, 0],
        [0, 1, 1, 0, 0, 1],
        [0, 1, 0, 0, 1, 0],
        [0, 1, 0, 0, 0, 2]]

迷宫求解问题可以描述为一个递归过程。

对于一个当前位置,判断该位置是否为终点(2)、墙(1)、已经走过(3)。如果不是上述情况,则说明该位置可通但未走过(0)。可从该位置走向其四邻(上、下、左、右四个位置)。对于每个邻接点,重复这个过程。例如:

def go_maze(x, y):
    #该位置是否为终点(2)、墙(1)、已经走过(3)
    if maze[x][y]==2:
      print(’到达终点:', x, ", ", y)
      return True
    elif maze[x][y]==1:
      #print(’墙:', x, ", ", y)
      return False
    elif maze[x][y] >=3:
      #print(’已经访问过:', x, ", ", y)
      return False

    #从该位置向四邻探索
    print(’访问 %d, %d' %(x, y))
    maze[x][y]=3             #标记该位置已经访问过

    # 向4邻探索
    if((x < len(maze)-1 and go_maze(x+1, y))
      or(y > 0 and go_maze(x, y-1))
      or(x > 0 and go_maze(x-1, y))
      or(y < len(maze)-1 and go_maze(x, y+1))):

      return True

    maze[x][y]=4    #此路也不通
    return False

go_maze(0, 0)
print(*maze, sep='\n')

输出:

访问 0,0
访问 0,1
访问 0,2
访问 1,2
访问 2,2
访问 2,1
访问 2,0
访问 3,0
访问 4,0
访问 5,0
访问 1,3
访问 0,3
访问 0,4
访问 1,4
访问 2,4
访问 3,4
访问 3,3
访问 4,3
访问 5,3
访问 5,2
访问 4,2
访问 5,4
到达终点: 5 , 5
[[3, 3, 3, 3, 3, 1],
 [1, 1, 3, 3, 3, 1],
 [4, 4, 4, 1, 3, 0],
 [4, 1, 1, 3, 3, 1],
 [4, 1, 4, 3, 1, 0],
 [4, 1, 4, 3, 3, 2]]

思考:打印的位置还包括回退的位置,如何打印一个没有回退的路径?