递归的递归之书:第三章到第四章

121 阅读58分钟

三、经典递归算法

原文:Chapter 3 - Classic Recursion Algorithms

译者:飞龙

协议:CC BY-NC-SA 4.0

如果你上了计算机科学课,递归单元肯定会涵盖本章介绍的一些经典算法。编码面试(由于缺乏合适的评估候选人的方法,通常抄袭大一计算机科学课程笔记)也可能涉及到它们。本章介绍了递归中的六个经典问题以及它们的解决方案。

我们首先介绍三个简单的算法:对数组中的数字求和、反转文本字符串以及检测字符串是否为回文。然后我们探讨解决汉诺塔难题的算法,实现泛洪填充绘图算法,并解决荒谬的递归 Ackermann 函数。

在这个过程中,你将学习到递归函数参数中的头尾技术。当尝试提出递归解决方案时,我们还会问自己三个问题:什么是基本情况?递归函数调用传递了什么参数?递归函数调用传递的参数如何接近基本情况?随着经验的增加,回答这些问题应该会更加自然。

对数组中的数字求和

我们的第一个例子很简单:给定一个整数列表(在 Python 中)或一个整数数组(在 JavaScript 中),返回所有整数的总和。例如,像sum([5, 2, 4, 8])这样的调用应该返回19

这个问题用循环很容易解决,但用递归解决需要更多的思考。在阅读第二章之后,你可能也会注意到这个算法与递归的能力不够匹配,无法证明递归的复杂性。然而,在编码面试中,对数组中的数字求和(或者基于线性数据结构处理数据的其他计算)是一个常见的递归问题,值得我们关注。

为了解决这个问题,让我们来看看实现递归函数的头尾技术。这个技术将递归函数的数组参数分成两部分:(数组的第一个元素)和(包括第一个元素之后的所有内容的新数组)。我们定义递归的sum()函数来通过将头部添加到尾部数组的总和来找到数组参数的整数的总和。为了找出尾部数组的总和,我们将其递归地作为数组参数传递给sum()

因为尾部数组比原始数组参数少一个元素,所以我们最终将调用递归函数并传递一个空数组。空数组参数很容易求和,不需要更多的递归调用;它只是0。根据这些事实,我们对三个问题的答案如下:

  1. 什么是基本情况?一个空数组,其和为0

  2. 递归函数调用传递了什么参数?原始数字数组的尾部,比原始数组参数少一个数字。

  3. 这个参数如何变得更接近基本情况?数组参数每次递归调用都会减少一个元素,直到变成长度为零的空数组。

这是sumHeadTail.py,一个用于对数字列表求和的 Python 程序:

Python

def sum(numbers):
    if len(numbers) == 0: # BASE CASE
        return 0 # ❶
    else: # RECURSIVE CASE
        head = numbers[0] # ❷
        tail = numbers[1:] # ❸
        return head + sum(tail) # ❹

nums = [1, 2, 3, 4, 5]
print('The sum of', nums, 'is', sum(nums))
nums = [5, 2, 4, 8]
print('The sum of', nums, 'is', sum(nums))
nums = [1, 10, 100, 1000]
print('The sum of', nums, 'is', sum(nums))

这是等效的 JavaScript 程序sumHeadTail.html

JavaScript

<script type="text/javascript">
function sum(numbers) {
    if (numbers.length === 0) { // BASE CASE
        return 0; // ❶
    } else { // RECURSIVE CASE
        let head = numbers[0]; // ❷
        let tail = numbers.slice(1, numbers.length); // ❸
        return head + sum(tail); // ❹
    }
}

let nums = [1, 2, 3, 4, 5];
document.write('The sum of ' + nums + ' is ' + sum(nums) + "<br />");
nums = [5, 2, 4, 8];
document.write('The sum of ' + nums + ' is ' + sum(nums) + "<br />");
nums = [1, 10, 100, 1000];
document.write('The sum of ' + nums + ' is ' + sum(nums) + "<br />");
</script>

这些程序的输出如下所示:

The sum of [1, 2, 3, 4, 5] is 15
The sum of [5, 2, 4, 8] is 19
The sum of [1, 10, 100, 1000] is 1111

当使用空数组参数调用时,我们的函数的基本情况简单地返回0❶。在递归情况中,我们从原始的numbers参数中形成头❷和尾部❸。请记住,tail的数据类型是一个数字数组,就像numbers参数一样。但是head的数据类型只是一个单一的数字值,而不是一个带有一个数字值的数组。sum()函数的返回值也是一个单一的数字值,而不是一个数字数组;这就是为什么我们可以在递归情况中将headsum(tail)相加❹。

每次递归调用都将一个越来越小的数组传递给sum(),使其更接近空数组的基本情况。例如,图 3-1 显示了对sum([5, 2, 4, 8])的调用堆栈的状态。

在这个图中,堆栈中的每张卡片代表一个函数调用。每张卡片的顶部是函数名和调用时传递的参数。其下是局部变量:numbers参数,以及在调用过程中创建的headtail局部变量。卡片底部是函数调用返回的head + sum(tail)表达式。当创建一个新的递归函数时,一个新的卡片被推到堆栈上。当函数调用返回时,顶部的卡片从堆栈中弹出。

一系列代表调用堆栈上的帧对象的卡片堆叠。依次,新的顶部卡片代表对 sum()传递[5, 2, 4, 8]的调用,然后传递[2, 4, 8],然后传递[4, 8],然后传递[8],然后传递一个空列表。然后顶部卡片被移除,首先移除空列表卡片,然后[8]卡片,然后[4, 8]卡片,然后[2, 4, 8]卡片,然后[5, 2, 4, 8]卡片。

图 3-1:当运行sum([5, 2, 4, 8])时调用堆栈的状态

我们可以使用sum()函数作为应用头尾技术到其他递归函数的模板。例如,你可以将sum()函数从对数字数组求和的函数更改为concat()函数,用于将字符串数组连接在一起。基本情况将返回一个空字符串作为空数组参数,而递归情况将返回头字符串与传递尾部的递归调用的返回值连接在一起。

回想一下第二章,递归特别适用于涉及树状结构和回溯的问题。数组、字符串或其他线性数据结构可以被视为树状结构,尽管这是一个只有一个分支的树,就像图 3-2 中所示的那样。

两幅图像,一幅是每个节点都被圈起来的树,另一幅是每个弯曲处都被圈起来的树枝,并且圈内写着数字 8、4、2 和 5。

图 3-2:一个[5, 2, 4, 8]数组(右侧)就像一个只有一个分支的树状数据结构(左侧)。

我们的递归函数不必要的关键“告诉”是它从不在处理的数据上进行任何回溯。它对数组中的每个元素进行单次遍历,这是基本循环可以完成的事情。此外,Python 递归求和函数比直接迭代算法慢大约 100 倍。即使性能不是问题,递归sum()函数如果传递一个要求求和的数目为数万的列表会导致堆栈溢出。递归是一种高级技术,但并不总是最佳方法。

在第五章中,我们将研究使用分而治之策略的递归求和函数,在第八章中,我们将研究使用尾调用优化的递归函数。这些替代的递归方法解决了本章中求和函数的一些问题。

反转字符串

像对数组中的数字求和一样,反转字符串是另一个经常被引用的递归算法,尽管迭代解决方案很简单。因为字符串本质上是一个由单个字符组成的数组,所以我们将为我们的rev()函数采用头部和尾部的方法,就像我们为求和算法所做的那样。

让我们从可能的最小的字符串开始。一个空字符串和一个单字符字符串已经是它们自己的反转。这自然形成了我们的基本情况:如果字符串参数是''′A′这样的字符串,我们的函数应该简单地返回字符串参数。

对于更长的字符串,让我们尝试将字符串分割成头部(仅为第一个字符)和尾部(第一个字符之后的所有字符)。对于一个两个字符的字符串,比如′XY′′X′是头部,′Y′是尾部。要反转字符串,我们需要将头部放在尾部后面:′YX′

这个算法对更长的字符串有效吗?要反转像′CAT′这样的字符串,我们会将它分成头部′C′和尾部′AT′。但仅仅将头部放在尾部后面并不能反转字符串;它给我们的是′ATC′。实际上,我们想要做的是将头部放在尾部的反转后面。换句话说,′AT′会反转成′TA′,然后将头部添加到末尾会产生反转后的字符串′TAC′

我们如何反转尾部?嗯,我们可以递归调用rev()并将尾部传递给它。暂时忘记我们函数的实现,专注于它的输入和输出:rev()接受一个字符串参数,并返回一个将参数的字符反转的字符串。

考虑如何实现像rev()这样的递归函数可能很困难,因为它涉及到一个鸡和蛋的问题。为了编写rev()的递归情况,我们需要调用一个反转字符串的函数,也就是rev()。只要我们对我们的递归函数的参数和返回值有一个坚实的理解,我们就可以使用“信任飞跃”技术来解决这个鸡和蛋问题,即使我们还没有完成编写它。

在递归中进行信任飞跃并不是一个可以保证您的代码无错误的神奇技术。它只是一种观点,可以打破您在思考如何实现递归函数时可能遇到的心理程序员障碍。信任飞跃要求您对递归函数的参数和返回值有坚定的理解。

请注意,信任飞跃技术只有在编写递归情况时才有帮助。您必须将一个接近基本情况的参数传递给递归调用。您不能简单地传递递归函数接收到的相同参数,就像这样:

def rev(theString):
    return rev(theString) # This won't magically work.

继续我们的′CAT′例子,当我们将尾部′AT′传递给rev()时,在那个函数调用中,头部是′A′,尾部是′T′。我们已经知道单个字符字符串的反转就是它自己;这是我们的基本情况。因此,对rev()的第二次调用将′AT′反转为′TA′,这正是之前对rev()的调用所需要的。图 3-3 显示了在所有对rev()的递归调用期间调用堆栈的状态。

让我们问rev()函数的三个递归算法问题:

  1. 基本情况是什么?零个或一个字符的字符串。

  2. 递归函数调用传递了什么参数?原始字符串参数的尾部,比原始字符串参数少一个字符。

  3. 这个参数如何变得更接近基本情况?每次递归调用时,数组参数都会减少一个元素,直到成为一个零长度的数组。

