dfs求連通塊


遞歸

遞歸是什么?絕大部分人都會說:自己調用自己,剛開始我也是這樣理解遞歸的。確實沒錯,遞歸的確是自己調用自己。遞歸簡單的應用:編寫一個能計算斐波那契數列的函數,也就是這樣:

int fb(int n){
    if(n == 1 || n == 2) return 1;
    return fb(n-1) + fb(n-2);
}

相信絕大部分人都能看懂這段代碼。遞歸除了可以用自己調用自己這樣描述之外,還可以這樣表示遞歸函數:遞推式+邊界處理。很顯然,fb(n) = fb(n-1) + fb(n-2)就是這個計算斐波那契數列的遞推式,而上面的if語句就是邊界處理。但是,當我接觸到二叉樹這個數據結構時,這樣的遞歸定義顯然還不夠完整,還差一點。我們先介紹一個數據結構,鏈表,鏈表是常見的基礎數據結構,對於鏈表的實現書和網上都有講,那么我們可不可以給鏈表這樣一個遞歸定義:

鏈表:要么結點為空,要么由結點和子節點構成

這樣定義了鏈表后,其實二叉樹的遞歸定義就好理解了:

二叉樹:要么為空,要么由根結點、左子樹和右子樹組成,而左子樹和右子樹分別是一棵二叉樹。 摘自《算法競賽入門經典》

鏈表和二叉樹

看完了二叉樹,就可以想到二叉樹的結點是可以用類似與實現鏈表的方法來進行實現:

struct node{
    int value;
    node *left, *right;
};

和鏈表相比,二叉樹只不過是多了一個指針而已。但是呢,多了一個指針有變得有點麻煩:我們在遍歷鏈表時,只需要用一個循環就能遍歷完鏈表中的結點。

struct node{
    int value;
    node *next;
};

void fun(node* root){  //root是鏈表頭,即鏈表第一個元素的地址(指針)
    for(node *p = root; p != NULL; p=p->next){
        //操作
    }
}

而對於二叉樹要寫多少個循環呢,一個,兩個?我們發現,二叉樹並不能像鏈表一樣簡單的遍歷,因為二叉樹每到一個結點就有兩個方向可以走,並不像一個for循環只規定一個方向。還有一個問題:當我們從根節點出發時,如果按照我們普通的方法遍歷,應該是從左到右遍歷,也就是先遍歷左子樹,遍歷完后再遍歷右子樹。

當遍歷左子樹時,我們發現,這個左子樹的跟結點也連接有左子樹和右子樹。

這時遍歷過程如果用循環寫就變得異常復雜,最關鍵的是,怎樣從左子樹遍歷完后開始右子樹的遍歷。有的人說,到了樹的末端就停止左子樹的遍歷,然后進行右子樹的遍歷。但是右子樹又要從哪個結點開始遍歷呢,所以我們還要寫一個回溯的代碼,而這僅僅用循環是很難實現的,下面是回溯圖:

看起來非常復雜,其實我們用遞歸就可以解決這個問題,關鍵是我們對遞歸怎樣進一步地去理解。我們再次看回斐波那契函數的代碼:

int fb(int n){
    if(n == 1 || n == 2) return 1;
    return fb(n-1) + fb(n-2);
}

其中if(n == 1 || n == 2) return 1;之前被認為是邊界處理,其實這里還有個操作:回溯,也就是return 1;這個語句。在遞歸到達邊界后,就會把值返回給上一個狀態。下面的return fb(n-1) + fb(n-2);中的return的作用也是回溯的操作。那么,這個遞歸函數還有什么值得研究的嗎?之前我們說的遞推式fb(n-1) + fb(n-2),在這里我們把它稱為要重復做的事。所以,根據上面的解釋,我們又可以這樣理解遞歸:遞歸是可以幫你完成要重復做的事情,只要你規定好邊界和處理好回溯的問題。那么遞歸相比於我們普通寫的循環(遞推)有什么優勢呢?首先,相同點我們都知道,就是同樣可以完成要重復做的事,不同在於循環一般是完成單方向的重復做的事,如果是多方向的重復做的事可能要寫多重循環,甚至多重循環都不一定解決的了,代碼實現相對較難。而遞歸呢,則單方向和多方向要完成重復做的事都可以,而且關注點只是重復做的事情,處理好邊界和回溯問題就行了,減少思考的時間(這個時間因情況而定,如果你每一步遞歸全都要思考一遍,把過程寫出來,自然是會消耗不少時間,減少時間的前提是你把要重復做的事抽象化出來,處理好邊界問題后相信遞歸能計算出來),我先擺上遍歷二叉樹代碼:

struct node{
    int value;
    node *left, *right;
};

void dfs(node* root){    //root為二叉樹的根節點的地址(指針)
    if(root == NULL) return;  //邊界處理,如果到達邊界就回溯
    //重復要做的事
    dfs(root->left); 
    dfs(root->right);
    return;   //遍歷完左子樹和右子樹后返回
}

是不是遞歸函數的代碼很簡潔?我們分析為什么遍歷二叉樹可以這樣寫:看回二叉樹的遞歸定義:結點,左子樹和右子樹。所以我們在遍歷時重復的操作是遍歷左子樹和右子樹,那么怎樣遍歷左子樹和右子樹呢?首先肯定是要到左子樹和右子樹的根節點才能繼續遍歷。於是完整要做的重復事情是:到達一個節點后,遍歷它的左結點和右結點。於是代碼就變成這樣:

void dfs(node* root){    //到達一個結點
    dfs(root->left);    //遍歷左結點
    dfs(root->right);   //遍歷右結點
}

這時遞歸函數的主要框架已經完成,也就是我們搞定了要重復做的事。接下來就要考慮邊界和回溯的問題。首先考慮邊界吧。當到達樹的底部時:

我們怎樣停止遍歷,也就是判斷的依據是什么?
我們可以看到上圖,一個結點是邊界的標志是它的左右子節點都為空,也就是:

root->left == NULL && root->right == NULL

於是原來的代碼可以這樣寫:

void dfs(node* root){    //到達一個結點
    if(root->left == NULL && root->right == NULL) return;  //如果左右結點為空則不再遍歷左結點和右結點
    dfs(root->left);    //遍歷左結點
    dfs(root->right);   //遍歷右結點
}

當然,也可以這樣寫:

void dfs(node* root){    //到達一個結點
    if(root == NULL) return; //當結點為空時就回溯
    dfs(root->left);    //遍歷左結點
    dfs(root->right);   //遍歷右結點
}

這樣寫看起來更簡潔一些,為什么可行呢?當我們遍歷到最后一個結點時,這個代碼會繼續遍歷左結點,然后到了左結點這個狀態。檢查這個結點,發現為空,所以返回。返回后遍歷右結點,發現右結點也為空,所以返回。然后遍歷完左結點和右結點后返回。這里有些小伙伴可能會有些疑問?為什么遍歷完左結點和右結點后會返回呢?這里沒有返回代碼啊!其實這里的返回只是省略不寫,因為是void類型啊,執行完后就會自動返回。所以完整的遍歷二叉樹的代碼是:

struct node{
    int value;
    node *left, *right;
};

void dfs(node* root){    
    if(root == NULL) return;  
    //其他操作可以寫在這里,比如查找值等等
    dfs(root->left); 
    dfs(root->right);
}

這里有個小坑:如果你的邊界處理是這樣:if(root->left == NULL && root->right == NULL) return;你要執行的操作應該在這個語句前面,否則會導致最后一個點遍歷不了。所以最好寫成上面完整代碼的形式。

dfs

終於講到dfs,我要die了 dfs:深度優先搜索,英文全稱:Depth-First-Search。剛剛遍歷二叉樹時,我們的函數名是不是寫成了dfs?對,沒錯,剛剛遍歷二叉樹的方法就是一種dfs。那么,dfs如何實現呢?我想大家應該都猜到了:遞歸。所以理解遞歸尤為關鍵。這里給一道dfs的經典例題:https://www.cnblogs.com/happy-MEdge/p/10544533.html


免責聲明!

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



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