AI貪吃蛇前瞻——基於Dijkstra算法的最短路徑問題


    在貪吃蛇流程結構優化之后,我又不滿足於親自操刀控制這條蠢蠢的蛇,干脆就讓它升級成AI,我來看程序自己玩,哈哈。

 一、Dijkstra算法原理

作為一種廣為人知的單源最短路徑算法,Dijkstra用於求解帶權有向圖的單源最短路徑的問題。所謂單源,就是一個源頭,也即一個起點。該算法的本質就是一個廣度優先搜索,由中心向外層層層拓展,直到遇到終點或者遍歷結束。該算法在搜索的過程中需要兩個表S及Q,S用來存儲已掃描過的節點,Q存儲剩下的節點。起點s距離dist[s] = 0;其余點的值為無窮大(具體實現時,表示為某一不可能達到的數字即可)。開始時,從Q中選擇一點u,放入S,以u為當前點,修改u周圍點的距離。重復上述步驟,直到Q為空。

 

二、Dijkstra算法在AI貪吃蛇問題中的變化

2.1 地圖的表示方法

    與平時見到的各種連通圖問題不同,貪吃蛇游戲中的地圖可以看成是標准的矩形,也即,一個二維數組,圖中各個相鄰節點的權值為1。因此,我們可以用一個邊長*邊長的二維數組作為算法的主體數據結構,講地圖有關的數據都集成在數組里。既然選擇了二維數組,就要考慮數組元素類型的問題,即我們的數組應該存儲哪些信息。作為主要的數據結構,我們希望我們的數組能存儲自身的坐標,起點到自身的最短路徑,因此我們可以定義這樣的一個結構體:

  1. typedef struct loca{  
  2.     int x;  
  3.     int y;  
  4. }Local;  
  5.     
  6. typedef struct unit{  
  7.     int value;  
  8.     Local local;  
  9. }Unit;  

 

    又因為我們需要得到最短路徑以求得貪吃蛇下一步的方向,所以在結構體里加一個指針,指向前一個節點的位置。

  1. typedef struct loca{  
  2.     int x;  
  3.     int y;  
  4. }Local;  
  5.     
  6. typedef struct unit{  
  7.     int value;  
  8.     Local local;  
  9.     struct unit *pre;  
  10. }Unit;  

 

假設地圖為一個正方形,因此創建一個邊長*邊長大小的二維數組:

  1. #define N 5  
  2.     
  3. Unit mapUnit[N][N];  

 

2.2 隊列——待處理節點的集合

    有了mapUnit之后,我們還需要一個數據結構來存儲接下來需要處理的節點的信息。在此我選擇了一個隊列,由於C語言不提供標准的接口,就自己草草的寫了一個。

  1. typedef struct queue{  
  2.     int head,tail;  
  3.     Local queue[N*N];  
  4. }Queue;  
  5.     
  6. Queue que;  

 

使用了一個定長的數組來作為隊列結構,所以為了應對所有的結果,將其長度設為N*N。也正因為是定長數組,隊列的進隊與出隊只需操作表示下標值的head與tail即可。這樣雖然不節約空間,但勝在實現方便。

  1. void push(int x,int y)  
  2. {  
  3.     que.tail++;  
  4.     que.queue[que.tail].x = x;  
  5.     que.queue[que.tail].y = y;  
  6. }  
  7.     
  8. void pop()  
  9. {  
  10.     que.head++;  
  11. }  

 

由於push操作有一個自增操作,所以在初始化時需要將tail設為-1,這樣在push第一個節點時可保證head與tail指向同一個位置。

 

2.3 console坐標——地圖的初始化

    在我的貪吃蛇鏈表實現中,前端展示時通過后台的計算邏輯+Pos函數來實現的,也就是現在后台計算結果,再推動前台的變化。因此Pos(),也就是使光標跳轉到控制台某位置的函數就尤為重要,這也直接影響了整個項目各元素的坐標表示方法。

    簡單來說就是console的坐標表示類似於坐標軸中第四象限的表示方法,當然元素都為正值。

