递归程序设计方法及其优化,程序设计王冠上的明珠

目录

1. 递归的本质

2. 河内塔问题

2. 兔子问题和斐波那契数列

3. 通配符匹配问题

4. 排列组合问题

5. 人字形铁路问题

6. 递归程序优化之一——避免重复递归

7. 递归程序优化之二——消除死循环

7.1 倒水问题

7.2 倒水办法

8. 八皇后问题

9. 求24点


递归程序设计方法(简称递归)是指函数直接或者间接调用自己的现象。递归是一种有趣的程序设计方法,往往区区几行代码就能达到神奇的效果。递归同时是一种非常数学化的方法,其背后有着深刻的数学原理。递归是程序设计方法王冠上的明珠,学会使用和优化递归是一个有灵魂的软件工程师与一般软件工程师之间最重要的区别之一。

1. 递归的本质

递归的本质就是数学归纳法。什么是数学归纳法?比如,如果我们要证明前n个奇数的和等于n^2,例如:1+3+5=3^2,1+3+5+7=4^2,也就是说1+3+……+(2n-1)=n^2。那么怎么证明呢?

第一步,简称为确定归纳边界。当n=1时看看定理是否成立。当然,定理显然是成立的,因为1=1^2

第二步,称为归纳假设。假设n=k时定理成立。即前k个奇数的和等于k^2

第三步,称为递归推导,看看n=k+1时定理是否成立。因为1+3+……+(2k-1)+(2(k+1)-1)= k^2+2k+1=(k+1)^2,可见,n=k+1时定理也是成立的。

综合上述,我们认为前n个奇数的和的确等于n^2​​​​​​​,这就是数学归纳法。递归实际上也是按照这三步来的,分别简称为确定递归边界、递归假设和递归推导。不同之处仅仅在于,数学归纳法试图证明一个定理成立,而递归的目的是根据输入生成输出。

下面我们通过例子来说明如何正确地使用递归。

2. 河内塔问题

递归程序的一个著名例子就是Hanoi塔(中文翻译为河内塔或汉诺塔)问题。

有三根插在地上的杆子A、B、C。A杆上套有n个中间有孔的碟子,碟子的直径从上到下分别是1、2、……、n(参见上图)。现在我们试图把所有碟子从A杆移到C杆,条件是:

  1. 任意一个杆子上只有最上面的碟子可以移动;
  2. 任何情况下大碟子都不能放在小碟子的上面;
  3. B杆可以作为中转用。

比如,当n=4时,移动步骤是这样的:

  1. Move 1 from A ==> B
  2. Move 2 from A ==> C
  3. Move 1 from B ==> C
  4. Move 3 from A ==> B
  5. Move 1 from C ==> A
  6. Move 2 from C ==> B
  7. Move 1 from A ==> B
  8. Move 4 from A ==> C
  9. Move 1 from B ==> C
  10. Move 2 from B ==> A
  11. Move 1 from C ==> A
  12. Move 3 from B ==> C
  13. Move 1 from A ==> B
  14. Move 2 from A ==> C
  15. Move 1 from B ==> C

解决问题的第一件事情就是确定问题的输入和输出。河内塔问题的输出很明确。输入是什么呢?可能有人认为输入是碟子的个数,比如n。这个想法是错误的,因为我们并不清楚这n个碟子在哪个杆子上。所以输入参数除了n之外,应该还有abc三个参数,分别表示来源、中转和目的地。注意,abc的初值分别是字符串"A"、"B"和"C"。

确定了输入和输出之后,下面按照递归的三个步骤解决Hanoi塔问题。

第一步,确定递归边界。所谓递归边界就是输入参数要满足的一个条件,在这个条件下,原问题可以很容易得到解决。河内塔问题在什么情况下最容易解决?显然,当输入参数n=1时,问题最好解决。因为此时a上仅有一个碟子,直接把这个碟子从a移到c即可,这就是递归边界。注意,该递归边界只与n有关,与其他参数无关。

第二步,递归假设。这里可以假设n-1个碟子可以从abc中的任意一个杆子移到另外任意一个杆子上。可能你会问:“怎么才能把n-1个碟子从一个杆子移到另一个杆子上?”这个问题不用回答,因为它是假设成立的。编程序也可以假设?是的,可以假设,这正是递归程序设计神奇而有魅力的地方。

关于递归假设,你要注意两点:第一,递归假设时所用到的参数应该比原问题的参数更靠近边界。比如上面分析中,n-1就比n更靠近边界;第二,参数的个数、每个参数的类型和作用都应该与原参数一一对应。违反了这两点中的任何一点都会导致递归的失败。这是因为,递归的本质是把一个问题转化为一个规模更小一点的问题解决。所谓规模小,表现在函数参数上,就是指比原参数更靠近边界。如果相反操作,就会导致问题离边界越来越远,最终导致递归陷入死循环,问题求解失败。

n-1个碟子先从A杆移到B杆 

