在貪吃蛇流程結構優化之后,我又不滿足於親自操刀控制這條蠢蠢的蛇,干脆就讓它升級成AI,我來看程序自己玩,哈哈。
一、Dijkstra算法原理
作為一種廣為人知的單源最短路徑算法,Dijkstra用於求解帶權有向圖的單源最短路徑的問題。所謂單源,就是一個源頭,也即一個起點。該算法的本質就是一個廣度優先搜索,由中心向外層層層拓展,直到遇到終點或者遍歷結束。該算法在搜索的過程中需要兩個表S及Q,S用來存儲已掃描過的節點,Q存儲剩下的節點。起點s距離dist[s] = 0;其余點的值為無窮大(具體實現時,表示為某一不可能達到的數字即可)。開始時,從Q中選擇一點u,放入S,以u為當前點,修改u周圍點的距離。重復上述步驟,直到Q為空。
二、Dijkstra算法在AI貪吃蛇問題中的變化
2.1 地圖的表示方法
與平時見到的各種連通圖問題不同,貪吃蛇游戲中的地圖可以看成是標准的矩形,也即,一個二維數組,圖中各個相鄰節點的權值為1。因此,我們可以用一個邊長*邊長的二維數組作為算法的主體數據結構,講地圖有關的數據都集成在數組里。既然選擇了二維數組,就要考慮數組元素類型的問題,即我們的數組應該存儲哪些信息。作為主要的數據結構,我們希望我們的數組能存儲自身的坐標,起點到自身的最短路徑,因此我們可以定義這樣的一個結構體:
-
typedef struct loca{
-
int x;
-
int y;
-
}Local;
-
-
typedef struct unit{
-
int value;
-
Local local;
-
}Unit;
又因為我們需要得到最短路徑以求得貪吃蛇下一步的方向,所以在結構體里加一個指針,指向前一個節點的位置。
-
typedef struct loca{
-
int x;
-
int y;
-
}Local;
-
-
typedef struct unit{
-
int value;
-
Local local;
-
struct unit *pre;
-
}Unit;
假設地圖為一個正方形,因此創建一個邊長*邊長大小的二維數組:
-
#define N 5
-
-
Unit mapUnit[N][N];
2.2 隊列——待處理節點的集合
有了mapUnit之后,我們還需要一個數據結構來存儲接下來需要處理的節點的信息。在此我選擇了一個隊列,由於C語言不提供標准的接口,就自己草草的寫了一個。
-
typedef struct queue{
-
int head,tail;
-
Local queue[N*N];
-
}Queue;
-
-
Queue que;
使用了一個定長的數組來作為隊列結構,所以為了應對所有的結果,將其長度設為N*N。也正因為是定長數組,隊列的進隊與出隊只需操作表示下標值的head與tail即可。這樣雖然不節約空間,但勝在實現方便。
-
void push(int x,int y)
-
{
-
que.tail++;
-
que.queue[que.tail].x = x;
-
que.queue[que.tail].y = y;
-
}
-
-
void pop()
-
{
-
que.head++;
-
}
由於push操作有一個自增操作,所以在初始化時需要將tail設為-1,這樣在push第一個節點時可保證head與tail指向同一個位置。
2.3 console坐標——地圖的初始化
在我的貪吃蛇鏈表實現中,前端展示時通過后台的計算邏輯+Pos函數來實現的,也就是現在后台計算結果,再推動前台的變化。因此Pos(),也就是使光標跳轉到控制台某位置的函數就尤為重要,這也直接影響了整個項目各元素的坐標表示方法。
簡單來說就是console的坐標表示類似於坐標軸中第四象限的表示方法,當然元素都為正值。