所以對於一個N*N的數組,我們可以這樣初始化:

  1. void InitializeMapUnit()  
  2. {  
  3.     que.head = 0;  
  4.     que.tail = -1;  
  5.     
  6.     for(int i = 0;i<N;i++)  
  7.         for(int j = 0;j<N;j++)  
  8.         {  
  9.             mapUnit[i][j].local.x = i;  
  10.             mapUnit[i][j].local.y = j;  
  11.             mapUnit[i][j].pre = NULL;  
  12.             mapUnit[i][j].value = N*N;   
  13.         }  
  14. }  

 

將隊列的初始化放在這個函數里實屬無奈,這兩行語句,又不能在初始化時賦值,又不能在函數體外賦值,放main函數嫌它亂,單獨一個函數嫌它慢….就放在地圖初始化里了…

 

三、計算,BFS!

3.1 設置起點

    基礎的結構與初始化完成后,就需要開始計算了。在此之前,我們需要一個坐標,來作為路徑問題的出發點。

  1. void setOrigin(int x,int y)  
  2. {  
  3.     mapUnit[x][y].value = 0;  
  4.     push(x,y);  
  5. }  

 

將地圖上該點位置的值設為0后,將其壓入隊列中。在第一輪的BFS中,它四周的點,將成為第二輪計算的原點。

 

3.2 BFS框架

    在該地圖的BFS中,我們將依托隊列各個元素,來處理它們的鄰接節點。兩個循環,可以揭示大體的框架:

  1. void bfs(int end_x,int end_y)  
  2. {  
  3.     //當前需要處理的節點   
  4.     for(int i = head;i<=tail;i++)  
  5.     {  
  6.         //  四個方向   
  7.         for(int j = 0;j<4;j++)  
  8.         {  
  9.             // 新節點   
  10.             if(mapUnit[new_x][new_y].value == N*N)  
  11.             {  
  12.                 //設置屬性   
  13.             }  
  14.             //  處理過的節點,取小值  
  15.             else       
  16.             {  
  17.                 //屬性更改Or不變   
  18.             }  
  19.         }  
  20.     }  
  21.     //下一輪   
  22.     bfs();  
  23. }  

 

3.3 變化的隊列

    BFS的主體循環依賴於隊列的head與tail,但是對新節點的push操作改變了tail的值,所以我們需要在循環開始前將此時(上一輪BFS的結果)的隊列狀態保存下來,避免隊列變化對BFS的影響。

  1. int head = que.head;  
  2. int tail = que.tail;  
  3. //當前隊列  
  4. for(int i = head;i<=tail;i++)  
  5. {  
  6.     // TODO...   
  7. }   

 

3.4 節點的坐標

    在原來寫的BFS中,要獲取一個節點的下標需要將一個結構體層層剝開,數組的下標是一個結構體某元素的某元素,繞來繞去,可讀性早已被獻祭了。

所以這次我吸取了教訓,在內循環,也就是處理周圍節點時,將其坐標先存儲在變量中,用來確保程序的可讀性。

  1. for(int i = head;i<=tail;i++)  
  2. {  
  3.         int base_x = que.queue[i].x;  
  4.         int base_y = que.queue[i].y;  
  5.             
  6.         //  四個方向   
  7.         for(int j = 0;j<4;j++)  
  8.         {  
  9.             int new_x = base_x + direct[j][0];  
  10.             int new_y = base_y + direct[j][1];  
  11.                 
  12.             // TODO...   
  13.         }   
  14. }  

 

所以我們可以構建出這樣一個移動的二維數組:

  1. //          方向,                     
  2. int direct[4][2] = {{0,-1},{0,1},{-1,0},{1,0}};  

 

3.4.1 數組越界的處理

    在得到了待處理節點的坐標后,需要對其進行判斷,確保它在數組內部。

  1. if(stepRight(new_x,new_y) == false)  
  2.     continue;  

 

函數細節如下:

  1. bool stepRight(int x,int y)  
  2. {  
  3.     if(x >= N || y >= N ||  
  4.         x < 0 || y < 0)  
  5.         return false;  
  6.     return true;  
  7. }  

 

 

