代碼已經發布到了github:https://github.com/roadwide/AI-Homework
如果幫到你了,希望給個star鼓勵一下
1 深度優先遍歷搜索(DFS)
1.1算法介紹
深度優先搜索算法(Depth-First-Search,DFS)是一種用於遍歷或搜索樹或圖的算法。沿着樹的深度遍歷樹的節點,盡可能深的搜索樹的分支。當節點v的所在邊都己被探尋過,搜索將回溯到發現節點v的那條邊的起始節點。這一過程一直進行到已發現從源節點可達的所有節點為止。如果還存在未被發現的節點,則選擇其中一個作為源節點並重復以上過程,整個進程反復進行直到所有節點都被訪問為止。屬於盲目搜索。
以上圖為例,簡述DFS的過程。首先從根節點"1"出發,按一定的順序遍歷其子節點,這里我們假設優先遍歷左邊的。所以,在遍歷"1"之后,我們到了節點"2",此時"2"仍有子節點,所以應繼續向下遍歷,下一個節點是"3",然后是"4"。到了"4"之后,沒有子節點了,說明我們已經將這一條路遍歷完了,接着我們應該回溯,應該回到"4"的父節點,也就是"3"。因為"3"還有一個子節點"5"沒有遍歷,所以下一個我們應該遍歷的是"5"。遍歷完"5"之后又發現一條路到頭了,再次回溯依然回溯到其父節點"3",此時"3"的所有子節點都已經遍歷完了,因該接着回溯到"3"的父節點"2",然后檢查"2"是否有沒有遍歷完的子節點。按照這樣的規則,完成所有節點的遍歷。最終得到的遍歷順序是"1-2-3-4-5-6-7-8-9-10-11-12"
在介紹了DFS在遍歷樹的應用后,我們將其應用於八數碼問題的解決。八數碼問題也稱為九宮問題。在3×3的棋盤,擺有八個棋子,每個棋子上標有1至8的某一數字,不同棋子上標的數字不相同。棋盤上還有一個空格,與空格相鄰的棋子可以移到空格中。要求解決的問題是:給出一個初始狀態和一個目標狀態,找出一種從初始轉變成目標狀態的移動棋子步數最少的移動步驟。所謂問題的一個狀態就是棋子在棋盤上的一種擺法。棋子移動后,狀態就會發生改變。解八數碼問題實際上就是找出從初始狀態到達目標狀態所經過的一系列中間過渡狀態。
上面說的DFS遍歷的樹是已經存在的,我們只需要按照規定的遍歷方法就能完成遍歷,而對於八數碼問題,沒有已經存在的路徑供我們遍歷,需要我們從初始狀態向下延伸(也就是上下左右移動)才能構造出類似的樹。
以上圖為例。在使用DFS進行搜索時,每個狀態都會按照一定的順序進行上下左右移動(在上圖中是下、左、右、上的順序),一次移動后會產生一個新的狀態,然后以新狀態為起點繼續按約定的順序(例如先向下)移動。終止的條件是找到解或者達到深度界限。那么如果按照圖中下、左、右、上的順序搜索后的結果將會是最左邊的一條路一直是優先向下移動,如果不能向下則依次會是左、右、上的一種。
1.2實驗代碼
import copy #棋盤的類,實現移動和擴展狀態 class grid: def __init__(self, stat): self.pre = None self.target = [[1, 2, 3], [8, 0, 4], [7, 6, 5]] self.stat = stat self.find0() self.update() #更新深度和距離和 def update(self): self.fH() self.fG() # G是深度,也就是走的步數 def fG(self): if (self.pre != None): self.G = self.pre.G + 1 else: self.G = 0 # H是和目標狀態距離之和,可以用來判斷是否找到解 def fH(self): self.H = 0 for i in range(3): for j in range(3): targetX = self.target[i][j] nowP = self.findx(targetX) self.H += abs(nowP[0] - i) + abs(nowP[1] - j) #以三行三列的形式輸出當前狀態 def see(self): print("depth:", self.G) for i in range(3): print(self.stat[i]) print("-" * 10) # 查看找到的解是如何從頭移動的 def seeAns(self): ans = [] ans.append(self) p = self.pre while (p): ans.append(p) p = p.pre ans.reverse() for i in ans: i.see() #找到數字x的位置 def findx(self, x): for i in range(3): if (x in self.stat[i]): j = self.stat[i].index(x) return [i, j] #找到0的位置,也就是空白格的位置 def find0(self): self.zero = self.findx(0) #對當前狀態進行擴展,也就是上下左右移動,返回的列表中是狀態的二維列表,不是對象 def expand(self): i = self.zero[0] j = self.zero[1] gridList = [] if (j == 2 or j == 1): gridList.append(self.left()) if (i == 2 or i == 1): gridList.append(self.up()) if (i == 0 or i == 1): gridList.append(self.down()) if (j == 0 or j == 1): gridList.append(self.right()) return gridList # deepcopy多維列表的復制,防止指針賦值將原列表改變 # move只能移動行或列,即row和col必有一個為0 #對當前狀態進行移動的函數 def move(self, row, col): newStat = copy.deepcopy(self.stat) tmp = self.stat[self.zero[0] + row][self.zero[1] + col] newStat[self.zero[0]][self.zero[1]] = tmp newStat[self.zero[0] + row][self.zero[1] + col] = 0 return newStat def up(self): return self.move(-1, 0) def down(self): return self.move(1, 0) def left(self): return self.move(0, -1) def right(self): return self.move(0, 1) # 判斷狀態g是否在狀態集合中,g是對象,gList是對象列表 #返回的結果是一個列表,第一個值是真假,如果是真則第二個值是g在gList中的位置索引 def isin(g, gList): gstat = g.stat statList = [] for i in gList: statList.append(i.stat) if (gstat in statList): res = [True, statList.index(gstat)] else: res = [False, 0] return res #計算逆序數之和 def N(nums): N=0 for i in range(len(nums)): if(nums[i]!=0): for j in range(i): if(nums[j]>nums[i]): N+=1 return N #根據逆序數之和判斷所給八數碼是否可解 def judge(src,target): N1=N(src) N2=N(target) if(N1%2==N2%2): return True else: return False #初始狀態 startStat = [[2, 8, 3], [1, 0, 4], [7, 6, 5]] g = grid(startStat) #判斷所給的八數碼受否有解 if(judge(startStat,g.target)!=True): print("所給八數碼無解,請檢查輸入") exit(1) #visited儲存的是已經擴展過的節點 visited = [] time = 0 #用遞歸的方式進行DFS遍歷 def DFSUtil(v, visited): global time #判斷是否達到深度界限 if (v.G > 4): return time+=1 #判斷是否已經找到解 if (v.H == 0): print("found and times", time, "moves:", v.G) v.seeAns() exit(1) #對當前節點進行擴展 visited.append(v.stat) expandStats = v.expand() w = [] for stat in expandStats: tmpG = grid(stat) tmpG.pre = v tmpG.update() if (stat not in visited): w.append(tmpG) for vadj in w: DFSUtil(vadj, visited) #visited查重只對一條路,不是全局的,每條路開始時都為空 #因為如果全局查重,會導致例如某條路在第100層找到的狀態,在另一條路是第2層找到也會被當做重復 #進而導致明明可能會找到解的路被放棄 visited.pop() DFSUtil(g, visited) #如果找到解程序會在中途退出,走到下面這一步證明沒有找到解 print("在當前深度下沒有找到解,請嘗試增加搜索深度")
1.3實驗結果
以下面這個八數碼為例,用DFS進行搜索。
將找出的解從初始狀態一步一步輸出到解狀態。
可以看出總共進行了15次遍歷,在某一條路的第4層找到了解。
下面我們來看一看DFS的所有15次遍歷,以此來更深入的理解DFS的原理。稍微對代碼進行改動,使其輸出遍歷次數和當前層數。由於結果太長,為了方便展示,下面將以樹的形式展示。
上面輸出的解就是按照紅色路線標注找到的,從遍歷次數可以看出DFS是一條道走到黑的找法,因為設置的深度界限是4,所以每一條路最多找到第4層。
1.4實驗總結
1、為什么要設置深度界限?
因為理論上我們只需要一條路就可以找到解,只要不停地向下擴展就可以了。而這樣做的缺點是會繞遠路,也許第一條路找到第100層才找到解,但第二條路找兩層就能找到解。從DFS的原理出發,我們不難看出這一點。還有一個問題是其狀態數太多了,在不設置深度界限的情況下經常出現即使程序的棧滿了依然沒有找到解的情況。所以理論只是理論,在堅持"一條道走到黑"時,很可能因為程序"爆棧"而走到了黑還是沒有找到解。
2、如何進行回溯?
在八數碼問題中,我們回溯的條件只有一個,就是達到深度界限了。因為在找到解時會退出,找不到時會繼續向下擴展。回溯的過程是先回溯到父節點,檢查父節點是否還能擴展其他節點,如果能,就擴展新的節點並繼續向下搜索,如果不能則遞歸地繼續向上回溯。
3、出現重復狀態怎么解決?
不難想出假如按照下、左、右、上這樣地順序進行搜索時,在第三層時就會出現和初始狀態相同的情況。因為第二層向一個方向移動,第三層會有一個向反方向移動的狀態也就是回到初始狀態了。這樣不僅增加了運算量,而且沒有意義,會出現很多冗余步驟。所以我們應該設置一個查重的表,將已經遍歷過的狀態存入這個表中,當再次遇到這種情況時我們就跳過。
那么這個查重的表是該對於全局而言呢,還是每條路的查重表是獨立的呢?在經過很多測試之后,我發現這個查重表對每條路獨立是更好的。因為在一條路上出現的狀態所需要的步數和另一條需要的步數不一定相同,也就是說我在第一條路上的第100層找到了某個狀態,放入了查重表中,但是這個狀態可能在另一條路上第2層就能找到,或許再下面幾層就能找到解了,可是由於被放進了全局查重表中而放棄了這個條路的擴展,也損失了更快找到解的機會。所以一條路一個查重表是好的。
4、由於需要設置深度界限,每條路都會在深度界限處截至,而如果所給的八數碼的最優解大於深度界限,就會出現遍歷完所有情況都找不解。而在事先不知道最優解的深度的情況下這個深度界限很難確定,設置大了會增大搜索時間,設置小了會找不到解。這也是DFS的一個缺點。
5、DFS不一定能找到最優解。因為深度界限的原因,找到的解可能在最優解和深度界限之間。
2 廣度優先遍歷搜索(BFS)
2.1算法介紹
廣度優先搜索算法(英語:Breadth-First-Search,縮寫為BFS),是一種圖形搜索算法。簡單的說,BFS是從根節點開始,沿着樹的寬度遍歷樹的節點。如果所有節點均被訪問,則算法中止。BFS是一種盲目搜索法,目的是系統地展開並檢查圖中的所有節點,以找尋結果。
BFS會先訪問根節點的所有鄰居節點,然后再依次訪問鄰居節點的鄰居節點,直到所有節點都訪問完畢。在具體的實現中,使用open和closed兩個表,open是一個隊列,每次對open進行一次出隊操作(並放入closed中),並將其鄰居節點進行入隊操作。直到隊列為空時即完成了所有節點的遍歷。closed表在遍歷樹時其實沒有用,因為子節點只能從父節點到達。但在進行圖的遍歷時,一個節點可能會由多個節點到達,所以此時為了防止重復遍歷應該每次都檢查下一個節點是否已經在closed中了。
依然使用上面的這個例子,如果使用BFS進行遍歷,那么節點的訪問順序是"1-2-7-8-3-6-9-12-4-5-10-11"。可以看出來BFS進行遍歷時是一層一層的搜索的。
在應用BFS算法進行八數碼問題搜索時需要open和closed兩個表。首先將初始狀態加入open隊列,然后進行出隊操作並放入closed中,對出隊的狀態進行擴展(所謂擴展也就是找出其上下左右移動后的狀態),將擴展出的狀態加入隊列,然后繼續循環出隊-擴展-入隊的操作,直到找到解為止。
上圖這個例子中,紅圈里的數字是遍歷順序。當找到解時一直往前找父節點即可找出求解的移動路線。
2.2實驗代碼
import copy #棋盤的類,實現移動和擴展狀態 class grid: def __init__(self,stat): self.pre=None self.target=[[1,2,3],[8,0,4],[7,6,5]] self.stat=stat self.find0() self.update() #更新深度和距離和 def update(self): self.fH() self.fG() #G是深度,也就是走的步數 def fG(self): if(self.pre!=None): self.G=self.pre.G+1 else: self.G=0 #H是和目標狀態距離之和,可以用來判斷是否達到最優解 def fH(self): self.H=0 for i in range(3): for j in range(3): targetX=self.target[i][j] nowP=self.findx(targetX) self.H+=abs(nowP[0]-i)+abs(nowP[1]-j) #查看當前狀態 def see(self): print("depth:",self.G) for i in range(3): print(self.stat[i]) print("-"*10) #查看找到的解是如何從頭移動的 def seeAns(self): ans=[] ans.append(self) p=self.pre while(p): ans.append(p) p=p.pre ans.reverse() for i in ans: i.see() #找到數字x的位置,返回其坐標 def findx(self,x): for i in range(3): if(x in self.stat[i]): j=self.stat[i].index(x) return [i,j] #找到0,也就是空白格的位置 def find0(self): self.zero=self.findx(0) #對當前狀態進行所有可能的擴展,返回一個擴展狀態的列表 def expand(self): i=self.zero[0] j=self.zero[1] gridList=[] if(j==2 or j==1): gridList.append(self.left()) if(i==2 or i==1): gridList.append(self.up()) if(i==0 or i==1): gridList.append(self.down()) if(j==0 or j==1): gridList.append(self.right()) return gridList #deepcopy多維列表的復制,防止指針賦值將原列表改變 #move只能移動行或列,即row和col必有一個為0 #對當前狀態進行移動 def move(self,row,col): newStat=copy.deepcopy(self.stat) tmp=self.stat[self.zero[0]+row][self.zero[1]+col] newStat[self.zero[0]][self.zero[1]]=tmp newStat[self.zero[0]+row][self.zero[1]+col]=0 return newStat def up(self): return self.move(-1,0) def down(self): return self.move(1,0) def left(self): return self.move(0,-1) def right(self): return self.move(0,1) #計算逆序數之和 def N(nums): N=0 for i in range(len(nums)): if(nums[i]!=0): for j in range(i): if(nums[j]>nums[i]): N+=1 return N #根據逆序數之和判斷所給八數碼是否可解 def judge(src,target): N1=N(src) N2=N(target) if(N1%2==N2%2): return True else: return False #初始化狀態 startStat=[[2,8,3],[1,0,4],[7,6,5]] g=grid(startStat) if(judge(startStat,g.target)!=True): print("所給八數碼無解,請檢查輸入") exit(1) visited=[] queue=[g] time=0 while(queue): time+=1 v=queue.pop(0) #判斷是否找到解 if(v.H==0): print("found and times:",time,"moves:",v.G) #查看找到的解是如何從頭移動的 v.seeAns() break else: #對當前狀態進行擴展 visited.append(v.stat) expandStats=v.expand() for stat in expandStats: tmpG=grid(stat) tmpG.pre=v tmpG.update() if(stat not in visited): queue.append(tmpG)
2.3實驗結果
仍然用相同的例子,用BFS進行搜索。
將找出的解從初始狀態一步一步輸出到解狀態。
從結果中可以看出總共進行了27次遍歷,並在第4層時找到了解狀態。
下面我們來看一看BFS的所有27次遍歷,以此來更深入的理解BFS的原理。稍微對代碼進行改動,使其輸出遍歷次數和當前層數。由於結果太長,為了方便展示,下面將以樹的形式展示。
上面輸出的解就是按照紅色路線標注找到的,從遍歷次數可以看出是一層一層的找。
2.4實驗總結
由於BFS是一層一層找的,所以一定能找到解,並且是最優解。雖然能找到最優解,但它的盲目性依然是一個很大的缺點。從上面的遍歷樹狀圖中,每一層都比上一層元素更多,且是近似於指數型的增長。也就是說,深度每增加一,這一層的搜索速度就要增加很多。
3 A*算法實現8數碼問題
3.1算法介紹
Astar算法是一種求解最短路徑最有效的直接搜索方法,也是許多其他問題的常用啟發式算法。它的啟發函數為f(n)=g(n)+h(n),其中,f(n) 是從初始狀態經由狀態n到目標狀態的代價估計,g(n) 是在狀態空間中從初始狀態到狀態n的實際代價,h(n) 是從狀態n到目標狀態的最佳路徑的估計代價。
h(n)是啟發函數中很重要的一項,它是對當前狀態到目標狀態的最小代價h*(n)的一種估計,且需要滿足
h(n)<=h*(n)
也就是說h(n)是h*(n)的下界,這一要求保證了Astar算法能夠找到最優解。這一點很容易想清楚,因為滿足了這一條件后,啟發函數的值總是小於等於最優解的代價值,也就是說尋找過程是在朝着一個可能是最優解的方向或者是比最優解更小的方向移動,如果啟發函數值恰好等於實際最優解代價值,那么搜索算法在一直嘗試逼近最優解的過程中會找到最優解;如果啟發函數值比最優解的代價要低,雖然無法達到,但是因為方向一致,會在搜索過程中發現最優解。
h是由我們自己設計的,h函數設計的好壞決定了Astar算法的效率。h值越大,算法運行越快。但是在設計評估函數時,需要注意一個很重要的性質:評估函數的值一定要小於等於實際當前狀態到目標狀態的代價。否則雖然程序運行速度加快,但是可能在搜索過程中漏掉了最優解。相對的,只要評估函數的值小於等於實際當前狀態到目標狀態的代價,就一定能找到最優解。所以,在這個問題中我們可以將評估函數設定為1-8八數字當前位置到目標位置的曼哈頓距離之和。
Astar算法與BFS算法的不同之處在於每次會根據啟發函數的值來進行排序,每次先出隊的是啟發函數值最小的狀態。
Astar算法可以被認為是Dijkstra算法的擴展。Dijkstra算法在搜索最短距離時是已知了各個節點之間的距離,而對於Astar而言,這個已知的距離被啟發函數值替換。
3.2實驗代碼
import copy #棋盤的類,實現移動和擴展狀態 class grid: def __init__(self,stat): self.pre=None #目標狀態 self.target=[[1,2,3],[8,0,4],[7,6,5]] #stat是一個二維列表 self.stat=stat self.find0() self.update() #更新啟發函數的相關信息 def update(self): self.fH() self.fG() self.fF() #G是深度,也就是走的步數 def fG(self): if(self.pre!=None): self.G=self.pre.G+1 else: self.G=0 #H是和目標狀態距離之和 def fH(self): self.H=0 for i in range(3): for j in range(3): targetX=self.target[i][j] nowP=self.findx(targetX) #曼哈頓距離之和 self.H+=abs(nowP[0]-i)+abs(nowP[1]-j) #F是啟發函數,F=G+H def fF(self): self.F=self.G+self.H #以三行三列的形式輸出當前狀態 def see(self): for i in range(3): print(self.stat[i]) print("F=",self.F,"G=",self.G,"H=",self.H) print("-"*10) #查看找到的解是如何從頭移動的 def seeAns(self): ans=[] ans.append(self) p=self.pre while(p): ans.append(p) p=p.pre ans.reverse() for i in ans: i.see() #找到數字x的位置 def findx(self,x): for i in range(3): if(x in self.stat[i]): j=self.stat[i].index(x) return [i,j] #找到0,也就是空白格的位置 def find0(self): self.zero=self.findx(0) #擴展當前狀態,也就是上下左右移動。返回的是一個狀態列表,也就是包含stat的列表 def expand(self): i=self.zero[0] j=self.zero[1] gridList=[] if(j==2 or j==1): gridList.append(self.left()) if(i==2 or i==1): gridList.append(self.up()) if(i==0 or i==1): gridList.append(self.down()) if(j==0 or j==1): gridList.append(self.right()) return gridList #deepcopy多維列表的復制,防止指針賦值將原列表改變 #move只能移動行或列,即row和col必有一個為0 #向某個方向移動 def move(self,row,col): newStat=copy.deepcopy(self.stat) tmp=self.stat[self.zero[0]+row][self.zero[1]+col] newStat[self.zero[0]][self.zero[1]]=tmp newStat[self.zero[0]+row][self.zero[1]+col]=0 return newStat def up(self): return self.move(-1,0) def down(self): return self.move(1,0) def left(self): return self.move(0,-1) def right(self): return self.move(0,1) #判斷狀態g是否在狀態集合中,g是對象,gList是對象列表 #返回的結果是一個列表,第一個值是真假,如果是真則第二個值是g在gList中的位置索引 def isin(g,gList): gstat=g.stat statList=[] for i in gList: statList.append(i.stat) if(gstat in statList): res=[True,statList.index(gstat)] else: res=[False,0] return res #計算逆序數之和 def N(nums): N=0 for i in range(len(nums)): if(nums[i]!=0): for j in range(i): if(nums[j]>nums[i]): N+=1 return N #根據逆序數之和判斷所給八數碼是否可解 def judge(src,target): N1=N(src) N2=N(target) if(N1%2==N2%2): return True else: return False #Astar算法的函數 def Astar(startStat): #open和closed存的是grid對象 open=[] closed=[] #初始化狀態 g=grid(startStat) #檢查是否有解 if(judge(startStat,g.target)!=True): print("所給八數碼無解,請檢查輸入") exit(1) open.append(g) #time變量用於記錄遍歷次數 time=0 #當open表非空時進行遍歷 while(open): #根據啟發函數值對open進行排序,默認升序 open.sort(key=lambda G:G.F) #找出啟發函數值最小的進行擴展 minFStat=open[0] #檢查是否找到解,如果找到則從頭輸出移動步驟 if(minFStat.H==0): print("found and times:",time,"moves:",minFStat.G) minFStat.seeAns() break #走到這里證明還沒有找到解,對啟發函數值最小的進行擴展 open.pop(0) closed.append(minFStat) expandStats=minFStat.expand() #遍歷擴展出來的狀態 for stat in expandStats: #將擴展出來的狀態(二維列表)實例化為grid對象 tmpG=grid(stat) #指針指向父節點 tmpG.pre=minFStat #初始化時沒有pre,所以G初始化時都是0 #在設置pre之后應該更新G和F tmpG.update() #查看擴展出的狀態是否已經存在與open或closed中 findstat=isin(tmpG,open) findstat2=isin(tmpG,closed) #在closed中,判斷是否更新 if(findstat2[0]==True and tmpG.F<closed[findstat2[1]].F): closed[findstat2[1]]=tmpG open.append(tmpG) time+=1 #在open中,判斷是否更新 if(findstat[0]==True and tmpG.F<open[findstat[1]].F): open[findstat[1]]=tmpG time+=1 #tmpG狀態不在open中,也不在closed中 if(findstat[0]==False and findstat2[0]==False): open.append(tmpG) time+=1 stat=[[2, 8, 3], [1, 0 ,4], [7, 6, 5]] Astar(stat)
3.3實驗結果
仍然用相同的例子,用Astar進行搜索。
將找出的解從初始狀態一步一步輸出到解狀態。
從結果中可以看出總共進行了9次遍歷,並在第4層時找到了解狀態。
下面我們來看一看Astar的所有9次遍歷,以此來更深入的理解Astar的原理。稍微對代碼進行改動,使其輸出遍歷次數和當前狀態的啟發信息(其中F是啟發值,G是當前深度,H是不在位棋子的曼哈頓距離之和)。由於結果太長,為了方便展示,下面將以樹的形式展示。
上面輸出的解就是按照紅色路線標注找到的,從遍歷次數和相應狀態的啟發信息可以看出每次對啟發函數值最小的狀態進行擴展,依次進行搜索。
3.4實驗總結
從三個算法的遍歷次數可以看出Astar算法更加優秀,能夠更快的找到解。但是因為上面給出的八數碼題目太簡單了,只需要4步就能解決問題,所以看起來優勢沒有那么明顯。下面我們選擇另一個比較難的,需要更多移動步數的題目,以此來體現啟發式搜索相較於盲目搜索的優越性。用三種算法搜索下面八數碼的解。
下面是比較的結果
可以看出來Astar在一百次遍歷之內就找到了解,而另外兩個盲目搜索算法則需要幾千次才搜索到。