递归的递归之书:第十章到第十二章

121 阅读1小时+

十、文件查找器

原文:Chapter 10 - File Finder

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,你将编写自己的递归程序,根据自定义需求搜索文件。你的计算机已经有一些文件搜索命令和应用程序,但通常它们只能根据部分文件名检索文件。如果你需要进行奇特、高度特定的搜索怎么办?例如,如果你需要找到所有具有偶数字节的文件,或者文件名包含每个元音字母的文件?

你可能永远不需要专门进行这些搜索,但是你可能会有奇怪的搜索条件。如果你不能自己编写这个搜索,你就会很倒霉。

正如你所学到的,递归特别适用于具有树状结构的问题。你的计算机上的文件系统就像一棵树,就像你在图 2-6 中看到的那样。每个文件夹都分成子文件夹,这些子文件夹又可以分成其他子文件夹。我们将编写一个递归函数来遍历这棵树。

完整的文件搜索程序

让我们首先看一下递归文件搜索程序的完整源代码。本章的其余部分将逐个解释代码的每个部分。将文件搜索程序的源代码复制到名为fileFinder.py的文件中:

import os

def hasEvenByteSize(fullFilePath):
    """Returns True if fullFilePath has an even size in bytes,
    otherwise returns False."""
    fileSize = os.path.getsize(fullFilePath)
    return fileSize % 2 == 0

def hasEveryVowel(fullFilePath):
    """Returns True if the fullFilePath has a, e, i, o, and u,
    otherwise returns False."""
    name = os.path.basename(fullFilePath).lower()
    return ('a' in name) and ('e' in name) and ('i' in name) and ('o' in name) and ('u' in name)

def walk(folder, matchFunc):
    """Calls the match function with every file in the folder and its
    subfolders. Returns a list of files that the match function
    returned True for."""
    matchedFiles = [] # This list holds all the matches.
    folder = os.path.abspath(folder) # Use the folder's absolute path.

    # Loop over every file and subfolder in the folder:
    for name in os.listdir(folder):
        filepath = os.path.join(folder, name)
        if os.path.isfile(filepath):
            # Call the match function for each file:
            if matchFunc(filepath):
                matchedFiles.append(filepath)
        elif os.path.isdir(filepath):
            # Recursively call walk for each subfolder, extending
            # the matchedFiles with their matches:
            matchedFiles.extend(walk(filepath, matchFunc))
    return matchedFiles

print('All files with even byte sizes:')
print(walk('.', hasEvenByteSize))
print('All files with every vowel in their name:')
print(walk('.', hasEveryVowel))

文件搜索程序的主要函数是walk(),它在基本文件夹及其子文件夹中“遍历”整个文件范围。它调用另外两个实现自定义搜索条件的函数中的一个。在这个程序的上下文中,我们将这些称为匹配函数。匹配函数调用返回True,如果文件符合搜索条件;否则,返回False

walk()函数的工作是为它遍历的每个文件夹中的每个文件调用匹配函数。让我们更详细地看一下代码。

匹配函数

在 Python 中,你可以将函数本身作为参数传递给函数调用。在下面的示例中,callTwice()函数调用其函数参数两次,无论是sayHello()还是sayGoodbye()

Python

>>> def callTwice(func):
...     func()
...     func()
...
>>> def sayHello():
...     print('Hello!')
...
>>> def sayGoodbye():
...     print('Goodbye!')
...
>>> callTwice(sayHello)
Hello!
Hello!
>>> callTwice(sayGoodbye)
Goodbye!
Goodbye!

callTwice()函数调用作为func参数传递给它的任何函数。请注意,我们从函数参数中省略了括号,而是写成callTwice(sayHello),而不是callTwice(sayHello())。这是因为我们传递的是sayHello()函数本身,而不是调用sayHello()并传递其返回值。

walk()函数接受一个匹配函数参数作为其搜索条件。这使我们能够自定义文件搜索的行为,而无需修改walk()函数本身的代码。我们稍后会看一下walk()。首先,让我们看一下程序中的两个示例匹配函数。

查找具有偶数字节的文件

第一个匹配函数找到具有偶数字节大小的文件:

Python

import os

def hasEvenByteSize(fullFilePath):
    """Returns True if fullFilePath has an even size in bytes,
    otherwise returns False."""
    fileSize = os.path.getsize(fullFilePath)
    return fileSize % 2 == 0

我们导入os模块,该模块在整个程序中用于通过getsize()basename()等函数获取有关计算机上文件的信息。然后我们创建一个名为hasEvenByteSize()的匹配函数。所有匹配函数都接受一个名为fullFilePath的字符串参数,并返回TrueFalse来表示匹配或不匹配。

os.path.getsize()函数确定fullFilePath中文件的大小(以字节为单位)。然后我们使用%模运算符来确定这个数字是否是偶数。如果是偶数,return语句返回True;如果是奇数,返回False。例如,让我们考虑 Windows 操作系统中附带的记事本应用程序的大小(在 macOS 或 Linux 上,尝试在*/bin/ls*程序上运行这个函数):

Python

>>> import os
>>> os.path.getsize('C:/Windows/system32/notepad.exe')
211968
>>> 211968 % 2 == 0
True

hasEvenByteSize()匹配函数可以使用任何 Python 函数来查找有关fullFilePath文件的更多信息。这使您能够为任何搜索条件编写代码。当walk()对文件夹和子文件夹中的每个文件调用匹配函数时,匹配函数会为每个文件返回TrueFalse。这告诉walk()文件是否匹配。

查找包含所有元音字母的文件名

让我们来看下一个匹配函数:

def hasEveryVowel(fullFilePath):
    """Returns True if the fullFilePath has a, e, i, o, and u,
    otherwise returns False."""
    name = os.path.basename(fullFilePath).lower()
    return ('a' in name) and ('e' in name) and ('i' in name) and ('o' in name) and ('u' in name)

我们调用os.path.basename()来从文件路径中删除文件夹名称。Python 对字符串进行区分大小写的比较,这确保了hasEveryVowel()不会因为文件名中的元音字母是大写而漏掉任何元音字母。例如,调用os.path.basename('C:/Windows/system32/notepad.exe')返回字符串notepad.exe。这个字符串的lower()方法调用返回字符串的小写形式,这样我们只需要检查其中的小写元音字母。本章后面的“用于处理文件的有用 Python 标准库函数”探讨了一些更多用于获取文件信息的函数。

我们使用一个带有长表达式的return语句,如果name包含aeiou,则表达式求值为True,表示文件符合搜索条件。否则,return语句返回False

递归walk()函数

匹配函数检查文件是否符合搜索条件,而walk()函数找到所有要检查的文件。递归的walk()函数会传入一个要搜索的基础文件夹的名称,以及一个要对文件夹中的每个文件调用的匹配函数。

walk()函数也会递归地对基础文件夹中的每个子文件夹进行调用。这些子文件夹成为递归调用中的基础文件夹。让我们对这个递归函数提出三个问题:

  1. 什么是基本情况?当函数完成对给定基础文件夹中的每个文件和子文件夹的处理时。

  2. 递归函数调用传递了什么参数?要搜索的基础文件夹和用于查找匹配文件的匹配函数。对于该文件夹中的每个子文件夹,都会使用子文件夹作为新的文件夹参数进行递归调用。

  3. 这个参数如何变得更接近基本情况?最终,函数要么在所有子文件夹上递归调用自身,要么遇到没有任何子文件夹的基础文件夹。

图 10-1 显示了一个示例文件系统以及对walk()的递归调用,它以C:\为基础文件夹进行调用。

图形描绘了文件系统中每个文件夹以及对walk()函数的相应调用。基础文件夹 C:\对应于“walk(‘C:\’, hasEvenByteSize)”。文件夹“spam”对应于“walk(‘C:\spam’, hasEvenByteSize)”。在“spam”中,文件夹“eggs”对应于“walk(‘C:\eggs’, hasEvenByteSize)”,文件夹“ham”对应于“walk(‘C:\spam\ham’, hasEvenByteSize)”。在“eggs”中,文件夹“bacon”对应于“walk(‘C\spam\eggs\bacon’, hasEvenByteSize)”

图 10-1:一个示例文件系统和递归的walk()函数对其的调用

让我们来看一下walk()函数的代码:

def walk(folder, matchFunc):
    """Calls the match function with every file in the folder and its
    subfolders. Returns a list of files that the match function
    returned True for."""
    matchedFiles = [] # This list holds all the matches.
    folder = os.path.abspath(folder) # Use the folder's absolute path.

walk()函数有两个参数:folder是要搜索的基础文件夹的字符串(我们可以传入'.'来指代 Python 程序所在的当前文件夹),matchFunc是一个 Python 函数,它接受一个文件名并在函数说它是搜索匹配时返回True。否则,函数返回False

函数的下一部分检查folder的内容:

Python

 # Loop over every file and subfolder in the folder:
    for name in os.listdir(folder):
        filepath = os.path.join(folder, name)
        if os.path.isfile(filepath):

for循环调用os.listdir()返回folder文件夹内容的列表。此列表包括所有文件和子文件夹。对于每个文件,我们通过将文件夹与文件或文件夹的名称连接起来创建完整的绝对路径。如果名称指的是文件,则os.path.isfile()函数调用返回True,我们将检查文件是否是搜索匹配项:

Python

 # Call the match function for each file:
            if matchFunc(filepath):
                matchedFiles.append(filepath)

我们调用匹配函数,将for循环当前文件的完整绝对文件路径传递给它。请注意,matchFuncwalk()的一个参数的名称。如果hasEvenByteSize()hasEveryVowel()或另一个函数作为matchFunc参数的参数传递,则walk()将调用该函数。如果filepath包含根据匹配算法匹配的文件,则将其添加到matches列表中:

Python

 elif os.path.isdir(filepath):
            # Recursively call walk for each subfolder, extending
            # the matchedFiles with their matches:
            matchedFiles.extend(walk(filepath, matchFunc))

否则,如果for循环的文件是子文件夹,则os.path.isdir()函数调用返回True。然后我们将子文件夹传递给递归函数调用。递归调用返回子文件夹(及其子文件夹)中所有匹配文件的列表,然后将其添加到matches列表中:

 return matchedFiles

for循环完成后,matches列表包含此文件夹(及其所有子文件夹)中的所有匹配文件。此列表成为walk()函数的返回值。

调用 walk()函数

现在我们已经实现了walk()函数和一些匹配函数,我们可以运行我们自定义的文件搜索。我们将'.'字符串作为walk()的第一个参数传递,这是一个特殊的目录名称,表示当前目录,以便它使用程序运行的文件夹作为基本文件夹进行搜索:

Python

print('All files with even byte sizes:')
print(walk('.', hasEvenByteSize))
print('All files with every vowel in their name:')
print(walk('.', hasEveryVowel))

此程序的输出取决于计算机上的文件,但这演示了您如何为任何搜索条件编写代码。例如,输出可能如下所示:

Python

All files with even byte sizes:
['C:\\Path\\accesschk.exe', 'C:\\Path\\accesschk64.exe', 
'C:\\Path\\AccessEnum.exe', 'C:\\Path\\ADExplorer.exe', 
'C:\\Path\\Bginfo.exe', 'C:\\Path\\Bginfo64.exe', 
'C:\\Path\\diskext.exe', 'C:\\Path\\diskext64.exe', 
'C:\\Path\\Diskmon.exe', 'C:\\Path\\DiskView.exe', 
'C:\\Path\\hex2dec64.exe', 'C:\\Path\\jpegtran.exe', 
'C:\\Path\\Tcpview.exe', 'C:\\Path\\Testlimit.exe', 
'C:\\Path\\wget.exe', 'C:\\Path\\whois.exe']
All files with every vowel in their name:
['C:\\Path\\recursionbook.bat']

用于处理文件的有用的 Python 标准库函数

