探索迷宮
探討一個與蓬勃發展的機器人領域相關的問題:走出迷宮。如果你有一個Roomba掃地機器人,或許
能利用學到的知識對它進行重新編程。我們要解決的問題是幫助小烏龜走出虛擬的迷宮。迷宮問題源自忒修
斯大戰牛頭怪的古希臘神話傳說。相傳,在迷宮里殺死牛頭怪之后,忒修斯用一個線團找到了迷宮的出口。
假設小烏龜被放置在迷宮里的某個位置,我們要做的是幫助它爬出迷宮,如圖所示
為簡單起見,假設迷宮被分成許多格,每一格要么是空的,要么被牆堵上。小烏龜只能沿着空的
格子爬行,如果遇牆,就必須轉變方向。它需要如下的系統化過程來找到出路。
(1)從起始位置開始,壽險向北移動一格,然后在新的位置再遞歸地重復本過程。
(2)如果第一步往北行不通,就嘗試向南移動一格,然后在遞歸重復本過程。
(3)如果向南也行不通,就嘗試向西移動一格,然后遞歸地重復本過程。
(4)如果向北、向南、向西都不行,就嘗試向東移動一格,然后遞歸地重復本過程。
(5)如果四個方向都不行,就意味着沒有出路。
整個過程看上去非常簡單,但是有許多細節需要討論。假設遞歸過程的第一步是向北移動一格。
根據上述過程,下一步也是向北移動一格。但是,如果北面有牆,必須根據遞歸過程的第二步向南移
動一格。不行的是向南移動一格之后回到起點。如果繼續執行該遞歸過程,就會又向北移動一格,然
后又退回來,從而陷入無限循環中。所以,必須通過一格策略來記住到過的地方,本例假設小烏龜一
邊爬,一邊丟面包屑。如果往某個方向走一格之后發現有面包屑,就知道應該退回去,然后嘗試遞歸
過程的下一步。查看這個算法的代碼時會發現,退回去就是從遞歸函數調用中返回。
和考察其他遞歸算法時一樣,讓我們來看看上述算法的基本情況,其中一些可以根據之前的描述
猜到。這個算法需要考慮以下4種基本情況。
(1)小烏龜遇到牆。由於格子被牆堵上,因此無法再繼續探索。
(2)小烏龜遇到了已經走過的格子。在這種情況下,我們不希望它繼續探索,不然會陷入循環。
(3)小烏龜找到了出口。
(4)四個方向都行不通。
為了使程序運行起來,需要通過一種方式標識迷宮。我們使用turtle模塊來繪制和探索迷宮,以增
加趣味性。迷宮對象提供下列方法,可用編寫搜索算法。
--init--讀入一個代表迷宮數據文件,初始化迷宮的內部表示,並且找到小烏龜的起始位置。
drawMaze在屏幕上的一個窗口中繪制迷宮。
updatePosition更新迷宮的內部表示,並且修改小烏龜在迷宮中的位置。
isExit檢查小烏龜的當前位置是否為迷宮的出口。
除此之外,Maze類還重載了索引運算符[ ],以便算法訪問任一格的狀態。
以下代買清單展示了搜索函數searchFrom的代碼。該函數接受3個參數:迷宮對象、起始行,以及
起始列。由於該函數的每一次遞歸調用在邏輯上都是重新開始搜索的,因此定義接受3個參數非常重要。
def searchFrom(maze, startRow, startColumn): # 從初始位置開始嘗試四個方向,直到找到出路。 # 1. 遇到障礙 if maze[startRow][startColumn] == OBSTACLE: return False # 2. 發現已經探索過的路徑或死胡同 if maze[startRow][startColumn] == TRIED or maze[startRow][startColumn]== DEAD_END: return False # 3. 發現出口 if maze.isExit(startRow, startColumn): maze.updatePosition(startRow, startColumn, PART_OF_PATH)#顯示出口位置,注釋則不顯示此點 return True maze.updatePosition(startRow, startColumn, TRIED)#更新迷宮狀態、設置海龜初始位置並開始嘗試 # 4. 依次嘗試每個方向 found = searchFrom(maze, startRow - 1, startColumn) or \ searchFrom(maze, startRow + 1, startColumn) or \ searchFrom(maze, startRow, startColumn - 1) or \ searchFrom(maze, startRow, startColumn + 1) if found: #找到出口 maze.updatePosition(startRow, startColumn, PART_OF_PATH)#返回其中一條正確路徑 else: #4個方向均是死胡同 maze.updatePosition(startRow, startColumn, DEAD_END) return found
該函數做的第一件事就是調用updatePosition(第2行)。這樣做是為了對算法進行可視化,以便我們
看到小烏龜如何在迷宮中尋找出口。接着,該函數檢查前3中基本情況:是否遇到了牆(第5行)是否遇到
了已經走過的格子(第8行)是否找到了出口(第11行)如果沒有一種情況符合,則繼續遞歸搜索。
遞歸搜素調用了4個searchFrom。很難預測一共會進行多少個遞歸調用,這是因為它們都是用布爾運
算符or連接起來的。如果第一次調用searchFrom后返回Ture,那么就不必進行后續的調用。可以這樣理解:
向北移動一格是離開迷宮的路徑的一步。如果向北沒有能夠走出迷宮,那么嘗試下一個遞歸調用,即向南移
動。如果向南失敗了,就嘗試向西,最后向東。如果所有的遞歸調用都失敗了,就說明遇到了死胡同。請下
載或自己輸入代碼,改變4個遞歸調用的順序,看看結果如何。
Maze類的方法定義,--init--方法只接受一個參數,即文件名。該文本文件包含迷宮的信息,其中+代表
牆,空格代表空格子,S代表起始位置。迷宮內部表示是一個列表,其元素也是列表。實例變量mazelist的每
一行是一個列表,其中每一格包含一個字符。其內部如下。
drawMaze方法使用以上內部表示在屏幕上繪制初始迷宮。
updatePosition方法使用相同的內部表示檢查小烏龜是否遇到牆。同時,它會更改內部表示,使用.和-來分
別表示小烏龜遇到了走過的格子和死胡同。此外,updatePosition方法還使用輔助函數moveTurtle和dropBread
crumb來更新屏幕上的信息。
isExit方法檢查小烏龜的當前位置是否為出口,條件是小烏龜已經爬到迷宮邊緣:第0行、第0列、最后一行
或者最后一列。
import turtle # 迷宮類 class Maze(object): # 讀取迷宮數據,初始化迷宮內部,並找到海龜初始位置。 def __init__(self, mazeFileName): rowsInMaze = 0 #初始化迷宮行數 columnsInMaze = 0 #初始化迷宮列數 self.mazelist = [] #初始化迷宮列表 mazeFile = open(mazeFileName, 'r') #讀取迷宮文件 for line in mazeFile: #按行讀取 rowList = [] #初始化行列表 col = 0 #初始化列 for ch in line[:-1]: #這樣會丟失最后一列 rowList.append(ch) #添加到行列表 if ch == 'S': #S為烏龜初始位置,即迷宮起點 self.startRow = rowsInMaze #烏龜初始行 self.startCol = col #烏龜初始列 col = col + 1 #下一列 rowsInMaze = rowsInMaze + 1 #下一行 self.mazelist.append(rowList) #行列表添加到迷宮列表 columnsInMaze = len(rowList) #獲取迷宮總列數 print(self.mazelist) self.rowsInMaze = rowsInMaze #設置迷宮總行數 self.columnsInMaze = columnsInMaze #設置迷宮總列數 self.xTranslate = -columnsInMaze/2 #設置迷宮左上角的初始x坐標 self.yTranslate = rowsInMaze/2 #設置迷宮左上角的初始y坐標 self.t = turtle.Turtle() #創建一個海龜對象 #給當前指示點設置樣式(類似鼠標箭頭),海龜形狀為參數指定的形狀名,指定的形狀名應存在於TurtleScreen的shape字典中。多邊形的形狀初始時有以下幾種:"arrow", "turtle", "circle", "square", "triangle", "classic"。 self.t.shape('turtle') self.wn = turtle.Screen() #創建一個能在里面作圖的窗口 # 設置世界坐標系,原點在迷宮正中心。參數依次為畫布左下角x軸坐標、左下角y軸坐標、右上角x軸坐標、右上角y軸坐標 self.wn.setworldcoordinates(-(columnsInMaze -1)/2 - 0.5, -(rowsInMaze - 1)/2 - 0.5, (columnsInMaze - 1)/2 + 0.5, (rowsInMaze - 1)/2 + 0.5) # 在屏幕上繪制迷宮 def drawMaze(self): self.t.speed(20) #繪圖速度 for y in range(self.rowsInMaze): #按單元格依次循環迷宮 for x in range(self.columnsInMaze): if self.mazelist[y][x] == OBSTACLE: #如果迷宮列表的該位置為障礙物,則畫方塊 self.drawCenteredBox(x + self.xTranslate, -y + self.yTranslate, 'orange') # 畫方塊 def drawCenteredBox(self, x, y, color): self.t.up() #畫筆抬起 self.t.goto(x - 0.5, y - 0.5) #前往參數位置,此處0.5偏移量的作用是使烏龜的探索路線在單元格的正中心位置 self.t.color(color) #方塊邊框為橙色 self.t.fillcolor('green') #方塊內填充綠色 self.t.setheading(90) #設置海龜的朝向,標准模式:0 - 東,90 - 北,180 - 西,270 - 南。logo模式:0 - 北,90 - 東,180 - 南,270 - 西。 self.t.down() #畫筆落下 self.t.begin_fill() #開始填充 for i in range(4): #畫方塊邊框 self.t.forward(1) #前進1個單位 self.t.right(90) #右轉90度 self.t.end_fill() #結束填充 # 移動海龜 def moveTurtle(self, x, y): self.t.up() #畫筆抬起 # setheading()設置海龜朝向,towards()從海龜位置到由(x, y),矢量或另一海龜位置連線的夾角。此數值依賴於海龜初始朝向,由"standard"、"world"或"logo" 模式設置所決定。 self.t.setheading(self.t.towards(x + self.xTranslate, -y + self.yTranslate)) self.t.goto(x + self.xTranslate, -y + self.yTranslate) #前往目標位置 # 畫路徑圓點 def dropBreadcrumb(self, color): self.t.dot(color) #dot(size=None, color)畫路徑圓點 # 用以更新迷宮內的狀態及在窗口中改變海龜位置,行列參數為烏龜的初始坐標。 def updatePosition(self, row, col, val): self.mazelist[row][col] = val #設置該標記狀態為當前單元格的值 self.moveTurtle(col, row) #移動海龜 if val == PART_OF_PATH: #其中一條成功路徑的圓點的顏色 color = 'green' elif val == TRIED: #嘗試用的圓點的顏色 color = 'black' elif val == DEAD_END: #死胡同用的圓點的顏色 color = 'red' self.dropBreadcrumb(color) #畫路徑圓點並上色 # 用以判斷當前位置是否為出口。 def isExit(self, row, col): return (row == 0 or row == self.rowsInMaze - 1 or col == 0 or col == self.columnsInMaze - 1) #根據海龜位置是否在迷宮的4個邊線位置判斷 # 返回鍵對應的值,影響searchFrom()中maze[startRow][startColumn]值的獲取 def __getitem__(self, key): return self.mazelist[key] if __name__ == '__main__': PART_OF_PATH = 'O' #部分路徑 TRIED = '.' #嘗試 OBSTACLE = '+' #障礙 DEAD_END = '-' #死胡同 myMaze = Maze('maze.txt')#實例化迷宮類,maze文件是使用“+”字符作為牆壁圍出空心正方形空間,並用字母“S”來表示起始位置的迷宮文本文件。 myMaze.drawMaze() #在屏幕上繪制迷宮。 searchFrom(myMaze, myMaze.startRow, myMaze.startCol) #探索迷宮