[算法入門]——深度優先搜索(DFS)


深度優先搜索(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。

輸出格式

輸出:若干數的法式子。

輸入輸出樣例

輸入 #1
7
輸出 #1
1+1+1+1+1+1+1
1+1+1+1+1+2
1+1+1+1+3
1+1+1+2+2
1+1+1+4
1+1+2+3
1+1+5
1+2+2+2
1+2+4
1+3+3
1+6
2+2+3
2+5
3+4
 

條件:

n ≤ 8

分析一下題目,不難發現,輸出樣例中,每一行(每一組解)后面的數字一定不小於前面的數字,並且所有數字之和一定等於且不大於n,那么我們可以大致分析出dfs的參數:
//從左到右的參數依次為:不能小於該數,現在選擇第幾個數字,數字之和 
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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM