4.11. 迷宫探索¶
4.11. Exploring a Maze
在这一部分,我们将讨论一个与不断扩展的机器人世界相关的问题:如何找到迷宫的出口?如果你有一个 Roomba 吸尘器在你的宿舍(难道所有大学生都有吗?),你会希望能使用你在这一部分学到的知识重新编程它。我们要解决的问题是帮助我们的海龟找到迷宫中的出口。迷宫问题的根源可以追溯到希腊神话中的忒修斯,忒修斯被送入迷宫去杀死弥诺陶洛斯。忒修斯使用了一球线团来帮助他在完成任务后找到出路。在我们的问题中,我们假设我们的海龟被丢在迷宫的某个地方,必须找到出口。请查看 Figure 4.12
以了解我们在这一部分要解决的问题。
为了简化问题,我们将假设迷宫被划分为方格。每个方格要么是开放的,要么被墙壁占据。海龟只能通过开放的方格。如果海龟碰到墙壁,它必须尝试不同的方向。海龟需要一个系统的程序来找到迷宫的出口。程序如下:
- 从起始位置开始,我们首先尝试向北走一个方格,然后从那里递归地尝试我们的程序。
- 如果我们在第一步尝试北方路径没有成功,那么我们将向南走一步,并递归地重复我们的程序。
- 如果南方也没有成功,我们将尝试向西走一步,并递归应用我们的程序。
- 如果北方、南方和西方都没有成功,那么我们将从东边的一步位置递归地应用程序。
- 如果这些方向都不行,那么迷宫中没有出路,我们就失败了。
这听起来很简单,但首先有几个细节需要讨论。假设我们第一次递归步骤是向北走。按照我们的程序,下一步也会向北。但是,如果北方被墙壁挡住,我们必须查看程序的下一步并尝试向南。不幸的是,这一步向南将我们带回到最初的位置。如果我们从那里应用递归程序,我们将返回一步到北方,形成一个无限循环。因此,我们必须有一个策略来记住我们已经去过的地方。在这种情况下,我们假设我们有一袋面包屑可以沿途丢弃。如果我们在某个方向上走了一步,发现那个方格上已经有了面包屑,我们知道我们应该立即退回并尝试程序中的下一个方向。正如我们将看到的,回退就像从递归函数调用中返回一样简单。
就像我们对所有递归算法所做的那样,让我们回顾基本情况。根据前面的描述,你可能已经猜到了其中的一些。在这个算法中,有四个基本情况需要考虑:
- 海龟碰到墙壁。由于方格被墙壁占据,不能进行进一步的探索。
- 海龟发现了一个已经被探索过的方格。我们不想继续从这个位置进行探索,以免进入循环。
- 我们发现了一个未被墙壁占据的外边界。换句话说,我们找到了迷宫的出口。
- 我们在四个方向上都未能成功探索某个方格。
为了使程序正常工作,我们需要一种表示迷宫的方式。Figure 4.13
是一个迷宫数据文件的示例。
++++++++++++++++++++++
+ + ++ ++ +
+ + + +++ + ++
+ + + ++ ++++ + ++
+++ ++++++ +++ + +
+ ++ ++ +
+++++ ++++++ +++++ +
+ + +++++++ + +
+ +++++++ S + +
+ + +++
++++++++++++++++++ +++
为了让这更加有趣,我们将使用 turtle
模块来绘制和探索我们的迷宫,以便我们可以观察这个算法的实际效果。Maze
对象将提供以下方法供我们在编写搜索算法时使用:
__init__
读取表示迷宫的数据显示文件,初始化迷宫的内部表示,并找到海龟的起始位置。draw_maze
在屏幕窗口中绘制迷宫。update_position
更新迷宫的内部表示,并在窗口中更改海龟的位置。is_exit
检查当前位置是否是迷宫的出口。
Maze
类还重载了索引操作符 []
,以便我们的算法可以轻松访问任何特定方格的状态。
Listing 4.11
包括了 Maze
类方法(Listings 4.12
--4.15
)和 search_from
函数(Listing 4.16
)使用的全局常量。
START = "S"
OBSTACLE = "+"
TRIED = "."
DEAD_END = "-"
PART_OF_PATH = "O"
__init__
方法以文件名作为唯一参数。该文件是一个文本文件,通过使用 “+” 字符表示墙壁,空格表示开放方格,字母 “S” 表示起始位置。
class Maze:
def __init__(self, maze_filename):
with open(maze_filename, "r") as maze_file:
self.maze_list = [
[ch for ch in line.rstrip("\n")]
for line in maze_file.readlines()
]
self.rows_in_maze = len(self.maze_list)
self.columns_in_maze = len(self.maze_list[0])
for row_idx, row in enumerate(self.maze_list):
if START in row:
self.start_row = row_idx
self.start_col = row.index(START)
break
self.x_translate = -self.columns_in_maze / 2
self.y_translate = self.rows_in_maze / 2
self.t = turtle.Turtle()
self.t.shape("turtle")
self.wn = turtle.Screen()
self.wn.setworldcoordinates(
-(self.columns_in_maze - 1) / 2 - 0.5,
-(self.rows_in_maze - 1) / 2 - 0.5,
(self.columns_in_maze - 1) / 2 + 0.5,
(self.rows_in_maze - 1) / 2 + 0.5,
)
迷宫的内部表示是一个列表的列表。maze_list
实例变量的每一行也是一个列表。这个二级列表包含每个方格的一个字符,使用上述描述的字符。对于 Figure 13
中的数据文件,内部表示看起来如下:
[ ['+', '+', '+', '+', '+', ..., '+', '+', '+', '+', '+']
['+', ' ', ' ', ' ', '+', ..., ' ', '+', ' ', ' ', ' ']
['+', ' ', '+', ' ', ' ', ..., ' ', '+', ' ', '+', '+']
['+', ' ', '+', ' ', '+', ..., ' ', '+', ' ', '+', '+']
['+', '+', '+', ' ', '+', ..., ' ', '+', ' ', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., ' ', ' ', ' ', ' ', '+']
['+', '+', '+', '+', '+', ..., '+', '+', '+', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., ' ', ' ', '+', ' ', '+']
['+', ' ', '+', '+', '+', ..., '+', ' ', ' ', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., '+', ' ', '+', '+', '+']
['+', '+', '+', '+', '+', ..., '+', ' ', '+', '+', '+'] ]
draw_maze
方法使用这种内部表示来绘制迷宫的初始视图在屏幕上(Figure 4.12
)。
def draw_maze(self):
self.t.speed(10)
self.wn.tracer(0)
for y in range(self.rows_in_maze):
for x in range(self.columns_in_maze):
if self.maze_list[y][x] == OBSTACLE:
self.draw_centered_box(
x + self.x_translate, -y + self.y_translate, "orange"
)
self.t.color("black")
self.t.fillcolor("blue")
self.wn.update()
self.wn.tracer(1)
def draw_centered_box(self, x, y, color):
self.t.up()
self.t.goto(x - 0.5, y - 0.5)
self.t.color
(color)
self.t.fillcolor(color)
self.t.setheading(90)
self.t.down()
self.t.begin_fill()
for i in range(4):
self.t.forward(1)
self.t.right(90)
self.t.end_fill()
update_position
方法如 Listing 4.14
所示,使用相同的内部表示来查看海龟是否碰到墙壁。它还用 “.” 或 “-” 更新内部表示,以表示海龟是否访问过某个方格或该方格是否是死胡同。此外,update_position
方法使用两个辅助方法,move_turtle
和 drop_bread_crumb
,来更新屏幕上的视图。
def update_position(self, row, col, val=None):
if val:
self.maze_list[row][col] = val
self.move_turtle(col, row)
if val == PART_OF_PATH:
color = "green"
elif val == OBSTACLE:
color = "red"
elif val == TRIED:
color = "black"
elif val == DEAD_END:
color = "red"
else:
color = None
if color:
self.drop_bread_crumb(color)
def move_turtle(self, x, y):
self.t.up()
self.t.setheading(self.t.towards(x + self.x_translate, -y + self.y_translate))
self.t.goto(x + self.x_translate, -y + self.y_translate)
def drop_bread_crumb(self, color):
self.t.dot(10, color)
最后,is_exit
方法使用海龟的当前位置来测试是否到达了出口。出口条件发生在海龟到达迷宫的边缘时,即第零行、第零列、最右列或最底行。
def is_exit(self, row, col):
return (
row == 0
or row == self.rows_in_maze - 1
or col == 0
or col == self.columns_in_maze - 1
)
def __getitem__(self, idx):
return self.maze_list[idx]
让我们查看 search_from
函数的代码。该函数的代码显示在 Listing 4.16
中。注意,这个函数接受三个参数:一个 Maze
对象、起始行和起始列。这很重要,因为作为递归函数,搜索逻辑上从每次递归调用中重新开始。
def search_from(maze, row, column):
# 尝试从这个点的四个方向中找到出口
maze.update_position(row, column)
# 基本情况返回值:
# 1. 碰到障碍物,返回 False
if maze[row][column] == OBSTACLE:
return False
# 2. 找到已被探索过的方格
if maze[row][column] in [TRIED, DEAD_END]:
return False
# 3. 找到一个未被障碍物占据的外边界
if maze.is_exit(row, column):
maze.update_position(row, column, PART_OF_PATH)
return True
maze.update_position(row, column, TRIED)
# 否则,使用逻辑短路依次尝试每个方向
found = (
search_from(maze, row - 1, column)
or search_from(maze, row + 1, column)
or search_from(maze, row, column - 1)
or search_from(maze, row, column + 1)
)
if found:
maze.update_position(row, column, PART_OF_PATH)
else:
maze.update_position(row, column, DEAD_END)
return found
在查看算法时,你会发现代码的第一步(第3行)是调用 update_position
。这只是为了帮助你可视化算法,以便你可以准确地看到海龟如何探索迷宫。接下来,算法检查四个基本情况中的前三个:海龟是否碰到墙壁(第6行)?海龟是否回到了已探索的方格(第9行)?海龟是否找到了出口(第12行)?如果这些条件都不成立,我们将继续递归搜索。
你会注意到在递归步骤中,有四个递归调用到 search_from
。由于这些调用通过 or
语句连接在一起,很难预测会使用多少个递归调用。如果第一个调用 search_from
返回 True
,那么其余的三个调用将不会被需要。这意味着,如果有一个有效的路径向 (row - 1, column)
(或北方,如果你想以地理方位思考)可以通向迷宫的出口。如果北方没有好的路径,接下来会尝试向南。如果南方失败,则尝试西方,最后尝试东方。如果所有四个递归调用都返回 False
,那么我们发现了一个死胡同。你应该下载或输入整个程序并尝试通过更改这些调用的顺序来进行实验。
完整的程序显示在 ActiveCode 4.11.1 <completemaze>
中。该程序使用数据文件 maze2.txt
,如下所示。注意,它是一个更简单的示例文件,因为出口离海龟的起始位置非常近。
++++++++++++++++++++++
+ + ++ ++ +
+ ++++++++++
+ + ++ ++++ +++ ++
+ + + + ++ +++ +
+ ++ ++ + +
+++++ + + ++ + +
+++++ +++ + + ++ +
+ + + S+ + +
+++++ + + + + + +
++++++++++++++++++++++
活动: 4.11.1 完整的迷宫解决器 | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
|
自我检查
修改迷宫搜索程序,使得对 search_from
的调用顺序不同。观察程序的运行情况。你能解释为什么行为不同吗?你能预测在给定的调用顺序更改下海龟将遵循什么路径吗?
In this section we will look at a problem that has relevance to the expanding world of robotics: how do you find your way out of a maze? If you have a Roomba vacuum cleaner for your dorm room (don’t all college students?) you will wish that you could reprogram it using what you have learned in this section. The problem we want to solve is to help our turtle find its way out of a virtual maze. The maze problem has roots as deep as the Greek myth about Theseus, who was sent into a maze to kill the Minotaur. Theseus used a ball of thread to help him find his way back out again once he had finished off the beast. In our problem we will assume that our turtle is dropped down somewhere into the middle of the maze and must find its way out. Look at `Figure 4.12 to get an idea of where we are going in this section.
To make it easier for us we will assume that our maze is divided up into squares. Each square of the maze is either open or occupied by a section of wall. The turtle can only pass through the open squares of the maze. If the turtle bumps into a wall, it must try a different direction. The turtle will require a systematic procedure to find its way out of the maze. Here is the procedure:
- From our starting position we will first try going north one square and then recursively try our procedure from there.
- If we are not successful by trying a northern path as the first step then we will take a step to the south and recursively repeat our procedure.
- If south does not work then we will try a step to the West as our first step and recursively apply our procedure.
- If north, south, and west have not been successful then we will apply the procedure recursively from a position one step to our east.
- If none of these directions works then there is no way to get out of the maze and we fail.
Now that sounds pretty easy, but there are a couple of details to talk about first. Suppose we take our first recursive step by going north. By following our procedure, our next step would also be to the north. But if the north is blocked by a wall, we must look at the next step of the procedure and try going to the south. Unfortunately, that step to the south brings us right back to our original starting place. If we apply the recursive procedure from there, we will just go back one step to the North and be in an infinite loop. So we must have a strategy to remember where we have been. In this case we will assume that we have a bag of bread crumbs we can drop along our way. If we take a step in a certain direction and find that there is a bread crumb already on that square, we know that we should immediately back up and try the next direction in our procedure. As we will see when we look at the code for this algorithm, backing up is as simple as returning from a recursive function call.
As we do for all recursive algorithms, let us review the base cases. Some of them you may already have guessed based on the description in the previous paragraph. In this algorithm, there are four base cases to consider:
- The turtle has run into a wall. Since the square is occupied by a wall, no further exploration can take place.
- The turtle has found a square that has already been explored. We do not want to continue exploring from this position so we don't get into a loop.
- We have found an outside edge, not occupied by a wall. In other words, we have found an exit from the maze.
- We have explored a square unsuccessfully in all four directions.
For our program to work we will need to have a way to represent the maze. Figure 4.13
is an example of a maze data file.
++++++++++++++++++++++
+ + ++ ++ +
+ + + +++ + ++
+ + + ++ ++++ + ++
+++ ++++++ +++ + +
+ ++ ++ +
+++++ ++++++ +++++ +
+ + +++++++ + +
+ +++++++ S + +
+ + +++
++++++++++++++++++ +++
To make this even more interesting we are going to use the turtle
module to draw and explore our maze so we can watch this algorithm in action. The Maze
object will provide the following methods for us to use in writing our search algorithm:
__init__
Reads in a data file representing a maze, initializes the internal representation of the maze, and finds the starting position for the turtle.draw_maze
Draws the maze in a window on the screen.update_position
Updates the internal representation of the maze and changes the position of the turtle in the window.is_exit
Checks to see if the current position is an exit from the maze.
The Maze
class also overloads the index operator []
so that our algorithm can easily access the status of any particular square.
Listing 4.11 <lst_maze_globals>
includes the global constants used by the Maze
class methods (Listings 4.12 <lst_maze_init>
--4.15 <lst_maze_aux>
) and the search_from
function (Listing 4.16 <lst_maze_search>
).
START = "S"
OBSTACLE = "+"
TRIED = "."
DEAD_END = "-"
PART_OF_PATH = "O"
The __init__
method takes the name of a file as its only parameter. This file is a text file that represents a maze by using “+” characters for walls, spaces for open squares, and the letter “S” to indicate the starting position.
class Maze:
def __init__(self, maze_filename):
with open(maze_filename, "r") as maze_file:
self.maze_list = [
[ch for ch in line.rstrip("\n")]
for line in maze_file.readlines()
]
self.rows_in_maze = len(self.maze_list)
self.columns_in_maze = len(self.maze_list[0])
for row_idx, row in enumerate(self.maze_list):
if START in row:
self.start_row = row_idx
self.start_col = row.index(START)
break
self.x_translate = -self.columns_in_maze / 2
self.y_translate = self.rows_in_maze / 2
self.t = turtle.Turtle()
self.t.shape("turtle")
self.wn = turtle.Screen()
self.wn.setworldcoordinates(
-(self.columns_in_maze - 1) / 2 - 0.5,
-(self.rows_in_maze - 1) / 2 - 0.5,
(self.columns_in_maze - 1) / 2 + 0.5,
(self.rows_in_maze - 1) / 2 + 0.5,
)
The internal representation of the maze is a list of lists. Each row of the maze_list
instance variable is also a list. This secondary list contains one character per square using the characters described above. For the data file in Figure 13
the internal representation looks like the following:
[ ['+', '+', '+', '+', '+', ..., '+', '+', '+', '+', '+']
['+', ' ', ' ', ' ', '+', ..., ' ', '+', ' ', ' ', ' ']
['+', ' ', '+', ' ', ' ', ..., ' ', '+', ' ', '+', '+']
['+', ' ', '+', ' ', '+', ..., ' ', '+', ' ', '+', '+']
['+', '+', '+', ' ', '+', ..., ' ', '+', ' ', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., ' ', ' ', ' ', ' ', '+']
['+', '+', '+', '+', '+', ..., '+', '+', '+', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., ' ', ' ', '+', ' ', '+']
['+', ' ', '+', '+', '+', ..., '+', ' ', ' ', ' ', '+']
['+', ' ', ' ', ' ', ' ', ..., '+', ' ', '+', '+', '+']
['+', '+', '+', '+', '+', ..., '+', ' ', '+', '+', '+'] ]
The draw_maze
method uses this internal representation to draw the initial view of the maze on the screen (Figure 4.12
).
def draw_maze(self):
self.t.speed(10)
self.wn.tracer(0)
for y in range(self.rows_in_maze):
for x in range(self.columns_in_maze):
if self.maze_list[y][x] == OBSTACLE:
self.draw_centered_box(
x + self.x_translate, -y + self.y_translate, "orange"
)
self.t.color("black")
self.t.fillcolor("blue")
self.wn.update()
self.wn.tracer(1)
def draw_centered_box(self, x, y, color):
self.t.up()
self.t.goto(x - 0.5, y - 0.5)
self.t.color(color)
self.t.fillcolor(color)
self.t.setheading(90)
self.t.down()
self.t.begin_fill()
for i in range(4):
self.t.forward(1)
self.t.right(90)
self.t.end_fill()
The update_position
method, as shown in Listing 4.14
uses the same internal representation to see if the turtle has run into a wall. It also updates the internal representation with a “.” or “-” to indicate that the turtle has visited a particular square or if the square is part of a dead end. In addition, the update_position
method uses two helper methods, move_turtle
and drop_bread_crumb
, to update the view on the screen.
def update_position(self, row, col, val=None):
if val:
self.maze_list[row][col] = val
self.move_turtle(col, row)
if val == PART_OF_PATH:
color = "green"
elif val == OBSTACLE:
color = "red"
elif val == TRIED:
color = "black"
elif val == DEAD_END:
color = "red"
else:
color = None
if color:
self.drop_bread_crumb(color)
def move_turtle(self, x, y):
self.t.up()
self.t.setheading(self.t.towards(x + self.x_translate, -y + self.y_translate))
self.t.goto(x + self.x_translate, -y + self.y_translate)
def drop_bread_crumb(self, color):
self.t.dot(10, color)
Finally, the is_exit
method uses the current position of the turtle to test for an exit condition. An exit condition occurs whenever the turtle has navigated to the edge of the maze, either row zero or column zero, or the far-right column or the bottom row.
def is_exit(self, row, col):
return (
row == 0
or row == self.rows_in_maze - 1
or col == 0
or col == self.columns_in_maze - 1
)
def __getitem__(self, idx):
return self.maze_list[idx]
Let’s examine the code for the search function which we call search_from
. The code is shown in Listing 4.16
. Notice that this function takes three parameters: a Maze
object, the starting row, and the starting column. This is important because as a recursive function the search logically starts again with each recursive call.
def search_from(maze, row, column):
# Try each of four directions from this point until we find a way out.
maze.update_position(row, column)
# Base Case return values:
# 1. We have run into an obstacle, return false
if maze[row][column] == OBSTACLE:
return False
# 2. We have found an already explored square
if maze[row][column] in [TRIED, DEAD_END]:
return False
# 3. We have found an exit
if maze.is_exit(row, column):
maze.update_position(row, column, PART_OF_PATH)
return True
maze.update_position(row, column, TRIED)
# Otherwise, use logical short circuiting to try each direction
# in turn (if needed)
found = (
search_from(maze, row - 1, column)
or search_from(maze, row + 1, column)
or search_from(maze, row, column - 1)
or search_from(maze, row, column + 1)
)
if found:
maze.update_position(row, column, PART_OF_PATH)
else:
maze.update_position(row, column, DEAD_END)
return found
As you look through the algorithm you will see that the first thing the code does (line 3) is call update_position
. This is simply to help you visualize the algorithm so that you can watch exactly how the turtle explores its way through the maze. Next the algorithm checks for the first three of the four base cases: Has the turtle run into a wall (lines 6)? Has the turtle circled back to a square already explored (line 9)? Has the turtle found an exit (line 12)? If none of these conditions is true then we continue the search recursively.
You will notice that in the recursive step there are four recursive calls to search_from
. It is hard to predict how many of these recursive calls will be used since they are all connected by or
statements. If the first call to search_from
returns True
then none of the last three calls would be needed. You can interpret this as meaning that a step to (row - 1, column)
(or north if you want to think geographically) is on the path leading out of the maze. If there is not a good path leading out of the maze to the north then the next recursive call is tried, this one to the south. If south fails then try west, and finally east. If all four recursive calls return False
then we have found a dead end. You should download or type in the whole program and experiment with it by changing the order of these calls.
The complete program is shown in ActiveCode 4.11.1 <completemaze>
. This program uses the data file maze2.txt
shown below. Note that it is a much more simple example file in that the exit is very close to the starting position of the turtle.
++++++++++++++++++++++
+ + ++ ++ +
+ ++++++++++
+ + ++ ++++ +++ ++
+ + + + ++ +++ +
+ ++ ++ + +
+++++ + + ++ + +
+++++ +++ + + ++ +
+ + + S+ + +
+++++ + + + + + +
++++++++++++++++++++++
Activity: 4.11.1 Complete Maze Solver | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
|
Self Check
Modify the maze search program so that the calls to search_from
are in a different order. Watch the program run. Can you explain why the behavior is different? Can you predict what path the turtle will follow for a given change in order?
创建日期: 2024年9月9日