让我们看看一些函数,这些函数在编写自己的匹配函数时可能会对您有所帮助。Python 附带的标准库模块中有几个有用的函数,用于获取有关文件的信息。其中许多位于osshutil模块中,因此您的程序必须在调用这些函数之前运行import osimport shutil

查找有关文件名称的信息

传递给匹配函数的完整文件路径可以使用os.path.basename()os.path.dirname()函数分解为基本名称和目录名称。您还可以调用os.path.split()将这些名称作为元组获取。在 Python 的交互式 shell 中输入以下内容。在 macOS 或 Linux 上,尝试使用/bin/ls作为文件名:

Python

>>> import os
>>> filename = 'C:/Windows/system32/notepad.exe'
>>> os.path.basename(filename)
'notepad.exe'
>>> os.path.dirname(filename)
'C:/Windows/system32'
>>> os.path.split(filename)
('C:/Windows/system32', 'notepad.exe')
>>> folder, file = os.path.split(filename)
>>> folder
'C:/Windows/system32'
>>> file
'notepad.exe'

您可以在这些字符串值上使用 Python 的任何字符串方法来帮助评估文件是否符合您的搜索条件,例如hasEveryVowel()匹配函数中的lower()

查找有关文件时间戳的信息

文件具有指示它们创建时间、上次修改时间和上次访问时间的时间戳。Python 的os.path.getctime()os.path.getmtime()os.path.getatime()分别将这些时间戳作为浮点值返回,指示自Unix 纪元以来的秒数,即 1970 年 1 月 1 日协调世界时(UTC)时区的午夜。在交互式 shell 中输入以下内容:

Python

> import os
> filename = 'C:/Windows/system32/notepad.exe'
> os.path.getctime(filename)
1625705942.1165037
> os.path.getmtime(filename)
1625705942.1205275
> os.path.getatime(filename)
1631217101.8869188

这些浮点值对程序来说很容易使用,因为它们只是单个数字,但您需要使用 Python 的time模块中的函数使它们对人类更容易阅读。time.localtime()函数将 Unix 纪元时间戳转换为计算机所在时区的struct_time对象。struct_time对象具有几个属性,其名称以tm_开头,用于获取日期和时间信息。在交互式 shell 中输入以下内容:

Python

>>> import os
>>> filename = 'C:/Windows/system32/notepad.exe'
>>> ctimestamp = os.path.getctime(filename)
>>> import time
>>> time.localtime(ctimestamp)
time.struct_time(tm_year=2021, tm_mon=7, tm_mday=7, tm_hour=19, 
tm_min=59, tm_sec=2, tm_wday=2, tm_yday=188, tm_isdst=1)
>>> st = time.localtime(ctimestamp)
>>> st.tm_year
2021
>>> st.tm_mon
7
>>> st.tm_mday
7
>>> st.tm_wday
2
>>> st.tm_hour
19
>>> st.tm_min
59
>>> st.tm_sec
2

请注意,tm_mday属性是月份的日期,范围是131tm_wday属性是星期几,从星期一的0开始,星期二的1,依此类推,直到星期日的6

如果需要time_struct对象的简短、可读的字符串,请将其传递给time.asctime()函数:

Python

>>> import os
>>> filename = 'C:/Windows/system32/notepad.exe'
>>> ctimestamp = os.path.getctime(filename)
>>> import time
>>> st = time.localtime(ctimestamp)
>>> time.asctime(st)
'Wed Jul  7 19:59:02 2021'

time.localtime()函数返回本地时区的struct_time对象,time.gmtime()函数返回 UTC 或格林威治标准时间时区的struct_time对象。将以下内容输入交互式 shell:

Python

>>> import os
>>> filename = 'C:/Windows/system32/notepad.exe'
>>> ctimestamp = os.path.getctime(filename)
>>> import time
>>> ctimestamp = os.path.getctime(filename)
>>> time.localtime(ctimestamp)
time.struct_time(tm_year=2021, tm_mon=7, tm_mday=7, tm_hour=19, 
tm_min=59, tm_sec=2, tm_wday=2, tm_yday=188, tm_isdst=1)
>>> time.gmtime(ctimestamp)
time.struct_time(tm_year=2021, tm_mon=7, tm_mday=8, tm_hour=0, 
tm_min=59, tm_sec=2, tm_wday=3, tm_yday=189, tm_isdst=0)

这些os.path函数(返回 Unix 纪元时间戳)与time函数(返回struct_time对象)之间的交互可能会令人困惑。图 10-2 显示了从文件名字符串开始的代码链,以获取时间戳的各个部分。

流程图。箭头从“文件名”指向“os.path.getctime()、os.path.getmtime()、os.path.getatime()”指向“time.localtime()、time.gmtime()”指向“time.asctime()、.tm_year、.tm_mon、.tm_mday、.tm_wday、.tm_hour、.tm_min、.tm_sec。”

图 10-2:从文件名到时间戳的各个属性

最后,time.time()函数返回自 Unix 纪元以来到当前时间的秒数。

修改您的文件

walk()函数返回与您的搜索条件匹配的文件列表后,您可能希望对它们进行重命名、删除或执行其他操作。Python 标准库中的shutilos模块具有执行此操作的函数。此外,第三方模块send2trash也可以将文件发送到操作系统的回收站,而不是永久删除它们。

要移动文件,请使用shutil.move()函数并提供两个参数。第一个参数是要移动的文件,第二个是要将其移动到的文件夹。例如,您可以调用以下内容:

Python

>>> import shutil
>>> shutil.move('spam.txt', 'someFolder')
'someFolder\\spam.txt'

shutil.move()函数返回文件的新文件路径字符串。您还可以指定文件名以同时移动和重命名文件:

Python

>>> import shutil
>>> shutil.move('spam.txt', 'someFolder\\newName.txt')
'someFolder\\newName.txt'

如果第二个参数缺少文件夹,您可以只指定一个新名称以在当前文件夹中重命名文件:

Python

>>> import shutil
>>> shutil.move('spam.txt', 'newName.txt')
'newName.txt'

请注意,shutil.move()函数既移动又重命名文件,类似于 Unix 和 macOS 的mv命令移动和重命名文件。没有单独的shutil.rename()函数。

要复制文件,请使用shutil.copy()函数并提供两个参数。第一个参数是要复制的文件的文件名,第二个参数是副本的新名称。例如,您可以调用以下内容:

Python

>>> import shutil
>>> shutil.copy('spam.txt', 'spam-copy.txt')
'spam-copy.txt'

shutil.copy()函数返回副本的名称。要删除文件,请调用os.unlink()函数并将要删除的文件的名称传递给它:

Python

>>> import os
>>> os.unlink('spam.txt')
>>>

使用unlink而不是delete的名称是因为它删除了与文件链接的文件名的技术细节。但由于大多数文件只有一个链接的文件名,这种取消链接也会删除文件。如果您不理解这些文件系统概念,也没关系,只需知道os.unlink()会删除文件。

调用os.unlink()会永久删除文件,如果程序中的错误导致函数删除错误的文件,这可能是危险的。相反,您可以使用send2trash模块的send2trash()函数将文件放入操作系统的回收站。要安装此模块,请在 Windows 命令提示符上运行run python -m pip install --user send2trash,或在 macOS 或 Linux 终端上运行run python3 -m pip install。安装模块后,您将能够使用import send2trash导入它。

将以下内容输入交互式 shell:

Python

>>> open('deleteme.txt', 'w').close() # Create a blank file.
>>> import send2trash
>>> send2trash.send2trash('deleteme.txt')

此示例创建一个名为deleteme.txt的空文件。调用send2trash.send2trash()(模块和函数同名),此文件将被移除到回收站。

摘要

本章的文件搜索项目使用递归来“遍历”文件夹及其所有子文件夹的内容。文件查找程序的walk()函数递归地导航这些文件夹,将自定义搜索条件应用于每个子文件夹中的每个文件。搜索条件被实现为匹配函数,这些函数被传递给walk()函数。这使我们能够通过编写新函数而不是修改walk()中的代码来更改搜索条件。

我们的项目有两个匹配函数,用于查找文件大小为偶数字节或包含其名称中的每个元音字母,但您可以编写自己的函数传递给walk()。这就是编程的力量;您可以为自己的需求创建商业应用程序中不可用的功能。

进一步阅读

Python 内置的os.walk()函数的文档(类似于文件查找器项目中的walk()函数)位于docs.python.org/3/library/os.html#os.walk。您还可以在我的书Automate the Boring Stuff with Python第九章中了解有关计算机文件系统和 Python 文件函数的更多信息,第 2 版(No Starch Press,2019)位于automatetheboringstuff.com/2e/chapter9

Python 标准库中的datetime模块还有更多与时间戳数据交互的方法。您可以在Automate the Boring Stuff with Python第十七章中了解更多信息,第 2 版位于automatetheboringstuff.com/2e/chapter17

十一、迷宫生成器

原文:Chapter 11 - Maze Generator

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章描述了一个解决迷宫的递归算法,但另一个递归算法生成迷宫。在本章中,我们将以第四章中迷宫求解程序相同的格式生成迷宫。因此,无论您是迷宫的解决者还是创建者,现在您都有能力将编程应用于此任务。

该算法通过访问迷宫中的一个起始空间,然后递归地访问相邻的空间来工作。随着算法继续访问相邻空间,迷宫的走廊被“刻出”。如果算法到达没有相邻空间的死胡同,它会回溯到先前的空间,直到找到一个未访问的相邻空间,并继续从那里访问。当算法回溯到起始空间时,整个迷宫已经生成。

我们将在这里使用的递归回溯算法生成的迷宫倾向于具有长走廊(连接分支交叉点的迷宫空间)并且相当容易解决。但是,这种算法比许多其他迷宫生成算法(如 Kruskal 算法或 Wilson 算法)更容易实现,因此它是该主题的很好介绍。

完整的迷宫生成器程序

让我们首先看一下程序的完整 Python 和 JavaScript 源代码,该程序使用递归回溯算法生成迷宫。本章的其余部分将逐个解释代码的每个部分。

将此 Python 代码复制到名为mazeGenerator.py的文件中:

Python

import random

WIDTH = 39 # Width of the maze (must be odd).
HEIGHT = 19 # Height of the maze (must be odd).
assert WIDTH % 2 == 1 and WIDTH >= 3
assert HEIGHT % 2 == 1 and HEIGHT >= 3
SEED = 1
random.seed(SEED)

# Use these characters for displaying the maze:
EMPTY = ' '
MARK = '@'
WALL = chr(9608) # Character 9608 is '█'
NORTH, SOUTH, EAST, WEST = 'n', 's', 'e', 'w'

# Create the filled-in maze data structure to start: 
maze = {}
for x in range(WIDTH):
    for y in range(HEIGHT):
        maze[(x, y)] = WALL # Every space is a wall at first.

def printMaze(maze, markX=None, markY=None):
    """Displays the maze data structure in the maze argument. The
    markX and markY arguments are coordinates of the current
    '@' location of the algorithm as it generates the maze."""

    for y in range(HEIGHT):
        for x in range(WIDTH):
            if markX == x and markY == y:
                # Display the '@' mark here:
                print(MARK, end='')
            else:
                # Display the wall or empty space:
                print(maze[(x, y)], end='')
        print() # Print a newline after printing the row.