所以對於一個N*N的數組,我們可以這樣初始化:
-
void InitializeMapUnit()
-
{
-
que.head = 0;
-
que.tail = -1;
-
-
for(int i = 0;i<N;i++)
-
for(int j = 0;j<N;j++)
-
{
-
mapUnit[i][j].local.x = i;
-
mapUnit[i][j].local.y = j;
-
mapUnit[i][j].pre = NULL;
-
mapUnit[i][j].value = N*N;
-
}
-
}
將隊列的初始化放在這個函數里實屬無奈,這兩行語句,又不能在初始化時賦值,又不能在函數體外賦值,放main函數嫌它亂,單獨一個函數嫌它慢….就放在地圖初始化里了…
三、計算,BFS!
3.1 設置起點
基礎的結構與初始化完成后,就需要開始計算了。在此之前,我們需要一個坐標,來作為路徑問題的出發點。
-
void setOrigin(int x,int y)
-
{
-
mapUnit[x][y].value = 0;
-
push(x,y);
-
}
將地圖上該點位置的值設為0后,將其壓入隊列中。在第一輪的BFS中,它四周的點,將成為第二輪計算的原點。
3.2 BFS框架
在該地圖的BFS中,我們將依托隊列各個元素,來處理它們的鄰接節點。兩個循環,可以揭示大體的框架:
-
void bfs(int end_x,int end_y)
-
{
-
//當前需要處理的節點
-
for(int i = head;i<=tail;i++)
-
{
-
// 四個方向
-
for(int j = 0;j<4;j++)
-
{
-
// 新節點
-
if(mapUnit[new_x][new_y].value == N*N)
-
{
-
//設置屬性
-
}
-
// 處理過的節點,取小值
-
else
-
{
-
//屬性更改Or不變
-
}
-
}
-
}
-
//下一輪
-
bfs();
-
}
3.3 變化的隊列
BFS的主體循環依賴於隊列的head與tail,但是對新節點的push操作改變了tail的值,所以我們需要在循環開始前將此時(上一輪BFS的結果)的隊列狀態保存下來,避免隊列變化對BFS的影響。
-
int head = que.head;
-
int tail = que.tail;
-
//當前隊列
-
for(int i = head;i<=tail;i++)
-
{
-
// TODO...
-
}
3.4 節點的坐標
在原來寫的BFS中,要獲取一個節點的下標需要將一個結構體層層剝開,數組的下標是一個結構體某元素的某元素,繞來繞去,可讀性早已被獻祭了。

所以這次我吸取了教訓,在內循環,也就是處理周圍節點時,將其坐標先存儲在變量中,用來確保程序的可讀性。
-
for(int i = head;i<=tail;i++)
-
{
-
int base_x = que.queue[i].x;
-
int base_y = que.queue[i].y;
-
-
// 四個方向
-
for(int j = 0;j<4;j++)
-
{
-
int new_x = base_x + direct[j][0];
-
int new_y = base_y + direct[j][1];
-
-
// TODO...
-
}
-
}
所以我們可以構建出這樣一個移動的二維數組:
-
// 方向, 上 下 左 右
-
int direct[4][2] = {{0,-1},{0,1},{-1,0},{1,0}};
3.4.1 數組越界的處理
在得到了待處理節點的坐標后,需要對其進行判斷,確保它在數組內部。
-
if(stepRight(new_x,new_y) == false)
-
continue;
函數細節如下:
-
bool stepRight(int x,int y)
-
{
-
if(x >= N || y >= N ||
-
x < 0 || y < 0)
-
return false;
-
return true;
-
}
3.5 新節點的處理
終於到了訪問鄰接坐標的時候。一個節點四周的節點,有可能沒有被訪問過,也可能以及被訪問過。我們在初始化時就將所有節點的值設為了一個MAX,通過對值得判斷,可以推斷出其是否為新節點。
-
if(mapUnit[new_x][new_y].value == N*N)
-
{
-
// ...
-
}
-
else //取小值
-
{
-
// ...
-
}
3.5.1 未處理節點的處理
對於未處理的節點,對其的操作有兩部。一是初始化,值的初始化與指針的初始化。由於兩點間的距離為1,所以該節點的值為前一個節點的值+1,當然,他的pre指針也指向前一個節點。
-
mapUnit[new_x][new_y].value = mapUnit[base_x][base_y].value +1;
-
mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];
-
push(new_x,new_y);
3.5.2 已處理節點的處理
對於已處理過的節點,需要先將其做一個判斷,即尋找最短路徑,將其自身的value與前一節點value+1比較,再處理。
-
mapUnit[new_x][new_y].value = MIN(mapUnit[new_x][new_y].value,mapUnit[base_x][base_y].value +1);
-
if(mapUnit[new_x][new_y].value != mapUnit[new_x][new_y].value)
-
mapUnit[new_x][new_y].pre = &mapUnit[base_x][base_y];
3.6 隊列的刷新
在處理完一層節點后,新的節點導致了隊列中tail的增加,但是head並沒有減少,所以在新一輪BFS前,需要將隊列的head移動到真正的頭部去。
-
for(int i = head;i<=tail;i++)
-
pop();
在這兒也需要當前BFS輪數前的隊列數據。
3.7 最短路徑
在地圖的遍歷完成之后,我們就可以任取一點,得到起點到該點的最短路徑。
-
void getStep(int x,int y)
-
{
-
Unit *scan = &mapUnit[x][y];
-
if(scan->pre!= NULL)
-
{
-
int x = scan->local.x;
-
int y = scan->local.y;
-
scan = scan->pre;
-
getStep(scan->local.x,scan->local.y);
-
printf(" -> ");
-
}
-
printf("(%d,%d)",x,y);
-
}
四、此路不通——障礙的引入
在貪吃蛇中,由於蛇身長度的存在,以及蛇頭咬到自身就結束的特例,我們需要在算法中加入障礙的元素。
對於這個新加入的元素,我們設置一個坐標結構體的數組,來存儲所有的障礙。
-
#define WALL_CNT 3
-
Local Wall[WALL_CNT];
用一個函數來設置障礙:
-
void setWall(void)
-
{
-
Wall[0].x = 1;
-
Wall[0].y = 1;
-
-
Wall[1].x = 1;
-
Wall[1].y = 2;
-
-
Wall[2].x = 2;
-
Wall[2].y = 1;
-
}
由於這個項目里數據用於模塊測試的隨機性,所以手動設置每一個坐標。在之后的貪吃蛇AI中,將接受一個數組——蛇身,來自動完成賦值。
如果將障礙與地圖邊界等同來看,就能將障礙的判斷整合進stepRight()函數。
-
bool stepRight(int x,int y)
-
{
-
// out of map
-
if(x >= N || y >= N ||
-
x < 0 || y < 0)
-
return false;
-
// wall
-
for(int i = 0;i<WALL_CNT;i++)
-
if(Wall[i].x == x && Wall[i].y == y)
-
return false;
-
-
return true;
-
}
五、簡單版本的測試
完成了上訴的模塊后,項目就可以無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數據類型。"
有了這隨時能走的"閃現"功能,跳出復雜嵌套函數還是事兒嘛?
-
#include <setjmp.h>
-
jmp_buf jump_buffer;
-
-
int main (void)
-
{
-
//...
-
if(setjmp(jump_buffer) == 0)
-
bfs(finishing_x,finishing_y);
-
//...
-
}
由於跳轉需要判斷當前節點是否為終點,而終點又是一個局部變量,所以需要改變bfs函數,使其攜帶終點參數。
再在處理完一個節點后,判斷其是否為終點,是則退出。
-
for(int i = head;i<=tail;i++)
-
{
-
//...
-
// 四個方向
-
for(int j = 0;j<4;j++)
-
{
-
//...
-
if(mapUnit[new_x][new_y].value == N*N)
-
{
-
//...
-
}
-
else //取小值
-
{
-
//...
-
}
-
if(new_x == end_x && new_y == end_y)
-
{
-
longjmp(jump_buffer, 1);
-
}
-
}
-
}
6.2 無最短路徑時的處理
在判斷某一點的路徑時,可先判斷其是否存在最短路徑,存在則輸出,否則給出提示信息。
-
void getStepNext(int x,int y)
-
{
-
Unit *scan = &mapUnit[x][y];
-
if(scan->pre!= NULL)
-
{
-
int x = scan->local.x;
-
int y = scan->local.y;
-
scan = scan->pre;
-
getStepNext(scan->local.x,scan->local.y);
-
printf(" -> ");
-
}
-
printf("(%d,%d)",x,y);
-
}
-
-
void getStep(int x,int y,int orgin_x,int orgin_y)
-
{
-
Unit *scan = &mapUnit[x][y];
-
Pos(0,10);
-
-
if(scan->pre == NULL)
-
{
-
printf("NO Path To Point (%d,%d) From Point (%d,%d)!\n",x,y,orgin_x,orgin_y);
-
}
-
else
-
{
-
getStepNext(x,y);
-
}
-
}
七、優化后效果

八、寫在后面
算法大體上完成了,將其改為貪吃蛇AI也只需做少量修改。盡量抽個時間把AI寫完,不過可能需要一段時間。
在最短路徑的解法上,Dijkstra算法並不是最理想的解法。盲目搜索的效率很低。考慮到地圖上存在着兩點間距離等信息,可以使用一種啟發式搜索算法,如BFS(Best-First Search),以及大名鼎鼎的A*算法。在中文互聯網我能找到的有關於A*算法的資料不多,將來得花些時間好好研究下。
源碼地址:https://github.com/MagicXyxxx/Algorithms
