回溯法 之 馬周遊(馬跳日)問題
回溯法的應用很多,下面講述一個有趣的馬周遊問題。
馬周遊(馬跳日)問題:在一個 8*8 的棋盤上(如下圖)一匹馬從任意位置開始,恰好走過棋盤中的每一格(每個格子有且只能走一次),並且最後還可以回到起點位置。
這個問題其實可以進行推廣:即棋盤大小不一定是 8*8 ,只要棋盤大小 M * N 滿足:
① M >=6 ;N>= 6 ② M N都是偶數 ③ | M-N | <=2
當然這個問題還可以縮小:即馬周遊最後不一定要回到原點,只要遍歷走完棋盤中的所有格子即可。
顯然常規的解法就是採用回溯法,並且要在回溯過程中進行剪枝。
一、下面從馬周遊最簡單的問題描述開始做起:即馬周遊只需要遍歷走完棋盤中的所有格子即可,不要求最後要回到起點位置
解法說明:其實很容易理解到,馬周遊棋盤,也就是要遍歷棋盤中的所有格子有且只能一次,那麼很顯然就是一個圖的遍歷問題了。怎麼理解呢?
在前面的文章 八皇后 中,每個皇后都有 8 個位置的選擇,那麼對應來說就是一個滿八叉樹(也可以看做是圖)的遍歷。下面給出一個四皇后問題的圖解。在這個圖中我們就可以清楚的看到圖的遍歷過程,而且是深度優先遍歷。
那麼同樣的,關於馬周遊問題,也是同樣要進行圖的深度優先遍歷過程。那麼深度優先遍歷過程可以遞迴實現,可以非遞迴實現。下面分別給出其實現程式碼。
(1)遞迴實現
#include<iostream> #include <stdlib.h> #include <iomanip> using namespace std; //馬周遊的棋盤,注意使用的時候是從下表為1開始 int board[100][100]; int fx[]= {2,1,-1,-2,-2,-1,1,2}; int fy[]= {-1,-2,-2,-1,1,2,2,1}; int n; //棋盤大小 //引數x,y 表示棋盤的位置 //檢測(x,y) 對應位置在棋盤中是否合法 bool check(int x,int y) { if(x<1 || y<1 || x>n || y>n || board[x][y] != 0) return false; return true; } //輸出結果 void outputResult(int n) { for(int i=1; i<=n; i++) { cout<<endl<<endl; for(int j=1; j<=n; j++) { cout<<setw(3)<<board[i][j]<<" "; } } cout<<endl<<endl; } void runTable(int a,int b,int number) { if(number == n*n) //已經走完棋盤中所有的點 { outputResult(n); //輸出 exit(1); } for(int i=0; i<8; i++) //表示每一個格都有八種走法 { if(check(a + fx[i],b + fy[i])) { int x = a + fx[i]; int y = b + fy[i]; board[x][y] = number+1; //走到下一個位置,設定其序號為 number+1 runTable(x, y,number+1); board[x][y] = 0;//回溯 } } } //遞迴走法 void horseRun(int x,int y) { int number = 1; board[x][y] = number; //首先確定起始位置這個格是序號為1 runTable(x, y,number); } int main() { cout<<"輸入棋盤大小n:"; cin>>n; int x,y; cout<<"輸入馬周遊起始位置x(1~n),y(1~n):"; cin>>x>>y; horseRun(x,y); return 0; }
執行效果:
說明:以上程式在執行棋盤大小為 6*6 的時候,可以很快跑出結果,但是在棋盤大小為 8*8 的時候,就要花費幾秒,說明這個執行的效果不是很好。
(2)非遞迴實現
#include<iostream> #include <iomanip> #include <queue> using namespace std; //在某一格子的八種走法 int fx[]= {2,1,-1,-2,-2,-1,1,2}; int fy[]= {-1,-2,-2,-1,1,2,2,1}; typedef struct { int x,y; //座標 int number; //序號 } Point; //棋盤中的格子 //馬周遊的棋盤,注意使用的時候是從下表為1開始 Point board[10000][10000]; int n; //棋盤大小 int step =1; //序號 //輸出結果 void outputResult(int n) { for(int i=1; i<=n; i++) { cout<<endl<<endl; for(int j=1; j<=n; j++) { cout<<setw(3)<<board[i][j].number<<" "; } } cout<<endl<<endl; } bool check(int x,int y) { if(x<1 || y<1 || x>n || y>n || board[x][y].number != 0) return false; return true; } //下一位置有多少種走法 int nextPosHasSteps(int x, int y) { int steps = 0; for (int i = 0; i < 8; ++i) { if (check(x + fx[i], y + fy[i])) steps++; } return steps; } //非遞迴的走法 void horseRun(Point point) { queue<Point> pointQueue; pointQueue.push(point); Point temp; while(!pointQueue.empty()) { temp = pointQueue.front(); pointQueue.pop(); board[temp.x][temp.y].number = step++; int minStep = 8; int flag = 0; for(int i=0; i<8; i++) //出下一位置走法最少的進入對列 { int x=temp.x + fx[i]; int y=temp.y + fy[i]; if(check(x,y)) { if(nextPosHasSteps(x,y) <= minStep) { minStep = nextPosHasSteps(x,y); Point t; t.x = x; t.y = y; if(flag) pointQueue.pop(); pointQueue.push(t); flag = 1; } } } } } int main() { cout<<"輸入棋盤大小n:"; cin>>n; Point startPoint; cout<<"輸入馬周遊起始位置x(1~n),y(1~n):"; cin>>startPoint.x>>startPoint.y; horseRun(startPoint); //輸出結果 outputResult(n); return 0; }
說明:在程式中已經用到了一個剪枝:即每一次都優先走下一位置走法最少的。 關於剪枝的內容下面會具體講述。
執行效果:
說明:此種解法可以很快的跑出結果,甚至在 幾千乘以幾千的棋盤中,都是幾乎瞬間跑出結果,效果十分的好,因為這裡面用了一個很關鍵的剪枝。
二、完整解決馬周遊問題:既要遍歷走完棋盤中所有的格子,最後還要回到起點。
有了上面第一個簡化版馬周遊的解決經驗,那麼完整解決馬周遊問題,無法就是再新增一個限制條件:最後要回到起點。
那麼首先還是要先介紹一下在這個馬周遊回溯過程中要用到的剪枝(如果不用剪枝,那麼演算法執行效率會很低)。
使用剪枝有3處:
1、使用Warnsdorff's rule。在當前位置(Now)考慮下一個位置(Next)的時候,優先選擇下一個的位置(Next)走法最少的那個。作為當前位置(Now)的下一位置(Next)。
譬如說:下圖所示,當前位置現在要確定下一位置,那麼就要所有的下一個位置進行考察,看看假如走到下一個位置,它的下一個位置又有多少種走法,選擇下一個位置可能走法最少的作為當前位置(Now)的下一個位置(Next)。
2、在進行了第一點的剪枝後,如果可以優先選擇的下一個位置不止一個,則優先選擇離中心位置較遠的位置作為下一步(即靠近邊邊的位置)。
通俗點理解,第一點的剪枝就是走那些位置可能走到機會比較小的,反正走到的機會小,那麼先走完總是好的,要不然你兜一圈回來,還是要走這一個位置的。
第二點的剪枝就是走法儘量從邊邊走,然後是往中間靠。
3、第三點的剪枝,每次都從棋盤的中間點開始出發,然後求出一條合法路徑後再平移映射回待求路徑。
怎麼理解呢?所謂馬周遊棋盤,最後還要回到起點。也就是在棋盤中找到一條哈密頓迴路。那麼不管你是從哪裡開始的,最後都是會在這個哈密頓迴路中的,那麼選取的中點的位置也肯定是在這個迴路上的。
最後,找到這個這個以中點為起點的哈密頓迴路後,根據設定起點在這個迴路中的序號,映射回以這個位置為起點的馬周遊路線即可。
至於為什麼要從棋盤中間位置開始呢? 我就不太能解釋了。
知道了上面的三種剪枝方式,那麼具體是要如何實現的呢?
(1)關於第一點和第二點的剪枝,二者關聯很大。那麼我們可以將二者結合起來。放到一個結構體中,這個結構體表徵是的是下一位置。
typedef struct NextPos
{
int nextPosSteps; //表示下一位置有多少種走法;走法少的優先考慮
int nextPosDirection; //下一位置相對於當前位置的方位
int nextPosToMidLength; //表示當前位置距中間點距離;距離中間點遠的優先考慮
//
bool operator < (const NextPos &a) const
{
return nextPosSteps > a.nextPosSteps && nextPosToMidLength < a.nextPosToMidLength;
}
};
注意其中,下一位置走法少的優先,下一位置距離中點遠的優先。
這樣我們在挑選下一個位置的時候,可以將符合要求的放到一個優先佇列中,這樣選取下一位置的時候直接從優先佇列拿出了就好了(省去排序的工作)。
(2)關於第三點的剪枝,其實就是涉及到最後輸出結果,這個比較簡單。
下面完整給出程式碼實現:
#include <iostream>
#include <stdlib.h>
#include <iomanip>
#include <queue>
using namespace std;
typedef struct
{
int x;
int y;
} Step;
Step step[8] = { {-2, -1}, {-1, -2}, { 1, -2}, { 2, -1}, { 2, 1}, { 1, 2}, {-1, 2}, {-2,1} };
typedef struct NextPos
{
int nextPosSteps; //表示下一位置有多少種走法;走法少的優先考慮
int nextPosDirection; //下一位置相對於當前位置的方位
int nextPosToMidLength; //表示當前位置距中間點距離;距離中間點遠的優先考慮
//
bool operator < (const NextPos &a) const
{
return nextPosSteps > a.nextPosSteps && nextPosToMidLength < a.nextPosToMidLength;
}
};
int board[100][100];
int M,N; //棋盤大小
//檢測這個位置是否可以走
bool check(int x, int y)
{
if (x >= 0 && x < M && y >= 0 && y < N && board[x][y] == 0)
return true;
return false;
}
//下一位置有多少種走法
int nextPosHasSteps(int x, int y)
{
int steps = 0;
for (int i = 0; i < 8; ++i)
{
if (check(x + step[i].x, y + step[i].y))
steps++;
}
return steps;
}
//判斷是否回到起點
bool returnStart(int x, int y)
{
//校驗最後是否可以回到起點,也就是棋盤的中間位置
int midx,midy;
midx = M / 2 - 1;
midy = N / 2 - 1;
for (int i = 0; i < 8; ++i)
if (x + step[i].x == midx && y + step[i].y == midy)
return true;
return false;
}
//輸出結果
void outputResult(int xstart,int ystart)
{
int num = M * N;
int k = num - board[xstart][ystart];
for (int i = 0; i < M; ++i)
{
cout<<endl<<endl;
for (int j = 0; j < N; ++j)
{
board[i][j] = (board[i][j] + k) % num + 1;
cout<<setw(5)<<board[i][j];
}
}
cout<<endl<<endl;
}
//某一位置距離棋盤中心的距離
int posToMidLength(int x,int y)
{
int midx = M / 2 - 1;
int midy = N / 2 - 1;
return (abs(x - midx) + abs(y - midy));
}
void BackTrace(int t, int x, int y,int xstart,int ystart)
{
//找到結果
if (t == M * N && returnStart(x,y)) //遍歷了棋盤的所以位置,並且最後可以回到起點,形成迴路
{
outputResult(xstart,ystart);
exit(1);
}
else
{
priority_queue<NextPos> nextPosQueue;
for (int i = 0; i < 8; ++i)
{
if (check(x + step[i].x, y + step[i].y))
{
NextPos aNextPos;
aNextPos.nextPosSteps = nextPosHasSteps(x + step[i].x, y + step[i].y);
aNextPos.nextPosDirection = i;
aNextPos.nextPosToMidLength = posToMidLength(x + step[i].x,y + step[i].y);
nextPosQueue.push(aNextPos);
}
}
while(nextPosQueue.size())
{
int d = nextPosQueue.top().nextPosDirection;
nextPosQueue.pop();
x += step[d].x;
y += step[d].y;
board[x][y] = t + 1;
BackTrace(t + 1, x, y,xstart,ystart);
//回溯
board[x][y] = 0;
x -= step[d].x;
y -= step[d].y;
}
}
}
void horseRun(int xstart,int ystart)
{
//初始化棋盤
for (int i = 0; i < M; i++)
for (int j = 0; j < N; j++)
board[i][j] = 0;
int midx = M / 2 -1;
int midy = N / 2 -1;
board[midx][midy] = 1; //從棋盤的中間的位置開始馬周遊
BackTrace(1, midx, midy,xstart,ystart);
}
int main(void)
{
//馬周遊起始位置
int x, y;
cout<<"請輸入棋盤大小m*n|m-n|<=2 且 m和n都為偶數 且 m,n < 20 :";
cin>>M>>N;
cout<<"請輸入馬周遊起始位置--橫縱座標0 <= x < "<<M<<"和0 <= y < "<<N<<" :";
cin>>x>>y;
horseRun(x,y); //執行馬周遊
return 0;
}
執行效果:
說明:這個程式的極限是 20 ,當棋盤大小達到 20 * 20 的時候就很難跑出結果,但是小於20的棋盤都可以很快的跑出結果。
好了,關於馬周遊問題就講述到這裡,歡迎交流討論。