第三步,递归推导。就是利用河内塔问题在n-1个碟子上的解来推导问题在n个碟子上的解。既然n-1个碟子可以从任意一个杆子移到另外任意一个杆子上,那么我们可以这样移:以c作为中转,先把a最上面的n-1个碟子移到b杆上(参见上图),然后再把a杆剩下的直径为n的那个碟子直接移到c杆上(参见下图)。

最后把b上的n-1个碟子移到c上(参见下图)。这样问题就得到了解决。

递归程序设计的原则就是:首先用一个if语句判断当前满不满足递归边界条件。如果满足就按递归边界处理,否则利用递归假设和递归推导进行编程即可。河内塔问题的递归程序如下所示:

解决河内塔问题的Python递归程序

从本质上讲,递归程序设计方法仍然是一种自顶向下的程序设计方法。因为主程序调用的子程序就是主程序自己。唯一区别在于一般自顶向下调用时不用考虑是否更靠近边界,而递归调用必须要考虑这个问题。

2. 兔子问题和斐波那契数列

一对刚出生的小白兔两个月后就成熟并生下一对小兔子,之后每个月都能生一对小兔子。刚生下的小兔子过两个月以后也成熟了,也能每个月生一对小兔子,……,以此类推。假设兔子永远不会翘辫子,现在给你一对刚出生的小白兔,问n个月后有几对兔子?

显然,输入参数是n。递归边界是n=0或1,此时小兔子还没有出生,所以兔子数目都是1对。递归假设是任意n-1,n-2,......个月的兔子数目都是已知的。

当n>2时,每个月的兔子由上个月的兔子和本月新出生的兔子组成。而本月新出生的兔子数与两个月前的兔子总数相同,因为那时即使是刚出生的兔子现在也成熟了,也能在本月生出一对新的小兔子。所以,本题的解就是著名的斐波那契(Fibnacci)数列:F(n) = F(n-1) + F(n-2), F(1) = F(0) = 1。

解决兔子问题的递归程序

def get_rabbits(months):
    if months <= 1:     # 递归边界
        return 1
    # 每个月的兔子数等于上个月兔子数+这个月新增兔子数
    # 而这个月新增兔子数=两个月前的兔子数,因为那时即使刚出生的兔子现在也成熟了
    return get_rabbits(months - 1) + get_rabbits(months - 2)

if __name__ == '__main__':
    for months in range(11):
        print(months, ':%6d' % get_rabbits(months), 'pairs')

运行结果如下:

0 :     1 pairs

1 :     1 pairs

2 :     2 pairs

3 :     3 pairs

4 :     5 pairs

5 :     8 pairs

6 :    13 pairs

7 :    21 pairs

8 :    34 pairs

9 :    55 pairs

10 :    89 pairs

3. 通配符匹配问题

如果说上节的兔子问题还可以用非递归的方法实现的话,那么下面这个例子就很难用非递归方法来实现了。

假设“*”可以匹配0个或0个以上的字符,“?”可以匹配且仅匹配一个字符。请写一个递归函数match(pattern, str)判断字符串str是否与模式pattern匹配。

比如,在操作系统里寻找一个文件时,不必写全文件的名字。可以使用*和?这两个通配符。比如AB*C?.doc表示任何以AB打头,倒数第二个字符是C的doc文档。

我们仍然按照递归三部曲来考虑这个问题的解法。

第一,确定边界条件。

在什么情况下我们可以直接判断str是否与pattern匹配?显然,如果str是个空字符串(即长度为0的字符串)的话,那pattern也必须是个空字符串两者才能匹配。反过来,如果pattern是个空字符串的话,那它也只能和空字符串匹配。所以这个边界条件是str或pattern是空字符串。

第二,递归假设。

假设在pattern或者str比原来少一个字符情况下match函数总能正确地判定二者是否匹配。注意递归假设所用到的参数要比原参数更靠近边界,只要满足这个条件,任何假设都是合理的。

第三步,递归推导。

我们可以考虑pattern的第一个字符first。

  1. 如果它是普通字符,看它是否与str的第一个字符相等,如果不等意味着pattern和str不匹配;如果相等,则还要看pattern的剩余部分与str的剩余部分是否匹配。
  2. 如果first是字符?,则str的第一个字符与它匹配,只需考虑pattern和str各自的剩余部分是否匹配即可。
  3. 如果first是字符*,则又分为两种情况考虑:
  1. 字符*只匹配0个字符,这意味着我们可以把*删除,然后考虑pattern剩下的部分是否与str匹配即可。
  2. 字符*匹配1个或1个以上的字符,这意味着str除了第一个字符以外,还可以有0个或0个以上的字符与*匹配。所以,可以把str的第一个字符删除,然后看它剩下的部分与pattern是否匹配即可。

综上所述,给出Python递归代码如下:

解决字符串匹配问题的递归程序

