[演算法入門]——廣度優先搜尋(BFS)
廣度優先搜尋
廣度優先搜尋,也就是BFS(Breadth First Search),又稱寬度優先搜尋(不過這個才是正式名稱吧)。BFS不像DFS,DFS是在一條路上越走越遠,稍有不慎便有放飛自我的可能,而BFS是將當前節點附近的節點全部訪問一遍,再去訪問下一個節點附近的節點。這麼聽來可能會有點拗口,不過我們先畫一張圖來便於理解:畫圖.exe
你從1點出發,目標是遍歷所有的節點。你先將1點新增到佇列中(紅色),然後依次遍歷1點旁邊的鄰居(附件節點),將其新增到佇列中:
然後,你再依次遍歷2節點的鄰居,但是2節點附近的1節點和3節點已經被新增到佇列中,所以不予新增節點。
依次訪問完1、2、3、4後,發現節點5附近還有一個節點6,於是也將6新增到已訪問。
而所有節點訪問所需要的最短路徑如下圖:
看上去很簡單,我們只需要依次遍歷所有已經訪問過的節點,再將已經訪問過的節點的鄰居加入到佇列中。
在BFS中有一個重要的資料結構:佇列。那麼,什麼是佇列呢?
佇列好比生活中的隊伍,保持著先進先出的思想。下圖是一個沒有節點的佇列:
步驟1,在剛才舉的例子中,我們先將1節點新增到佇列中:
步驟2,隨後,我們獲取佇列中的第一個元素,將它的鄰居新增到佇列中:
步驟3,我們彈出(類似刪除)隊首元素:
重複步驟2和步驟3的行為,直到佇列為空。
或許會發現,有沒有可能一個節點被反覆新增到佇列中,導致死迴圈呢?這種情況是務必避免的。我們可以引入一個數組vis,如果i節點被新增過,於是vis[i] = 1(表示已經入過隊),這要只需要在新增節點的過程中,判斷該節點是否進過佇列。
大致模板:
1 void BFS() 2 { 3 初始化,新增開始節點(例如1) 4 while (佇列不為空) 5 { 6 獲取隊首元素,將vis設定為1 //保險 7 for (遍歷該元素所有鄰居) 8 { 9 if(該鄰居進過隊) 10 { 11 入隊,將vis設定為1 //保險 12 } 13 } 14 彈出隊首元素 15 } 16 }
可能說得不好理解,所以這裡給出一份流程圖讓大家慢慢琢磨:
這裡再強調一下,鄰居入隊時要判斷該鄰居是否已經入過隊,並且每次迴圈最後一定要彈出隊首元素。不過,或許有人發現了——為什麼DFS有回溯,但是BFS卻不用回溯呢?DFS所得出來的每一個解,都不一定是最優解,所以需要回溯。但是BFS的特性已經保證所得出的解是最優解,除非你寫錯了。但是證明這一點非常困難,需要很長篇幅,而且BFS不能處理一些例外的題目,比如帶邊權圖。
我們針對剛才的例子,來寫一份從1到達所有節點所需步數的程式碼:
1 #include <iostream> 2 #include <cstring> 3 #include <queue>//佇列 4 using namespace std; 5 struct Edge 6 { 7 int next, to; 8 }edge[200]; 9 int EdgeNum = 0;//邊的數量 10 int head[100]; 11 void Add(int from, int to) 12 { 13 EdgeNum++; 14 edge[EdgeNum].next = head[from]; 15 edge[EdgeNum].to = to; 16 head[from] = EdgeNum; 17 } 18 int vis[100], num[100]; 19 /***主體部分,其他可省略不看***/ 20 //變數說明:edge->邊,vis->是否入過隊,num->所需步數 21 void BFS() 22 { 23 memset(vis, 0, sizeof(vis));//0代表未入隊 24 memset(num, 0, sizeof(num));//所需要的的步數 25 //佇列,儲存索引 26 queue<int> q; 27 //入隊 28 q.push(1); 29 while(!q.empty())//佇列不為空 30 { 31 int front = q.front();//獲取隊首元素 32 vis[front] = 1;//記錄為入過隊 33 for (int i = head[front];i;i = edge[i].next)//遍歷該節點的鄰居 34 { 35 int to = edge[i].to;//獲取鄰居節點的索引(這樣寫方便閱讀,可省略) 36 if (vis[to] == 0)//如果未入隊 37 { 38 q.push(to);//將該鄰居入隊 39 vis[to] = 1;//記錄為入過隊 40 num[to] = num[front] + 1;//更新所需步數 41 } 42 } 43 q.pop();//注意,必須出隊!沒寫的話會一直死迴圈到世界末日 44 } 45 } 46 /***主體部分,其他可省略不看***/ 47 int main() 48 { 49 //n->節點數,q->邊數 50 int n, q; 51 cin >> n >> q; 52 //建邊 53 for (int i = 1;i <= q;i++) 54 { 55 int u, v; 56 cin >> u >> v; 57 Add(u, v); 58 Add(v, u); 59 } 60 BFS(); 61 //列印結果 62 for (int i = 1;i <= n;i++) 63 cout << num[i] << " "; 64 return 0;//華麗結尾 65 }
引入例題
快樂的講解例題時間又到了~(≧▽≦)/~
這裡引入的是洛谷P1135 奇怪的電梯(話說是哪個人可以想到這麼奇怪的電梯的)
題目描述
呵呵,有一天我做了一個夢,夢見了一種很奇怪的電梯。大樓的每一層樓都可以停電梯,而且第ii層樓(1 \le i \le N)(1≤i≤N)上有一個數字K_i(0 \le K_i \le N)Ki(0≤Ki≤N)。電梯只有四個按鈕:開,關,上,下。上下的層數等於當前樓層上的那個數字。當然,如果不能滿足要求,相應的按鈕就會失靈。例如:3, 3 ,1 ,2 ,53,3,1,2,5代表了K_i(K_1=3,K_2=3,…)Ki(K1=3,K2=3,…),從11樓開始。在11樓,按“上”可以到44樓,按“下”是不起作用的,因為沒有-2−2樓。那麼,從AA樓到BB樓至少要按幾次按鈕呢?
輸入格式
共二行。
第一行為33個用空格隔開的正整數,表示N,A,B(1≤N≤200, 1≤A,B≤N)N,A,B(1≤N≤200,1≤A,B≤N)。
第二行為NN個用空格隔開的非負整數,表示K_iKi。
輸出格式
一行,即最少按鍵次數,若無法到達,則輸出-1。
輸入輸出樣例
輸入 #1 5 1 5 3 3 1 2 5 輸出 #1 3不過經過我的實際嘗試,這道題可以用DFS寫。但是為了不砸自己場子,先講講如何用BFS寫。
這道題就是一個十分經典的最短路的問題。作為一道黃題,不是很難,但是很香(BB啥快講啊)。
我們先大致整理一下,每一層樓的鄰居是誰?在這道題中,每層樓有至多兩個鄰居,分別是向上走的樓層和向下走的樓層。當然,題目中有一個隱含限制條件:每層樓必須在1~N之間。換句話說,如果這個鄰居層數超過N或小於1,那麼這個鄰居就是不可用的,就不能新增到佇列中。
將這個限制條件加上vis來判斷是否訪問過,就可以很好解決這個問題了:
1 #include <iostream> 2 #include <queue> 3 using namespace std; 4 const int SIZE = 10000; 5 int N, A, B, 6 k[SIZE],//電梯上的數字 7 vis[SIZE],//這裡有沒有走過 8 num[SIZE];//到每一層所需要的次數 9 int BFS() 10 { 11 queue<int> q;//佇列,記錄電梯的索引(層數) 12 q.push(A); 13 num[A] = 0; 14 while(!q.empty()) 15 { 16 int front = q.front(); //front代表層數 17 vis[front] = 1; 18 if (front == B)//確認過眼神,你就是B層! 19 return num[B]; 20 int up = front + k[front], down = front - k[front];//up代表向上走所到的層數,down代表朝下走所到的層數 21 //如果所到的樓層在1-N之間,且沒有被走過 22 if (up >= 1 && up <= N && !vis[up]) vis[up] = 1, q.push(up), num[up] = num[front] + 1; 23 if (down >= 1 && down <= N && !vis[down]) vis[down] = 1, q.push(down), num[down] = num[front] + 1; 24 q.pop();//出隊 25 } 26 return -1;//到頭沒找到B層_(:з」∠)_ 27 } 28 int main() 29 { 30 cin >> N >> A >> B;//輸入N,A,B 31 for (int i = 1;i <= N;i++) 32 cin >> k[i];//每層樓上的數字 33 //開啟你的BFS之旅 34 cout << BFS();//列印結果 35 }
484非常簡單?其實可以有一點點優化,比如在22行和23行中,我們可以發現,如果電梯朝上走的話,就一定不會小於1,朝下走的話就一定不會大於N,所以可以刪去大約14個字元(有必要這麼斤斤計較嗎)。這道題還可以用DFS來寫,但是程式碼是我很久以前寫的,或許很多地方沒寫清(關鍵是沒打註釋)
1 #include <cstdio> 2 int n, a, b, k[205], to[205] = {}, yes = 0x7ffffff; 3 int x (int cing, int cs) 4 { 5 if (cs > yes) 6 return 0; 7 if (cing == b) 8 if (yes > cs) 9 yes = cs; 10 to[cing] = 1; 11 if(cing + k[cing] <= n && !to[cing + k[cing]]) 12 { 13 x(cing + k[cing], cs + 1); 14 } 15 if(cing - k[cing] > -1 && !to[cing - k[cing]]) 16 { 17 x(cing - k[cing], cs + 1); 18 } 19 to[cing] = 0; 20 } 21 int main () 22 { 23 scanf ("%d%d%d", &n, &a, &b); 24 for (int i = 1;i <= n;i++) 25 { 26 scanf ("%d", &k[i]); 27 to[i] = 0; 28 } 29 to[a] = 1; 30 x (a, 0); 31 if (yes != 0x7ffffff) 32 printf ("%d", yes); 33 else 34 printf ("-1"); 35 }用深搜寫啦
BFS與DFS的區別,以及優缺點
BFS在本質上和DFS一樣,都是暴力演算法,暴力求解。而且泛用性很廣。許多題目都可以用BFS或DFS騙上20、30甚至50分,但是BFS與DFS往往會是空間或時間開銷很大,甚至無法控制。
BFS與DFS的區別,通俗點來說,BFS就是浪費空間來獲取時間上的優勢,比如一張10*10的平面圖,BFS起碼需要一個二維陣列來儲存,並且使用100單位的記憶體。DFS就是浪費時間來節省空間,就像剛才的10*10的平面圖一樣,DFS最多需要40單位的記憶體,如果有優化甚至只需要20單位,但是時間開銷極大,因為同一個節點會被訪問許多次。
BFS
優點:速度相對來說會快很多,並且可以暴力做出許多題目。
缺點:但是遇到資料刁鑽的情況,就得不到用武之地。而且沒有做好優化就MLE讓你懷疑人生。速度不一定會比DFS快。一道非常經典的題目“埃及分數”就很難用BFS做出來,就算做出來了空間開銷也無法讓人承受(評測姬:我太難了)。
DFS
優點:空間開銷很小,並且思路容易想到,在有剪枝優化或記憶化搜尋之後,可以很輕鬆做出許多題目。
缺點:太慢了,太慢了,太慢了!動不動就return 3221225725(棧溢位),資料大(超過100左右)那麼一點點就掛了。
關於學習DFS
如果你想看看DFS的用法,可以參考我的另外一篇部落格,或者以下我推薦的部落格:
https://www.cnblogs.com/DWVictor/p/10048554.html(例子超多)
https://www.jianshu.com/p/bff70b786bb6(講解詳細)
https://www.cnblogs.com/skywang12345/p/3711483.html(原始碼多)
https://www.cnblogs.com/TheAzureDeepSpace/p/13454102.html(我寫的,不解釋。手動滑稽)