【dawn·資料結構】迷宮問題(C++)

Iridescent_fd發表於2020-12-11

簡要說明:
(1)題目來源:課程。
(2)由於作者水平限制和時間限制,程式碼本身可能仍有一些瑕疵,仍有改進的空間。也歡迎大家一起來討論。
——一個大二剛接觸《資料結構》課程的菜雞留

題目簡介

給定一個m×n(3≤ m,n <1001)迷宮,其中1表示牆壁,0表示通路。你可以使用一個二維陣列maze[m][n]來表示這個迷宮,其中起點和終點固定在maze[1][0]和maze[m-2][n-1]的位置。
你可以有8種前進方向,即分別是正北、正南、正西、正東、西北、西南、東北、東南(假設行座標自大到小為正北方向)。
要求寫出一個程式,能夠判斷給定迷宮是否存在一條從起點到終點的通路(即在路徑上的maze[i][j]=0)。
輸入樣例要求:

第一行輸入一個正整數m,表示迷宮陣列maze的行數。
第二行輸入一個正整數n,表示迷宮陣列maze的列數。
接下去m行分別輸入由01組成的字串,字串的長度應恰等於n,即表示maze陣列第i行。

參考樣例1:2

輸入:
5
7
1111111
0010001
1101111
1010000
1111111
輸出:
TRUE

參考樣例2:3

輸入:
5
7
1111111
0010001
1111111
1010000
1111111
輸出:
FALSE

思路分析

注:僅代表個人思路。

(1) 首先要確定maze[][]的存放形式。由於一個格子只可能有兩種情況,或是牆壁,或是路徑,因此可設為bool陣列。設定為int陣列也沒有任何問題。
(2) 確定尋找路徑的方法。由於在尋找路徑的過程中,有可能會沿著非牆壁格子走到“死衚衕”,因此此時需要有一個“回退”的過程。考慮到這麼一個過程實則是不斷試探和回退,因此命名為“回溯法”。每次回溯將重新訪問在到達當前格子前的一個格子,這樣的訪問方式與棧(stack)十分相似。因此,教材中也把這個問題放在了棧這一資料結構的相關例項中。
(3) 確定回溯法後又遇到一個問題,棧中我們需要儲存什麼資料。如果僅儲存位置,那麼在回退後的訪問的方向應該如何確定?如果是隨機決定,那麼能否得出正確的結果將是一個不確定的結果,這有悖於我們解決這一問題的初衷。因此棧中還需要存放在上一次試探中我們選擇的方向,回退後方向進行改變。如果當前格子已經試探過了所有方向,那麼就需要進一步回退。
(4) 但同樣還會引申出一個問題,如果迷宮中有環(圈)呢?如果迷宮設計得正好那麼巧妙,按照上面的試探方法,就極有可能無法走出這個圈——這就對應程式的“死迴圈”。因此在每次試探格子時,除了要與之前試探過的方向不同,我們還應該判斷這個格子我們在已走路徑中是否走到過。如果是,那麼就構成一個圈,我們就不應該試探這個方向。因此除了單單儲存迷宮格子是牆壁還是道路,還需要有這麼一個標記——就另外需要一個bool型的二維陣列,大小與maze一致,我們記作mark[m][n]。
(5) 來審視一下mark[m][n]的設計:如果在當前已走過的路徑中已經走過這個格子,就設mark[i][j]=true。在具體實現時我們需要這麼一個步驟:將mark[m][n]的所有元素初始化為false。在試探到格子maze[i][j]時,如果maze[i][j]=false,就不必擔心構成圈的問題;如果maze[i][j]=false,表明無需進行這一步試探,那麼需要更改方向。此外,由於是“已走路徑”的標記,由退出maze[i1,j1]回退到maze[i2,j2]時,需要更改mark[i1,j1]=false。
(6) 進一步考察mark[m][n]=true帶來的意義。設想這麼一個情景:在回退時不重設mark為false時,如果我們試探到maze[i][j],發現mark[i][j]=true,會得出什麼結論。首先,maze[i][j]一定在之前就被試探過,並且已經回退過。換句話說,由maze[i][j]為起點,之後的所有路徑是都無法到達終點的。因此,mark[i][j]的第二個含義就是會暗示這個格子是否可以到達終點。由此在實現mark[m][n]時,基於(5)的討論,我們並不需要在由maze[i1, j1]回退到maze[i2,j2]時重新設定mark[i1,j1]=false。mark[i1,j1]=true給我們提供的資訊,遠比是否會構成一個圈多得多——雖然這看上去有違我們給每個maze[i][j]一個標記的初衷。在補充部分中,會簡單討論這樣的改進會有怎樣的好處。
(7) 來回顧一下整個過程:從起點開始,從最初方向開始,每次從當前所在格子由上一次記錄的方向確定下一個試探的方向,判斷下一個格子是否可以到達(需要滿足兩個條件:maze[i][j]=true,mark[i][j]=false)。如果可以到達,那麼以新的格子為起點繼續試探。如果不可以,需要再次改變方向。當方向不能再次改變時,需要回退到到達當前格子前的最後一個格子,以那個格子為起點繼續試探。無法回退表示沒有路徑。如果下一個可到達的格子是終點,那麼就找到路徑。接下去的程式碼就是對這一個回顧的具體實現。