def match(s, p):
    if len(p) == 0:
        return len(s) == 0

    first = p[0]
    if first == '?':
        return len(s) > 0 and match(s[1:], p[1:])
    if first == '*':
        return match(s, p[1:]) or len(s) > 0 and match(s[1:], p)
    return len(s) > 0 and first == s[0] and match(s[1:], p[1:])

def _test_match(s, p, result):
    print('%s, %s, %s, %s' % (s, p, result, match(s, p)))

if __name__ == '__main__':
    _test_match('ababaab', 'a*b', True)
    _test_match('ababaab', '*abab*', True)
    _test_match('ababaab', 'a*a?b', True)

    _test_match('ababaab', 'a*bb', False)
    _test_match('ababaab', 'aabab*', False)
    _test_match('ababaab', 'a*b?b', False)

_test_match()函数名以下划线打头,这意味着这是一个私有方法,不能被其他Python文件通过import或者from语句引用。

代码中使用了形如s[1:]的语法,表示取s从第二个字符开始直到最后的所有字符构成的新字符串。比如"abcde"[1:]的结果是"bcde",Python把字符串看成是字符的列表,所以可以用求子列表的办法获得子串,再如:"abcde"[2:4]和"abcde"[:4]的结果分别是"cd"和"abcd"。而在C、C++、Java中要取一个子串则必须调用内部函数。代码运行结果如下:

ababaab, a*b, True, True
ababaab, *abab*, True, True
ababaab, a*a?b, True, True
ababaab, a*bb, False, False
ababaab, aabab*, False, False
ababaab, a*b?b, False, False

​​​​​​​4. 排列组合问题

从5个不同的小球里任取3个的组合有多少?这是一个典型的组合问题,答案是C(5, 2)即10种。但是我们现在并不是要用数学方法求组合的解,而是要求编写一个递归函数comb(mn)以求得从m个小球里任取n个的组合数,其中m ≥ n始终成立。

我们根据递归程序三步曲来考虑这个函数。

第一,确定递归边界。在什么情况下,组合数可以直接求得,而不必进行递归?显然当n=0时,不论m等于多少,组合数都是1。把n=0作为递归边界已经足够了。但是,如果还有其他边界则也应该考虑在内,这样有助于程序在递归过程中更快地接近边界从而提高程序运行速度,减少内存占用。如果n==m,则意味着把所有的小球都选中,这样的组合数也只有1个。

第二,递归假设。我们假设只要把m和/或n减1,则组合数总能求得出来。

第三,递归推导。我们把最后一个小球Z拎出来单独考虑。如果最后取出来的n个小球里不包含Z,这种情况相当于从除了Z之外的m-1个小球里取n个,根据递归假设,共有comb(m-1, n)种组合。反之,如果取出来的n个小球里包含Z,这种情况相当于从除了Z之外的m-1个小球里取n-1个小球的组合,根据递归假设,共有comb (m-1, n-1)种组合。代码如下:

解决组合问题的递归程序

def comb(m, n):
    if n == 0 or m == n:
        return 1
    return comb(m-1, n-1) + comb(m-1, n)

if __name__ == '__main__':
    for m in range(1, 10+1):
        for n in range(0, m+1):
            print('C(%d, %d) = %d' % (m, n, comb(m, n)))

上述代码只是计算了组合数,如果要计算所有可能的组合则只需把comb的返回值从整数改为列表,comb(m-1, n-1) + comb(m-1, n)改为两个列表的合并即可:

输出所有组合

def comb(m, n):
    if n == 0:
        return [[]]  # 仅有一个空组合
    if m == n:
        # 输出仅含有0~m-1的组合
        return [[e for e in range(m)]]
    # 计算含有最后一个小球(m-1)的所有组合
    result = [c + [m-1] for c in comb(m-1, n-1)]
    return result + comb(m-1, n)

if __name__ == '__main__':
    for m in range(1, 10+1):
        for n in range(0, m+1):
            print('C(%d, %d) = %s' % (m, n, comb(m, n)))

输出结果:

C(1, 0) = [[]]
C(1, 1) = [[0]]
C(2, 0) = [[]]
C(2, 1) = [[1], [0]]
C(2, 2) = [[0, 1]]
C(3, 0) = [[]]
C(3, 1) = [[2], [1], [0]]
C(3, 2) = [[1, 2], [0, 2], [0, 1]]
C(3, 3) = [[0, 1, 2]]
......

解决了组合数问题,请读者思考:如何解决排列问题呢?同样可以把最后一个小球Z拿出来单独考虑,如果最后取出来的n个小球里不包含Z,这种情况相当于从除了Z之外的m-1个小球里取n个的排列,根据递归假设,共有perm(m-1, n)种排列。反之,如果取出来的n个小球里包含Z,这种情况相当于从除了Z之外的m-1个小球里取n-1个小球的排列,然后把Z插入这n-1个小球之间,共有n个可能性,两个数相乘共有n * perm(m-1, n-1)种排列。代码如下:

解决排列问题的递归程序

def perm(m, n):
    if n == 0:
        return 1
    result = 0 if m == n else perm(m-1, n)  # 不包含第一个小球
    result += perm(m-1, n-1) * n;  # 包含第一个小球
    return result

if __name__ == '__main__':
    for m in range(1, 10+1):
        for n in range(0, m+1):
            print('C(%d, %d) = %d' % (m, n, perm(m, n)))

用类似的办法也可以输出所有的排列数,代码略。

5. 人字形铁路问题

如下图,有一段人字形的铁路,火车只能从右边铁路线驶进,并且只能从左边铁路线驶出。驶进来的火车可以停在人字形的上半部分铁路线上,从而让后进来的火车先从左边铁路线驶出。当然它也可以进来之后不作停留直接驶出。假设右边有n列火车,问从左边驶出的n列火车的排列有多少种?

人字形铁路问题:火车只能右边进左边出

比如假设右边有3列火车ABC,则从左边驶出的火车的排列只有5种:ABCACBBACBCACBA。3列火车的所有6种排列里唯有CAB是不可能的。因为C要想第一个出来,则AB必须进入人字形铁路的上半部分不出来,等C进入再出来之后再出来。此时,列车出来的顺序只能是CBA,而不可能是CAB

显然,这一题的输入参数是右边等待进入铁路的火车的数量n。这当然没有错误,可问题是接下来的递归推导比较困难。为了解决这个难题,我们再增加一个参数m,表示待在铁路上半部分的火车的数量,初值是0。

显然,递归边界是n = 0。此时,不论m等于多少,这些火车都只能按照次序一一驶出。所以排列数是1。

递归假设比较好理解,我们这里略过不提。对于递归推导,当两个参数分别是nm时,我们有两种方法分解问题。第一,右边等待的n列火车中的第一列开进铁路,这时问题参数分别转化为n-1、m+1;第二,停在上半部分铁路上的m列火车中的第一列驶出,这时问题参数分别转化为nm-1,当然前提是m>0。这两种方法得到的排列不会重复,并且不可能存在某个排列是这两种方法覆盖不了的。所以我们分别用这两组参数进行递归调用,再把结果相加即可。代码如下:

解决人字形铁路问题的递归程序

def get_trains(n, m=0):
    if n == 0:
        return 1
    result = get_trains(n-1, m+1)
    if m > 0:
        result += get_trains(n, m-1)
    return result

if __name__ == '__main__':
    for n in range(1, 10+1):
        print(n, get_trains(n))

运行结果如下:

1 1
2 2
3 5
4 14
5 42
6 132
7 429
8 1430
9 4862
10 16796

这个问题有意思的地方在于,虽然m的值加1了,但是子问题的参数还是比原问题更靠近递归边界,这是因为递归边界仅跟参数n有关,跟m无关。

6. 递归程序优化之一——避免重复递归

如果不加以优化的话,递归很容易出现重复计算的问题。比如前面计算斐波那契数列,根据公式有F(n) = F(n-1) + F(n-2)。这意味着为了计算F(8),必须计算F(7)和F(6)。而为了计算F(7),必须计算F(6)和F(5),......。这里F(6)就被计算了两次。一般地,递归程序越靠近边界,重复计算的次数就会呈指数增加。当求F(36)时,电脑已经完全僵死,没有反应了。

那怎么解决这个问题呢?规范的方法是这样的:

  1. 要保证递归程序除了参数之外没有读写任何外部数据。这是为了保证程序的优化只在程序内部发生作用,不会影响到外部环境。这个特点称为函数不变性(Immutable),与数据不变性相对应。函数不变性的另一种理解是,只要参数相同,函数总是返回相同的结果。比如随机数发生函数numpy.random.randint()以及日期函数time.time()就不具有不变性。一般地,如果函数是可变的,可以把相关的外部数据引入参数列表中就能解决这个问题。另外,如果函数只是读取外部数据,没有改写它,那函数也是不可变的。
  2. 在函数的参数中增加一个字典型(dict)的参数d,作用是保存递归调用的中间结果。字典是一种能够根据键获取值的数据类型,Python中的字典相当于Java中的Map。当发生重复调用时,从d中直接获取结果即可,不必进行重复的递归调用。
  3. 把函数当前所有参数集中起来构成一个元组t,在递归假设和递归推导之前看看t是否在d中存在,如果存在,说明本次调用是一个重复调用,直接返回d[t]的结果即可。
  4. 如果d[t]不存在,则进行正常的递归假设和递归推导,递归假设(即递归调用)时,不要忘记带上参数d
  5. 最后,在所有可能退出函数的地方(比如 return语句前或者函数体最后一个语句之后)以t为键把函数的返回值存入字典d中。

综上所述,代码 3‑2的优化结果如下:

优化求解斐波那契数列的递归程序

def get_rabbits(months, d=None):
    if months <= 1:        # 递归边界
        return 1
    if d is None:
        d = {}              # 创建一个空字典
    elif months in d:       # 如果是重复递归
        return d[months]
    result = get_rabbits(months - 1, d) + get_rabbits(months - 2, d)
    d[months] = result
    return result

if __name__ == '__main__':
    for months in range(501):
        print(months, ':%6d' % get_rabbits(months), 'pairs')

其中参数d=None表示参数d的缺省值是None,这样在调用这个函数时,可以不为d提供实参,此时形参d的值就是None。None是Python的关键字,表示空指针的意思,相当于C、C++和Java中的null。因为Python在处理任何非基本类型数据时,使用的都是数据的地址,所以,当一个参数或者变量不指向任何对象时,可以赋予它一个None值。

可能有读者会觉得奇怪,既然如此,那么为什么不把d的缺省值定为空字典(即{})?这样还能避免像上述代码的第一行那样对等于None的d进行处理。这是因为字典是非基本类型,你看到的是空字典{},实际是指向空字典的指针,这意味着所有调用这个函数的地方,只要参数d缺省,对应的实参实际指向的是同一个空字典。这样两次不同调用之间就会产生干扰。

注意,虽然d是可以缺省的,但在递归假设时不要省略它,见代码的倒数第6行。初学者很容易在这里犯错。

判断一个键是否在字典中存在用in操作,见代码第6行if month in d。这个操作不仅对字典,对所有其他序列类型如列表(list)、元组(tuple)、集合(set)也管用。

经过上述优化,程序不但能计算50个月的兔子数,500个月的也能飞快算出。如果不优化,假设F(1)和F(0)分别只需1毫秒进行计算,则第50个月的兔子数需要146天才能计算出来,100个月的需要112亿年才能算出。

下面代码是优化人字形铁路问题的结果:

优化的人字形铁路问题递归程序

def get_trains(n, m=0, d=None):
    if n == 0:
        return 1
    if d is None:
        d = {}
    t = (n, m)
    if t in d:
        return d[t]
    result = get_trains(n-1, m+1, d)
    if m > 0:
        result += get_trains(n, m-1, d)
    d[t] = result
    return result

if __name__ == '__main__':
    for n in range(1, 100+1):
        print(n, get_trains(n))

读者可以分别用老程序和新程序计算100列火车的排列数,可以体验到截然不同的效果。

7. 递归程序优化之二——消除死循环

递归的第二个问题是死循环。死循环的产生是因为递归假设时,参数并没有向递归边界靠近。下面看一个例子。

7.1 倒水问题

假设有一大一小两个没有刻度的杯子,大小杯子的容量分别是5升和3升。假设可以供给无限量的水,请问用这两个杯子如何倒出4升水出来[1]

程序的参数是ab分别表示大小杯子中当前的水量。初值都是0。根据递归三步曲,递归边界显然是a == 4 or b == 4[2]。递归假设略。这一题的关键是如何进行递归推导。

因为当前参数的状态是(ab),所以有八种操作可以改变当前状态:

综上所述,给出代码如下:

解决倒水问题的递归程序(存在死循环)

class Pour:
    def __init__(self, A, B, G):
        self.A = A  # 大杯子容量
        self.B = B  # 小杯子容量
        self.G = G  # 目标量

    def pour(self, a=0, b=0):
        # a: 大杯子水量
        # b: 小杯子水量
        if a == self.G or b == self.G:
            return True
        if a > 0 and self.pour(0, b):
            return True
        if b > 0 and self.pour(a, 0):
            return True
        if a < self.A and self.pour(self.A, b):
            return True
        if b < self.B and self.pour(a, self.B):
            return True
        if a < self.A and a+b >= self.A and self.pour(self.A, a+b-self.A):
            return True
        if b < self.B and a+b >= self.B and self.pour(a+b-self.B, self.B):
            return True
        if a+b <= self.A and self.pour(a+b, 0):
            return True
        if a+b <= self.B and self.pour(0, a+b):
            return True
        return False

if __name__ == '__main__':
    p = Pour(5, 3, 4)
    print(p.pour())

代码中使用了Python的类。类是对象的模版。理解类必须先理解对象。对象是数据和操作数据的函数的封闭环境。本题要编写一个倒水的递归函数,该函数需要5个参数:当前大小杯子中的水量、大小杯子的容量和目标水量。其中大小杯子的容量和目标水量在函数运行时是不会改变的,直接放在形参列表中显得多余,这就提示我们用一个封闭环境封装这三个数据,而递归函数pour将在这个环境中定义,从而使得它可以存取那三个数据。这就是类Pour的由来。

