深度優先搜索(DFS)
深度優先搜索叫DFS(Depth First Search)。OK,那么什么是深度優先搜索呢?_?
樣例:
舉個例子,你在一個方格網絡中,可以簡單理解為我們的地圖,要從A點到B點找到最短路徑:

我們要制定一個策略,以此來建立遞歸函數。在這種情況下,先往右一直走或往下走,如果往上走或往左走,便必然得不到最優解。
此時你從A點出發,一直朝着右走:

發現右邊已經沒有可以訪問的節點了,再選擇朝下遞歸:

此時找不到可以往右走或往下走的點了,所以只好返回,一直返回到第一個可用節點:

如上重復,在朝下遞歸:

我們便得到了一個答案:4!雖然程序實際運行情況不會這么簡單,所以有時需要考慮更加周到一點。但是我們知道這么多就夠了。(你甚至可以寫個斷點來看它到底干了啥)。
以此,我們可以大體總結一下深度優先搜索的一個基本思想:從一個節點出發,一直到找不到可行節點時,再選擇返回。
深度優先搜索是建立在以棧為基礎的算法,而遞歸又恰好符合這個特性,我們可以大致寫出深度優先搜索的偽代碼:
void dfs(所走的次數, 其他參數) { if (找到了符合條件的節點) { //更新最小值 return; } for (遍歷所有方法) { //如果找到了一個可行節點,便遞歸到下一個棧幀 if (該方法可行) { dfs(所走次數 + 1, 其他參數); } } }
但是這樣子寫會有一個問題:同樣的一個節點會被走很多次!這還不是最可怕的,在沒有總結出 “如果往上走或往左走,便必然得不到最優解” 的情況下,甚至可能會出現程序放飛自我,A->B,然后B->A,如此死循環然后棧溢出。我們只好引用一個二維數組,只要走過這個節點便對其進行標記表示這里走過了,往后的遞歸就不能再訪問此節點,來避免A->B B->A的尷尬情況。
void dfs(所走的次數, 其他參數) { if (找到了符合條件的節點) { //更新最小值 return; } for (遍歷所有方法) { if (該方法可行) { //標記該節點走過 dfs(所走次數 + 1, 其他參數); //回溯:將該節點標記未走過 } } }
既然我們寫出了代碼,為什么還要進行一個叫“回溯(sù)”的事情呢?我們寫代碼時不能保證一定可以得到最優解,或許從一條路搜索過來需要走5次,從另一條路走過來只要3次。如果沒有回溯的話,就會使走過的節點無法再走,更優的解無法覆蓋原來的解,需要3次的走法就無法覆蓋只需要5次的走法,從而得不到最優解。盡管如此,深度優先搜索的時間開支依舊不小。再打個比方,你已經知道走這條路不是最優解了,但你還是不得不把它走完。所以,我們就需要用到剪枝來剔除不必要的搜索。在這里,剪枝方案就是:如果當前所使用的步數,已經大於等於到終點的所需的步數,那么就舍去這條路線。因為從此處為起點出發的任何一個節點都不可能是最優解了。
int ans = 0x7f7f7f7f //答案,初始值可以看做無限大
void dfs(所走的次數, 其他參數) { if (所走的次數 > ans) { return; } if (找到了符合條件的節點) { //更新最小值 return; } for (遍歷所有方法) { if (該方法可行) { //標記該節點走過 dfs(所走次數 + 1, 其他參數); //回溯:將該節點標記未走過 } } }
這樣,就是一個標准的深度優先搜索模板。我們也可以針對其他例題進行修改(有些情況甚至連剪枝都剪不了)。
例題(洛谷P2404):
現在引入一道例題:
題目描述
任何一個大於1的自然數n,總可以拆分成若干個小於n的自然數之和。現在給你一個自然數n,要求你求出n的拆分成一些數字的和。每個拆分后的序列中的數字從小到大排序。然后你需要輸出這些序列,其中字典序小的序列需要優先輸出。
輸入格式
輸入:待拆分的自然數n。
輸出格式
輸出:若干數的加法式子。
輸入輸出樣例
條件:
n ≤ 8
//從左到右的參數依次為:不能小於該數,現在選擇第幾個數字,數字之和 void dfs(int num, int now, int sum)
{
...
}
其中的num可以簡化略掉,但是為了方便閱讀還是加上去了。
那么,我們還需要一個數組來保存所找到的解,我們把它定義為s[10]。題目中n不會大於8,但是為了避免一些奇奇怪怪的錯誤,故開為10。很多以索引為1開頭的寫法,都推薦把數組開大一點(反正評測姬上的內存也不是自家的)。
//s數組用來存儲當前的解。題目中給定n不可能大於8,但是為了避免一些奇怪的錯誤,故將數組s開大一點 int s[10], n;
那么,如何判斷是否找到了一個可行解呢?其實不難,當參數sum剛好等於n時,即可輸出答案(不是大於等於),這樣就無需去計算當期解的和,減少運算量。
//找到了一個可行解便打印出來 if (sum == n) { for (int i = 1;i < now - 1;i++) printf ("%d+", s[i]); printf ("%d\n", s[now - 1]); return; }
那么接下來就是枚舉所有可行的數字,此時num就派上用場了:接下來可行的數字一定在num到n之間。而now就是存儲當前操作第幾個數字的索引(索引聽不懂的回去補課)
//遍歷所有可行數字,如果是i<=n的話,深搜到最后會將n自己打印出來 for (int i = num;i < n;i++) { s[now] = i; dfs(i, now + 1, sum + i); s[now] = 0; //此行可省略 }
代碼中第6行可以省略。為什么可以省略呢?因為以我們的寫法,是不會再次訪問s[now]的,所以就沒必要回溯。
全部代碼:
#include <cstdio> //s數組用來存儲當前的解。題目中給定n不可能大於8,但是為了避免一些奇怪的錯誤,故將數組s開大一點 int s[10], n; //從左到右的參數依次為:不能小於該數,現在選擇第幾個數字,數字之和 void dfs(int num, int now, int sum) { //如果總和超過了n,就直接返回 if (sum > n) return; //找到了一個可行解便打印出來 if (sum == n) { for (int i = 1;i < now - 1;i++) printf ("%d+", s[i]); printf ("%d\n", s[now - 1]); return; } //遍歷所有可行數字,如果是i<=n的話,深搜到最后會將n自己打印出來 for (int i = num;i < n;i++) { s[now] = i; dfs(i, now + 1, sum + i); s[now] = 0; //此行可省略 } } int main() { scanf ("%d", &n); dfs(1, 1, 0);
return 0; }
總結:
深度優先搜索其中函數很好寫,但是大體思路有一點難以理解,甚至會出現玄學代碼的情況(改一個符號便出現完全例外的情況),所以寫代碼的時候要盡可能嚴謹,並且不要亂剪枝。深度優先搜索本質上是一個暴力算法,並且多以遞歸+回溯的形式出現,如果優化和剪枝不好並且數據刁鑽,經常出現TLE的情況。
題外話:
我的BFS博客(戳):https://www.cnblogs.com/TheAzureDeepSpace/p/13497668.html
這是我寫的第一個博客,可能會出現一些奇奇怪怪的錯誤,不過可以在評論區留言,我一定會改的(艾瑪好緊張QAQ)