程式碼部分

#include <iostream>
#include <fstream>
#include <string>
#include <stack>
using namespace std;

struct node {  //棧中需要儲存這樣的一個資料型別
	node(int ix=0, int iy=0, int id=0):x(ix),y(iy),d(id) {}
	int x;
	int y;
	int d;
};

struct move {  //由方向體現具體的移動方式,為了簡便程式碼
	move(int ia=0, int ib=0):a(ia),b(ib) {}
	int a;
	int b;
};

class Maze {
public:
	//建構函式 
	Maze(int im, int in, string *is);
	
	//判斷是否存在路徑 
	bool GetPath(); 

private:
	int m;
	int n;
	bool *maze;  //1表示牆壁,0表示通路 
	bool *mark;  //1表示走過,0表示為走過 
	move mv[8];
	
	bool& maze_node(int i, int j);
	bool& mark_node(int i, int j);
};

//Maze類的建構函式
Maze::Maze(int im, int in, string *is) {
	m=im;
	n=in;
	maze=new bool[m*n];
	mark=new bool[m*n];
	for (int i=0;i<m*n;i++)  mark[i]=false;
	//構造move陣列, 表示8個方向
	mv[0]=move(-1,0);  //向左
	mv[1]=move(-1,1);  //左上 
	mv[2]=move(0,1);  //向上
	mv[3]=move(1,1);  //右上
	mv[4]=move(1,0);  //向右
	mv[5]=move(1,-1);  //右下
	mv[6]=move(0,-1);  //向下
	mv[7]=move(-1,-1);  //左下
	//將string陣列儲存的資訊放入maze中.
	for (int i=0;i<n;i++)
		for (int j=0;j<m;j++)  maze[j*n+i]=!(is[j][i]-'0');
}

//獲取行座標i, 列下標j的maze結點資訊
bool& Maze::maze_node(int i, int j) {
	return maze[i*n+j];
}

//獲取行座標i, 列下標j的mark標記資訊
bool& Maze::mark_node(int i, int j) {
	return mark[i*n+j];
} 

//尋找路徑
bool Maze::GetPath() {
	int c_m, c_n;  //表示當前格子的下標
	int e_m, e_n;  //表示預期格子的下標
	int dir;  //表示方向 
	node current(1,0,-1);  //請結合程式碼考慮為什麼第三個引數傳入-1?
	stack<node> s;
	if (!maze_node(1,0)||!maze_node(m-2,n-1)) {  //起點與終點需符合規範
		cerr<<"輸入不合規範."<<endl;
		return false;
	}
	s.push(current);
	mark_node(1,0)=true;	
	while (!s.empty()) {
		s.pop(&current);  //current=s.top(); s.pop();
		c_m=current.x;
		c_n=current.y;
		dir=current.d;
		while (dir<7) {
			e_m=current.x+mv[++dir].a;
			e_n=current.y+mv[dir].b;
			if (e_m==m-2&&e_n==n-1)  return true;  //到達終點 
			if (e_m>=0&&e_n>=0&&e_m<m&&e_n<n&&maze_node(e_m,e_n)&&!mark_node(e_m,e_n)) {  //預期格子是路徑並且沒有走過(避免構成迴路) 
				mark_node(e_m,e_n)=true;
				current.x=e_m;
				current.y=e_n;
				current.d=dir;
				s.push(current);
				c_m=e_m;
				c_n=e_n;
				dir=-1;
			}
		}
	}
	return false;
} 

//將至多2個字元長的字串S解讀為對應的int值. 不合規範情況下將返回-1.
//假設輸入的m,n都滿足3≤m,n<100.
int STRtoINT(const string& S) {
	switch (S.length()) {
		case 0: return -1;  //不允許輸入字串為空
		case 1: return S[0];
		case 2: return 10*(S[0]-'0')+S[1]-'0';
		default: return -1;
	}
}

//主函式 
int main() {
	int m, n;
	string s, *sa;
	ifstream ifs;
	ifs.open("MazeCreate.txt");
	getline(ifs,s);
	m=STRtoINT(s);
	getline(ifs,s);
	n=STRtoINT(s);
	if (m==-1||n==-1) {  //以防輸入空string
		cerr<<"輸入不合規範."<<endl;
		return -1;
	}
	sa=new string[m];
	for (int i=0;i<m;i++)  getline(ifs,sa[i]);
	Maze mz(m,n,sa); 
	if(mz.GetPath())  cout<<"TRUE";
	else  cout<<"FALSE";
}

補充部分

(1) 在GetPath()函式內需要注意幾點:首先,下一步探測的格子是否可能越界,以免帶來在陣列訪問等上的一些問題。再者,一些細節需要注意,如while(dir<7)中,為什麼是“<”而不是更符合認知的“≤”4
(2) 程式碼本身出於精簡性的考慮,並沒有完全按照在思路分析(7)中討論到的那樣來安排。譬如,在方向的試探上使用的是while()迴圈,而沒有不斷地push()和pop()。5可以根據註腳內容重新編排一下迴圈體。
(3) 回到思路分析(6)的結束部分,我們來討論一下賦予mark[i][j]一個“有悖於初衷的、新的”含義的好處。如果不加以改進,我們可以設想到程式中會有基於maze[i][j]為起點進行無法到達終點的無用探測的重複,重複次數會取決於maze[i][j]與周圍聯通的程度,效率也會更低。因此,來考察一下改進後的演算法的時間複雜度。迷宮一共有m×n個格子,在最壞的情況下,每一個格子需要探查8個方向,即一共需要8mn次試探。而由於mark[][]的改進,事實上每一個格子只會被訪問到一次,因此時間複雜度為mn,這是一個不錯的改進。6
(4) 說點比較主觀的:這一篇是應室友提議寫出來的,於是乎也寫到挺晚的,不過個人覺得還是很有意義的。至少在寫程式碼時是我個人自己手把手地寫一遍,相當於實踐了一遍。雖然竊以為之前的數獨問題會比這個問題更難一些,但這也是在《資料結構》中第一次遇到回溯法的題目,加上思路分析中的(5)~(6)也確實可能是我在獨立地解決相關問題時會缺乏考慮的一點,因此也是一次很好的鞏固和學習了。


  1. 由於C++提供new分配空間,此處設定m和n過大可能會導致申請失敗,讀者可根據情況自行調整程式碼中的maze[][]的定義方式(即預先開設好固定大小的空間,然後由兩個int型資料管理意義上的大小)。 ↩︎

  2. 即存在有這麼一條通路:(x表示通路;b表示起點,e表示終點)
    1 1 1 1 1 1 1
    b x 1 0 0 0 1
    1 1 x 1 1 1 1
    1 0 1 x x x e
    1 1 1 1 1 1 1 ↩︎

  3. 不難驗證根據圖中的輸入我們無法找到一條通路。 ↩︎

  4. 這是由於dir的自增在while迴圈的迴圈體中實現。可以自行模擬程式的操作流程,以方便對這裡的迴圈條件的設定的理解。 ↩︎

  5. 相對應地,在訪問新的格子時,需要重新設定c_m, c_n與dir,而沒有采取更合乎討論過程中的步驟,即在到達新格子時將新格子壓入棧後,重新外層迴圈,並在外層迴圈最初讀取棧頂node的資料,然後繼續試探。 ↩︎

  6. 我個人還有一個想法。在探討到這個問題時,讓我聯想到了《資料結構》講到串的模式匹配時的最樸素的BF(Brute force)演算法時,用到的一個再合適不過的形容詞——健忘。我想在這個過程中,如果每次回退就重新設定mark[i][j],這似乎也是一種健忘的體現。不過我在此仍有一個小的疑問,在解數獨問題中,在每次回退時我都重新設定了一下格子的狀態(對應rePosition()函式),這個過程是否也“過於”健忘。目前我的想法是,格子對應的_position_[]確實有可能在回退的過程中需要更改。如果一成不變,可能會引起錯誤的結果。因此這樣的“健忘”是保證正確所需要的。 ↩︎

相關文章