语句class Pour:中class是关键字表示定义一个类,冒号之后定义成员函数。__init__()是构造函数的名字,表示创建Pour类型对象时应该调用的函数。该函数带有四个参数,其中self表示当前正在被创建的对象。所有成员函数的第一个参数都必须是self,这不但区分了成员函数和全局函数,而且还便于成员函数存取封装在对象中的数据。ABG分别是大小杯子的容量和目标水量。构造函数体中有是三个形如self.A = A的赋值操作,表示对象定义了三个成员变量,名字也分别叫ABG,值分别等于参数ABG的值。这样就实现了Pour对象对大小杯子的容量和目标水量这三个数据的封装。

接下来定义的成员函数pour才是解决倒水问题的核心。由于上述理由,除了self之外该函数只需当前大小杯子中的水量这两个参数即可。其他三个数据从对象的成员变量中取得即可。为了方便调用方的调用,代码还设置这两个参数的缺省值为0。

pour函数体中,第一个if语句是判断递归边界,剩下的8个if语句是对8个倒水操作的模拟,每个倒水操作都会导致一个递归调用。

运行这个程序会发现程序报告RecursionError: maximum recursion depth exceeded in comparison即递归深度越界错。原因就在于,每一次倒水操作的确会改变当前参数的状态,比如(ab)(a, 3),但是不能保证新的状态(a, 3)会更靠近递归边界(即大小杯子中有一个杯子的水量接近4),从而导致递归不能正常终止。Python递归深度是有限制的[3],超过这个限制就会报上述错误。 

解决这个问题的办法与避免重复递归类似,用一个集合s保存参数的状态,如果当前的状态曾经出现过,则意味着程序出现了死循环,此时应该直接退出程序即可。代码如下:

避免倒水问题中的死循环

class Pour:
    def __init__(self, A, B, G):
        self.A = A  # 大杯子容量
        self.B = B  # 小杯子容量
        self.G = G  # 目标量

    def pour(self, a=0, b=0, s:set=None):
        # a: 大杯子水量
        # b: 小杯子水量
        if a == self.G or b == self.G:
            return True

        if s is None:
            s = set()   # 空集合
        else:
            key = (a, b)
            if key in s:    # 如果出现死循环
                return False
            s.add(key)

        if a > 0 and self.pour(0, b, s):
            return True
        if b > 0 and self.pour(a, 0, s):
            return True
        if a < self.A and self.pour(self.A, b, s):
            return True
        if b < self.B and self.pour(a, self.B, s):
            return True
        if a < self.A and a+b >= self.A and self.pour(self.A, a+b-self.A, s):
            return True
        if b < self.B and a+b >= self.B and self.pour(a+b-self.B, self.B, s):
            return True
        if a+b <= self.A and self.pour(a+b, 0, s):
            return True
        if a+b <= self.B and self.pour(0, a+b, s):
            return True
        return False

if __name__ == '__main__':
    p = Pour(5, 3, 4)
    print(p.pour())

其中pour()函数的第三个参数说明是s:set=None,冒号(:)用来说明参数s的类型,等号用来说明s的缺省值。注意,Python不是一个强类型语言,所以参数的类型是可以不说明的。这里说明参数类型的目的是使程序员明确该参数的类型以避免对其采取错误的操作。

Java、C、C++是强类型语言,强类型语言的优点是程序在编译阶段就能发现很多语法错误,比如成员方法使用错误等。所以强类型语言特别适合计算机高级语言的初学者,以便规范地使用变量和参数。但是强类型语言有两个限制:

  1. 变量和参数必须先声明后使用。
  2. 形参和实参的类型必须一致或者至少是相容的[4]

这些限制对初学者来说是有必要的,但对有经验的程序员来说就有些繁琐。更重要的是,有时并不能事先确定变量或者参数的类型,这样就限制了程序员的编程。

7.2 倒水办法

上述代码只回答了能否倒出4升水的问题,没有回答怎么倒的问题。为了解决这个问题可以在参数列表中增加一个字典(dict),用来保存当前状态来自于哪个状态。由于上述代码中本来就有一个集合(set)类型的参数s,它就是以参数状态为键的,所以可以把它升级为字典,以保存每个状态的父状态,同时仍然保留其避免递归死循环的作用。

除了s之外,还要增加一个参数parent以表明当前状态的父状态是谁。代码如下:

解决怎么倒水问题