def visit(x, y):
    """"Carve out" empty spaces in the maze at x, y and then
    recursively move to neighboring unvisited spaces. This
    function backtracks when the mark has reached a dead end."""
 maze[(x, y)] = EMPTY # "Carve out" the space at x, y.
    printMaze(maze, x, y) # Display the maze as we generate it.
    print('\n\n')

    while True:
        # Check which neighboring spaces adjacent to
        # the mark have not been visited already:
        unvisitedNeighbors = []
        if y > 1 and (x, y - 2) not in hasVisited:
            unvisitedNeighbors.append(NORTH)

        if y < HEIGHT - 2 and (x, y + 2) not in hasVisited:
            unvisitedNeighbors.append(SOUTH)

        if x > 1 and (x - 2, y) not in hasVisited:
            unvisitedNeighbors.append(WEST)

        if x < WIDTH - 2 and (x + 2, y) not in hasVisited:
            unvisitedNeighbors.append(EAST)

        if len(unvisitedNeighbors) == 0:
            # BASE CASE
            # All neighboring spaces have been visited, so this is a
            # dead end. Backtrack to an earlier space:
            return
        else:
            # RECURSIVE CASE
            # Randomly pick an unvisited neighbor to visit:
            nextIntersection = random.choice(unvisitedNeighbors)

            # Move the mark to an unvisited neighboring space:

            if nextIntersection == NORTH:
                nextX = x
                nextY = y - 2
                maze[(x, y - 1)] = EMPTY # Connecting hallway.
            elif nextIntersection == SOUTH:
                nextX = x
                nextY = y + 2
                maze[(x, y + 1)] = EMPTY # Connecting hallway.
            elif nextIntersection == WEST:
                nextX = x - 2
                nextY = y
                maze[(x - 1, y)] = EMPTY # Connecting hallway.
            elif nextIntersection == EAST:
                nextX = x + 2
                nextY = y
                maze[(x + 1, y)] = EMPTY # Connecting hallway.

            hasVisited.append((nextX, nextY)) # Mark as visited.
            visit(nextX, nextY) # Recursively visit this space.

# Carve out the paths in the maze data structure:
hasVisited = [(1, 1)] # Start by visiting the top-left corner.
visit(1, 1)

# Display the final resulting maze data structure:
printMaze(maze)

将此 JavaScript 代码复制到名为mazeGenerator.html的文件中:

JavaScript

<script type="text/javascript">

const WIDTH = 39; // Width of the maze (must be odd).
const HEIGHT = 19; // Height of the maze (must be odd).
console.assert(WIDTH % 2 == 1 && WIDTH >= 2);
console.assert(HEIGHT % 2 == 1 && HEIGHT >= 2);

// Use these characters for displaying the maze:
const EMPTY = "&nbsp;";
const MARK = "@";
const WALL = "&#9608;"; // Character 9608 is ′█′
const [NORTH, SOUTH, EAST, WEST] = ["n", "s", "e", "w"];

// Create the filled-in maze data structure to start:
let maze = {};
for (let x = 0; x < WIDTH; x++) {
    for (let y = 0; y < HEIGHT; y++) {
        maze[[x, y]] = WALL; // Every space is a wall at first.
    }
}

function printMaze(maze, markX, markY) {
    // Displays the maze data structure in the maze argument. The
    // markX and markY arguments are coordinates of the current
    // '@' location of the algorithm as it generates the maze.
    document.write('<code>');
    for (let y = 0; y < HEIGHT; y++) {
        for (let x = 0; x < WIDTH; x++) {
            if (markX === x && markY === y) {
                // Display the ′@′ mark here:
                document.write(MARK);
            } else {
                // Display the wall or empty space:
                document.write(maze[[x, y]]);
            }
        }
        document.write('<br />'); // Print a newline after printing the row.
    }
    document.write('</code>');
}

function visit(x, y) {
    // "Carve out" empty spaces in the maze at x, y and then
    // recursively move to neighboring unvisited spaces. This
    // function backtracks when the mark has reached a dead end.

 maze[[x, y]] = EMPTY; // "Carve out" the space at x, y.
    printMaze(maze, x, y); // Display the maze as we generate it.
    document.write('<br /><br /><br />');

    while (true) {
        // Check which neighboring spaces adjacent to
        // the mark have not been visited already:
        let unvisitedNeighbors = [];
        if (y > 1 && !JSON.stringify(hasVisited).includes(JSON.stringify([x, y - 2]))) {
            unvisitedNeighbors.push(NORTH);
        }
        if (y < HEIGHT - 2 && 
        !JSON.stringify(hasVisited).includes(JSON.stringify([x, y + 2]))) {
            unvisitedNeighbors.push(SOUTH);
        }
        if (x > 1 && 
        !JSON.stringify(hasVisited).includes(JSON.stringify([x - 2, y]))) {
            unvisitedNeighbors.push(WEST);
        }
        if (x < WIDTH - 2 && 
        !JSON.stringify(hasVisited).includes(JSON.stringify([x + 2, y]))) {
            unvisitedNeighbors.push(EAST);
        }

        if (unvisitedNeighbors.length === 0) {
            // BASE CASE
            // All neighboring spaces have been visited, so this is a
            // dead end. Backtrack to an earlier space:
            return;
        } else {
            // RECURSIVE CASE
            // Randomly pick an unvisited neighbor to visit:
            let nextIntersection = unvisitedNeighbors[
            Math.floor(Math.random() * unvisitedNeighbors.length)];

            // Move the mark to an unvisited neighboring space:
            let nextX, nextY;
            if (nextIntersection === NORTH) {
                nextX = x;
                nextY = y - 2;
                maze[[x, y - 1]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === SOUTH) {
                nextX = x;
                nextY = y + 2;
                maze[[x, y + 1]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === WEST) {
                nextX = x - 2;
                nextY = y;
                maze[[x - 1, y]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === EAST) {
                nextX = x + 2;
                nextY = y;
                maze[[x + 1, y]] = EMPTY; // Connecting hallway.
            }
 hasVisited.push([nextX, nextY]); // Mark space as visited.
            visit(nextX, nextY); // Recursively visit this space.
        }
    }
}

// Carve out the paths in the maze data structure:
let hasVisited = [[1, 1]]; // Start by visiting the top-left corner.
visit(1, 1);

// Display the final resulting maze data structure:
printMaze(maze);
</script>

当您运行此程序时,它会产生大量文本,将填满终端窗口或浏览器,并显示迷宫构建的每一步。您将不得不向上滚动以查看整个输出。

迷宫数据结构开始时是一个完全填满的二维空间。递归回溯算法在这个迷宫中给出一个起始点,然后访问一个先前未访问的相邻空间,在这个过程中“挖出”任何走廊空间。然后它在一个以前未访问过的相邻空间上递归调用自身。如果所有相邻空间都已经被访问过,算法就会陷入死胡同,并回溯到先前访问过的空间以访问它的未访问的邻居。当算法回溯到起始位置时,程序结束。

通过运行迷宫生成器程序,您可以看到这个算法的运行过程。当迷宫被挖出时,它会使用@字符显示当前的 x,y 坐标。这个过程看起来像图 11-1。请注意,右上角的第五张图在到达死胡同后回溯到了一个先前的空间,以探索从那个空间的新邻居方向。

显示迷宫一行一行地创建的图表。每次遇到死胡同时,该行都会回溯。最终填满整个屏幕。

图 11-1:递归回溯算法“挖出”的迷宫

让我们更详细地看一下代码。

设置迷宫生成器的常量

迷宫生成器使用了几个常量,我们可以在运行程序之前更改这些常量以改变迷宫的大小和外观。这些常量的 Python 代码如下:

Python

import random

WIDTH = 39 # Width of the maze (must be odd).
HEIGHT = 19 # Height of the maze (must be odd).
assert WIDTH % 2 == 1 and WIDTH >= 3
assert HEIGHT % 2 == 1 and HEIGHT >= 3
SEED = 1
random.seed(SEED)

JavaScript 代码如下:

JavaScript

<script type="text/javascript">

const WIDTH = 39; // Width of the maze (must be odd).
const HEIGHT = 19; // Height of the maze (must be odd).
console.assert(WIDTH % 2 == 1 && WIDTH >= 3);
console.assert(HEIGHT % 2 == 1 && HEIGHT >= 3);

常量WIDTHHEIGHT决定了迷宫的大小。它们必须是奇数,因为我们的迷宫数据结构要求迷宫的访问空间之间有墙壁,留下奇数维度。为了确保WIDTHHEIGHT常量被正确设置,我们使用断言来阻止程序如果常量不是奇数或太小的话。

程序依赖于一个随机种子值来根据相同的种子值重现相同的迷宫。这个程序的 Python 版本让我们通过调用random.seed()函数来设置这个值。不幸的是,JavaScript 没有一种明确设置种子值的方法,每次运行程序都会生成不同的迷宫。

Python 代码继续设置一些常量:

Python

# Use these characters for displaying the maze:
EMPTY = ' '
MARK = '@'
WALL = chr(9608) # Character 9608 is '█'
NORTH, SOUTH, EAST, WEST = 'n', 's', 'e', 'w'

这些常量的 JavaScript 代码如下:

JavaScript

// Use these characters for displaying the maze:
const EMPTY = "&nbsp;";
const MARK = "@";
const WALL = "&#9608;"; // Character 9608 is ′█′
const [NORTH, SOUTH, EAST, WEST] = ["n", "s", "e", "w"];

EMPTYWALL常量影响了迷宫在屏幕上的显示方式。MARK常量用于指出算法在迷宫中的位置。NORTHSOUTHEASTWEST常量表示标记可以通过迷宫的方向,并用于使代码更易读。

创建迷宫数据结构

迷宫数据结构是一个 Python 字典或 JavaScript 对象,它的键是 Python 元组或 JavaScript 数组,表示迷宫中每个空间的 x,y 坐标。这些键的值是WALLEMPTY常量的字符串。这个字符串表示这个空间是迷宫中的阻挡墙还是可通过的空白空间。

例如,图 11-2 中的迷宫由以下数据结构表示:

{(0, 0): '█', (0, 1): '█', (0, 2): '█', (0, 3): '█', (0, 4): '█', 
(0, 5): '█', (0, 6): '█', (1, 0): '█', (1, 1): ' ', (1, 2): ' ', 
(1, 3): ' ', (1, 4): ' ', (1, 5): ' ', (1, 6): '█', (2, 0): '█', 
(2, 1): '█', (2, 2): '█', (2, 3): '█', (2, 4): '█', (2, 5): ' ', 
(2, 6): '█', (3, 0): '█', (3, 1): ' ', (3, 2): '█', (3, 3): ' ', 
(3, 4): ' ', (3, 5): ' ', (3, 6): '█', (4, 0): '█', (4, 1): ' ', 
(4, 2): '█', (4, 3): ' ', (4, 4): '█', (4, 5): '█', (4, 6): '█', 
(5, 0): '█', (5, 1): ' ', (5, 2): ' ', (5, 3): ' ', (5, 4): ' ', 
(5, 5): ' ', (5, 6): '█', (6, 0): '█', (6, 1): '█', (6, 2): '█', 
(6, 3): '█', (6, 4): '█', (6, 5): '█', (6, 6): '█'}

一个网格的图表,其 x 轴和 y 轴从 0 到 6 编号,为网格中的每个单元格分配了数值 x 和 y。

图 11-2:一个可以用数据结构表示的示例迷宫

程序必须从每个空间设置为WALL开始。然后递归的visit()函数通过将空间设置为EMPTY来挖出迷宫的走廊和交叉点:

Python

# Create the filled-in maze data structure to start:
maze = {}
for x in range(WIDTH):
    for y in range(HEIGHT):
        maze[(x, y)] = WALL # Every space is a wall at first.

相应的 JavaScript 代码如下:

JavaScript

// Create the filled-in maze data structure to start:
let maze = {};
for (let x = 0; x < WIDTH; x++) {
    for (let y = 0; y < HEIGHT; y++) {
        maze[[x, y]] = WALL; // Every space is a wall at first.
    }
}

我们在maze全局变量中创建空字典(在 Python 中)或对象(在 JavaScript 中)。for循环遍历每个可能的 x,y 坐标,将每个设置为WALL,以创建一个完全填充的迷宫。调用visit()将从这个数据结构中刻出迷宫的走廊,将其中的空间设置为EMPTY

打印迷宫数据结构

为了表示迷宫作为数据结构,Python 程序使用字典,JavaScript 程序使用对象。在这个结构中,键是包含两个整数的列表或数组,分别代表 x 和 y 坐标,而值要么是WALL要么是EMPTY单字符字符串。因此,我们可以在 Python 代码中通过maze[(x, y)]或在 JavaScript 代码中通过maze[[x, y]]访问迷宫中坐标 x,y 的墙壁或空走廊空间。

printMaze()的 Python 代码如下开始:

Python

def printMaze(maze, markX=None, markY=None):
    """Displays the maze data structure in the maze argument. The
    markX and markY arguments are coordinates of the current
    '@' location of the algorithm as it generates the maze."""

    for y in range(HEIGHT):
        for x in range(WIDTH):

printMaze()的 JavaScript 代码如下开始:

JavaScript

function printMaze(maze, markX, markY) {
    // Displays the maze data structure in the maze argument. The
    // markX and markY arguments are coordinates of the current
    // '@' location of the algorithm as it generates the maze.
    document.write('<code>');
 for (let y = 0; y < HEIGHT; y++) {
        for (let x = 0; x < WIDTH; x++) {

printMaze()函数在屏幕上打印作为迷宫参数传递的迷宫数据结构。可选地,如果传递了markXmarkY整数参数,则在打印的迷宫中,MARK常量(我们设置为@)将出现在这些 x,y 坐标上。为了确保迷宫以等宽字体打印,JavaScript 版本在打印迷宫本身之前写入 HTML 标签<code>。没有这个 HTML 标签,迷宫将在浏览器中显示扭曲。

在函数内部,嵌套的for循环遍历迷宫数据结构中的每个空间。这些for循环分别从0HEIGHT的 y 坐标和从0WIDTH的 x 坐标进行迭代。

在内部的for循环中,如果当前的 x,y 坐标与标记的位置(算法当前正在刻划的位置)匹配,程序会在MARK常量中显示@。Python 代码如下:

Python

 if markX == x and markY == y:
                # Display the '@' mark here:
                print(MARK, end='')
            else:
                # Display the wall or empty space:
                print(maze[(x, y)], end='')

        print() # Print a newline after printing the row.

JavaScript 代码如下:

JavaScript

 if (markX === x && markY === y) {
                // Display the ′@′ mark here:
                document.write(MARK);
            } else {
                // Display the wall or empty space:
                document.write(maze[[x, y]]);
            }
        }
        document.write('<br />'); // Print a newline after printing the row.
    }
    document.write('</code>');
}

否则,程序通过在maze数据结构中打印maze[(x, y)](在 Python 中)或maze[[x, y]](在 JavaScript 中)来显示WALLEMPTY常量的字符。在内部的for循环完成对 x 坐标的迭代后,我们在行末打印一个换行符,为下一行做准备。

使用递归回溯算法

visit()函数实现了递归回溯算法。该函数有一个列表(在 Python 中)或数组(在 JavaScript 中),用于跟踪先前的visit()函数调用已经访问过的 x,y 坐标。它还就地修改了存储迷宫数据结构的全局maze变量。visit()的 Python 代码如下开始:

Python

def visit(x, y):
    """"Carve out" empty spaces in the maze at x, y and then
    recursively move to neighboring unvisited spaces. This
    function backtracks when the mark has reached a dead end."""
    maze[(x, y)] = EMPTY # "Carve out" the space at x, y.
    printMaze(maze, x, y) # Display the maze as we generate it.
    print('\n\n')

visit()的 JavaScript 代码如下开始:

JavaScript

function visit(x, y) {
    // "Carve out" empty spaces in the maze at x, y and then
    // recursively move to neighboring unvisited spaces. This
    // function backtracks when the mark has reached a dead end.

    maze[[x, y]] = EMPTY; // "Carve out" the space at x, y.
    printMaze(maze, x, y); // Display the maze as we generate it.
    document.write('<br /><br /><br />');

visit()函数接受 x,y 坐标作为迷宫中算法正在访问的位置的参数。然后函数将maze中这个位置的数据结构更改为空格。为了让用户看到迷宫生成的进展,它调用printMaze(),将xy参数作为标记的当前位置传递进去。

接下来,递归回溯调用visit(),使用先前未访问的相邻空间的坐标。Python 代码继续如下:

Python

 while True:
        # Check which neighboring spaces adjacent to
        # the mark have not been visited already:
        unvisitedNeighbors = []
        if y > 1 and (x, y - 2) not in hasVisited:
            unvisitedNeighbors.append(NORTH)

        if y < HEIGHT - 2 and (x, y + 2) not in hasVisited:
            unvisitedNeighbors.append(SOUTH)

        if x > 1 and (x - 2, y) not in hasVisited:
            unvisitedNeighbors.append(WEST)

        if x < WIDTH - 2 and (x + 2, y) not in hasVisited:
            unvisitedNeighbors.append(EAST)

JavaScript 代码继续如下:

 while (true) {
        // Check which neighboring spaces adjacent to
        // the mark have not been visited already:
        let unvisitedNeighbors = [];
        if (y > 1 && !JSON.stringify(hasVisited).includes(JSON.stringify([x, y - 2]))) {
            unvisitedNeighbors.push(NORTH);
        }
        if (y < HEIGHT - 2 && !JSON.stringify(hasVisited).includes(JSON.stringify([x, y + 2]))) {
            unvisitedNeighbors.push(SOUTH);
        }
        if (x > 1 && !JSON.stringify(hasVisited).includes(JSON.stringify([x - 2, y]))) {
            unvisitedNeighbors.push(WEST);
        }
        if (x < WIDTH - 2 && !JSON.stringify(hasVisited).includes(JSON.stringify([x + 2, y]))) {
            unvisitedNeighbors.push(EAST);
        }

while循环会继续循环,只要迷宫中这个位置还有未访问的相邻空间。我们在unvisitedNeighbors变量中创建一个未访问的相邻空间的列表或数组。四个if语句检查当前的 x,y 位置是否不在迷宫的边界上(这样我们仍然有相邻的空间要检查),以及相邻空间的 x,y 坐标是否已经出现在hasVisited列表或数组中。

如果所有相邻空间都已经被访问,函数将返回,以便可以回溯到较早的空间。Python 代码继续检查基本情况:

Python

 if len(unvisitedNeighbors) == 0:
            # BASE CASE
            # All neighboring spaces have been visited, so this is a
            # dead end. Backtrack to an earlier space:
            return

JavaScript 代码如下所示:

JavaScript

 if (unvisitedNeighbors.length === 0) {
            // BASE CASE
            // All neighboring spaces have been visited, so this is a
            // dead end. Backtrack to an earlier space:
            return;

递归回溯算法的基本情况是当没有未访问的相邻空间时发生。在这种情况下,函数简单地返回。visit()函数本身没有返回值。相反,递归函数调用visit()以副作用的方式修改全局maze变量中的迷宫数据结构。当对maze()的原始函数调用返回时,maze全局变量包含完全生成的迷宫。

Python 代码继续到这样的递归情况:

Python

 else:
            # RECURSIVE CASE
            # Randomly pick an unvisited neighbor to visit:
            nextIntersection = random.choice(unvisitedNeighbors)

            # Move the mark to an unvisited neighboring space:

            if nextIntersection == NORTH:
                nextX = x
                nextY = y - 2
                maze[(x, y - 1)] = EMPTY # Connecting hallway.
            elif nextIntersection == SOUTH:
                nextX = x
                nextY = y + 2
                maze[(x, y + 1)] = EMPTY # Connecting hallway.
            elif nextIntersection == WEST:
                nextX = x - 2
                nextY = y
                maze[(x - 1, y)] = EMPTY # Connecting hallway.
            elif nextIntersection == EAST:
                nextX = x + 2
                nextY = y
                maze[(x + 1, y)] = EMPTY # Connecting hallway.

            hasVisited.append((nextX, nextY)) # Mark space as visited.
            visit(nextX, nextY) # Recursively visit this space.

JavaScript 代码如下继续:

JavaScript

 } else {
            // RECURSIVE CASE
            // Randomly pick an unvisited neighbor to visit:
            let nextIntersection = unvisitedNeighbors[
            Math.floor(Math.random() * unvisitedNeighbors.length)];

            // Move the mark to an unvisited neighboring space:
            let nextX, nextY;
            if (nextIntersection === NORTH) {
                nextX = x;
                nextY = y - 2;
                maze[[x, y - 1]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === SOUTH) {
                nextX = x;
                nextY = y + 2;
                maze[[x, y + 1]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === WEST) {
                nextX = x - 2;
                nextY = y;
                maze[[x - 1, y]] = EMPTY; // Connecting hallway.
            } else if (nextIntersection === EAST) {
                nextX = x + 2;
                nextY = y;
 maze[[x + 1, y]] = EMPTY;    // Connecting hallway.
            }
            hasVisited.push([nextX, nextY]); // Mark space as visited.
            visit(nextX, nextY);             // Recursively visit this space.
        }
    }
}

unvisitedNeighbors列表或数组包含一个或多个NORTHSOUTHWESTEAST常量。我们选择其中一个方向作为下一个递归调用visit()的方向,然后使用这个方向的相邻空间的坐标设置nextXnextY变量。

之后,我们将nextXnextY的 x、y 坐标添加到hasVisited列表或数组中,然后对这个相邻空间进行递归调用。这样,visit()函数将继续访问相邻空间,通过将maze中的位置设置为EMPTY来 carve out 迷宫走廊。当前空间和相邻空间之间的连接走廊也被设置为EMPTY

当没有相邻空间存在时,基本情况简单地返回到较早的位置。在visit()函数中,执行跳回到while循环的开始。while循环中的代码再次检查哪些相邻空间尚未被访问,并对其中一个进行递归visit()调用,或者如果所有相邻空间已经被访问,则返回。

随着迷宫填满走廊并且每个空间都被访问,递归调用将继续返回,直到原始的visit()函数调用返回。此时,迷宫变量包含完全生成的迷宫。

开始递归调用链

递归visit()使用两个全局变量,mazehasVisitedhasVisited变量是一个包含算法访问过的每个空间的 x、y 坐标的列表或数组,并且从(1, 1)开始,因为那是迷宫的起点。这在 Python 代码中如下:

Python

# Carve out the paths in the maze data structure:
hasVisited = [(1, 1)] # Start by visiting the top-left corner.
visit(1, 1)

# Display the final resulting maze data structure:
printMaze(maze)

这个 JavaScript 代码如下:

JavaScript

// Carve out the paths in the maze data structure:
let hasVisited = [[1, 1]]; // Start by visiting the top-left corner.
visit(1, 1);

// Display the final resulting maze data structure:
printMaze(maze);

在设置hasVisited以包括 1, 1 的 x、y 坐标(迷宫的左上角)之后,我们使用这些坐标调用visit()。这个函数调用将导致生成迷宫走廊的所有递归函数调用。当这个函数调用返回时,hasVisited将包含迷宫的每个 x、y 坐标,而maze将包含完全生成的迷宫。

总结

正如你刚学到的,我们不仅可以使用递归来解决迷宫问题(通过遍历它们作为树数据结构),还可以使用递归回溯算法来生成迷宫。该算法在迷宫中“carves out”走廊,在遇到死胡同时回溯到较早的点。一旦算法被迫回溯到起点,迷宫就完全生成了。

我们可以将没有循环的良好连接的迷宫表示为 DAG——即树数据结构。递归回溯算法利用了递归算法适用于涉及树状数据结构和回溯的问题的思想。

进一步阅读

维基百科通常有关于迷宫生成的条目,其中包括关于递归回溯算法的部分,网址为en.wikipedia.org/wiki/Maze_generation_algorithm#Recursive_backtracker。我创建了一个基于浏览器的递归回溯算法的动画,展示了走廊的“雕刻”过程,网址为scratch.mit.edu/projects/17358777

如果你对迷宫生成感兴趣,你应该阅读 Jamis Buck 的《程序员的迷宫:编写自己的曲折小通道》(Pragmatic Bookshelf,2015)。

十二、滑动瓷砖求解器

原文:Chapter 12 - Sliding-Tile Solver

译者:飞龙

协议:CC BY-NC-SA 4.0

滑动瓷砖拼图,或15 拼图,是一个小拼图游戏,由一个 4×4 棋盘上的 15 个编号滑动瓷砖组成。一个瓷砖是缺失的,允许相邻的瓷砖滑入棋盘上的空白空间。玩家的目标是将瓷砖移动到数字顺序,就像图 12-1 中一样。这个游戏的一些版本在瓷砖上有一个图片的碎片,当拼图完成时可以组成一个完整的图像。

两个 4×4 编号瓷砖网格的图像,每个网格都缺少一个瓷砖。第一个网格的数字顺序错乱。第二个网格的数字从左到右按顺序排列为 1-15。

图 12-1:从数字滑动瓷砖拼图的混乱状态(左)到解决的有序状态(右)的解决方案

顺便说一句,数学家已经证明,即使最难的 15 拼图也可以在 80 步内解决。

递归解决 15 拼图

解决 15 拼图的算法类似于解决迷宫的算法。棋盘的每个状态(即,瓷砖的一种排列)都可以被看作是一个迷宫交叉口,有四条走廊可以通向。在 15 拼图的情况下,将瓷砖沿着四个方向滑动就像选择一个走廊,通向下一个交叉口。

就像你可以将迷宫转化为 DAG 一样,你也可以将 15 拼图转化为树图,就像图 12-2 一样。棋盘状态是节点,最多有四条边(代表滑动瓷砖的方向)通向其他节点(代表结果状态)。根节点是 15 拼图的起始状态。解决状态节点是瓷砖正确排列的状态。从根节点到解决状态的路径详细说明了解决拼图所需的滑动。

树图,其中每个节点都是一个 4×4 瓷砖拼图。顶部节点有两个子节点,代表玩家可以从该位置进行的两个可能移动,每个节点都有两个子节点,代表玩家可以从这些位置进行的所有可能移动。

图 12-2:解决 15 拼图的任务可以表示为一个图,其中瓷砖状态为节点,滑动为边。

有一些聪明的算法可以解决 15 拼图,但我们也可以递归地探索整个树图,直到找到从根节点到解决节点的路径。这个拼图的树可以用深度优先搜索(DFS)算法进行搜索。然而,与连接良好的迷宫不同,15 拼图的树图不是 DAG。相反,图的节点是无向的,因为你可以通过撤消之前做的滑动来遍历边的两个方向。

图 12-3 显示了两个节点之间的无向边的示例。因为可以在这两个节点之间来回移动,我们的 15 拼图算法在找到解决方案之前可能会遇到堆栈溢出。

两个瓷砖拼图。除了一个瓷砖在第二个拼图中向下滑动之外,其余瓷砖的位置完全相同。

图 12-3:15 拼图的节点之间有无向边(没有箭头头)因为滑动可以通过执行相反的滑动来撤消。

为了优化我们的算法,我们将避免撤销上一次滑动的滑动。然而,仅凭这种优化无法使算法免受堆栈溢出的影响。虽然它使树图中的边缘变得有向,但它并不会将拼图求解算法转变为 DAG,因为它具有从较低节点到较高节点的循环或循环。如果您以循环模式滑动瓷砖,就会发生这些循环,如图 12-4 所示。

通过箭头连接的十二块瓷砖拼图,形成完整的循环。在每个后续拼图中,一块瓷砖被滑出原位,直到拼图的状态与起始拼图的状态相同。

图 12-4:15 拼图图中循环的一个例子

图中的循环意味着底部的后续节点可能会回到顶部的节点。我们的求解算法可能会在这个循环中“卡住”,永远不会探索具有实际解决方案的分支。在实践中,这个无限循环会导致堆栈溢出。

我们仍然可以使用递归来解决 15 拼图。我们只需要为最大移动次数添加自己的基本情况,以避免导致堆栈溢出。然后,当达到最大滑动次数时,算法将开始回溯到较早的节点。如果 15 拼图求解器项目无法在 10 次滑动的所有可能组合中找到解决方案,它将尝试使用最多 11 次滑动。如果拼图在 11 次移动中无法解决,项目将尝试 12 次移动,依此类推。这可以防止算法陷入探索无限循环移动而不是探索较少移动可能解决方案的困境。

完整的滑动瓷砖求解程序

让我们首先看一下滑动瓷砖拼图求解程序的完整源代码。本章的其余部分将逐个解释代码的每个部分。

将代码的 Python 版本复制到名为slidingTileSolver.py的文件中:

Python

import random, time

DIFFICULTY = 40 # How many random slides a puzzle starts with.
SIZE = 4 # The board is SIZE x SIZE spaces.
random.seed(1) # Select which puzzle to solve.

BLANK = 0
UP = 'up'
DOWN = 'down'
LEFT = 'left'
RIGHT = 'right'

def displayBoard(board):
    """Display the tiles stored in `board` on the screen."""
    for y in range(SIZE): # Iterate over each row.
        for x in range(SIZE): # Iterate over each column.
            if board[y * SIZE + x] == BLANK:
                print('__ ', end='') # Display blank tile.
            else:
                print(str(board[y * SIZE + x]).rjust(2) + ' ', end='')
        print() # Print a newline at the end of the row.

def getNewBoard():
    """Return a list that represents a new tile puzzle."""
    board = []
    for i in range(1, SIZE * SIZE):
        board.append(i)
    board.append(BLANK)
    return board

def findBlankSpace(board):
    """Return an [x, y] list of the blank space's location."""
    for x in range(SIZE):
 for y in range(SIZE):
            if board[y * SIZE + x] == BLANK:
                return [x, y]

def makeMove(board, move):
    """Modify `board` in place to carry out the slide in `move`."""
    bx, by = findBlankSpace(board)
    blankIndex = by * SIZE + bx

    if move == UP:
        tileIndex = (by + 1) * SIZE + bx
    elif move == LEFT:
        tileIndex = by * SIZE + (bx + 1)
    elif move == DOWN:
        tileIndex = (by - 1) * SIZE + bx
    elif move == RIGHT:
        tileIndex = by * SIZE + (bx - 1)

    # Swap the tiles at blankIndex and tileIndex:
    board[blankIndex], board[tileIndex] = board[tileIndex], board[blankIndex]

def undoMove(board, move):
    """Do the opposite move of `move` to undo it on `board`."""
    if move == UP:
        makeMove(board, DOWN)
    elif move == DOWN:
        makeMove(board, UP)
    elif move == LEFT:
        makeMove(board, RIGHT)
    elif move == RIGHT:
        makeMove(board, LEFT)

def getValidMoves(board, prevMove=None):
    """Returns a list of the valid moves to make on this board. If
    prevMove is provided, do not include the move that would undo it."""

    blankx, blanky = findBlankSpace(board)

    validMoves = []
    if blanky != SIZE - 1 and prevMove != DOWN:
        # Blank space is not on the bottom row.
        validMoves.append(UP)

    if blankx != SIZE - 1 and prevMove != RIGHT:
        # Blank space is not on the right column.
        validMoves.append(LEFT)

    if blanky != 0 and prevMove != UP:
        # Blank space is not on the top row.
        validMoves.append(DOWN)

 if blankx != 0 and prevMove != LEFT:
        # Blank space is not on the left column.
        validMoves.append(RIGHT)

    return validMoves

def getNewPuzzle():
    """Get a new puzzle by making random slides from the solved state."""
    board = getNewBoard()
    for i in range(DIFFICULTY):
        validMoves = getValidMoves(board)
        makeMove(board, random.choice(validMoves))
    return board

def solve(board, maxMoves):
    """Attempt to solve the puzzle in `board` in at most `maxMoves`
    moves. Returns True if solved, otherwise False."""
    print('Attempting to solve in at most', maxMoves, 'moves...')
    solutionMoves = [] # A list of UP, DOWN, LEFT, RIGHT values.
    solved = attemptMove(board, solutionMoves, maxMoves, None)

    if solved:
        displayBoard(board)
        for move in solutionMoves:
            print('Move', move)
            makeMove(board, move)
            print() # Print a newline.
            displayBoard(board)
            print() # Print a newline.

        print('Solved in', len(solutionMoves), 'moves:')
        print(', '.join(solutionMoves))
        return True # Puzzle was solved.
    else:
        return False # Unable to solve in maxMoves moves.

def attemptMove(board, movesMade, movesRemaining, prevMove):
    """A recursive function that attempts all possible moves on `board`
    until it finds a solution or reaches the `maxMoves` limit.
    Returns True if a solution was found, in which case `movesMade`
    contains the series of moves to solve the puzzle. Returns False
    if `movesRemaining` is less than 0."""

    if movesRemaining < 0:
        # BASE CASE - Ran out of moves.
        return False

    if board == SOLVED_BOARD:
        # BASE CASE - Solved the puzzle.
        return True

    # RECURSIVE CASE - Attempt each of the valid moves:
 for move in getValidMoves(board, prevMove):
        # Make the move:
        makeMove(board, move)
        movesMade.append(move)

        if attemptMove(board, movesMade, movesRemaining - 1, move):
            # If the puzzle is solved, return True:
            undoMove(board, move) # Reset to the original puzzle.
            return True

        # Undo the move to set up for the next move:
        undoMove(board, move)
        movesMade.pop() # Remove the last move since it was undone.
    return False # BASE CASE - Unable to find a solution.

# Start the program:
SOLVED_BOARD = getNewBoard()
puzzleBoard = getNewPuzzle()
displayBoard(puzzleBoard)
startTime = time.time()

maxMoves = 10
while True:
    if solve(puzzleBoard, maxMoves):
        break # Break out of the loop when a solution is found.
    maxMoves += 1
print('Run in', round(time.time() - startTime, 3), 'seconds.')

将代码的 JavaScript 版本复制到名为slidingTileSolver.html的文件中:

<script type="text/javascript">
const DIFFICULTY = 40; // How many random slides a puzzle starts with.
const SIZE = 4; // The board is SIZE x SIZE spaces.

const BLANK = 0;
const UP = "up";
const DOWN = "down";
const LEFT = "left";
const RIGHT = "right";

function displayBoard(board) {
    // Display the tiles stored in `board` on the screen.
    document.write("<pre>");
    for (let y = 0; y < SIZE; y++) { // Iterate over each row.
        for (let x = 0; x < SIZE; x++) { // Iterate over each column.
            if (board[y * SIZE + x] == BLANK) {
                document.write('__ '); // Display blank tile.
            } else {
                document.write(board[y * SIZE + x].toString().padStart(2) + " ");
            }
        }
 document.write("<br />"); // Print a newline at the end of the row.
    }
    document.write("</pre>");
}

function getNewBoard() {
    // Return a list that represents a new tile puzzle.
    let board = [];
    for (let i = 1; i < SIZE * SIZE; i++) {
        board.push(i);
    }
    board.push(BLANK);
    return board;
}

function findBlankSpace(board) {
    // Return an [x, y] array of the blank space's location.
    for (let x = 0; x < SIZE; x++) {
        for (let y = 0; y < SIZE; y++) {
            if (board[y * SIZE + x] === BLANK) {
                return [x, y];
            }
        }
    }
}

function makeMove(board, move) {
    // Modify `board` in place to carry out the slide in `move`.
    let bx, by;
    [bx, by] = findBlankSpace(board);
    let blankIndex = by * SIZE + bx;

    let tileIndex;
    if (move === UP) {
        tileIndex = (by + 1) * SIZE + bx;
    } else if (move === LEFT) {
        tileIndex = by * SIZE + (bx + 1);
    } else if (move === DOWN) {
        tileIndex = (by - 1) * SIZE + bx;
    } else if (move === RIGHT) {
        tileIndex = by * SIZE + (bx - 1);
    }

    // Swap the tiles at blankIndex and tileIndex:
    [board[blankIndex], board[tileIndex]] = [board[tileIndex], board[blankIndex]];
}

function undoMove(board, move) {
    // Do the opposite move of `move` to undo it on `board`.
 if (move === UP) {
        makeMove(board, DOWN);
    } else if (move === DOWN) {
        makeMove(board, UP);
    } else if (move === LEFT) {
        makeMove(board, RIGHT);
    } else if (move === RIGHT) {
        makeMove(board, LEFT);
    }
}

function getValidMoves(board, prevMove) {
    // Returns a list of the valid moves to make on this board. If
    // prevMove is provided, do not include the move that would undo it.

    let blankx, blanky;
    [blankx, blanky] = findBlankSpace(board);

    let validMoves = [];
    if (blanky != SIZE - 1 && prevMove != DOWN) {
        // Blank space is not on the bottom row.
        validMoves.push(UP);
    }
    if (blankx != SIZE - 1 && prevMove != RIGHT) {
        // Blank space is not on the right column.
        validMoves.push(LEFT);
    }
    if (blanky != 0 && prevMove != UP) {
        // Blank space is not on the top row.
        validMoves.push(DOWN);
    }
    if (blankx != 0 && prevMove != LEFT) {
        // Blank space is not on the left column.
        validMoves.push(RIGHT);
    }
    return validMoves;
}

function getNewPuzzle() {
    // Get a new puzzle by making random slides from the solved state.
    let board = getNewBoard();
    for (let i = 0; i < DIFFICULTY; i++) {
        let validMoves = getValidMoves(board);
        makeMove(board, validMoves[Math.floor(Math.random() * validMoves.length)]);
    }
    return board;
}

function solve(board, maxMoves) {
    // Attempt to solve the puzzle in `board` in at most `maxMoves`
    // moves. Returns true if solved, otherwise false.
    document.write("Attempting to solve in at most " + maxMoves + " moves...<br />");
    let solutionMoves = []; // A list of UP, DOWN, LEFT, RIGHT values.
 let solved = attemptMove(board, solutionMoves, maxMoves, null);

    if (solved) {
        displayBoard(board);
        for (let move of solutionMoves) {
            document.write("Move " + move + "<br />");
            makeMove(board, move);
            document.write("<br />"); // Print a newline.
            displayBoard(board);
            document.write("<br />"); // Print a newline.
        }
        document.write("Solved in " + solutionMoves.length + " moves:<br />");
        document.write(solutionMoves.join(", ") + "<br />");
        return true; // Puzzle was solved.
    } else {
        return false; // Unable to solve in maxMoves moves.
    }
}

function attemptMove(board, movesMade, movesRemaining, prevMove) {
    // A recursive function that attempts all possible moves on `board`
    // until it finds a solution or reaches the `maxMoves` limit.
    // Returns true if a solution was found, in which case `movesMade`
    // contains the series of moves to solve the puzzle. Returns false
    // if `movesRemaining` is less than 0.

    if (movesRemaining < 0) {
        // BASE CASE - Ran out of moves.
        return false;
    }

    if (JSON.stringify(board) == SOLVED_BOARD) {
        // BASE CASE - Solved the puzzle.
        return true;
    }

    // RECURSIVE CASE - Attempt each of the valid moves:
    for (let move of getValidMoves(board, prevMove)) {
        // Make the move:
        makeMove(board, move);
        movesMade.push(move);

        if (attemptMove(board, movesMade, movesRemaining - 1, move)) {
            // If the puzzle is solved, return true:
            undoMove(board, move); // Reset to the original puzzle.
            return true;
        }

        // Undo the move to set up for the next move:
        undoMove(board, move);
        movesMade.pop(); // Remove the last move since it was undone.
    }
    return false; // BASE CASE - Unable to find a solution.
}
 // Start the program:
const SOLVED_BOARD = JSON.stringify(getNewBoard());
let puzzleBoard = getNewPuzzle();
displayBoard(puzzleBoard);
let startTime = Date.now();

let maxMoves = 10;
while (true) {
    if (solve(puzzleBoard, maxMoves)) {
        break; // Break out of the loop when a solution is found.
    }
    maxMoves += 1;
}
document.write("Run in " + Math.round((Date.now() - startTime) / 100) / 10 + " seconds.<br />");
</script>

程序的输出如下所示:

 7  1  3  4
 2  5 10  8
__  6  9 11
13 14 15 12
Attempting to solve in at most 10 moves...
Attempting to solve in at most 11 moves...
Attempting to solve in at most 12 moves...
# --snip--
 1  2  3  4
 5  6  7  8
 9 10 11 __
13 14 15 12

Move up

 1  2  3  4
 5  6  7  8
 9 10 11 12
13 14 15 __

Solved in 18 moves:
left, down, right, down, left, up, right, up, left, left, down, 
right, right, up, left, left, left, up
Run in 39.519 seconds.

请注意,当 JavaScript 在浏览器中运行时,代码必须在显示任何输出之前完成。在那之前,它可能会看起来已经冻结,您的浏览器可能会询问您是否想要提前停止它。您可以忽略这个警告,让程序继续工作,直到解决了拼图。

程序的递归attemptMove()函数通过尝试每种可能的滑动组合来解决滑动瓷砖拼图。该函数给出一个要尝试的移动。如果这解决了拼图,函数将返回一个布尔值True。否则,它将调用attemptMove()以及它可以进行的所有其他可能移动,并在超过最大移动次数之前找不到解决方案时返回一个布尔值False。我们稍后将更详细地探讨这个函数。

我们用来表示滑动瓷砖板的数据结构是一个整数列表(在 Python 中)或数组(在 JavaScript 中),其中0表示空白空间。在我们的程序中,这个数据结构通常存储在一个名为board的变量中。board[y * SIZE + x]处的值与板上坐标 x,y 处的瓷砖匹配,如图 12-5 所示。例如,如果SIZE常量为4,则在坐标 3, 1 处的值可以在board[1 * 4 + 3]找到。

这个小计算使我们能够使用一维数组或列表来存储二维瓷砖板的值。这种编程技术不仅在我们的项目中有用,而且对于任何必须存储在数组或列表中的二维数据结构都很有用,比如以字节流存储的二维图像。

两个滑动瓷砖拼图。在第一个拼图中,每个瓷砖和空白空间都由它们的 x、y 坐标表示。在第二个拼图中,瓷砖和空白空间从 0 到 15 编号。坐标对应于以下编号的瓷砖:0,0 对应于 0;1,0 对应于 1;2,0 对应于 2;3,0 对应于 3;0,1 对应于 4;1,1 对应于 5;2,1 对应于 6;3,1 对应于 7;0,2 对应于 8;1,2 对应于 9;2,2 对应于 10;3,2 对应于 11;0,3 对应于 12;1,3 对应于 13;2,3 对应于 14;3,3(空白空间)对应于 15。

图 12-5:板上每个空间的 x、y 坐标(左)和相应的数据结构索引(右)

让我们来看一些示例数据结构。之前在图 12-1 的左侧显示的混乱瓷砖的板将被表示为以下内容:

  1. [15, 2, 1, 12, 8, 5, 6, 11, 4, 9, 10, 7, 3, 14, 13, 0]

在图 12-1 的右侧,解决的有序拼图将被表示为:

  1. [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0]

我们程序中的所有函数都将期望遵循这种格式的板数据结构。

不幸的是,4×4 版本的滑动瓷砖拼图有太多可能的移动,普通笔记本电脑需要数周才能解决。您可以将SIZE常量从4更改为3,以解决一个更简单的 3×3 版本的拼图。完成的、有序的 3×3 拼图的数据结构将如下所示:

  1. [1, 2, 3, 4, 5, 6, 7, 8, 0]

设置程序的常量

在源代码的开头,程序使用一些常量使代码更易读。Python 代码如下:

Python

import random, time

DIFFICULTY = 40 # How many random slides a puzzle starts with.
SIZE = 4 # The board is SIZE x SIZE spaces.
random.seed(1) # Select which puzzle to solve.

BLANK = 0
UP = 'up'
DOWN = 'down'
LEFT = 'left'
RIGHT = 'right'

JavaScript 代码如下:

JavaScript

<script type="text/javascript">
const DIFFICULTY = 40; // How many random slides a puzzle starts with.
const SIZE = 4; // The board is SIZE x SIZE spaces.

const BLANK = 0;
const UP = "up";
const DOWN = "down";
const LEFT = "left";
const RIGHT = "right";

为了获得可重现的随机数,Python 程序将随机数种子设置为1。相同的种子值将始终产生相同的随机拼图,这对于调试很有用。您可以将种子值更改为任何其他整数以创建不同的拼图。JavaScript 没有办法设置其随机种子值,slidingtilesolver.html也没有类似的功能。

SIZE常量设置了方形板的大小。您可以将此大小更改为任何值,但 4×4 板是标准的,而 3×3 板对于测试很有用,因为程序很快就能解决它们。BLANK常量在拼图数据结构中用于表示空白空间,必须保持为0UPDOWNLEFTRIGHT常量用于使代码可读,类似于第十一章中迷宫生成器项目中的NORTHSOUTHWESTEAST常量。

将滑动瓷砖拼图表示为数据

滑动瓷砖板的数据结构只是一个整数列表或数组。它代表实际拼图板的方式是程序中的函数如何使用它。该程序中的displayBoard()getNewBoard()findBlankSpace()和其他函数都处理这个数据结构。

显示板

第一个函数displayBoard()在屏幕上打印板数据结构。displayBoard()函数的 Python 代码如下:

Python

def displayBoard(board):
    """Display the tiles stored in `board` on the screen."""
    for y in range(SIZE): # Iterate over each row.
        for x in range(SIZE): # Iterate over each column.
            if board[y * SIZE + x] == BLANK:
                print('__ ', end='') # Display blank tile.
            else:
                print(str(board[y * SIZE + x]).rjust(2) + ' ', end='')
        print() # Print a newline at the end of the row.

displayBoard()函数的 JavaScript 代码如下:

function displayBoard(board) {
    // Display the tiles stored in `board` on the screen.
    document.write("<pre>");
    for (let y = 0; y < SIZE; y++) { // Iterate over each row.
        for (let x = 0; x < SIZE; x++) { // Iterate over each column.
            if (board[y * SIZE + x] == BLANK) {
                document.write('__ '); // Display blank tile.
            } else {
                document.write(board[y * SIZE + x].toString().padStart(2) + " ");
            }
        }
        document.write("<br />");
    }
    document.write("</pre>");
}

嵌套的一对for循环遍历板上的每一行和每一列。第一个for循环遍历 y 坐标,第二个for循环遍历 x 坐标。这是因为程序需要在打印换行字符之前打印单行的所有列,以继续下一行。

if语句检查当前 x、y 坐标处的瓷砖是否为空白瓷砖。如果是,程序打印两个下划线并带有一个尾随空格。否则,else块中的代码打印带有尾随空格的瓷砖编号。尾随空格是屏幕上分隔瓷砖编号的内容。如果瓷砖编号是一个数字,rjust()padStart()方法将插入一个额外的空格,以便瓷砖编号与屏幕上的两位数对齐。

例如,假设左侧的混乱拼图在图 12-1 中由这个数据结构表示:

[15, 2, 1, 12, 8, 5, 6, 11, 4, 9, 10, 7, 3, 14, 13, 0]

当数据结构传递给displayBoard()时,它会打印以下文本:

15  2  1 12 
 8  5  6 11 
 4  9 10  7 
 3 14 13 __

创建一个新的板数据结构

接下来,getNewBoard()函数返回一个新的板数据结构,其中瓷砖放在它们有序的、解决的位置上。getNewBoard()函数的 Python 代码如下:

Python

def getNewBoard():
    """Return a list that represents a new tile puzzle."""
    board = []
    for i in range(1, SIZE * SIZE):
        board.append(i)
    board.append(BLANK)
    return board

getNewBoard()函数的 JavaScript 代码如下:

JavaScript

function getNewBoard() {
    // Return a list that represents a new tile puzzle.
    let board = [];
    for (let i = 1; i < SIZE * SIZE; i++) {
        board.push(i);
    }
    board.push(BLANK);
    return board;
}

getNewBoard()函数返回适合于SIZE常量(3×3 或 4×4)中的整数的板数据结构。for循环生成这个列表或数组,其中包含从1SIZE的平方,最后一个是0(存储在BLANK常量中的值),表示右下角的空白空间。

找到空白空间的坐标

我们的程序使用findBlankSpace()函数来找到板上空白空间的 x、y 坐标。Python 代码如下:

Python

def findBlankSpace(board):
    """Return an [x, y] list of the blank space's location."""
    for x in range(SIZE):
        for y in range(SIZE):
            if board[y * SIZE + x] == BLANK:
                return [x, y]

JavaScript 代码如下:

JavaScript

function findBlankSpace(board) {
    // Return an [x, y] array of the blank space's location.
    for (let x = 0; x < SIZE; x++) {
        for (let y = 0; y < SIZE; y++) {
            if (board[y * SIZE + x] === BLANK) {
                return [x, y];
            }
        }
    }
}

displayBoard()函数一样,findBlankSpace()函数有一对嵌套的for循环。这些for循环将循环遍历板数据结构中的每个位置。当board[y * SIZE + x]代码找到空白空间时,它会以 Python 列表或 JavaScript 数组中的两个整数的形式返回 x 和 y 坐标。

进行移动

接下来,makeMove()函数接受两个参数:一个板数据结构和一个UPDOWNLEFTRIGHT方向,用于在该板上滑动一个瓷砖。这段代码相当重复,所以使用bxby这样的简短变量名来表示空白空间的 x 和 y 坐标。

为了进行移动,板数据结构交换了移动瓷砖的值与空白瓷砖的0的值。makeMove()函数的 Python 代码如下:

Python

def makeMove(board, move):
    """Modify `board` in place to carry out the slide in `move`."""
    bx, by = findBlankSpace(board)
    blankIndex = by * SIZE + bx

    if move == UP:
        tileIndex = (by + 1) * SIZE + bx
    elif move == LEFT:
        tileIndex = by * SIZE + (bx + 1)
    elif move == DOWN:
        tileIndex = (by - 1) * SIZE + bx
    elif move == RIGHT:
        tileIndex = by * SIZE + (bx - 1)

    # Swap the tiles at blankIndex and tileIndex:
    board[blankIndex], board[tileIndex] = board[tileIndex], board[blankIndex]

makeMove()函数的 JavaScript 代码如下:

function makeMove(board, move) {
    // Modify `board` in place to carry out the slide in `move`.
    let bx, by;
    [bx, by] = findBlankSpace(board);
    let blankIndex = by * SIZE + bx;

 let tileIndex;
    if (move === UP) {
        tileIndex = (by + 1) * SIZE + bx;
    } else if (move === LEFT) {
        tileIndex = by * SIZE + (bx + 1);
    } else if (move === DOWN) {
        tileIndex = (by - 1) * SIZE + bx;
    } else if (move === RIGHT) {
        tileIndex = by * SIZE + (bx - 1);
    }

    // Swap the tiles at blankIndex and tileIndex:
    [board[blankIndex], board[tileIndex]] = [board[tileIndex], board[blankIndex]];
}

if语句根据move参数确定要移动的瓷砖的索引。然后,函数通过交换board[blankindex]处的BLANK值和board[tileIndex]处的编号瓷砖来“滑动”瓷砖。makeMove()函数不返回任何内容。相反,它直接修改了board数据结构。

Python 有a, b = b, a的语法来交换两个变量的值。对于 JavaScript,我们需要将它们包装在一个数组中,比如[a, b] = [b, a]来执行交换。我们在函数的最后使用这种语法来交换board[blankIndex]board[tileIndex]中的值。

撤消移动

接下来,在递归算法的回溯部分,我们的程序需要撤消移动。这就像在与初始移动相反的方向上进行移动一样简单。undoMove()函数的 Python 代码如下:

Python

def undoMove(board, move):
    """Do the opposite move of `move` to undo it on `board`."""
    if move == UP:
        makeMove(board, DOWN)
    elif move == DOWN:
        makeMove(board, UP)
    elif move == LEFT:
        makeMove(board, RIGHT)
    elif move == RIGHT:
        makeMove(board, LEFT)

undoMove()函数的 JavaScript 代码如下:

JavaScript

function undoMove(board, move) {
    // Do the opposite move of `move` to undo it on `board`.
    if (move === UP) {
        makeMove(board, DOWN);
    } else if (move === DOWN) {
        makeMove(board, UP);
    } else if (move === LEFT) {
        makeMove(board, RIGHT);
 } else if (move === RIGHT) {
        makeMove(board, LEFT);
    }
}

我们已经将交换逻辑编程到makeMove()函数中,所以undoMove()可以调用该函数来执行与move参数相反的方向。这样,通过makeMove(someBoard, someMove)函数调用在一个假设的someBoard数据结构上进行的假设的someMove移动可以通过调用undoMove(someBoard, someMove)来撤消。

设置一个新的谜题

要创建一个新的打乱的拼图,我们不能简单地将方块放在随机位置,因为一些方块的配置会产生无效的、无法解决的拼图。相反,我们需要从一个已解决的拼图开始,然后进行许多随机移动。解决这个拼图就变成了弄清楚哪些滑动可以撤消这些随机滑动,以恢复到原始的有序配置。

但并不总是可以在四个方向中的每个方向上进行移动。例如,如果空白区域在右下角,就像图 12-6 中一样,方块只能向下或向右滑动,因为没有方块可以向左或向上滑动。此外,如果在图 12-6 中向上滑动 7 号方块是上一个移动,那么向下滑动就会被移除作为有效的移动,因为它会撤消上一个移动。

带有右下角空白区域的滑动方块拼图。箭头指示了两种可能的移动方式:将 7 号方块向下滑动,将 13 号方块向右滑动。

图 12-6:如果空白区域在右下角,向下和向右是唯一有效的滑动方向。

为了帮助我们,我们需要一个getValidMoves()函数,它可以告诉我们在给定的板块数据结构上哪些滑动方向是可能的:

Python

def getValidMoves(board, prevMove=None):
    """Returns a list of the valid moves to make on this board. If
    prevMove is provided, do not include the move that would undo it."""

    blankx, blanky = findBlankSpace(board)

    validMoves = []
 if blanky != SIZE - 1 and prevMove != DOWN:
        # Blank space is not on the bottom row.
        validMoves.append(UP)

    if blankx != SIZE - 1 and prevMove != RIGHT:
        # Blank space is not on the right column.
        validMoves.append(LEFT)

    if blanky != 0 and prevMove != UP:
        # Blank space is not on the top row.
        validMoves.append(DOWN)

    if blankx != 0 and prevMove != LEFT:
        # Blank space is not on the left column.
        validMoves.append(RIGHT)

    return validMoves

这个函数的 JavaScript 代码如下:

JavaScript

function getValidMoves(board, prevMove) {
    // Returns a list of the valid moves to make on this board. If
    // prevMove is provided, do not include the move that would undo it.

    let blankx, blanky;
    [blankx, blanky] = findBlankSpace(board);

    let validMoves = [];
    if (blanky != SIZE - 1 && prevMove != DOWN) {
        // Blank space is not on the bottom row.
        validMoves.push(UP);
    }
    if (blankx != SIZE - 1 && prevMove != RIGHT) {
        // Blank space is not on the right column.
        validMoves.push(LEFT);
    }
    if (blanky != 0 && prevMove != UP) {
        // Blank space is not on the top row.
        validMoves.push(DOWN);
    }
    if (blankx != 0 && prevMove != LEFT) {
        // Blank space is not on the left column.
        validMoves.push(RIGHT);
    }
    return validMoves;
}

getValidMoves()函数的第一件事是调用findBlankSpace()并将空白区域的 x、y 坐标存储在变量blankxblanky中。接下来,函数使用一个空的 Python 列表或空的 JavaScript 数组设置了validMoves变量,用于保存滑动的所有有效方向。

回顾图 12-5,y 坐标为0表示板块的顶边缘。如果blanky,空白区域的 y 坐标,不是0,那么我们知道空白区域不在顶边缘。如果前一个移动也不是DOWN,那么up就是一个有效的移动,代码会将UP添加到validMoves中。

同样,左边缘的 x 坐标为0,底边缘的 y 坐标为SIZE - 1,右边缘的 x 坐标为SIZE - 1。使用表达式SIZE - 1可以确保这段代码无论板块是 3×3、4×4 还是其他任何尺寸都能正常工作。getValidMoves()函数对所有四个方向进行这些检查,然后返回validMoves

接下来,getNewPuzzle()函数返回程序要解决的打乱板块的数据结构。不能简单地将方块随机放在板块上,因为一些方块的配置会产生无法解决的拼图。为了避免这种情况,getNewPuzzle()函数从有序的解决板块开始,然后对其应用大量的随机滑动。解决这个拼图实际上就是找出撤消这些滑动的移动。getNewPuzzle()函数的 Python 代码如下:

Python

def getNewPuzzle():
    """Get a new puzzle by making random slides from the solved state."""
    board = getNewBoard()
    for i in range(DIFFICULTY):
        validMoves = getValidMoves(board)
        makeMove(board, random.choice(validMoves))
    return board

JavaScript 代码如下:

function getNewPuzzle() {
    // Get a new puzzle by making random slides from the solved state.
    let board = getNewBoard();
    for (let i = 0; i < DIFFICULTY; i++) {
        let validMoves = getValidMoves(board);
        makeMove(board, validMoves[Math.floor(Math.random() * validMoves.length)]);
    }
    return board;
}

调用getNewBoard()获取了一个有序、解决状态的板块数据结构。for循环调用getValidMoves()来获取给定板块当前状态下的有效移动列表,然后从列表中随机选择一个移动调用makeMove()。无论validMoves列表或数组包含什么组合的UPDOWNLEFTRIGHT值,Python 中的random.choice()函数和 JavaScript 中的Math.floor()Math.random()函数都会处理从validMoves中进行随机选择。

DIFFICULTY常量确定for循环从makeMove()中应用多少随机滑动。DIFFICULTY中的整数越高,拼图就会变得更加混乱。尽管这会导致一些纯粹偶然地撤销先前的移动的移动,例如向左滑动然后立即向右滑动,但是通过足够的滑动,函数会产生一个彻底混乱的棋盘。为了测试目的,DIFFICULTY设置为40,允许程序在大约一分钟内产生一个解决方案。对于一个更真实的 15 拼图,你应该将DIFFICULTY改为200

在创建和打乱board棋盘数据结构之后,getNewPuzzle()函数返回它。

递归解决滑动拼图

现在我们已经有了创建和操作拼图数据结构的函数,让我们创建通过递归滑动每个可能方向的拼图解决函数。

attemptMove()函数在一个棋盘数据结构上执行单个滑动,然后递归调用自身,对棋盘可以进行的每个有效移动调用一次。存在多个基本情况。如果棋盘数据结构处于已解状态,则函数返回布尔值True;如果达到了最大移动次数,则返回布尔值False。此外,如果递归调用返回了True,那么attemptMove()应该返回True,如果所有有效移动的递归调用都返回了False,那么attemptMove()应该返回False

solve()函数

solve()函数接受一个棋盘数据结构和算法在回溯之前应尝试的最大移动次数。然后它执行对attemptMove()的第一次调用。如果这第一次对attemptMove()的调用返回True,那么solve()中的代码会显示解决拼图的一系列步骤。如果返回False,那么solve()中的代码会告诉用户在这个最大移动次数下找不到解决方案。

Python 中的solve()代码开始如下:

Python

def solve(board, maxMoves):
    """Attempt to solve the puzzle in `board` in at most `maxMoves`
    moves. Returns True if solved, otherwise False."""
    print('Attempting to solve in at most', maxMoves, 'moves...')
    solutionMoves = [] # A list of UP, DOWN, LEFT, RIGHT values.
    solved = attemptMove(board, solutionMoves, maxMoves, None)

JavaScript 中的solve()代码开始如下:

function solve(board, maxMoves) {
    // Attempt to solve the puzzle in `board` in at most `maxMoves`
    // moves. Returns true if solved, otherwise false.
    document.write("Attempting to solve in at most " + maxMoves + " moves...<br />");
    let solutionMoves = []; // A list of UP, DOWN, LEFT, RIGHT values.
    let solved = attemptMove(board, solutionMoves, maxMoves, null);

solve()函数有两个参数:board包含要解决的拼图的数据结构,maxMoves是函数应该尝试的最大移动次数。solutionMoves列表或数组包含产生解决状态的UPDOWNLEFTRIGHT值的序列。attemptMove()函数在进行递归调用时会修改这个列表或数组。如果初始的attemptMove()函数找到解决方案并返回TruesolutionMoves包含解决方案的移动序列。

然后solve()函数对attemptMove()进行初始调用,并将其返回的TrueFalse存储在solved变量中。solve()函数的其余部分处理这两种情况:

Python

 if solved:
        displayBoard(board)
        for move in solutionMoves:
            print('Move', move)
            makeMove(board, move)
            print() # Print a newline.
            displayBoard(board)
            print() # Print a newline.

        print('Solved in', len(solutionMoves), 'moves:')
        print(', '.join(solutionMoves))
        return True # Puzzle was solved.
    else:
        return False # Unable to solve in maxMoves moves.

JavaScript 代码如下:

JavaScript

 if (solved) {
        displayBoard(board);
        for (let move of solutionMoves) {
            document.write("Move " + move + "<br />");
            makeMove(board, move);
            document.write("<br />"); // Print a newline.
            displayBoard(board);
            document.write("<br />"); // Print a newline.
        }
        document.write("Solved in " + solutionMoves.length + " moves:<br />");
        document.write(solutionMoves.join(", ") + "<br />");
        return true; // Puzzle was solved.
    } else {
        return false; // Unable to solve in maxMoves moves.
    }
}

如果attemptMove()找到解决方案,程序会运行solutionMoves列表或数组中收集的所有移动,并在每次滑动后显示棋盘。这向用户证明了attemptMove()收集的移动是拼图的真正解决方案。最后,solve()函数本身返回True。如果attemptMove()无法找到解决方案,solve()函数会简单地返回False

attemptMove()函数

让我们来看看attemptMove(),这是我们解决拼图的核心递归函数。记住滑动拼图产生的树图;调用attemptMove()来表示某个方向就像是沿着图的边缘前进到下一个节点。递归的attemptMove()调用会在树中进一步前进。当这个递归的attemptMove()调用返回时,它会回溯到先前的节点。当attemptMove()回溯到根节点时,程序执行已经返回到solve()函数。

Python 代码attemptMove()的开始如下:

Python

def attemptMove(board, movesMade, movesRemaining, prevMove):
    """A recursive function that attempts all possible moves on `board`
    until it finds a solution or reaches the `maxMoves` limit.
    Returns True if a solution was found, in which case `movesMade`
    contains the series of moves to solve the puzzle. Returns False
    if `movesRemaining` is less than 0."""

    if movesRemaining < 0:
        # BASE CASE - Ran out of moves.
        return False

    if board == SOLVED_BOARD:
        # BASE CASE - Solved the puzzle.
        return True

attemptMove()的 JavaScript 代码如下:

JavaScript

function attemptMove(board, movesMade, movesRemaining, prevMove) {
    // A recursive function that attempts all possible moves on `board`
    // until it finds a solution or reaches the `maxMoves` limit.
    // Returns true if a solution was found, in which case `movesMade`
    // contains the series of moves to solve the puzzle. Returns false
    // if `movesRemaining` is less than 0.

    if (movesRemaining < 0) {
        // BASE CASE - Ran out of moves.
        return false;
    }

    if (JSON.stringify(board) == SOLVED_BOARD) {
        // BASE CASE - Solved the puzzle.
        return true;
    }

attemptMove()函数有四个参数。board参数包含要解决的瓷砖拼图板数据结构。movesMade参数包含attemptMove()就地修改的列表或数组,添加了递归算法产生的UPDOWNLEFTRIGHT值。如果attemptMove()解决了拼图,movesMade将包含导致解决方案的移动。这个列表或数组也是solve()函数中的solutionMoves变量所指的。

solve()函数使用其maxMoves变量作为对attemptMove()的初始调用中的movesRemaining参数。每个递归调用传递maxMoves - 1作为maxMoves的下一个值,导致在进行更多递归调用时减少。当它变小于0时,attemptMove()函数停止进行额外的递归调用并返回False

最后,prevMove参数包含前一次调用attemptMove()所做的UPDOWNLEFTRIGHT值,以便它不会撤消该移动。对于对attemptMove()的初始调用,solve()函数传递 Python 的None或 JavaScript 的null值作为此参数,因为没有先前的移动存在。

attemptMove()代码的开始检查两个基本情况,如果movesRemaining变得小于0,则返回False,如果board处于解决状态,则返回TrueSOLVED_BOARD常量包含一个处于解决状态的板,我们可以将其与board中的数据结构进行比较。

attemptMove()的下一部分执行它在这个板上可以做的每个有效移动。Python 代码如下:

Python

 # RECURSIVE CASE - Attempt each of the valid moves:
    for move in getValidMoves(board, prevMove):
        # Make the move:
        makeMove(board, move)
        movesMade.append(move)

        if attemptMove(board, movesMade, movesRemaining - 1, move):
            # If the puzzle is solved, return True:
            undoMove(board, move) # Reset to the original puzzle.
            return True

JavaScript 代码如下:

JavaScript

 // RECURSIVE CASE - Attempt each of the valid moves:
    for (let move of getValidMoves(board, prevMove)) {
        // Make the move:
        makeMove(board, move);
        movesMade.push(move);

        if (attemptMove(board, movesMade, movesRemaining - 1, move)) {
            // If the puzzle is solved, return True:
            undoMove(board, move); // Reset to the original puzzle.
            return true;
        }

for循环将移动变量设置为getValidMoves()返回的每个方向。对于每次移动,我们调用makeMove()来修改板数据结构并将移动添加到movesMade中的列表或数组中。

接下来,代码递归调用attemptMove()来探索由movesRemaining设置的深度内所有可能的未来移动范围。将板和movesMade变量转发到这个递归调用。代码将递归调用的movesRemaining参数设置为movesRemaining - 1,使其减少一个。它还将prevMode参数设置为move,以便它不会立即撤消刚刚做出的移动。

如果递归调用返回True,则存在解决方案,并记录在movesMade列表或数组中。我们调用undoMove()函数,以便在执行返回到solve()后,board将包含原始拼图,然后返回True以指示已找到解决方案。

Python 代码attemptMove()的继续如下:

Python

 # Undo the move to set up for the next move:
        undoMove(board, move)
        movesMade.pop() # Remove the last move since it was undone.
    return False # BASE CASE - Unable to find a solution.

JavaScript 代码如下:

JavaScript

 // Undo the move to set up for the next move:
        undoMove(board, move);
        movesMade.pop(); // Remove the last move since it was undone.
    }
    return false; // BASE CASE - Unable to find a solution.
}

如果attemptMove()返回False,则找不到解决方案。在这种情况下,我们调用undoMove()并从movesMade列表或数组中删除最新的移动。

所有这些都是针对每个有效方向完成的。如果对这些方向的attemptMove()调用在达到最大移动次数之前找到解决方案,则attemptMove()函数返回False

开始解算器

solve()函数对于启动对attemptMove()的初始调用很有用,但程序仍然需要进行一些设置。此 Python 代码如下:

Python

# Start the program:
SOLVED_BOARD = getNewBoard()
puzzleBoard = getNewPuzzle()
displayBoard(puzzleBoard)
startTime = time.time()

此设置的 JavaScript 代码如下:

JavaScript

// Start the program:
const SOLVED_BOARD = JSON.stringify(getNewBoard());
let puzzleBoard = getNewPuzzle();
displayBoard(puzzleBoard);
let startTime = Date.now();

首先,SOLVED_BOARD常量设置为由getNewBoard()返回的有序的解决板。这个常量不是在源代码的顶部设置的,因为需要在调用它之前定义getNewBoard()函数。

接下来,从getNewPuzzle()返回一个随机拼图并存储在puzzleBoard变量中。这个变量包含将要解决的拼图板数据结构。如果您想解决特定的 15 拼图而不是随机的拼图,您可以用包含您想要解决的拼图的列表或数组替换对getNewPuzzle()的调用。

puzzleBoard中的板被显示给用户,并且当前时间存储在startTime中,以便程序可以计算算法的运行时间。Python 代码继续如下:

Python

maxMoves = 10
while True:
    if solve(puzzleBoard, maxMoves):
        break # Break out of the loop when a solution is found.
    maxMoves += 1
print('Run in', round(time.time() - startTime, 3), 'seconds.')

JavaScript 代码如下:

let maxMoves = 10;
while (true) {
    if (solve(puzzleBoard, maxMoves)) {
        break; // Break out of the loop when a solution is found.
    }
    maxMoves += 1;
}
document.write("Run in " + Math.round((Date.now() - startTime) / 100) / 10 + " seconds.<br />");
</script>

程序开始尝试在最多 10 步内解决puzzleBoard中的拼图。无限的while循环调用solve()。如果找到解决方案,solve()会在屏幕上打印解决方案并返回True。在这种情况下,这里的代码可以跳出无限的while循环并打印算法的总运行时间。

否则,如果solve()返回False,则maxMoves增加1,循环再次调用solve()。这使程序尝试逐渐更长的移动组合来解决拼图。这种模式一直持续到solve()最终返回True

总结

15 拼图是将递归原则应用于现实问题的一个很好的例子。递归可以对 15 拼图产生的状态树图执行深度优先搜索,以找到通往解决方案状态的路径。然而,一个纯粹的递归算法是行不通的,这就是为什么我们不得不进行一些调整。

问题在于 15 拼图有大量可能的状态,并且不形成 DAG。图中的边是无向的,并且图中包含循环。我们的解决算法需要确保它不会进行立即撤销上一步移动的移动,以便以一个方向遍历图。它还需要有算法愿意进行的最大移动次数,然后才开始回溯;否则,循环保证算法最终会递归太多并导致堆栈溢出。

递归并不一定是解决滑块拼图的最佳方法。除了最简单的拼图之外,通常的笔记本电脑根本无法在合理的时间内解决太多的组合。然而,我喜欢 15 拼图作为递归练习,因为它将 DAGs 和 DFS 的理论思想与现实问题联系起来。虽然 15 拼图是一个多世纪前发明的,但计算机的出现为探索解决这些有趣玩具的技术提供了丰富的工具。

进一步阅读

15-puzzles 的维基百科条目en.wikipedia.org/wiki/15_puzzle详细介绍了它们的历史和数学背景。

您可以在我的书《The Big Book of Small Python Projects》(No Starch Press,2021)中找到可玩的滑块拼图游戏的 Python 源代码,并在线查看inventwithpython.com/bigbookpython/project68.html