[C++]廣度優先搜尋(BFS)(附例題)
廣度優先搜尋(BFS)(附例題)
問題產生:
Isenbaev是國外的一個大牛。
現在有許多人要參加ACM ICPC。
一共有n個組,每組3個人。同組的3個人都是隊友。
大家都想知道自己與大牛的最小距離是多少。
大牛與自己的最小距離當然是0。大牛的隊友和大牛的最小距離是1。大牛的隊友的隊友和大牛的最小距離是2……以此類推。
如果實在和大牛沒有關係的只好輸出undefined了。
第一行讀入n。表示有n個組。1 ≤ n ≤ 100
接下來n行,每行有3個名字,名字之間用空格隔開。每個名字的開頭都是大寫的。
每行輸出一個名字,名字後面空格後輸出數字a或者字串undefined,a代表最小距離。
名字按字典序輸出。
Sample Input
7
Isenbaev Oparin Toropov
Ayzenshteyn Oparin Samsonov
Ayzenshteyn Chevdar Samsonov
Fominykh Isenbaev Oparin
Dublennykh Fominykh Ivankov
Burmistrov Dublennykh Kurpilyanskiy
Cormen Leiserson Rivest
Sample Output
Ayzenshteyn 2
Burmistrov 3
Chevdar 3
Cormen undefined
Dublennykh 2
Fominykh 1
Isenbaev 0
Ivankov 2
Kurpilyanskiy 3
Leiserson undefined
Oparin 1
Rivest undefined
Samsonov 2
Toropov 1
問題分析
解決這個問題的方法就是使用廣度優先搜尋。所以我們先提出廣度優先搜尋的知識點分析。
廣度優先搜尋
1. 前言
廣度優先搜尋(也稱寬度優先搜尋,縮寫BFS,以下采用廣度來描述)是連通圖的一種遍歷策略。因為它的思想是從一個頂點V0開始,輻射狀地優先遍歷其周圍較廣的區域,故得名。
一般可以用它做什麼呢?一個最直觀經典的例子就是走迷宮,我們從起點開始,找出到終點的最短路程,很多最短路徑演算法就是基於廣度優先的思想成立的。
演算法導論裡邊會給出不少嚴格的證明,我想盡量寫得通俗一點,因此採用一些直觀的講法來偽裝成證明,關鍵的point能夠幫你get到就好。
2.圖的概念
剛剛說的廣度優先搜尋是連通圖的一種遍歷策略,那就有必要將圖先簡單解釋一下。
如圖2-1所示,這就是我們所說的連通圖,這裡展示的是一個無向圖,連通即每2個點都有至少一條路徑相連,例如V0到V4的路徑就是V0->V1->V4。
一般我們把頂點用V縮寫,把邊用E縮寫。
3. 基本思路
常常我們有這樣一個問題,從一個起點開始要到一個終點,我們要找尋一條最短的路徑,從圖2-1舉例,如果我們要求V0到V6的一條最短路(假設走一個節點按一步來算)【注意:此處你可以選擇不看這段文字直接看圖3-1】,我們明顯看出這條路徑就是V0->V2->V6,而不是V0->V3->V5->V6。先想想你自己剛剛是怎麼找到這條路徑的:首先看跟V0直接連線的節點V1、V2、V3,發現沒有V6,進而再看剛剛V1、V2、V3的直接連線節點分別是:{V0、V4}、{V0、V1、V6}、{V0、V1、V5}(這裡畫刪除線的意思是那些頂點在我們剛剛的搜尋過程中已經找過了,我們不需要重新回頭再看他們了)。這時候我們從V2的連通節點集中找到了V6,那說明我們找到了這條V0到V6的最短路徑:V0->V2->V6,雖然你再進一步搜尋V5的連線節點集合後會找到另一條路徑V0->V3->V5->V6,但顯然他不是最短路徑。
你會看到這裡有點像輻射形狀的搜尋方式,從一個節點,向其旁邊節點傳遞病毒,就這樣一層一層的傳遞輻射下去,知道目標節點被輻射中了,此時就已經找到了從起點到終點的路徑。
我們採用示例圖來說明這個過程,在搜尋的過程中,初始所有節點是白色(代表了所有點都還沒開始搜尋),把起點V0標誌成灰色(表示即將輻射V0),下一步搜尋的時候,我們把所有的灰色節點訪問一次,然後將其變成黑色(表示已經被輻射過了),進而再將他們所能到達的節點標誌成灰色(因為那些節點是下一步搜尋的目標點了),但是這裡有個判斷,就像剛剛的例子,當訪問到V1節點的時候,它的下一個節點應該是V0和V4,但是V0已經在前面被染成黑色了,所以不會將它染灰色。這樣持續下去,直到目標節點V6被染灰色,說明了下一步就到終點了,沒必要再搜尋(染色)其他節點了,此時可以結束搜尋了,整個搜尋就結束了。然後根據搜尋過程,反過來把最短路徑找出來,圖3-1中把最終路徑上的節點標誌成綠色。
整個過程就如:
4 搜尋的流程圖
5 簡單例題
《迷宮問題》
定義一個二維陣列:
int maze[5][5] = {
0, 1, 0, 0, 0,
0, 1, 0, 1, 0,
0, 0, 0, 0, 0,
0, 1, 1, 1, 0,
0, 0, 0, 1, 0,
};
它表示一個迷宮,其中的1表示牆壁,0表示可以走的路,只能橫著走或豎著走,不能斜著走,要求程式設計序找出從左上角到右下角的最短路線。
題目保證了輸入是一定有解的。
也許你會問,這個跟廣度優先搜尋的圖怎麼對應起來?BFS的第一步就是要識別圖的節點跟邊!
5.1 識別節點的邊
節點就是某種狀態,邊就是節點與節點間的某種規則。
對應於《迷宮問題》,你可以這麼認為,節點就是迷宮路上的每一個格子(非牆),走迷宮的時候,格子間的關係是什麼呢?按照題目意思,我們只能橫豎走,因此我們可以這樣看,格子與它橫豎方向上的格子是有連通關係的,只要這個格子跟另一個格子是連通的,那麼兩個格子節點間就有一條邊。
如果說本題再修改成斜方向也可以走的話,那麼就是格子跟周圍8個格子都可以連通,於是一個節點就會有8條邊(除了邊界的節點)。
5.2 解題思路
對應於題目的輸入陣列:
0, 1, 0, 0, 0,
0, 1, 0, 1, 0,
0, 0, 0, 0, 0,
0, 1, 1, 1, 0,
0, 0, 0, 1, 0,
我們把節點定義為(x,y),(x,y)表示陣列maze的項maze[x][y]。
於是起點就是(0,0),終點是(4,4)。按照剛剛的思路,我們大概手工梳理一遍:
初始條件:
起點Vs為(0,0)
終點Vd為(4,4)
灰色節點集合Q={}
初始化所有節點為白色節點
開始我們的廣度搜索!
手工執行步驟【PS:你可以直接看圖4-1】:
1.起始節點Vs變成灰色,加入佇列Q,Q={(0,0)}
2.取出佇列Q的頭一個節點Vn,Vn={0,0},Q={}
3.把Vn={0,0}染成黑色,取出Vn所有相鄰的白色節點{(1,0)}
4.不包含終點(4,4),染成灰色,加入佇列Q,Q={(1,0)}
5.取出佇列Q的頭一個節點Vn,Vn={1,0},Q={}
6.把Vn={1,0}染成黑色,取出Vn所有相鄰的白色節點{(2,0)}
7.不包含終點(4,4),染成灰色,加入佇列Q,Q={(2,0)}
8.取出佇列Q的頭一個節點Vn,Vn={2,0},Q={}
9.把Vn={2,0}染成黑色,取出Vn所有相鄰的白色節點{(2,1), (3,0)}
10.不包含終點(4,4),染成灰色,加入佇列Q,Q={(2,1), (3,0)}
11.取出佇列Q的頭一個節點Vn,Vn={2,1},Q={(3,0)}
12. 把Vn={2,1}染成黑色,取出Vn所有相鄰的白色節點{(2,2)}
13.不包含終點(4,4),染成灰色,加入佇列Q,Q={(3,0), (2,2)}
14.持續下去,知道Vn的所有相鄰的白色節點中包含了(4,4)……
15.此時獲得了答案
起始你很容易模仿上邊過程走到終點,那為什麼它就是最短的呢?
怎麼保證呢?
我們來看看廣度搜索的過程中節點的順序情況:
你是否觀察到了,廣度搜索的順序是什麼樣子的?
圖中標號即為我們搜尋過程中的順序,我們觀察到,這個搜尋順序是按照上圖的層次關係來的,例如節點(0,0)在第1層,節點(1,0)在第2層,節點(2,0)在第3層,節點(2,1)和節點(3,0)在第3層。
我們的搜尋順序就是第一層->第二層->第三層->第N層這樣子。
我們假設終點在第N層,因此我們搜尋到的路徑長度肯定是N,而且這個N一定是所求最短的。
我們用簡單的反證法來證明:假設終點在第N層上邊出現過,例如第M層,M
5.3 核心程式碼
/**
* 廣度優先搜尋
* @param Vs 起點
* @param Vd 終點
*/
bool BFS(Node& Vs, Node& Vd){
queue<Node> Q;
Node Vn, Vw;
int i;
//初始狀態將起點放進佇列Q
Q.push(Vs);
hash(Vw) = true;//設定節點已經訪問過了!
while (!Q.empty()){//佇列不為空,繼續搜尋!
//取出佇列的頭Vn
Vn = Q.front();
//從佇列中移除
Q.pop();
while(Vw = Vn通過某規則能夠到達的節點){
if (Vw == Vd){//找到終點了!
//把路徑記錄,這裡沒給出解法
return true;//返回
}
if (isValid(Vw) && !visit[Vw]){
//Vw是一個合法的節點並且為白色節點
Q.push(Vw);//加入佇列Q
hash(Vw) = true;//設定節點顏色
}
}
}
return false;//無解
}
問題解決
對於這道題,首先我們把每個string名稱繫結一個numeric編號,用來在一個數組裡面儲存他們相應的距離。節點就是map的first部分,邊是同一個3維數組裡面的各個string。
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<iostream>
#include<map>
#include<string>
#include<queue>
using namespace std;
map<string, int> hh;
map<string, int>::iterator it;
queue<int> que;
string str;
string s[110][4];
int dis[11000];
int main() {
str = "Isenbaev";
int n;
scanf("%d", &n);
int tot = 0; // tot實際上是每個名稱的編號。
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= 3; j++) {
cin >> s[i][j];
if (hh.find(s[i][j]) == hh.end()) {
hh[s[i][j]] = ++tot;
}
}
}
bool flag = false;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= 3; j++) {
if (s[i][j] == str) {
flag = true;
break;
}
}
}
// 如果沒有找到str就全部都為undefined
if (!flag) {
for (it = hh.begin(); it != hh.end(); it++)
cout << it->first << " undefined\n";
return 0;
}
while (!que.empty()) que.pop(); // 清空佇列
que.push(hh[str]); // 把str的編號放入queue頭部
memset(dis, -1, sizeof(dis)); // 把所有的dis設為-1
dis[hh[str]] = 0; // str的dis為0
while (!que.empty()) { // 當que為空時,說明已經結束搜尋。
int x = que.front(); // 輸出頭部
que.pop();
for (int i = 1; i <= n; i++)
for (int j = 1; j <= 3; j++) {
int id = hh[s[i][j]]; // 為了找到頭部編號的dis
if (id != x) continue;
for (int k = 1; k <= 3; k++) {
int num = hh[s[i][k]];
if (dis[num] == -1) {
dis[num] = dis[x]+1;
que.push(num);
}
}
}
}
for (it = hh.begin(); it != hh.end(); it++) {
cout << it->first << " ";
if (dis[it->second] == -1)
cout << "undefined" << "\n";
else
cout << dis[it->second] << "\n";
}
}