时间轴显示了在对 rev 函数的每次递归调用期间调用堆栈的状态。它以参数“CAT”调用 rev 开始,theString 变量等于“CAT”,head 变量等于“C”,tail 变量等于“AT”,返回值为 rev(‘AT’)+‘C’。接下来,使用参数“AT”调用 rev,theString 为“AT”,head 为“A”,tail 为“T”,返回值为 rev(‘T’)+‘A’。然后,使用参数“T”调用 rev,theString 等于“T”,函数返回‘T’。在第四种状态下,使用参数“AT”调用 rev,theString 为“AT”,head 为“A”,tail 为“T”,函数返回‘T’+‘A’。最后,使用参数“CAT”调用 rev,theString 为“CAT”,head 为“C”,tail 为“AT”,函数返回‘TA’+‘C’。之后,调用堆栈为空。

图 3-3:rev()函数反转CAT字符串时调用堆栈的状态

这是一个用于反转字符串的 Python 程序reverseString.py

Python

def rev(theString):
    if len(theString) == 0 or len(theString) == 1: # ❶
        # BASE CASE
        return theString
    else:
        # RECURSIVE CASE
        head = theString[0] # ❷
        tail = theString[1:] # ❸
        return rev(tail) + head # ❹

print(rev('abcdef'))
print(rev('Hello, world!'))
print(rev(''))
print(rev('X'))

这是reverseString.html中等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
function rev(theString) {
    if (theString.length === 0 || theString.length === 1) { // ❶
        // BASE CASE
        return theString;
    } else {
        // RECURSIVE CASE
        var head = theString[0]; // ❷
        var tail = theString.substring(1, theString.length); // ❸
   return rev(tail) + head; // ❹
    }
}

document.write(rev("abcdef") + "<br />");
document.write(rev("Hello, world!") + "<br />");
document.write(rev("") + "<br />");
document.write(rev("X") + "<br />");
</script>

这些程序的输出如下:

fedcba
!dlrow ,olleH

X

我们的递归函数rev()返回与参数theString相反的字符串。让我们考虑最简单的字符串进行反转:空字符串和单个字符字符串会“反转”成它们自己。这是我们将开始的两个基本情况(尽管我们将它们与or||布尔运算符结合在一起)。对于递归情况,我们从theString中的第一个字符形成head,从第一个字符之后的每个字符形成tail。然后递归情况返回tail的反转,后跟head字符。

检测回文

回文是一个正向和反向拼写相同的单词或短语。Levelrace cartaco cata man, a plan, a canal . . . Panama都是回文的例子。如果您想要检测一个字符串是否是回文,您可以编写一个递归的isPalindrome()函数。

基本情况是零个或一个字符的字符串,根据其性质,无论是正向还是反向,它总是相同的。我们将使用类似于头尾技术的方法,只是我们将把字符串参数分成头部、中间和尾部字符串。如果头部和尾部字符相同,并且中间字符也形成回文,那么字符串就是回文。递归来自将中间字符串传递给isPalindrome()

让我们问isPalindrome()函数的三个递归算法问题:

  1. 基本情况是什么?零个或一个字符的字符串,它返回True,因为它总是一个回文。

  2. 递归函数调用传递了什么参数?字符串参数的中间字符。

  3. 这个参数如何变得更接近基本情况?每次递归调用时,字符串参数都会减少两个字符,直到成为零个或一个字符的字符串。

这是一个用于检测回文的 Python 程序palindrome.py

Python

def isPalindrome(theString):
    if len(theString) == 0 or len(theString) == 1:
        # BASE CASE
        return True
    else:
        # RECURSIVE CASE
        head = theString[0] # ❶
        middle = theString[1:-1] # ❷
        last = theString[-1] # ❸
        return head == last and isPalindrome(middle) # ❹

text = 'racecar'
print(text + ' is a palindrome: ' + str(isPalindrome(text)))
text = 'amanaplanacanalpanama'
print(text + ' is a palindrome: ' + str(isPalindrome(text)))
text = 'tacocat'
print(text + ' is a palindrome: ' + str(isPalindrome(text)))
text = 'zophie'
print(text + ' is a palindrome: ' + str(isPalindrome(text)))

以下是palindrome.html中等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
function isPalindrome(theString) {
    if (theString.length === 0 || theString.length === 1) {
        // BASE CASE
        return true;
    } else {
        // RECURSIVE CASE
        var head = theString[0]; // ❶
        var middle = theString.substring(1, theString.length -1); // ❷
        var last = theString[theString.length - 1]; // ❸
        return head === last && isPalindrome(middle); // ❹
    }
}

text = "racecar";
document.write(text + " is a palindrome: " + isPalindrome(text) + "<br />");
text = "amanaplanacanalpanama";
document.write(text + " is a palindrome: " + isPalindrome(text) + "<br />");
text = "tacocat";
document.write(text + " is a palindrome: " + isPalindrome(text) + "<br />");
text = "zophie";
document.write(text + " is a palindrome: " + isPalindrome(text) + "<br />");
</script>

这些程序的输出如下:

racecar is a palindrome: True
amanaplanacanalpanama is a palindrome: True
tacocat is a palindrome: True
zophie is a palindrome: False

基本情况返回True,因为零个或一个字符的字符串总是回文。否则,字符串参数被分成三部分:第一个字符❶,最后一个字符❸,以及它们之间的中间字符❷。

递归情况下的return语句❹利用了布尔短路,这是几乎所有编程语言的特性。在使用and&&布尔运算符连接的表达式中,如果左侧表达式为False,则右侧表达式是True还是False都无所谓,因为整个表达式都将是False。布尔短路是一种优化,如果左侧为False,则跳过and运算符的右侧表达式的评估。因此,在表达式head == last and isPalindrome(middle)中,如果head == lastFalse,则递归调用isPalindrome()将被跳过。这意味着一旦头部和尾部字符串不匹配,递归就会停止并简单地返回False

这个递归算法仍然是顺序的,就像前面章节中的求和和反转字符串函数一样,只是不是从数据的开始到结束,而是从数据的两端向中间移动。使用简单循环的迭代版本更加直接。我们在本书中介绍递归版本,因为这是一个常见的编码面试问题。

解决汉诺塔

汉诺塔是一个涉及堆叠的塔的难题。难题以最大的圆盘在底部开始,圆盘尺寸逐渐减小。每个圆盘的中心都有一个孔,这样圆盘可以在杆上相互堆叠。图 3-4 显示了一个木制的汉诺塔难题。

图片上是一个木制表面,上面有三根杆,第一根杆上放着一堆圆盘,从底部到顶部尺寸逐渐减小。

图 3-4:木制汉诺塔难题套装

要解决这个难题,玩家必须遵循三条规则将圆盘从一根杆移动到另一根杆:

  • 玩家一次只能移动一个圆盘。

  • 玩家只能将圆盘移动到塔的顶部或从塔的顶部移动。

  • 玩家永远不能将较大的圆盘放在较小的圆盘上。

Python 的内置turtledemo模块有一个汉诺塔演示,您可以通过在 Windows 上运行python -m turtledemo或在 macOS/Linux 上运行python3 -m turtledemo,然后从示例菜单中选择minimum_hanoi来查看。汉诺塔动画也可以通过互联网搜索轻松找到。

解决汉诺塔难题的递归算法并不直观。让我们从最小的情况开始:一个只有一个圆盘的汉诺塔。解决方案很简单:将圆盘移动到另一个杆上,然后完成。解决两个圆盘的情况稍微复杂一些:将较小的圆盘移动到一个杆上(我们称之为临时杆),将较大的圆盘移动到另一个杆上(我们称之为结束杆),最后将较小的圆盘从临时杆移动到结束杆。现在两个圆盘按正确顺序放在结束杆上。

一旦解决了三个圆盘的塔,你会发现出现了一个模式。要解决从起始杆到结束杆的n个圆盘的塔,必须执行以下操作:

  1. 通过将这些圆盘从起始杆移动到临时杆来解决n - 1 个圆盘的难题。

  2. 将第n个圆盘从起始杆移动到结束杆。

  3. 通过将这些圆盘从临时杆移动到结束杆来解决n - 1 个圆盘的难题。

就像斐波那契算法一样,汉诺塔算法的递归情况不是只做一次递归调用,而是做两次。如果我们画出一个解决四个盘子汉诺塔问题的操作的树形图,它看起来像图 3-6。解决四个盘子的难题需要与解决三个盘子的难题相同的步骤,以及移动第四个盘子和再次执行解决三个盘子难题的步骤。同样,解决三个盘子的难题需要与解决两个盘子的难题相同的步骤,再加上移动第三个盘子,依此类推。解决一个盘子的难题是微不足道的基本情况:它只涉及移动盘子。

图 3-5 中的树状结构暗示了递归方法对于解决汉诺塔难题是理想的。在这棵树中,执行从上到下,从左到右移动。

虽然对于人类来说,解决三个盘子或四个盘子的汉诺塔很容易,但是盘子数量的增加需要指数级增加的操作次数才能完成。对于n个盘子,至少需要 2n - 1 次移动才能解决。这意味着 31 个盘子的塔需要超过十亿次移动才能完成!

树状图显示解决四个盘子汉诺塔问题所需的一系列操作。根节点“解决 4”分成三个节点:一个代表将第四个盘子放在正确位置所需的移动,“移动 4”,和两个“解决 3”节点。每个“解决 3”节点分成自己的一系列代表移动和步骤的节点。

图 3-5:解决四个盘子汉诺塔的一系列操作

让我们为创建递归解决方案提出三个问题:

  1. 基本情况是什么?解决一个盘子的塔。

  2. 传递给递归函数调用的参数是什么?解决一个比当前大小小一个盘子的塔。

  3. 这个参数如何变得更接近基本情况?要解决的塔的大小每递归调用一次减少一个盘子,直到它是一个只有一个盘子的塔。

以下的towerOfHanoiSolver.py程序解决了汉诺塔难题,并显示了每一步的可视化:

import sys

# Set up towers A, B, and C. The end of the list is the top of the tower.
  TOTAL_DISKS = 6# Populate Tower A:
  TOWERS = {'A': list(reversed(range(1, TOTAL_DISKS + 1))), ❷
          'B': [],
          'C': []}

def printDisk(diskNum):
    # Print a single disk of width diskNum.
    emptySpace = ' ' * (TOTAL_DISKS - diskNum)
    if diskNum == 0:
        # Just draw the pole.
        sys.stdout.write(emptySpace + '||' + emptySpace)
    else:
        # Draw the disk.
        diskSpace = '@' * diskNum
        diskNumLabel = str(diskNum).rjust(2, '_')
        sys.stdout.write(emptySpace + diskSpace + diskNumLabel + diskSpace + emptySpace)

def printTowers():
    # Print all three towers.
 for level in range(TOTAL_DISKS, -1, -1):
        for tower in (TOWERS['A'], TOWERS['B'], TOWERS['C']):
            if level >= len(tower):
                printDisk(0)
            else:
                printDisk(tower[level])
        sys.stdout.write('\n')
    # Print the tower labels A, B, and C.
    emptySpace = ' ' * (TOTAL_DISKS)
    print('%s A%s%s B%s%s C\n' % (emptySpace, emptySpace, emptySpace, emptySpace, emptySpace))

def moveOneDisk(startTower, endTower):
    # Move the top disk from startTower to endTower.
    disk = TOWERS[startTower].pop()
    TOWERS[endTower].append(disk)

def solve(numberOfDisks, startTower, endTower, tempTower):
    # Move the top numberOfDisks disks from startTower to endTower.
    if numberOfDisks == 1:
        # BASE CASE
        moveOneDisk(startTower, endTower) ❸
        printTowers()
        return
    else:
        # RECURSIVE CASE
        solve(numberOfDisks - 1, startTower, tempTower, endTower) ❹
        moveOneDisk(startTower, endTower) ❺
        printTowers()
        solve(numberOfDisks - 1, tempTower, endTower, startTower) ❻
        return

# Solve:
printTowers()
solve(TOTAL_DISKS, 'A', 'B', 'C')

# Uncomment to enable interactive mode:
#while True:
#    printTowers()
#    print('Enter letter of start tower and the end tower. (A, B, C) Or Q to quit.')
#    move = input().upper()
#    if move == 'Q':
#        sys.exit()
#    elif move[0] in 'ABC' and move[1] in 'ABC' and move[0] != move[1]:
#        moveOneDisk(move[0], move[1])

这个towerOfHanoiSolver.html程序包含了等效的 JavaScript 代码:

<script type="text/javascript">
// Set up towers A, B, and C. The end of the array is the top of the tower.
  var TOTAL_DISKS = 6; // ❶
  var TOWERS = {"A": [], // ❷
              "B": [],
              "C": []};

// Populate Tower A:
for (var i = TOTAL_DISKS; i > 0; i--) {
    TOWERS["A"].push(i);
}

function printDisk(diskNum) {
    // Print a single disk of width diskNum.
    var emptySpace = " ".repeat(TOTAL_DISKS - diskNum);
    if (diskNum === 0) {
        // Just draw the pole.
        document.write(emptySpace + "||" + emptySpace);
    } else {
        // Draw the disk.
        var diskSpace = "@".repeat(diskNum);
        var diskNumLabel = String("___" + diskNum).slice(-2);
        document.write(emptySpace + diskSpace + diskNumLabel + diskSpace + emptySpace);
    }
}

function printTowers() {
    // Print all three towers.
    var towerLetters = "ABC";
    for (var level = TOTAL_DISKS; level >= 0; level--) {
        for (var towerLetterIndex = 0; towerLetterIndex < 3; towerLetterIndex++) {
            var tower = TOWERS[towerLetters[towerLetterIndex]];
            if (level >= tower.length) {
                printDisk(0);
            } else {
                printDisk(tower[level]);
            }
        }
        document.write("<br />");
    }
    // Print the tower labels A, B, and C.
    var emptySpace = " ".repeat(TOTAL_DISKS);
    document.write(emptySpace + " A" + emptySpace + emptySpace +
" B" + emptySpace + emptySpace + " C<br /><br />");
}

function moveOneDisk(startTower, endTower) {
    // Move the top disk from startTower to endTower.
    var disk = TOWERS[startTower].pop();
    TOWERS[endTower].push(disk);
}

function solve(numberOfDisks, startTower, endTower, tempTower) {
    // Move the top numberOfDisks disks from startTower to endTower.
    if (numberOfDisks == 1) {
        // BASE CASE
        moveOneDisk(startTower, endTower); ❸
        printTowers();
        return;
    } else {
        // RECURSIVE CASE
        solve(numberOfDisks - 1, startTower, tempTower, endTower); ❹
 moveOneDisk(startTower, endTower); ❺
        printTowers();
        solve(numberOfDisks - 1, tempTower, endTower, startTower); ❻
        return;
    }
}

// Solve:
document.write("<pre>");
printTowers();
solve(TOTAL_DISKS, "A", "B", "C");
document.write("</pre>");
</script>

当您运行此代码时,输出显示了每个盘子的移动,直到整个塔从 A 塔移动到 B 塔为止:

      ||            ||            ||      
     @_1@           ||            ||      
    @@_2@@          ||            ||      
   @@@_3@@@         ||            ||      
  @@@@_4@@@@        ||            ||      
 @@@@@_5@@@@@       ||            ||      
@@@@@@_6@@@@@@      ||            ||      
       A             B             C

      ||            ||            ||      
      ||            ||            ||      
    @@_2@@          ||            ||      
   @@@_3@@@         ||            ||      
  @@@@_4@@@@        ||            ||      
 @@@@@_5@@@@@       ||            ||      
@@@@@@_6@@@@@@      ||           @_1@     
       A             B             C
--snip--
      ||            ||            ||      
      ||            ||            ||      
      ||            ||            ||      
      ||            ||            ||      
      ||          @@_2@@          ||      
     @_1@        @@@_3@@@         ||      
@@@@@@_6@@@@@@  @@@@_4@@@@   @@@@@_5@@@@@ 
--snip--
       A             B             C
      ||            ||            ||      
      ||           @_1@           ||      
      ||          @@_2@@          ||      
      ||         @@@_3@@@         ||      
      ||        @@@@_4@@@@        ||      
      ||       @@@@@_5@@@@@       ||      
      ||      @@@@@@_6@@@@@@      ||      
       A             B             C

Python 版本也有交互模式,您可以在towerOfHanoiSolver.py的末尾取消注释代码行以玩交互版本。

您可以通过将程序顶部的TOTAL_DISKS常量❶设置为12来从较小的情况开始运行程序。在我们的程序中,Python 中的整数列表和 JavaScript 中的整数数组表示一个柱子。整数表示一个盘子,较大的整数表示较大的盘子。列表或数组的起始整数在柱子的底部,结束整数在柱子的顶部。例如,[6, 5, 4, 3, 2, 1]表示具有六个盘子的起始柱子,最大的盘子在底部,而[]表示没有盘子的柱子。TOWERS变量包含这三个列表❷。

基本情况只是将最小的盘子从起始柱移动到结束柱❸。n个盘子的递归情况执行三个步骤:解决n - 1 的情况❹,移动第n个盘子❺,然后再次解决n - 1 的情况❻。

使用泛洪填充

图形程序通常使用泛洪填充算法来填充任意形状的相同颜色区域为另一种颜色。图 3-6 显示了左上角的一个这样的形状。随后的面板显示了用灰色填充的形状的三个不同部分。泛洪填充从一个白色像素开始,一直扩散,直到遇到非白色像素,填充封闭空间。

泛洪填充算法是递归的:它从将单个像素更改为新颜色开始。然后在具有相同旧颜色的像素的任何邻居上调用递归函数。然后移动到邻居的邻居,依此类推,将每个像素转换为新颜色,直到填充封闭空间。

基本情况是像素的颜色是图像的边缘,或者不是旧颜色。由于达到基本情况是停止图像中每个像素的递归调用“传播”的唯一方法,因此该算法具有将所有连续像素从旧颜色更改为新颜色的紧急行为。

让我们问一下关于floodFill()函数的三个递归算法问题:

  1. 什么是基本情况?当 x 和 y 坐标是不是旧颜色的像素,或者在图像的边缘时。

  2. 递归函数调用传递了哪些参数?当前像素的四个相邻像素的 x 和 y 坐标是四个递归调用的参数。

  3. 这些参数如何接近基本情况?相邻像素的颜色与旧颜色或图像边缘不同。无论哪种情况,最终算法都会用完要检查的像素。

MS Paint 窗口的四个截图,包含相同的抽象、曲折形状。每个截图显示了不同的绘图封闭部分,颜色为灰色。

图 3-6:图形编辑器中的原始形状(左上角)和填充了三个不同区域的相同形状,颜色为浅灰色

我们的示例程序不是图像,而是使用单字符字符串列表来形成文本字符的 2D 网格,以表示“图像”。每个字符串代表一个“像素”,特定字符代表“颜色”。floodfill.py Python 程序实现了泛洪填充算法、图像数据和一个在屏幕上打印图像的函数:

Python

import sys

# Create the image (make sure it's rectangular!)
im = [list('..########################...........'), # ❶
      list('..#......................#...#####...'),
      list('..#..........########....#####...#...'),
      list('..#..........#......#............#...'),
      list('..#..........########.........####...'),
      list('..######......................#......'),
      list('.......#..#####.....###########......'),
      list('.......####...#######................')]

HEIGHT = len(im)
WIDTH = len(im[0])

def floodFill(image, x, y, newChar, oldChar=None):
    if oldChar == None:
        # oldChar defaults to the character at x, y.
        oldChar = image[y][x] # ❷
    if oldChar == newChar or image[y][x] != oldChar:
        # BASE CASE
        return

    image[y][x] = newChar # Change the character.

    # Uncomment to view each step:
    #printImage(image)

    # Change the neighboring characters.
    if y + 1 < HEIGHT and image[y + 1][x] == oldChar:
        # RECURSIVE CASE
        floodFill(image, x, y + 1, newChar, oldChar) # ❸
    if y - 1 >= 0 and image[y - 1][x] == oldChar:
        # RECURSIVE CASE
        floodFill(image, x, y - 1, newChar, oldChar) # ❹
    if x + 1 < WIDTH and image[y][x + 1] == oldChar:
        # RECURSIVE CASE
        floodFill(image, x + 1, y, newChar, oldChar) # ❺
    if x - 1 >= 0 and image[y][x - 1] == oldChar:
        # RECURSIVE CASE
        floodFill(image, x - 1, y, newChar, oldChar) # ❻
    return # BASE CASE # ❼

def printImage(image):
    for y in range(HEIGHT):
        # Print each row.
        for x in range(WIDTH):
            # Print each column.
            sys.stdout.write(image[y][x])
        sys.stdout.write('\n')
    sys.stdout.write('\n')

printImage(im)
floodFill(im, 3, 3, 'o')
printImage(im)

floodfill.html程序包含了等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
// Create the image (make sure it's rectangular!)
var im = ["..########################...........".split(""), // ❶
          "..#......................#...#####...".split(""),
          "..#..........########....#####...#...".split(""),
          "..#..........#......#............#...".split(""),
          "..#..........########.........####...".split(""),
          "..######......................#......".split(""),
          ".......#..#####.....###########......".split(""),
          ".......####...#######................".split("")];

var HEIGHT = im.length;
var WIDTH = im[0].length;

function floodFill(image, x, y, newChar, oldChar) {
    if (oldChar === undefined) {
        // oldChar defaults to the character at x, y.
        oldChar = image[y][x]; // ❷
    }
    if ((oldChar == newChar) || (image[y][x] != oldChar)) {
        // BASE CASE
        return;
    }

    image[y][x] = newChar; // Change the character.

    // Uncomment to view each step:
    //printImage(image);

    // Change the neighboring characters.
    if ((y + 1 < HEIGHT) && (image[y + 1][x] == oldChar)) {
        // RECURSIVE CASE
        floodFill(image, x, y + 1, newChar, oldChar); // ❸
    }
    if ((y - 1 >= 0) && (image[y - 1][x] == oldChar)) {
        // RECURSIVE CASE
        floodFill(image, x, y - 1, newChar, oldChar); // ❹
    }
    if ((x + 1 < WIDTH) && (image[y][x + 1] == oldChar)) {
        // RECURSIVE CASE
        floodFill(image, x + 1, y, newChar, oldChar); // ❺
    }
    if ((x - 1 >= 0) && (image[y][x - 1] == oldChar)) {
        // RECURSIVE CASE
        floodFill(image, x - 1, y, newChar, oldChar); // ❻
    }
    return; // BASE CASE # ❼
}

function printImage(image) {
    document.write("<pre>");
    for (var y = 0; y < HEIGHT; y++) {
        // Print each row.
        for (var x = 0; x < WIDTH; x++) {
            // Print each column.
            document.write(image[y][x]);
        }
        document.write("\n");
    }
    document.write("\n</ pre>");
}

printImage(im);
floodFill(im, 3, 3, "o");
printImage(im);
</script>

当运行此代码时,程序从坐标 3,3 开始填充由#字符绘制的形状的内部。它用o字符替换所有句号字符(.)。以下输出显示了之前和之后的图像:

..########################...........
..#......................#...#####...
..#..........########....#####...#...
..#..........#......#............#...
..#..........########.........####...
..######......................#......
.......#..#####.....###########......
.......####...#######................

..########################...........
..#oooooooooooooooooooooo#...#####...
..#oooooooooo########oooo#####ooo#...
..#oooooooooo#......#oooooooooooo#...
..#oooooooooo########ooooooooo####...
..######oooooooooooooooooooooo#......
.......#oo#####ooooo###########......
.......####...#######................

如果要查看泛洪填充算法在填充新字符时的每一步,请取消注释floodFill()函数中的printImage(image)行❶,然后再次运行程序。

图像由一个字符串字符的 2D 数组表示。我们可以将这个image数据结构、一个x坐标和一个y坐标以及一个新字符传递给floodFill()函数。函数会注意当前在xy坐标处的字符,并将其保存到oldChar变量中❷。

如果image中坐标xy处的当前字符与oldChar不同,这是我们的基本情况,函数就简单地返回。否则,函数继续进行四个递归情况:传递当前坐标的底部❸、顶部❹、右侧❺和左侧❻邻居的 x 和 y 坐标。在进行了这四个潜在的递归调用之后,函数的结尾是一个隐式的基本情况,在我们的程序中通过return语句❼明确表示。

泛洪填充算法不一定要是递归的。对于大图像,递归函数可能会导致堆栈溢出。如果我们使用循环和堆栈来实现泛洪填充,堆栈将以起始像素的 x 和 y 坐标开始。循环中的代码将弹出堆栈顶部的坐标,如果该坐标的像素与oldChar匹配,它将推送四个相邻像素的坐标。当堆栈为空时,因为基本情况不再将邻居推送到堆栈中,循环就结束了。

然而,泛洪填充算法不一定要使用堆栈。先进后出堆栈的推送和弹出对于回溯行为是有效的,但在泛洪填充算法中处理像素的顺序可以是任意的。这意味着我们同样可以有效地使用一个随机删除元素的集合数据结构。你可以在nostarch.com/recursive-book-recursion的可下载资源中找到这些迭代泛洪填充算法的实现,分别是 floodFillIterative.pyfloodFillIterative.html

使用 Ackermann 函数

Ackermann 函数 是以其发现者威廉·阿克曼命名的。数学家大卫·希尔伯特的学生(我们在第九章讨论的希尔伯特曲线分形),阿克曼于 1928 年发表了他的函数。数学家罗莎·彼得和拉斐尔·罗宾逊后来开发了本节中所介绍的函数的版本。

虽然 Ackermann 函数在高等数学中有一些应用,但它主要以高度递归函数的例子而闻名。即使是对其两个整数参数的轻微增加也会导致其递归调用次数大幅增加。

Ackermann 函数接受两个参数 mn,并且当 m0 时有一个基本情况,返回 n + 1。有两种递归情况:当 n0 时,函数返回 ackermann(m - 1, 1),当 n 大于 0 时,函数返回 ackermann(m - 1, ackermann(m, n - 1))。这些情况可能对你来说没有意义,但可以说,Ackermann 函数的递归调用次数增长得很快。调用 ackermann(1, 1) 会导致三次递归函数调用。调用 ackermann(2, 3) 会导致 43 次递归函数调用。调用 ackermann(3, 5) 会导致 42,437 次递归函数调用。调用 ackermann(5, 7) 会导致... 好吧,实际上我不知道有多少次递归函数调用,因为这将需要计算几倍于宇宙年龄的时间。

让我们回答构建递归算法时提出的三个问题:

  1. 什么是基本情况?当 m0 时。

  2. 递归函数调用传递了什么参数?下一个 m 参数传递了要么 m 要么 m - 1;下一个 n 参数传递了 1n - 1ackermann(m, n - 1) 的返回值。

  3. 这些参数如何接近基本情况?m 参数总是要么减小,要么保持相同的大小,所以它最终会达到 0

以下是一个 ackermann.py Python 程序:

def ackermann(m, n, indentation=None):
    if indentation is None:
        indentation = 0
    print('%sackermann(%s, %s)' % (' ' * indentation, m, n))

    if m == 0:
        # BASE CASE
        return n + 1
    elif m > 0 and n == 0:
        # RECURSIVE CASE
 return ackermann(m - 1, 1, indentation + 1)
    elif m > 0 and n > 0:
        # RECURSIVE CASE
        return ackermann(m - 1, ackermann(m, n - 1, indentation + 1), indentation + 1)

print('Starting with m = 1, n = 1:')
print(ackermann(1, 1))
print('Starting with m = 2, n = 3:')
print(ackermann(2, 3))

以下是等效的 ackermann.html JavaScript 程序:

<script type="text/javascript">
function ackermann(m, n, indentation) {
    if (indentation === undefined) {
        indentation = 0;
    }
    document.write(" ".repeat(indentation) + "ackermann(" + m + ", " + n + ")\n");

    if (m === 0) {
        // BASE CASE
        return n + 1;
    } else if ((m > 0) && (n === 0)) {
        // RECURSIVE CASE
        return ackermann(m - 1, 1, indentation + 1);
    } else if ((m > 0) && (n > 0)) {
        // RECURSIVE CASE
        return ackermann(m - 1, ackermann(m, n - 1, indentation + 1), indentation + 1);
    }
}

document.write("<pre>");
document.write("Starting with m = 1, n = 1:<br />");
document.write(ackermann(1, 1) + "<br />");
document.write("Starting with m = 2, n = 3:<br />");
document.write(ackermann(2, 3) + "<br />");
document.write("</pre>");
</script>

当你运行这段代码时,输出的缩进(由 indentation 参数设置)告诉你给定递归函数调用在调用堆栈上的深度:

Starting with m = 1, n = 1:
ackermann(1, 1)
 ackermann(1, 0)
  ackermann(0, 1)
 ackermann(0, 2)
3
Starting with m = 2, n = 3:
ackermann(2, 3)
 ackermann(2, 2)
  ackermann(2, 1)
   ackermann(2, 0)
--snip--
    ackermann(0, 6)
   ackermann(0, 7)
  ackermann(0, 8)
9

你也可以尝试 ackermann(3, 3),但任何更大的参数可能需要太长时间来计算。为了加快计算速度,尝试注释掉除了打印 ackermann() 的最终返回值之外的所有 print()document.write() 调用。

请记住,即使像 Ackermann 函数这样的递归算法也可以作为迭代函数实现。迭代 Ackermann 算法在nostarch.com/recursive-book-recursion的可下载资源中实现为 ackermannIterative.pyackermannIterative.html

摘要

本章涵盖了一些经典的递归算法。对于每一个,我们都提出了三个重要的问题,你在设计自己的递归函数时应该总是问的:什么是基本情况?递归函数调用传递了什么参数?这些参数如何接近基本情况?如果它们没有,你的函数将继续递归,直到导致堆栈溢出。

求和、字符串反转和回文检测递归函数都可以很容易地用简单的循环实现。关键的线索是它们都只对给定的数据进行一次遍历,没有回溯。正如第二章所解释的,递归算法特别适用于涉及类似树状结构并需要回溯的问题。

解决汉诺塔难题的树状结构表明它涉及回溯,因为程序执行从树的顶部到底部,从左到右运行。这使得它成为递归的一个主要候选者,特别是因为解决方案需要对较小的塔进行两次递归调用。

洪水填充算法直接适用于图形和绘图程序,以及检测连续区域形状的其他算法。如果你在图形程序中使用了油漆桶工具,你可能使用了洪水填充算法的一个版本。

阿克曼函数是递归函数在输入增加时增长速度之快的一个很好的例子。虽然它在日常编程中没有太多实际应用,但没有讨论递归的讨论是不完整的。但是,就像所有递归函数一样,它可以用循环和栈来实现。

进一步阅读

维基百科上有更多关于汉诺塔问题的信息,网址为en.wikipedia.org/wiki/Tower_of_Hanoi,而 Computerphile 的视频“Recursion 'Super Power' (in Python)”则介绍了如何在 Python 中解决汉诺塔问题,网址为youtu.be/8lhxIOAfDss。3Blue1Brown 的两部视频系列“Binary, Hanoi, and Sierpiński”通过探索汉诺塔、二进制数和谢尔宾斯基三角形分形之间的关系,提供了更详细的信息,网址为youtu.be/2SUvWfNJSsM

维基百科上有一个关于洪水填充算法在小图像上运行的动画,网址为en.wikipedia.org/wiki/Flood_fill

Computerphile 的视频“The Most Difficult Program to Compute?”讨论了阿克曼函数,网址为youtu.be/i7sm9dzFtEI。如果你想了解更多关于阿克曼函数在可计算性理论中的地位,Hackers in Cambridge 频道有一个关于原始递归和部分递归函数的五部视频系列,网址为youtu.be/yaDQrOUK-KY。该系列需要观众进行大量的数学思考,但你不需要太多的先前数学知识。

练习问题

通过回答以下问题来测试你的理解:

  1. 数组或字符串的头部是什么?

  2. 数组或字符串的尾部是什么?

  3. 本章对每个递归算法提出了哪三个问题?

  4. 递归中的信任飞跃是什么?

  5. 你在进行递归函数编写之前需要了解什么才能做出信任的飞跃?

  6. 线性数据结构(如数组或字符串)如何类似于树状结构?

  7. 递归的sum()函数是否涉及对其处理的数据的回溯?

  8. 在洪水填充程序中,尝试更改im变量的字符串,创建一个未完全封闭的C形状。当你尝试从C的中间进行洪水填充时会发生什么?

  9. 回答本章中每个递归算法的三个问题:

  10. 基本情况是什么?

  11. 递归函数调用传递了什么参数?

  12. 这个论点如何接近基本情况?

然后重新创建本章的递归算法,而不查看原始代码。

实践项目

为以下每个任务编写一个函数:

  1. 使用头尾技术,创建一个递归的concat()函数,该函数接受一个字符串数组,并将这些字符串连接成一个字符串返回。例如,concat(['Hello', 'World'])应返回HelloWorld

  2. 使用头尾技术,创建一个递归的product()函数,该函数接受一个整数数组,并返回它们的总乘积。这段代码几乎与本章中的sum()函数相同。但是,请注意,只有一个整数的数组的基本情况返回整数,空数组的基本情况返回1

  3. 使用泛洪填充算法,计算二维网格中的“房间”或封闭空间的数量。您可以通过创建嵌套的for循环,在网格中的每个字符上调用泛洪填充函数(如果是句点),以将句点更改为井字符。例如,以下数据将导致程序在网格中找到六个句点的位置,这意味着有五个房间(以及所有房间之外的空间)。

    ...##########....................................
    ...#........#....####..................##########
    ...#........#....#..#...############...#........#
    ...##########....#..#...#..........#...##.......#
    .......#....#....####...#..........#....##......#
    .......#....#....#......############.....##.....#
    .......######....#........................##....#
    .................####........####..........######

四、回溯和树遍历算法

原文:Chapter 4 - Backtracking and Tree Traversal Algorithms

译者:飞龙

协议:CC BY-NC-SA 4.0

在前几章中,您已经了解到递归特别适用于涉及树状结构和回溯的问题,例如解迷宫算法。要了解原因,请考虑树干分成多个分支。这些分支本身又分成其他分支。换句话说,树具有递归的、自相似的形状。

迷宫可以用树数据结构表示,因为迷宫分支成不同的路径,这些路径又分成更多的路径。当您到达迷宫的死胡同时,必须回溯到较早的分叉点。

遍历树图的任务与许多递归算法紧密相关,例如本章中的解迷宫算法和第十一章中的迷宫生成程序。我们将研究树遍历算法,并使用它们来在树数据结构中查找特定名称。我们还将使用树遍历算法来获取树中最深的节点的算法。最后,我们将看到迷宫可以表示为树数据结构,并使用树遍历和回溯来找到从迷宫起点到出口的路径。

使用树遍历

如果您在 Python 和 JavaScript 中编程,通常会使用列表、数组和字典数据结构。只有在处理特定计算机科学算法的低级细节时,才会遇到树数据结构,例如抽象语法树、优先队列、Adelson-Velsky-Landis(AVL)树等概念,超出了本书的范围。但是,树本身是非常简单的概念。

数据结构是由节点组成的数据结构,这些节点通过边连接到其他节点。节点包含数据,而表示与另一个节点的关系。节点也称为顶点。树的起始节点称为,末端的节点称为叶子。树始终只有一个根。

顶部的父节点与它们下面的零个或多个子节点之间有边。因此,叶子是没有子节点的节点,父节点是非叶节点,子节点是所有非根节点。树中的节点可以有多个子节点。将子节点连接到根节点的父节点也称为子节点的祖先。父节点和叶节点之间的子节点称为父节点的后代。树中的父节点可以有多个子节点。但是,除了根节点外,每个子节点都只有一个父节点。在树中,任何两个节点之间只能存在一条路径。

图 4-1 显示了一棵树的示例,以及三个不是树的结构示例。

四个图。第一个标有“树”的图有一个 A 节点,有两个子节点 B 和 C;B 有一个子节点 D;C 有两个子节点 E 和 F;E 有两个子节点 G 和 H。第二个图标有“不是树(子节点有多个父节点)”,有一个 A 节点,有两个子节点 B 和 C;B 有两个子节点 D 和 E;C 有两个子节点 E 和 F;E 有两个子节点 G 和 H。第三个图标有“不是树(子节点循环到祖先节点)”,有一个 A 节点,有两个子节点 B 和 C;B 有一个子节点 D;C 有两个子节点 E 和 F;D 有一个子节点 A;E 有两个子节点 G 和 H。第四个图标有“不是树(多个根节点)”,有两个根节点 Z 和 A;Z 有一个子节点 B;A 有两个子节点 B 和 C;B 有一个子节点 D;C 有两个子节点 E 和 F;E 有两个子节点 G 和 H。

图 4-1:一棵树(左)和三个非树的示例

正如你所看到的,子节点必须有一个父节点,不能有创建循环的边,否则该结构将不再被视为树。我们在本章中涵盖的递归算法仅适用于树数据结构。

Python 和 JavaScript 中的树形数据结构

树形数据结构通常向下生长,根在顶部。图 4-2 显示了使用以下 Python 代码(也是有效的 JavaScript 代码)创建的树:

root  = {'data': 'A', 'children': []}
node2 = {'data': 'B', 'children': []}
node3 = {'data': 'C', 'children': []}
node4 = {'data': 'D', 'children': []}
node5 = {'data': 'E', 'children': []}
node6 = {'data': 'F', 'children': []}
node7 = {'data': 'G', 'children': []}
node8 = {'data': 'H', 'children': []}
root['children'] = [node2, node3]
node2['children'] = [node4]
node3['children'] = [node5, node6]
node5['children'] = [node7, node8]

树形图和节点的先序、后序和中序遍历顺序。树的根节点是 A,有两个子节点 B 和 C。B 有一个子节点 D。C 有两个子节点 E 和 F,E 有两个子节点 G 和 H。先序遍历:A,B,D,C,E,G,H,F。后序遍历:D,B,G,H,E,F,C,A。中序遍历:D,B,A,G,E,H,C,F。

图 4-2:根为A,叶为DGHF的树,以及其遍历顺序

树中的每个节点包含一段数据(从AH的字母字符串)和其子节点的列表。图 4-2 中的先序、后序和中序信息将在后续章节中解释。

在这棵树的代码中,每个节点由一个 Python 字典(或 JavaScript 对象)表示,其中键data存储节点的数据,键children有其他节点的列表。我使用rootnode2node8变量来存储每个节点,并使代码更易读,但这不是必需的。以下 Python/JavaScript 代码等同于前面的代码清单,尽管对人类来说更难阅读:

root = {'data': 'A', 'children': [{'data': 'B', 'children': 
[{'data': 'D', 'children': []}]}, {'data': 'C', 'children': 
[{'data': 'E', 'children': [{'data': 'G', 'children': []}, 
{'data': 'H', 'children': []}]}, {'data': 'F', 'children': []}]}]}

图 4-2 中的树是一种特定类型的数据结构,称为有向无环图(DAG)。在数学和计算机科学中,是节点和边的集合,树是图的一种。图是有向的,因为其边有一个方向:从父节点到子节点。DAG 中的边不是无向的,即双向的。(一般树没有这个限制,可以有双向的边,包括从子节点返回到父节点。)图是无环的,因为没有从子节点到其祖先节点的循环,或循环;树的“分支”必须保持在同一方向上不断增长。

您可以将列表、数组和字符串视为线性树;根是第一个元素,节点只有一个子节点。这种线性树在其一个叶节点处终止。这些线性树称为链表,因为每个节点只有一个“下一个”节点,直到列表的末尾。图 4-3 显示了存储单词HELLO中字符的链表。

线性树图有五个节点。根节点“H”有一个子节点“E”,它有一个子节点“L”,它有一个子节点“L”,它有一个子节点“O”。

图 4-3:存储HELLO的链表数据结构。链表可以被认为是一种树数据结构。

我们将使用图 4-2 中的树代码作为本章的示例。树遍历算法将通过跟随边访问树中的每个节点,从根节点开始。

遍历树

我们可以编写代码从root中的根节点开始访问任何节点的数据。例如,在将树代码输入 Python 或 JavaScript 交互式 shell 后,运行以下命令:

>>> root['children'][1]['data']
'C'
>>> root['children'][1]['children'][0]['data']
'E'

我们的树遍历代码可以写成一个递归函数,因为树数据结构具有自相似的结构:父节点有子节点,每个子节点都是其自己子节点的父节点。树遍历算法确保您的程序可以访问或修改树中每个节点的数据,无论其形状或大小如何。

让我们针对树遍历代码提出三个关于递归算法的问题:

  1. 什么是基本情况?叶节点,它没有更多的子节点,也不需要更多的递归调用,导致算法回溯到先前的父节点。

  2. 传递给递归函数调用的参数是什么?要遍历的节点,其子节点将是下一个要遍历的节点。

  3. 这个参数如何变得更接近基本情况?DAG 中没有循环,因此遵循后代节点将始终最终到达叶节点。

请记住,特别深的树数据结构会导致堆栈溢出,因为算法遍历更深的节点。这是因为每个更深入树的层级都需要另一个函数调用,太多的函数调用而没有返回会导致堆栈溢出。然而,广泛、平衡良好的树不太可能会那么深。如果 1000 级深的树中的每个节点都有两个子节点,那么树将有大约 2¹⁰⁰⁰个节点。这比宇宙中的原子还多,而且您的树数据结构不太可能那么大。

树有三种树遍历算法:先序、后序和中序。我们将在接下来的三个部分讨论每一个。

先序树遍历

先序树遍历算法在遍历子节点之前访问节点的数据。如果您的算法需要在访问子节点的数据之前访问父节点的数据,则使用先序遍历。例如,在创建树数据结构的副本时使用先序遍历,因为您需要在副本树中创建子节点之前创建父节点。

以下的preorderTraversal.py程序有一个preorderTraverse()函数,它在访问节点数据之前首先遍历每个子节点,然后将其打印到屏幕上:

Python

root = {'data': 'A', 'children': [{'data': 'B', 'children': 
[{'data': 'D', 'children': []}]}, {'data': 'C', 'children': 
[{'data': 'E', 'children': [{'data': 'G', 'children': []}, 
{'data': 'H', 'children': []}]}, {'data': 'F', 'children': []}]}]}

def preorderTraverse(node):
    print(node['data'], end=' ') # Access this node's data.
    if len(node['children']) > 0: # ❶
        # RECURSIVE CASE
        for child in node['children']:
            preorderTraverse(child) # Traverse child nodes.
    # BASE CASE
    return # ❷

preorderTraverse(root)

等效的 JavaScript 程序在preorderTraversal.html中:

JavaScript

<script type="text/javascript">
root = {"data": "A", "children": [{"data": "B", "children": 
[{"data": "D", "children": []}]}, {"data": "C", "children": 
[{"data": "E", "children": [{"data": "G", "children": []}, 
{"data": "H", "children": []}]}, {"data": "F", "children": []}]}]};

function preorderTraverse(node) {
    document.write(node["data"] + " "); // Access this node's data.
    if (node["children"].length > 0) { // ❶
        // RECURSIVE CASE
        for (let i = 0; i < node["children"].length; i++) {

 preorderTraverse(node["children"][i]); // Traverse child nodes.
        }
    }
    // BASE CASE
    return; // ❷
}

preorderTraverse(root);
</script>

这些程序的输出是按照先序顺序的节点数据:

A B D C E G H F

当您查看图 4-1 中的树时,请注意先序遍历顺序在显示右节点之前显示左节点,并且在显示顶部节点之前显示底部节点。

所有树遍历都是通过将根节点传递给递归函数开始的。该函数进行递归调用,并将每个根节点的子节点作为参数传递。由于这些子节点有自己的子节点,遍历将继续直到到达没有子节点的叶节点。在这一点上,函数调用简单地返回。

如果节点有任何子节点❶,则递归情况发生,在这种情况下,将使用每个子节点作为节点参数进行递归调用。无论节点是否有子节点,基本情况始终发生在函数结束时返回❷。

后序树遍历

后序树遍历在访问节点数据之前遍历节点的子节点。例如,在删除树并确保不通过首先删除其父节点而使子节点“孤立”来访问根节点的情况下使用此遍历。以下 postorderTraversal.py 程序中的代码类似于前一节中的先序遍历代码,只是递归函数调用在 print()调用之前。

Python

root = {'data': 'A', 'children': [{'data': 'B', 'children': 
[{'data': 'D', 'children': []}]}, {'data': 'C', 'children': 
[{'data': 'E', 'children': [{'data': 'G', 'children': []}, 
{'data': 'H', 'children': []}]}, {'data': 'F', 'children': []}]}]}

def postorderTraverse(node):
    for child in node['children']:
        # RECURSIVE CASE
        postorderTraverse(child) # Traverse child nodes.
    print(node['data'], end=' ') # Access this node's data.
    # BASE CASE
    return

postorderTraverse(root)

postorderTraversal.html 程序包含等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
root = {"data": "A", "children": [{"data": "B", "children": 
[{"data": "D", "children": []}]}, {"data": "C", "children": 
[{"data": "E", "children": [{"data": "G", "children": []}, 
{"data": "H", "children": []}]}, {"data": "F", "children": []}]}]};

function postorderTraverse(node) {
    for (let i = 0; i < node["children"].length; i++) {
        // RECURSIVE CASE
        postorderTraverse(node["children"][i]); // Traverse child nodes.
    }
    document.write(node["data"] + " "); // Access this node's data.
    // BASE CASE
    return;
}

postorderTraverse(root);
</script>

这些程序的输出是节点数据按后序顺序排列:

D B G H E F C A

节点的后序遍历顺序显示左节点的数据在右节点之前,底部节点在顶部节点之前。当我们比较 postorderTraverse()和 preorderTraverse()函数时,我们发现名称有点不准确:pre 和 post 不是指节点被访问的顺序。节点总是以相同的顺序遍历;我们首先遍历子节点(称为深度优先搜索),而不是在深入之前访问每个级别的节点(称为广度优先搜索)。pre 和 post 指的是节点的数据何时被访问:在遍历节点的子节点之前或之后。

中序树遍历

二叉树是最多有两个子节点的树数据结构,通常称为左子节点和右子节点。中序树遍历遍历左子节点,然后访问节点数据,然后遍历右子节点。这种遍历在处理二叉搜索树的算法中使用(这超出了本书的范围)。inorderTraversal.py 程序包含执行这种遍历的 Python 代码:

Python

root = {'data': 'A', 'children': [{'data': 'B', 'children': 
[{'data': 'D', 'children': []}]}, {'data': 'C', 'children': 
[{'data': 'E', 'children': [{'data': 'G', 'children': []}, 
{'data': 'H', 'children': []}]}, {'data': 'F', 'children': []}]}]}

def inorderTraverse(node):
    if len(node['children']) >= 1:
        # RECURSIVE CASE
 inorderTraverse(node['children'][0]) # Traverse the left child.
    print(node['data'], end=' ') # Access this node's data.
    if len(node['children']) >= 2:
        # RECURSIVE CASE
        inorderTraverse(node['children'][1]) # Traverse the right child.
    # BASE CASE
    return

inorderTraverse(root)

inorderTraversal.html 程序包含等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
root = {"data": "A", "children": [{"data": "B", "children": 
[{"data": "D", "children": []}]}, {"data": "C", "children": 
[{"data": "E", "children": [{"data": "G", "children": []}, 
{"data": "H", "children": []}]}, {"data": "F", "children": []}]}]};

function inorderTraverse(node) {
    if (node["children"].length >= 1) {
        // RECURSIVE CASE
        inorderTraverse(node["children"][0]); // Traverse the left child.
    }
    document.write(node["data"] + " "); // Access this node's data.
    if (node["children"].length >= 2) {
        // RECURSIVE CASE
        inorderTraverse(node["children"][1]); // Traverse the right child.
    }
    // BASE CASE
    return;
}

inorderTraverse(root);
</script>

这些程序的输出如下:

D B A G E H C F

中序遍历通常指的是二叉树的遍历,尽管在遍历第一个节点之后和遍历最后一个节点之前处理节点数据将计为任何大小的树的中序遍历。

在树中查找八个字母的名称

我们可以使用深度优先搜索来查找树数据结构中的特定数据,而不是在遍历它们时打印出每个节点中的数据。我们将编写一个算法,用于在图 4-4 中搜索具有确切八个字母的名称的树。这是一个相当牵强的例子,但它展示了算法如何使用树遍历从树数据结构中检索数据。

具有根节点“Alice”的树图,其具有两个子节点“Bob”和“Caroline”。 “Bob”有一个子节点“Darya”。 “Caroline”有两个子节点“Eve”和“Fred”。 “Eve”有两个子节点“Gonzalo”和“Hadassah”。

图 4-4:存储在我们的 depthFirstSearch.py 和 depthFirstSearch.html 程序中的名称的树

让我们对我们的树遍历代码的递归算法提出三个问题。它们的答案类似于树遍历算法的答案:

  1. 基本情况是什么?要么是叶节点导致算法回溯,要么是包含八个字母名称的节点。

  2. 递归函数调用传递了什么参数?要遍历到的节点,其子节点将是下一个要遍历的节点。

  3. 这个参数如何接近基本情况?DAG 中没有循环,因此遵循后代节点将始终最终到达叶节点。

深度优先搜索.py 程序包含执行先序遍历的 Python 代码:

Python

root = {'name': 'Alice', 'children': [{'name': 'Bob', 'children': 
[{'name': 'Darya', 'children': []}]}, {'name': 'Caroline', 
'children': [{'name': 'Eve', 'children': [{'name': 'Gonzalo', 
'children': []}, {'name': 'Hadassah', 'children': []}]}, {'name': 'Fred', 'children': []}]}]}

def find8LetterName(node):
    print(' Visiting node ' + node['name'] + '...')

    # Preorder depth-first search:
    print('Checking if ' + node['name'] + ' is 8 letters...')
    if len(node['name']) == 8: return node['name'] # BASE CASE # ❶

    if len(node['children']) > 0:
        # RECURSIVE CASE
        for child in node['children']:
            returnValue = find8LetterName(child)
            if returnValue != None:
                return returnValue

 # Postorder depth-first search:
    #print('Checking if ' + node['name'] + ' is 8 letters...')
    #if len(node['name']) == 8: return node['name'] # BASE CASE # ❷

    # Value was not found or there are no children.
    return None # BASE CASE

print('Found an 8-letter name: ' + str(find8LetterName(root)))

depthFirstSearch.html 程序包含等效的 JavaScript 程序:

JavaScript

<script type="text/javascript">
root = {'name': 'Alice', 'children': [{'name': 'Bob', 'children': 
[{'name': 'Darya', 'children': []}]}, {'name': 'Caroline', 
'children': [{'name': 'Eve', 'children': [{'name': 'Gonzalo', 
'children': []}, {'name': 'Hadassah', 'children': []}]}, {'name': 'Fred', 'children': []}]}]};

function find8LetterName(node, value) {
    document.write("Visiting node " + node.name + "...<br />");

    // Preorder depth-first search:
    document.write("Checking if " + node.name + " is 8 letters...<br />");
    if (node.name.length === 8) return node.name; // BASE CASE // ❶

    if (node.children.length > 0) {
        // RECURSIVE CASE
        for (let child of node.children) {
            let returnValue = find8LetterName(child);
            if (returnValue != null) {
                return returnValue;
            }
        }
    }

    // Postorder depth-first search:
    document.write("Checking if " + node.name + " is 8 letters...<br />");
    //if (node.name.length === 8) return node.name; // BASE CASE // ❷

    // Value was not found or there are no children.
    return null; // BASE CASE
}

document.write("Found an 8-letter name: " + find8LetterName(root));
</script>

这些程序的输出如下:

Visiting node Alice...
Checking if Alice is 8 letters...
Visiting node Bob...
Checking if Bob is 8 letters...
Visiting node Darya...
Checking if Darya is 8 letters...
Visiting node Caroline...
Checking if Caroline is 8 letters...
Found an 8-letter name: Caroline

find8LetterName()函数的操作方式与我们先前的树遍历函数相同,只是不打印节点的数据,而是检查节点中存储的名称,并返回它找到的第一个八个字母的名称。您可以通过注释掉先前的名称长度比较和Checking if行❶,并取消注释后面的名称长度比较和Checking if行❷,将先序遍历更改为后序遍历。当您进行此更改时,函数找到的第一个八个字母的名称是Hadassah

Visiting node Alice...
Visiting node Bob...
Visiting node Darya...
Checking if Darya is 8 letters...
Checking if Bob is 8 letters...
Visiting node Caroline...
Visiting node Eve...
Visiting node Gonzalo...
Checking if Gonzalo is 8 letters...
Visiting node Hadassah...
Checking if Hadassah is 8 letters...
Found an 8-letter name: Hadassah

虽然两种遍历顺序都可以正确找到一个八个字母的名称,但更改树遍历的顺序可能会改变程序的行为。

获取最大树深度

算法可以通过递归询问其子节点有多深来确定树中最深的分支。节点的深度是它与根节点之间的边的数量。根节点本身的深度为 0,根节点的直接子节点的深度为 1,依此类推。您可能需要这些信息作为更大算法的一部分,或者为了收集关于树数据结构的一般大小的信息。

我们可以有一个名为getDepth()的函数,以一个节点作为参数,并返回其最深子节点的深度。叶节点(基本情况)只返回0

例如,给定图 4-1 中树的根节点,我们可以调用getDepth()并将其传递给根节点(A节点)。这将返回其子节点BC节点的深度,再加一。函数必须递归调用getDepth()来获取这些信息。最终,A节点将在C上调用getDepth(),它将在E上调用它。当E用其两个子节点GH调用getDepth()时,它们都返回0,因此在E上调用getDepth()返回1,使得在C上调用getDepth()返回2,并使在A(根节点)上调用getDepth()返回3。我们树的最大深度为三级。

让我们为getDepth()函数提出三个递归算法问题:

  1. 什么是基本情况?没有子节点的叶节点,其本质上具有一级深度。

  2. 递归函数调用传递了什么参数?我们想要找到最大深度的节点。

  3. 这个参数如何变得更接近基本情况?DAG 没有循环,因此跟随后代节点最终会到达一个叶节点。

以下getDepth.py程序包含了一个递归的getDepth()函数,返回树中最深节点包含的级数:

Python

root = {'data': 'A', 'children': [{'data': 'B', 'children': 
[{'data': 'D', 'children': []}]}, {'data': 'C', 'children': 
[{'data': 'E', 'children': [{'data': 'G', 'children': []}, 
{'data': 'H', 'children': []}]}, {'data': 'F', 'children': []}]}]}

def getDepth(node):
    if len(node['children']) == 0:
        # BASE CASE
        return 0
    else:
        # RECURSIVE CASE
        maxChildDepth = 0
        for child in node['children']:
            # Find the depth of each child node:
            childDepth = getDepth(child)
            if childDepth > maxChildDepth:
                # This child is deepest child node found so far:
                maxChildDepth = childDepth
        return maxChildDepth + 1

print('Depth of tree is ' + str(getDepth(root)))

getDepth.html程序包含了 JavaScript 等效代码:

JavaScript

<script type="text/javascript">
root = {"data": "A", "children": [{"data": "B", "children": 
[{"data": "D", "children": []}]}, {"data": "C", "children": 
[{"data": "E", "children": [{"data": "G", "children": []}, 
{"data": "H", "children": []}]}, {"data": "F", "children": []}]}]};

function getDepth(node) {
    if (node.children.length === 0) {
        // BASE CASE
        return 0;
    } else {
        // RECURSIVE CASE
        let maxChildDepth = 0;
        for (let child of node.children) {
            // Find the depth of each child node:
 let childDepth = getDepth(child);
            if (childDepth > maxChildDepth) {
                // This child is deepest child node found so far:
                maxChildDepth = childDepth;
            }
        }
        return maxChildDepth + 1;
    }
}

document.write("Depth of tree is " + getDepth(root) + "<br />");
</script>

这些程序的输出如下:

Depth of tree is 3

这与我们在图 4-2 中看到的相匹配:从根节点A到最低节点GH的级数是三级。

解决迷宫

虽然迷宫的形状和大小各不相同,简单连通迷宫,也称为完美迷宫,不包含循环。完美迷宫在任何两点之间都有且仅有一条路径,例如开始和出口。这些迷宫可以用 DAG 表示。

例如,图 4-5 显示了我们的迷宫程序解决的迷宫,图 4-6 显示了其 DAG 形式。大写的S标记着迷宫的开始,大写的E标记着出口。迷宫中标有小写字母的一些交叉点对应于 DAG 中的节点。

带有标有字母 s、d、b、a、c、f、e、g、i、j、h、k、n、m、l 和 e 的特定交叉点的迷宫。

图 4-5:本章中我们的迷宫程序解决的迷宫。一些交叉点有小写字母,对应于图 4-6 中的节点。

树图,迷宫中的每个交叉点都表示为一个节点。

图 4-6:在迷宫的 DAG 表示中,节点表示交叉点,边表示从交叉点到北、南、东或西的路径。一些节点具有小写字母,以对应图 4-5 中的交叉点。

由于这种结构上的相似性,我们可以使用树遍历算法来解决迷宫。这个树图中的节点表示迷宫解算器可以选择要遵循到下一个交叉点的北、南、东或西路径之一。根节点是迷宫的起点,叶节点表示死胡同。

递归情况发生在树遍历算法从一个节点移动到下一个节点时。如果树遍历到达叶节点(迷宫中的死胡同),算法已经达到了一个基本情况,并且必须回溯到较早的节点并跟随不同的路径。一旦算法到达出口节点,它从根节点到出口节点的路径代表了迷宫的解决方案。让我们问我们的三个递归算法关于解迷宫算法的问题:

  1. 什么是基本情况?到达死胡同或迷宫的出口。

  2. 递归函数调用传递了什么参数?x,y 坐标,迷宫数据以及已经访问过的 x,y 坐标的列表。

  3. 这个参数如何变得更接近基本情况?像泛洪填充算法一样,x,y 坐标不断移动到相邻的坐标,直到最终到达死胡同或最终出口。

这个mazeSolver.py程序包含了 Python 代码,用于解决存储在MAZE变量中的迷宫:

Python

# Create the maze data structure:
# You can copy-paste this from inventwithpython.com/examplemaze.txt
MAZE = """
#######################################################################
#S#                 #       # #   #     #         #     #   #         #
# ##### ######### # ### ### # # # # ### # # ##### # ### # # ##### # ###
# #   #     #     #     #   # # #   # #   # #       # # # #     # #   #
# # # ##### # ########### ### # ##### ##### ######### # # ##### ### # #
#   #     # # #     #   #   #   #         #       #   #   #   #   # # #
######### # # # ##### # ### # ########### ####### # # ##### ##### ### #
#       # # # #     # #     # #   #   #   #     # # #   #         #   #
# # ##### # # ### # # ####### # # # # # # # ##### ### ### ######### # #
# # #   # # #   # # #     #     #   #   #   #   #   #     #         # #
### # # # # ### # # ##### ####### ########### # ### # ##### ##### ### #
#   # #   # #   # #     #   #     #       #   #     # #     #     #   #
# ### ####### ##### ### ### ####### ##### # ######### ### ### ##### ###
#   #         #     #     #       #   # #   # #     #   # #   # #   # #
### ########### # ####### ####### ### # ##### # # ##### # # ### # ### #
#   #   #       # #     #   #   #     #       # # #     # # #   # #   #
# ### # # ####### # ### ##### # ####### ### ### # # ####### # # # ### #
#     #         #     #       #           #     #           # #      E#
#######################################################################
""".split('\n')

# Constants used in this program:
EMPTY = ' '
START = 'S'
EXIT = 'E'
PATH = '.'

# Get the height and width of the maze:
HEIGHT = len(MAZE)
WIDTH = 0
for row in MAZE: # Set WIDTH to the widest row's width.
    if len(row) > WIDTH:
        WIDTH = len(row)
# Make each row in the maze a list as wide as the WIDTH:
for i in range(len(MAZE)):
    MAZE[i] = list(MAZE[i])
    if len(MAZE[i]) != WIDTH:
        MAZE[i] = [EMPTY] * WIDTH # Make this a blank row.

def printMaze(maze):
    for y in range(HEIGHT):
        # Print each row.
        for x in range(WIDTH):
            # Print each column in this row.
            print(maze[y][x], end='')
        print() # Print a newline at the end of the row.
    print()

def findStart(maze):
    for x in range(WIDTH):
        for y in range(HEIGHT):
            if maze[y][x] == START:
                return (x, y) # Return the starting coordinates.

def solveMaze(maze, x=None, y=None, visited=None):
    if x == None or y == None:
        x, y = findStart(maze)
        maze[y][x] = EMPTY # Get rid of the 'S' from the maze.
    if visited == None:
        visited = [] # Create a new list of visited points.  # ❶

    if maze[y][x] == EXIT:
         return True # Found the exit, return True.

    maze[y][x] = PATH # Mark the path in the maze.
    visited.append(str(x) + ',' + str(y))  # ❷
    #printMaze(maze) # Uncomment to view each forward step. # ❸

    # Explore the north neighboring point:
    if y + 1 < HEIGHT and maze[y + 1][x] in (EMPTY, EXIT) and \
    str(x) + ',' + str(y + 1) not in visited:
        # RECURSIVE CASE
        if solveMaze(maze, x, y + 1, visited):
            return True # BASE CASE
    # Explore the south neighboring point:
    if y - 1 >= 0 and maze[y - 1][x] in (EMPTY, EXIT) and \
    str(x) + ',' + str(y - 1) not in visited:
        # RECURSIVE CASE
        if solveMaze(maze, x, y - 1, visited):
            return True # BASE CASE
 # Explore the east neighboring point:
    if x + 1 < WIDTH and maze[y][x + 1] in (EMPTY, EXIT) and \
    str(x + 1) + ',' + str(y) not in visited:
        # RECURSIVE CASE
        if solveMaze(maze, x + 1, y, visited):
            return True # BASE CASE
    # Explore the west neighboring point:
    if x - 1 >= 0 and maze[y][x - 1] in (EMPTY, EXIT) and \
    str(x - 1) + ',' + str(y) not in visited:
        # RECURSIVE CASE
        if solveMaze(maze, x - 1, y, visited):
            return True # BASE CASE

    maze[y][x] = EMPTY # Reset the empty space.
    #printMaze(maze) # Uncomment to view each backtrack step. # ❹

    return False # BASE CASE

printMaze(MAZE)
solveMaze(MAZE)
printMaze(MAZE)

mazeSolver.html程序包含了 JavaScript 等效代码:

JavaScript