3.5 新節點的處理

    終於到了訪問鄰接坐標的時候。一個節點四周的節點,有可能沒有被訪問過,也可能以及被訪問過。我們在初始化時就將所有節點的值設為了一個MAX,通過對值得判斷,可以推斷出其是否為新節點。

  1. if(mapUnit[new_x][new_y].value == N*N)  
  2. {  
  3.     // ...  
  4. }  
  5. else    //取小值   
  6. {  
  7.     // ...  
  8. }  

3.5.1 未處理節點的處理

    對於未處理的節點,對其的操作有兩部。一是初始化,值的初始化與指針的初始化。由於兩點間的距離為1,所以該節點的值為前一個節點的值+1,當然,他的pre指針也指向前一個節點。

  1. mapUnit[new_x][new_y].value = mapUnit[base_x][base_y].value +1;  
  2. mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];  
  3. push(new_x,new_y);  

 

3.5.2 已處理節點的處理

    對於已處理過的節點,需要先將其做一個判斷,即尋找最短路徑,將其自身的value與前一節點value+1比較,再處理。

  1. mapUnit[new_x][new_y].value = MIN(mapUnit[new_x][new_y].value,mapUnit[base_x][base_y].value +1);  
  2. if(mapUnit[new_x][new_y].value != mapUnit[new_x][new_y].value)  
  3. mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];  

 

3.6 隊列的刷新

    在處理完一層節點后,新的節點導致了隊列中tail的增加,但是head並沒有減少,所以在新一輪BFS前,需要將隊列的head移動到真正的頭部去。

  1. for(int i = head;i<=tail;i++)  
  2.     pop();  

 

在這兒也需要當前BFS輪數前的隊列數據。

 

3.7 最短路徑

    在地圖的遍歷完成之后,我們就可以任取一點,得到起點到該點的最短路徑。

  1. void getStep(int x,int y)  
  2. {  
  3.     Unit *scan = &mapUnit[x][y];  
  4.     if(scan->pre!= NULL)  
  5.     {  
  6.         int x = scan->local.x;  
  7.         int y = scan->local.y;  
  8.         scan = scan->pre;  
  9.         getStep(scan->local.x,scan->local.y);  
  10.         printf(" -> ");  
  11.     }  
  12.     printf("(%d,%d)",x,y);  
  13. }  

 

四、此路不通——障礙的引入

    在貪吃蛇中,由於蛇身長度的存在,以及蛇頭咬到自身就結束的特例,我們需要在算法中加入障礙的元素。

    對於這個新加入的元素,我們設置一個坐標結構體的數組,來存儲所有的障礙。

  1. #define WALL_CNT 3   
  2. Local Wall[WALL_CNT];  

 

    用一個函數來設置障礙:

  1. void setWall(void)  
  2. {  
  3.     Wall[0].x = 1;  
  4.     Wall[0].y = 1;  
  5.         
  6.     Wall[1].x = 1;  
  7.     Wall[1].y = 2;  
  8.         
  9.     Wall[2].x = 2;  
  10.     Wall[2].y = 1;  
  11. }  

 

    由於這個項目里數據用於模塊測試的隨機性,所以手動設置每一個坐標。在之后的貪吃蛇AI中,將接受一個數組——蛇身,來自動完成賦值。

    如果將障礙與地圖邊界等同來看,就能將障礙的判斷整合進stepRight()函數。

  1. bool stepRight(int x,int y)  
  2. {  
  3. //  out of map   
  4.     if(x >= N || y >= N ||  
  5.         x < 0 || y < 0)  
  6.         return false;  
  7. //  wall  
  8.     for(int i = 0;i<WALL_CNT;i++)  
  9.         if(Wall[i].x == x && Wall[i].y == y)  
  10.             return false;  
  11.         
  12.     return true;  
  13. }  

 

五、簡單版本的測試

    完成了上訴的模塊后,項目就可以無BUG但是低效的跑了。我們來試一試,在一個5*5的地圖中,起點在中間,為(2,2),終點在起點的上上方,為(2,0),設置三面圍牆,分別是(1,1),(2,1),(3,1)。如下:

    看看效果。

    圖中二維數組打印了各個坐標點的value,即該點到起點的最短路徑。25為牆,0為起點。可以看到到終點需要六步,路徑是先往左,再往上,左后向右到終點。

    任務完成,看似不錯。把終點換近一些看看,就(1,4)吧。

    喔,問題出來了。我取一個非常近的點,但是整張圖都被遍歷了,效率太低了,要改進。

    還有一個問題,如果將起點周圍四個點都設置為牆,結果應該是無法得到其余點的最短路徑,但現階段的結果還不盡如人意:

 