class Pour:
    def __init__(self, A, B, G):
        self.A = A  # 大杯子容量
        self.B = B  # 小杯子容量
        self.G = G  # 目标量

    def pour(self, a=0, b=0):
        s = {}
        result = self._pour(a, b, s)
        path = []
        while result is not None:
            path.insert(0, result)
            result = s[result]
        return None if len(path) == 0 else path

    def _pour(self, a=0, b=0, s:dict=None, parent=None):
        # a: 大杯子水量
        # b: 小杯子水量
        # s: s的类型从set改为dict
        # parent: 当前状态的父状态
        key = (a, b)
        if a == self.G or b == self.G:
            s[key] = parent
            return key

        if s is None:
            s = {}   # 空字典
        elif key in s:    # 如果出现死循环
            return None   # 返回None表示没有解

        s[key] = parent
        if a > 0:
            result = self._pour(0, b, s, key)
            if result is not None:
                return result
        if b > 0:
            result = self._pour(a, 0, s, key)
            if result is not None:
                return result
        if a < self.A:
            result = self._pour(self.A, b, s, key)
            if result is not None:
                return result
        if b < self.B:
            result = self._pour(a, self.B, s, key)
            if result is not None:
                return result
        if a < self.A and a+b >= self.A:
            result = self._pour(self.A, a+b-self.A, s, key)
            if result is not None:
                return result
        if b < self.B and a+b >= self.B:
            result = self._pour(a+b-self.B, self.B, s, key)
            if result is not None:
                return result
        if a+b <= self.A:
            result = self._pour(a+b, 0, s, key)
            if result is not None:
                return result
        if a+b <= self.B:
            result = self._pour(0, a+b, s, key)
            if result is not None:
                return result
        return None

if __name__ == '__main__':
    p = Pour(5, 3, 4)
    result = p.pour()
    if result is None:
        print('没有办法倒出那么多水')
    else:
        print(result)

代码中Pour类里定义了.pour()和._pour()两个成员函数,其中后者是递归函数,是解决问题的主要函数。前者是对后者的一个包装,起两个作用:第一,避免调用方考虑如何为参数s和parent提供实参;第二,当调用后者成功之后,可以通过字典s找到倒水的完整路径。

._pour()函数作了较大修改,首先,返回值从bool型改为元组。该元组表示倒水的最后状态。其次,参数s的类型从set改为dict。最后,还增加了一个参数parent已表明当前状态来自于哪个状态。

运行这个程序的结果是:

[(0, 0), (5, 0), (5, 3), (0, 3), (3, 0), (3, 3), (5, 1), (0, 1), (1, 0), (1, 3), (4, 0)]

表明了从两个空杯子开始如何倒出4升水的整个过程。

8. 八皇后问题

上图展示了一个8*8的国际象棋棋盘,八皇后问题就是指在该棋盘上放8个皇后,使得任意两个皇后不在同一行也不在同一列,还不在同一斜线(与水平线呈45。角)或反斜线(与水平线呈135。角)上。换句话说,就是避免任意两个皇后之间对杀,因为国际象棋中皇后可以任意横走、竖走、斜走,且不受长度限制。

按照递归三步曲可以解决这个问题。首先明确输入参数是index和points,前者表示当前要考虑的是第几个皇后(从0开始数),后者是一个列表(list),包含了之前的所有皇后的坐标。当前皇后的位置要避免与她们发生冲突。递归函数的返回值是一个bool值,以表明当前皇后是否有位置安放。

由于棋盘上每一行必定有一个皇后(否则必有一行含有至少两个皇后),所以,约定第i个皇后的行坐标就是ii = 012, ..., 7。我们的目标是确定每个皇后的列坐标。

递归边界显然是index=8,因为此时意味着所有皇后都已经安排就位,且列坐标都已经保存在points列表中了,所以直接返回True即可。

递归假设是当index参数变成index+1时,函数总能计算出该皇后能否安放成功。注意,本题index的初值是0,边界是8,所以应该使用index+1而不是通常的index-1作递归假设。

由于当前皇后的列坐标只有8个选择(即0~7),所以,可以依次尝试每一个可能的列坐标,如果该位置与所以points中的坐标都不冲突,则当前位置加入到points中,再用index+1和points进行递归调用。注意,如果递归调用返回的结果是False,意味着当前位置虽然与之前的皇后不冲突,但是与之后的皇后有冲突,所以应该尝试下一个可能位置。这里最重要的一件事是,在做新的尝试之前要把当前位置从points中删除。代码如下:

求解八皇后问题的Python程序

def solve_queens(index, points):
    if index == 8:
        return True

    for j in range(8):
        if ok(index, j, points): # 如果当前坐标与之前的皇后不冲突
            points.append(j)      # 插入列坐标
            if solve_queens(index+1, points): # 如果递归调用成功
                return True
            del points[-1]        # 删除刚插入的坐标数据
    return False

def ok(row, col, points): 
    # 判断坐标(row, col)是否与points里的坐标冲突
    for r, c in enumerate(points):
        if row == r or c == col or abs(row - r) == abs(col - c):
            return False
    return True

if __name__ == '__main__':
    points = []
    if solve_queens(0, points):
        # 打印8*8的棋盘
        for i, j in enumerate(points):
            print('- ' * j + '* ' + '- ' * (7 - j))
    else:
        print('无解')

程序运行的结果是:

* - - - - - - - 
- - - - * - - - 
- - - - - - - * 
- - - - - * - - 
- - * - - - - - 
- - - - - - * - 
- * - - - - - - 
- - - * - - - -

9. 求24点

给定4个1~13之间的数,用任意多的加减乘除和括号计算这4个数,使得最终结果是24,这就是著名的24点问题。本节课的目的是编写一个程序,能够用给定的4个数凑出24。也就是说,让电脑而不是人脑计算24点。这一题看起来难度很大,但是只要坚持递归三步曲问题一样可以解决。

首先把原问题分解为两个子问题:1) 4个数能凑出哪些表达式,以及每个表达式的值;2) 上述表达式中哪些的值是24。第二个问题好解决。第一个问题可以用一个递归程序解决,首先明确第一个问题的参数是nums,表示当前可以使用的数的列表。注意这里只能使用列表或者numpy.ndarray类型而不能使用集合,因为4个数中可能有重复。

明确参数后,可以知道当len(nums)=1时是递归边界。此时列表中仅有一个数,不能构成表达式,所以结果就是这个数本身。递归假设:只要nums中的数至少少一个,则函数能计算出所有可能的表达式及其对应的值。

递归推导是这样的,先把nums中的数分为任意的两组,比如假设nums中有3个数,则这3个数可以分为两组,每组中的数分别有1个和2个或者2个和1个。然后根据每组数量n,从nums中取n个数的组合,这样就确定了每组中具体有哪些数。接着进行递归调用,求出每组所能凑出的所有表达式及其值。最后让这两组表达式两两组合,用加减乘除连接成新的表达式,并算出结果即可。代码如下:

求24点的Python程序

import itertools
import numpy as np

def make_num(nums, target):
    # 用nums列表中的数,凑出target
    print(nums, target)
    result = get_exp_value_pairs(nums)
    for exp in result:
        if result[exp] == target:  # 如果表达式的值等于目标
            print(exp)
    print('完成')

def get_exp_value_pairs(nums):
    # 返回由nums中的数据所能构成的表达式及其值
    if len(nums) == 1:
        return {str(nums[0]): nums[0]}  # 表达式和值都是这个数本身
    result = {}
    for left in range(1, len(nums)): # 左表达式用到left个数
        # 从nums序列中,取left个数的组合
        for l_nums in itertools.combinations(nums, left):
            r_nums = nums.copy()
            for e in l_nums:
                r_nums.remove(e)
            l_result = get_exp_value_pairs(list(l_nums))  # 递归调用
            r_result = get_exp_value_pairs(r_nums)  # 递归调用
            for l_exp in l_result:  # 对每个左表达式循环
                l_val = l_result[l_exp] # 左表达式对应的值
                for r_exp in r_result:  # 对每个右表达式循环
                    r_val = r_result[r_exp]  # 右表达式的值
                    result['(%s + %s)' % (l_exp, r_exp)] = l_val + r_val
                    result['(%s - %s)' % (l_exp, r_exp)] = l_val - r_val
                    result['(%s - %s)' % (r_exp, l_exp)] = r_val - l_val
                    result['%s * %s' % (l_exp, r_exp)] = l_val * r_val
                    if r_val != 0:
                        result['%s / %s' % (l_exp, r_exp)] = l_val / r_val
                    if l_val != 0:
                        result['%s / %s' % (r_exp, l_exp)] = r_val / l_val
    return result

if __name__ == '__main__':
    # 生成1~13之间的4个随机数
    numbers = np.random.randint(1, 14, [4])
    make_num(list(numbers), 24)
    make_num([3, 3, 7, 7], 24)

在get_exp_value_pairs()的第4行,变量result用来保存最终结果,它的类型是字典而不是列表,这是因为结果中会存在重复的表达式,使用字典可以避免这种重复。同一个函数的第2个for循环中使用了itertools内部包的combinations(a, r)函数,该函数返回从序列a中取r个数的组合。比如combinations([3, 7, 5], 2)的结果是一个等价于列表[(3, 7), (3, 5), (7, 5)]的序列。

倒数第3行调用了numpy.random.randint(abs)函数,a和b表示随机数的上下界,其中下界是不包含在内的。参数s表示结果的形状,[4]表示返回一个含有4个随机数的向量。代码倒数第2行把这个向量转成了列表,因为get_exp_value_pairs()要用到列表的.copy()成员函数以复制出一个新列表。代码运行结果如下:

[12, 9, 10, 4] 24
12 * 10 / (9 - 4)
12 / (9 - 4) / 10
10 * 12 / (9 - 4)
10 / (9 - 4) * 12
完成
[3, 3, 7, 7] 24
7 * (3 + 3 / 7)
7 * (3 / 7 + 3)
(3 + 3 / 7) * 7
(3 / 7 + 3) * 7
完成


[1] 与五猴分桃问题一样,倒水问题实际有数学解。但这里不必关心其数学解,读者应关注于如何用递归方法解决一个看似困难的问题。

[2] 虽然小杯子中是不可能有4升水的,但是为了让程序适应不同的参数值,这里暂且加上这个条件。

[3] 参见sys.getrecursionlimit()和sys.setrecursionlimit()函数

[4] 比如整型一般就与浮点型相容。

  • 4
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

方林博士

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值