<script type="text/javascript">
// Create the maze data structure:
// You can copy-paste this from inventwithpython.com/examplemaze.txt
let MAZE = `
#######################################################################
#S#                 #       # #   #     #         #     #   #         #
# ##### ######### # ### ### # # # # ### # # ##### # ### # # ##### # ###
# #   #     #     #     #   # # #   # #   # #       # # # #     # #   #
# # # ##### # ########### ### # ##### ##### ######### # # ##### ### # #
#   #     # # #     #   #   #   #         #       #   #   #   #   # # #
######### # # # ##### # ### # ########### ####### # # ##### ##### ### #
#       # # # #     # #     # #   #   #   #     # # #   #         #   #
# # ##### # # ### # # ####### # # # # # # # ##### ### ### ######### # #
# # #   # # #   # # #     #     #   #   #   #   #   #     #         # #
### # # # # ### # # ##### ####### ########### # ### # ##### ##### ### #
#   # #   # #   # #     #   #     #       #   #     # #     #     #   #
# ### ####### ##### ### ### ####### ##### # ######### ### ### ##### ###
#   #         #     #     #       #   # #   # #     #   # #   # #   # #
### ########### # ####### ####### ### # ##### # # ##### # # ### # ### #
#   #   #       # #     #   #   #     #       # # #     # # #   # #   #
# ### # # ####### # ### ##### # ####### ### ### # # ####### # # # ### #
#     #         #     #       #           #     #           # #      E#
#######################################################################
`.split("\n");

// Constants used in this program:
const EMPTY = " ";
const START = "S";
const EXIT = "E";
const PATH = ".";

// Get the height and width of the maze:
const HEIGHT = MAZE.length;
let maxWidthSoFar = MAZE[0].length;
for (let row of MAZE) { // Set WIDTH to the widest row's width.
    if (row.length > maxWidthSoFar) {
        maxWidthSoFar = row.length;
    }
}
const WIDTH = maxWidthSoFar;
// Make each row in the maze a list as wide as the WIDTH:
for (let i = 0; i < MAZE.length; i++) {
    MAZE[i] = MAZE[i].split("");
    if (MAZE[i].length !== WIDTH) {
        MAZE[i] = EMPTY.repeat(WIDTH).split(""); // Make this a blank row.
    }
}

function printMaze(maze) {
    document.write("<pre>");
    for (let y = 0; y < HEIGHT; y++) {
        // Print each row.
        for (let x = 0; x < WIDTH; x++) {
            // Print each column in this row.
            document.write(maze[y][x]);
        }
        document.write("\n"); // Print a newline at the end of the row.
    }
    document.write("\n</ pre>");
}

function findStart(maze) {
    for (let x = 0; x < WIDTH; x++) {
        for (let y = 0; y < HEIGHT; y++) {
            if (maze[y][x] === START) {
                return [x, y]; // Return the starting coordinates.
            }
        }
    }
}

function solveMaze(maze, x, y, visited) {
    if (x === undefined || y === undefined) {
        [x, y] = findStart(maze);
        maze[y][x] = EMPTY; // Get rid of the 'S' from the maze.
    }
    if (visited === undefined) {
        visited = []; // Create a new list of visited points. // ❶
    }

    if (maze[y][x] == EXIT) {
         return true; // Found the exit, return true.
    }

    maze[y][x] = PATH; // Mark the path in the maze.
    visited.push(String(x) + "," + String(y));  // ❷
   //printMaze(maze) // Uncomment to view each forward step. # ❸

    // Explore the north neighboring point:
    if ((y + 1 < HEIGHT) && ((maze[y + 1][x] == EMPTY) || 
    (maze[y + 1][x] == EXIT)) && 
    (visited.indexOf(String(x) + "," + String(y + 1)) === -1)) {
        // RECURSIVE CASE
        if (solveMaze(maze, x, y + 1, visited)) {
            return true; // BASE CASE
        }
    }
    // Explore the south neighboring point:
    if ((y - 1 >= 0) && ((maze[y - 1][x] == EMPTY) || 
    (maze[y - 1][x] == EXIT)) && 
    (visited.indexOf(String(x) + "," + String(y - 1)) === -1)) {
        // RECURSIVE CASE
        if (solveMaze(maze, x, y - 1, visited)) {
            return true; // BASE CASE
        }
    }
    // Explore the east neighboring point:
    if ((x + 1 < WIDTH) && ((maze[y][x + 1] == EMPTY) || 
    (maze[y][x + 1] == EXIT)) && 
    (visited.indexOf(String(x + 1) + "," + String(y)) === -1)) {
        // RECURSIVE CASE
        if (solveMaze(maze, x + 1, y, visited)) {
            return true; // BASE CASE
        }
    }
    // Explore the west neighboring point:
    if ((x - 1 >= 0) && ((maze[y][x - 1] == EMPTY) || 
    (maze[y][x - 1] == EXIT)) && 
    (visited.indexOf(String(x - 1) + "," + String(y)) === -1)) {
        // RECURSIVE CASE
        if (solveMaze(maze, x - 1, y, visited)) {
            return true; // BASE CASE
        }
    }

    maze[y][x] = EMPTY; // Reset the empty space.
    //printMaze(maze); // Uncomment to view each backtrack step. # ❹
    return false; // BASE CASE
}

printMaze(MAZE);
solveMaze(MAZE);
printMaze(MAZE);
</script>

这段代码与递归解迷宫算法无直接关系。MAZE变量将迷宫数据存储为多行字符串,其中井号表示墙壁,S表示起点,E表示出口。这个字符串被转换为一个包含字符串列表的列表,每个字符串表示迷宫中的一个单个字符。这使我们能够访问MAZE[y][x](注意y在前)以获取原始MAZE字符串中 x,y 坐标处的字符。printMaze()函数可以接受这个列表-列表数据结构并在屏幕上显示迷宫。findStart()函数接受这个数据结构并返回S起点的 x,y 坐标。随意编辑迷宫字符串——但请记住,为了使解决算法起作用,迷宫不能有任何循环。

递归算法在solveMaze()函数中。这个函数的参数是迷宫数据结构,当前的 x 和 y 坐标,以及一个visited列表(如果没有提供,则创建)❶。visited列表包含先前访问过的所有坐标,因此当算法从死胡同回溯到较早的交叉点时,它知道它以前尝试过哪些路径,并且可以尝试不同的路径。从起点到出口的路径通过用句点(来自PATH常量)替换迷宫数据结构中的空格(匹配EMPTY常量)来标记。

解迷宫算法类似于我们在第三章中的泛洪填充程序,它“扩散”到相邻的坐标,但当它到达死胡同时,它会回溯到较早的交叉点。solveMaze()函数接收指示算法当前位置的 x,y 坐标。如果这是出口,函数返回True,导致所有递归调用也返回True。迷宫数据结构保持标记为解决方案路径。

否则,算法会在迷宫数据结构中标记当前的 x,y 坐标,并将这些坐标添加到“visited”列表中❷。然后它会查看当前坐标北面的 x,y 坐标,看看那个点是否没有超出地图边缘,是空白或出口空间,并且以前没有被访问过。如果满足这些条件,算法将使用北面的坐标进行递归调用solveMaze()。如果不满足这些条件或递归调用solveMaze()返回False,算法将继续检查南、东和西坐标。与泛洪填充算法一样,使用相邻坐标进行递归调用。

要更好地了解这个算法的工作原理,请取消solveMaze()函数内的两个printMaze(MAZE)调用❸ ❹。这将显示迷宫数据结构在尝试新路径、到达死胡同、回溯和尝试不同路径时的情况。

总结

本章探讨了几种使用树数据结构和回溯的算法,这些算法是适合使用递归算法解决的问题的特点。我们介绍了树数据结构,它由包含数据的节点和将节点联系在一起的边组成,这些边以父-子关系相互关联。特别是,我们研究了一种特定类型的树,称为有向无环图(DAG),它经常在递归算法中使用。递归函数调用类似于在树中遍历到子节点,而从递归函数调用返回类似于回溯到以前的父节点。

虽然递归在简单的编程问题中被滥用,但它非常适合涉及类似树的结构和回溯的问题。利用这些类似树的结构的想法,我们编写了几个用于遍历、搜索和确定树结构深度的算法。我们还展示了一个简单连通的迷宫具有类似树的结构,并利用递归和回溯来解决迷宫。

进一步阅读

树和树遍历远不止本章中介绍的 DAG 的简要描述。维基百科的文章en.wikipedia.org/wiki/Tree_(data_structure)en.wikipedia.org/wiki/Tree_traversal为这些概念提供了额外的背景信息,这些概念在计算机科学中经常使用。

Computerphile YouTube 频道还有一个名为“Maze Solving”的视频,讨论了这些概念。V. Anton Spraul,Think Like a Programmer(No Starch Press,2012)的作者,还有一个名为“Backtracking”的迷宫解决视频,网址为youtu.be/gBC_Fd8EE8A。freeCodeCamp 组织(freeCodeCamp.org)在youtu.be/A80YzvNwqXA上有一个关于回溯算法的视频系列。

除了解迷宫外,递归回溯算法还使用递归生成迷宫。您可以在en.wikipedia.org/wiki/Maze_generation_algorithm#Recursive_backtracker找到更多关于这个和其他迷宫生成算法的信息。

练习问题

通过回答以下问题来测试您的理解能力:

  1. 节点和边是什么?

  2. 根节点和叶节点是什么?

  3. 树遍历有哪三种顺序?

  4. DAG代表什么?

  5. 什么是循环,DAG 有循环吗?

  6. 什么是二叉树?

  7. 二叉树中的子节点称为什么?

  8. 如果父节点有一条边指向子节点,并且子节点有一条边返回父节点,这个图被认为是 DAG 吗?

  9. 树遍历算法中的回溯是什么?

对于以下树遍历问题,您可以使用第四章“Python 和 JavaScript 中的树数据结构”中的 Python/JavaScript 代码作为您的树,以及mazeSolver.pymazeSolver.html程序中的多行MAZE字符串作为迷宫数据。

  1. 回答本章中每个递归算法的三个问题:

  2. 什么是基本情况?

  3. 递归函数调用传递了什么参数?

  4. 这个论点如何更接近基本情况?

然后,重新创建本章中的递归算法,而不看原始代码。

练习项目

练习时,为以下每个任务编写一个函数:

  1. 创建一个逆中序搜索,执行中序遍历,但在遍历左子节点之前遍历右子节点。

  2. 创建一个函数,给定一个根节点作为参数,通过向原始树的每个叶节点添加一个子节点,使树深度增加一级。这个函数需要执行树遍历,检测是否已经到达叶节点,然后向叶节点添加一个且仅一个子节点。确保不要继续向这个新叶节点添加子节点,因为这最终会导致堆栈溢出。