六、優化

6.1 遍歷的半途結束

    在BFS中,如果找到了終點,那就可以退出遍歷,直接輸出結果。不過這樣的一個遞歸樹,要隨時終止可不容易。我一開始想到了"萬惡之源"goto,不過goto不能跨函數跳轉,隨后又想到了非本地跳轉setjmp與longjmp。

"與刺激的abort()和exit()相比,goto語句看起來是處理異常的更可行方案。不幸的是,goto是本地的:它只能跳到所在函數內部的標號上,而不能將控制權轉移到所在程序的任意地點(當然,除非你的所有代碼都在main體中)。

為了解決這個限制,C函數庫提供了setjmp()和longjmp()函數,它們分別承擔非局部標號和goto作用。頭文件<setjmp.h>申明了這些函數及同時所需的jmp_buf數據類型。"

 

    有了這隨時能走的"閃現"功能,跳出復雜嵌套函數還是事兒嘛?

  1. #include <setjmp.h>  
  2. jmp_buf jump_buffer;  
  3.     
  4. int main (void)  
  5. {  
  6.     //...  
  7.     if(setjmp(jump_buffer) == 0)  
  8.         bfs(finishing_x,finishing_y);  
  9.     //...  
  10. }  

    由於跳轉需要判斷當前節點是否為終點,而終點又是一個局部變量,所以需要改變bfs函數,使其攜帶終點參數。

    再在處理完一個節點后,判斷其是否為終點,是則退出。

  11. for(int i = head;i<=tail;i++)  
  12. {  
  13.     //...  
  14.     //  四個方向   
  15.     for(int j = 0;j<4;j++)  
  16.     {  
  17.         //...  
  18.         if(mapUnit[new_x][new_y].value == N*N)  
  19.         {  
  20.             //...  
  21.         }  
  22.         else    //取小值   
  23.         {  
  24.             //...  
  25.         }  
  26.         if(new_x == end_x && new_y == end_y)  
  27.         {  
  28.             longjmp(jump_buffer, 1);  
  29.         }  
  30.     }  
  31. }  

 

6.2 無最短路徑時的處理

    在判斷某一點的路徑時,可先判斷其是否存在最短路徑,存在則輸出,否則給出提示信息。

  1. void getStepNext(int x,int y)  
  2. {  
  3.     Unit *scan = &mapUnit[x][y];  
  4.     if(scan->pre!= NULL)  
  5.     {  
  6.         int x = scan->local.x;  
  7.         int y = scan->local.y;  
  8.         scan = scan->pre;  
  9.         getStepNext(scan->local.x,scan->local.y);  
  10.         printf(" -> ");  
  11.     }  
  12.     printf("(%d,%d)",x,y);  
  13. }  
  14.     
  15. void getStep(int x,int y,int orgin_x,int orgin_y)  
  16. {  
  17.     Unit *scan = &mapUnit[x][y];  
  18.     Pos(0,10);  
  19.         
  20.     if(scan->pre == NULL)  
  21.     {  
  22.         printf("NO Path To Point (%d,%d) From Point (%d,%d)!\n",x,y,orgin_x,orgin_y);  
  23.     }  
  24.     else  
  25.     {  
  26.         getStepNext(x,y);  
  27.     }  
  28. }  

 

七、優化后效果

 

八、寫在后面

    算法大體上完成了,將其改為貪吃蛇AI也只需做少量修改。盡量抽個時間把AI寫完,不過可能需要一段時間。

    在最短路徑的解法上,Dijkstra算法並不是最理想的解法。盲目搜索的效率很低。考慮到地圖上存在着兩點間距離等信息,可以使用一種啟發式搜索算法,如BFS(Best-First Search),以及大名鼎鼎的A*算法。在中文互聯網我能找到的有關於A*算法的資料不多,將來得花些時間好好研究下。

    源碼地址:https://github.com/MagicXyxxx/Algorithms    

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM