1. 程式人生 > >【演算法】廣度優先搜尋

【演算法】廣度優先搜尋

作者:raphealguo(at)qq.com


1.前言

廣度優先搜尋(也稱寬度優先搜尋,縮寫BFS,以下采用廣度來描述)是連通圖的一種遍歷策略。因為它的思想是從一個頂點V0開始,輻射狀地優先遍歷其周圍較廣的區域,故得名。 

一般可以用它做什麼呢?一個最直觀經典的例子就是走迷宮,我們從起點開始,找出到終點的最短路程,很多最短路徑演算法就是基於廣度優先的思想成立的。

演算法導論裡邊會給出不少嚴格的證明,我想盡量寫得通俗一點,因此採用一些直觀的講法來偽裝成證明,關鍵的point能夠幫你get到就好。

2.圖的概念

剛剛說的廣度優先搜尋是連通圖的一種遍歷策略,那就有必要將圖先簡單解釋一下。

2-1 

連通圖示例圖

如圖2-1所示,這就是我們所說的連通圖,這裡展示的是一個無向圖,連通即每2個點都有至少一條路徑相連,例如V0到V4的路徑就是V0->V1->V4。

一般我們把頂點用V縮寫,把邊用E縮寫。

3.廣度優先搜尋


3.1.演算法的基本思路

常常我們有這樣一個問題,從一個起點開始要到一個終點,我們要找尋一條最短的路徑,從圖2-1舉例,如果我們要求V0到V6的一條最短路(假設走一個節點按一步來算)【注意:此處你可以選擇不看這段文字直接看圖3-1,我們明顯看出這條路徑就是V0->V2->V6,而不是V0->V3->V5->V6。先想想你自己剛剛是怎麼找到這條路徑的:首先看跟V

0直接連線的節點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中把最終路徑上的節點標誌成綠色。

整個過程的例項圖如圖3-1所示。

初始全部都是白色(未訪問

即將搜尋起點V0(灰色)

已搜尋V0,即將搜尋V1、V2V3

……終點V6被染灰色,終止

找到最短路徑

3-1 尋找V0V6的過程

3.2.廣度優先搜尋流程圖


3-2 廣度優先搜尋的流程圖

在寫具體程式碼之前有必要先舉個例項,詳見第4節。

4.例項

第一節就講過廣度優先搜尋適用於迷宮類問題,這裡先給出POJ3984《迷宮問題》。

《迷宮問題》

定義一個二維陣列: 
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的第一步就是要識別圖的節點跟邊!

4.1.識別出節點跟邊

節點就是某種狀態,邊就是節點與節點間的某種規則。

對應於《迷宮問題》,你可以這麼認為,節點就是迷宮路上的每一個格子(非牆),走迷宮的時候,格子間的關係是什麼呢?按照題目意思,我們只能橫豎走,因此我們可以這樣看,格子與它橫豎方向上的格子是有連通關係的,只要這個格子跟另一個格子是連通的,那麼兩個格子節點間就有一條邊。

如果說本題再修改成斜方向也可以走的話,那麼就是格子跟周圍8個格子都可以連通,於是一個節點就會有8條邊(除了邊界的節點)。

4.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變成灰色,加入佇列QQ={(0,0)}

2.取出佇列Q的頭一個節點Vn,Vn={0,0},Q={}

3.把Vn={0,0}染成黑色,取出Vn所有相鄰的白色節點{(1,0)}

4.不包含終點(4,4),染成灰色,加入佇列QQ={(1,0)}

5.取出佇列Q的頭一個節點Vn,Vn={1,0},Q={}

6.把Vn={1,0}染成黑色,取出Vn所有相鄰的白色節點{(2,0)}

7.不包含終點(4,4),染成灰色,加入佇列QQ={(2,0)}

8.取出佇列Q的頭一個節點VnVn={2,0}Q={}

9.把Vn={2,0}染成黑色,取出Vn所有相鄰的白色節點{(2,1), (3,0)}

10.不包含終點(4,4),染成灰色,加入佇列QQ={(2,1), (3,0)}

11.取出佇列Q的頭一個節點VnVn={2,1}Q={(3,0)}

12. 把Vn={2,1}染成黑色,取出Vn所有相鄰的白色節點{(2,2)}

13.不包含終點(4,4),染成灰色,加入佇列QQ={(3,0), (2,2)}

14.持續下去,知道Vn的所有相鄰的白色節點中包含了(4,4)……

15.此時獲得了答案

起始你很容易模仿上邊過程走到終點,那為什麼它就是最短的呢?

怎麼保證呢?

我們來看看廣度搜索的過程中節點的順序情況:


4-1 迷宮問題的搜尋樹

你是否觀察到了,廣度搜索的順序是什麼樣子的?

圖中標號即為我們搜尋過程中的順序,我們觀察到,這個搜尋順序是按照上圖的層次關係來的,例如節點(0,0)在第1層,節點(1,0)在第2層,節點(2,0)在第3層,節點(2,1)和節點(3,0)在第3層。

我們的搜尋順序就是第一層->第二層->第三層->N層這樣子。

我們假設終點在第N層,因此我們搜尋到的路徑長度肯定是N,而且這個N一定是所求最短的。

我們用簡單的反證法來證明:假設終點在第N層上邊出現過,例如第M層,M<N,那麼我們在搜尋的過程中,肯定是先搜尋到第M層的,此時搜尋到第M層的時候發現終點出現過了,那麼最短路徑應該是M,而不是N了。

所以根據廣度優先搜尋的話,搜尋到終點時,該路徑一定是最短的。

4.3.程式碼

我給出以下程式碼用於解決上述題目(僅僅只是核心程式碼)

Cpp程式碼  收藏程式碼
  1. /** 
  2.  * 廣度優先搜尋 
  3.  * @param Vs 起點 
  4.  * @param Vd 終點 
  5.  */  
  6. bool BFS(Node& Vs, Node& Vd){  
  7.     queue<Node> Q;  
  8.     Node Vn, Vw;  
  9.     int i;  
  10.     //用於標記顏色當visit[i][j]==true時,說明節點訪問過,也就是黑色  
  11.     bool visit[MAXL][MAXL];  
  12.     //四個方向  
  13.     int dir[][2] = {  
  14.         {0, 1}, {1, 0},  
  15.         {0, -1}, {-1, 0}  
  16.     };  
  17.     //初始狀態將起點放進佇列Q  
  18.     Q.push(Vs);  
  19.     visit[Vs.x][Vs.y] = true;//設定節點已經訪問過了!  
  20.     while (!Q.empty()){//佇列不為空,繼續搜尋!  
  21.         //取出佇列的頭Vn  
  22.         Vn = Q.front();  
  23.         Q.pop();  
  24.         for(i = 0; i < 4; ++i){  
  25.             Vw = Node(Vn.x+dir[i][0], Vn.y+dir[i][1]);//計算相鄰節點  
  26.             if (Vw == Vd){//找到終點了!  
  27.                 //把路徑記錄,這裡沒給出解法  
  28.                 return true;//返回  
  29.             }  
  30.             if (isValid(Vw) && !visit[Vw.x][Vw.y]){  
  31.                 //Vw是一個合法的節點並且為白色節點  
  32.                 Q.push(Vw);//加入佇列Q  
  33.                 visit[Vw.x][Vw.y] = true;//設定節點顏色  
  34.             }  
  35.         }  
  36.     }  
  37.     return false;//無解  
  38. }  

5.核心程式碼

為了方便適用於大多數的題解,抽取核心程式碼如下:

Cpp程式碼  收藏程式碼
  1. /** 
  2.  * 廣度優先搜尋 
  3.  * @param Vs 起點 
  4.  * @param Vd 終點 
  5.  */  
  6. bool BFS(Node& Vs, Node& Vd){  
  7.     queue<Node> Q;  
  8.     Node Vn, Vw;  
  9.     int i;  
  10.     //初始狀態將起點放進佇列Q  
  11.     Q.push(Vs);  
  12.     hash(Vw) = true;//設定節點已經訪問過了!  
  13.     while (!Q.empty()){//佇列不為空,繼續搜尋!  
  14.         //取出佇列的頭Vn  
  15.         Vn = Q.front();  
  16.         //從佇列中移除  
  17.         Q.pop();  
  18.         while(Vw = Vn通過某規則能夠到達的節點){  
  19.             if (Vw == Vd){//找到終點了!  
  20.                 //把路徑記錄,這裡沒給出解法  
  21.                 return true;//返回  
  22.             }  
  23.             if (isValid(Vw) && !visit[Vw]){  
  24.                 //Vw是一個合法的節點並且為白色節點  
  25.                 Q.push(Vw);//加入佇列Q  
  26.                 hash(Vw) = true;//設定節點顏色  
  27.             }  
  28.         }  
  29.     }  
  30.     return false;//無解  
  31. }  

對於一個題目來說,要標誌節點是否訪問過,用陣列是一種很快速的方法,但有時資料量太大,很難用一個大陣列來記錄時,採用hash是最好的做法。實際上visit陣列在這裡也是充當hash的作用。(PS:至於hash是什麼?得自己去了解,它的作用是在O(1)的時間複雜度內取出某個值)

6.其他例項

6.1.題目描述

給定序列1 2 3 4 5 6,再給定一個k,我們給出這樣的操作:對於序列,我們可以將其中k個連續的數全部反轉過來,例如k = 3的時候,上述序列經過1步操作後可以變成:3 2 1 4 5 6 ,如果再對序列 3 2 1 4 5 6進行一步操作,可以變成3 4 1 2 5 6.

那麼現在題目就是,給定初始序列,以及結束序列,以及k的值,那麼你能夠求出從初始序列到結束序列的轉變至少需要幾步操作嗎?

6.2.思路

本題可以採用BFS求解,已經給定初始狀態跟目標狀態,要求之間的最短操作,其實也很明顯是用BFS了。

我們把每次操作完的序列當做一個狀態節點。那每一次操作就產生一條邊,這個操作就是規則。

假設起始節點是:{1 2 3 4 5 6},終點是:{3 4 1 2 5 6}

去除佇列中的起始節點時,將它的相鄰節點加入佇列,其相鄰節點就是對其操作一次的所有序列:

{3 2 1 4 5 6}、{1 4 3 2 5 6}{1 2 5 4 3 6}{1 2 3 6 5 4}

然後繼續搜尋即可得到終點,此時運算元就是搜尋到的節點所在的層數2

7.OJ題目

題目分類來自網路:

sicily1048 1444 1215 1135 1150 1151 1114

pku:1136 1249 1028 1191 3278 1426 3126 3087 3414 

8.總結

假設圖有V個頂點,E條邊,廣度優先搜尋演算法需要搜尋V個節點,因此這裡的消耗是O(V),在搜尋過程中,又需要根據邊來增加佇列的長度,於是這裡需要消耗O(E),總得來說,效率大約是O(V+E)

其實最影響BFS演算法的是在於Hash運算,我們前面給出了一個visit陣列,已經算是最快的Hash了,但有些題目來說可能Hash的速度要退化到O(lgn)的複雜度,當然了,具體還是看實際情況的。

BFS適合此類題目:給定初始狀態跟目標狀態,要求從初始狀態到目標狀態的最短路徑。

9.擴充套件

進而擴充套件的話就是雙向廣度搜索演算法,顧名思義,即是從起點跟終點分別做廣度優先搜尋,直到他們的搜尋過程中有一個節點相同了,於是就找到了起點跟終點的一條路徑。

騰訊筆試題目:假設每個人平均是有25個好友,根據六維理論,任何人之間的聯絡一定可以通過6個人而間接認識,間接通過N個人認識的,那他就是你的N度好友,現在要你程式設計驗證這個6維理論。

此題如果直接做廣度優先搜尋,那麼搜尋的節點數可能達到256,如果是用雙向的話,兩個樹分別只需要搜尋到3度好友即可,搜尋節點最多為253個,但是用雙向廣度演算法的話會有一個問題要解決,就是你如何在搜尋的過程中判斷第一棵樹中的節點跟第二棵樹中的節點有相同的呢?按我的理解,可以用Hash,又或者放進佇列的元素都是指向原來節點的指標,而每個節點加入一個color的屬性,這樣再搜尋過程中就可以根據節點的color來判斷是否已經被搜尋過了。