五分鍾讓你徹底理解二叉樹的非遞歸遍歷


什么是二叉樹

在計算機科學中二叉樹,binary tree,是一種數據結構,在該數據結構中每個節點最多有兩個子節點,如圖所示:

二叉樹的定義就是這樣簡單,但這種看起來很簡單的數據結構遍歷起來一點都不簡單。

如何遍歷二叉樹

所謂遍歷簡單的講就好比在迷宮中尋寶,寶物就藏在某一個樹節點當中,但我們並不知道具體在哪個節點上,因此要找到寶物就需要將全部的樹節點系統性的搜索一遍

那么該怎么系統性的搜索一遍二叉樹呢?

給定一個單鏈表你幾乎不需要思考就能知道該如何遍歷,很簡單,拿到頭節點后處理當前節點,然后拿到頭節點的下一個節點(next)重復上述過程直到節點為空。

你會看到遍歷鏈表的規則很簡單,原因就在於鏈表本身足夠簡單,就是一條線,但是二叉樹不一樣,二叉樹不是一條簡單的"線",而是一張三角形的"網"。

那么給定一棵二叉樹,你該如何遍歷呢?以上圖為例,你該如何系統性的搜索一遍所有的節點呢(1,2,3,4,5,6)?

有的同學可能已經看出來了,我們可以一層一層的搜索,依次從左到右遍歷每一層節點,直到當前層沒有節點為止,這是二叉樹的一種遍歷方法。樹的這種層級遍歷方法利用了樹的深度這一信息(相對於根節點來說),同一層的節點其深度相同,那么我們是不是可以利用樹有左右子樹這一特點來進行遍歷呢?答案是肯定的。

如上圖所示1的左子樹是2,2的左子樹是3,2的右子樹是4。。。

假設我們首先遍歷根節點1,然后呢,你可能會想然后遍歷2的左子樹吧,也就是2,當我們到了2這個節點之后再怎么辦呢?要遍歷2的右子樹嗎?顯然我們不應該去遍歷2的右子樹,為什么?原因很簡單,因為從節點1到節點2我們是沿着左子樹的方向來遍歷的,我們沒有理由到節點2的時候改變這一規則,接下來我們繼續沿用這一規則,也就是首先遍歷左子樹。

我們來到了節點3,節點3的左子樹為空,因此無需遍歷,然后呢?顯然我們應該遍歷節點3的右子樹,但是3的右子樹也為空,這時以3為根節點的樹全部遍歷完畢。

當遍歷完節點3后該怎么辦呢?如果你在迷宮中位於節點3,此時節點3已經是死胡同了,因此你需要做的就是沿着來時的路原路返回,回退到上一個節點也就是3的父節點2,這在計算機算法中被稱為回溯,這是系統性搜索過程中常見的操作,回到2后我們發現2的左子樹已經搜索完畢,因此接下來需要搜索的就是2的右子樹也就是節點4,因為節點4還沒有被搜索過,當來到節點4后我們可以繼續使用上述規則直到這顆樹種所有的節點搜索完畢為止,為什么到節點4的時候可以繼續沿用之前的規則,原因很簡單,因為以4為根節點的子樹本身也是一棵樹,因此上述規則同樣適用。

因此總結一下該規則就是:

處理當前節點;
搜索當前節點的左子樹;
左子樹搜索完畢后搜索當前節點的右子樹;

這種先處理當前節點,然后再處理當前節點的左子樹和右子樹的遍歷方式被稱為先序遍歷(pre_order);當然我們也可以先遍歷左子樹,然后處理當前節點再遍歷右子樹,這種遍歷順序被稱為中序遍歷(in_order);也可以先遍歷左子樹再遍歷右子樹,最后處理當前節點,這種遍歷順序被稱為后序遍歷(post_order)。

遞歸實現遍歷二叉樹

在講解遞歸遍歷二叉樹前我們首先用代碼表示一下二叉樹的結構:

struct tree {
struct tree* left;
struct tree* right;
int value;
};

從定義上我們可以看出樹本身就是遞歸定義的,二叉樹的左子樹是二叉樹(struct tree* left),二叉樹的右子樹也是二叉樹(struct tree* right)。假設給定一顆二叉樹t,我們該如何遍歷這顆二叉樹呢?

struct tree* t;  // 給定一顆二叉樹

有的同學可能會覺得二叉樹的遍歷是一個非常復雜的過程,真的是這樣的嗎?

我們再來看一下上一節中遍歷二叉樹的規則:

處理當前節點;
搜索當前節點的左子樹;
左子樹搜索完畢后搜索當前節點的右子樹;

假設我們已經實現了樹的遍歷函數,這個函數是這樣定義的:

void search_tree(struct tree* t);

只要調用search_tree函數我們就能把一棵樹的所有節點打印出來:

struct tree* t;   // 給定一顆二叉樹
search_tree(t);   // 打印二叉樹所有節點

要是真的有這樣一個函數實際上我們的任務就完成了,如果我問你用這個函數把樹t的左子樹節點都打印出來該怎么寫代碼你肯定會覺得侮辱智商,很簡單啊,不就是把樹t的左子樹傳給search_tree這個函數嗎?

seartch_tree(t->left);   // 打印樹t的左子樹

那么打印樹t的右子樹呢?同樣easy啊

search_tree(t->right); // 打印樹t的右子樹

是不是很簡單,那么打印當前節點的值呢?你肯定已經懶得搭理我了 😃

printf("%d ", t->value); // 打印根節點的值

至此我們可以打印出根節點的值,也可以打印出樹t的左子樹節點,也可以打印出樹t的右子樹節點,如果我問你既然這些問題都解決了,那么該如何實現search_tree()這個函數?

如果你不知道,那么就該我說這句話了:很簡單啊有沒有,不就是把上面幾行代碼寫在一起嘛

void search_tree(struct tree* t) {
  printf("%d ", t->value); // 打印根節點的值
  seartch_tree(t->left); // 打印樹t的左子樹
  search_tree(t->right); // 打印樹t的右子樹
}

是不是很簡單,是不是很easy,驚喜不驚喜,意外不意外,我們在僅僅只靠給出函數定義並憑借豐富想象的情況下就把這個函數給實現了 😃

上述代碼完美符合之前定義的規則。

當然我們需要對特殊情況進行處理,如果給定的一棵樹為空,那么直接返回,最終代碼就是:

void search_tree(struct tree* t) {
  if (t == NULL) // 如果是一顆空樹則直接返回
    return;
     
  printf("%d ", t->value); // 打印根節點的值
  seartch_tree(t->left); // 打印樹t的左子樹
  search_tree(t->right); // 打印樹t的右子樹
}

有的同學可能會一臉懵逼,這個函數就這樣實現了?正確嗎,不用懷疑,這段代碼無比正確,你可以自己構造一棵樹並試着運行一下這段代碼。

上述代碼就是樹的遞歸遍歷

我知道這些一臉懵逼的同學心里的怎么想的,這段代碼看上去確實正確,運行起來也正確,那么這段代碼的運行過程是什么樣的呢?

遞歸調用過程

假設有這樣一段代碼:

void C() {
}

void A() {
    B();
}

void main() {
    A();
}

A()會調用B(),B()會調用C(),那么函數調用過程如圖所示:

實際上每一個函數被調用時都有對應的一段內存,這段內存中保存了調用該函數時傳入的參數以及函數中定義的局部變量,這段內存被稱為函數幀,函數的調用過程具有數據結構中棧的性質,也就是先進后出,比如當函數C()執行完畢后該函數對應的函數幀釋放並回到函數B,函數B執行完畢后對應的函數幀被釋放並回到函數A。

有了上述知識我們就可以看一下樹的遞歸調用函數是如何執行的了。為簡單起見,我們給定一顆比較簡單的樹:

當在該樹上調用search_tree函數時整個遞歸調用過程是怎樣的呢,如圖所示:

首先在根節點1上調用search_tree(),當打印完當前節點的值后在1的左子樹節點上調用search_tree,這時第二個函數幀入棧;打印完當前節點的值(2)后在2的左子樹上調用search_tree,這樣第三個函數幀入棧;同樣是打印完當前節點的值后(3)在3的左子樹上調用search_tree,第四個函數幀入棧;由於3的左子樹為空,因此第四個函數幀執行第一句時就會退出,因此我們又來到了第三個函數幀,此時節點3的左子樹遍歷完畢,因此開始在3的右子樹節點上調用search_tree,接下來的過程如圖所示:

這個過程會一直持續直到節點1的右子樹也遍歷完畢后整個遞歸調用過程運行完畢。注意,函數幀中實際上不會包含代碼,這里為方便觀察search_tree的遞歸調用過程才加上去的。上圖中沒有將整個調用過程全部展示出來,大家可以自行推導節點5和節點6是如何遍歷的。

從這個過程中我們可以看到,函數的遞歸調用其實沒什么神秘的,和普通函數調用其實是一樣的,只不過遞歸函數的特殊之處在於調用的不是其它函數而是本身。

從上面的函數調用過程可以得出一個重要的結論,那就是遞歸函數不會一直調用下去,否則就是棧溢出了,即著名的Stack Overflow,那么遞歸函數調用棧在什么情況下就不再增長了呢,在這個例子中就是當給定的樹已經為空時遞歸函數調用棧將不再增長,因此對於遞歸函數我們必須指明在什么情況下遞歸函數將直接返回,也就是常說的遞歸函數的出口。

遞歸實現樹的三種遍歷方法

到目前為止,我們已經知道了該如何遍歷樹、如何用代碼實現以及代碼的調用過程,注意打印語句的位置:

printf("%d ", t->value); // 打印根節點的值
seartch_tree(t->left); // 打印樹t的左子樹
search_tree(t->right); // 打印樹t的右子樹

中序和后序遍歷都可以很容易的用遞歸遍歷方法來實現,如下為中序遍歷:

void search_in_order(struct tree* t) {
    if (t == NULL) // 如果是一顆空樹則直接返回
      return;
      
    search_in_order(t->left); // 打印樹t的左子樹
    printf("%d ", t->value); // 打印根節點的值
    search_in_order(t->right); // 打印樹t的右子樹
}

后序遍歷則為:

void search_post_order(struct tree* t) {
    if (t == NULL) // 如果是一顆空樹則直接返回
      return;
      
    search_in_order(t->left); // 打印樹t的左子樹
    search_in_order(t->right); // 打印樹t的右子樹
    printf("%d ", t->value); // 打印根節點的值
}

至此,有的同學可能會覺得樹的遍歷簡直是太簡單了,那么如果讓你用非遞歸的方式來實現樹的遍歷你該怎么實現呢?

在閱讀下面的內容之前請確保你已經真正理解了前幾節的內容

如果你還是不能徹底理解請再多仔細閱讀幾遍。

如何將遞歸轉為非遞歸

雖然遞歸實現簡單,但是遞歸函數有自己特定的問題,比如遞歸調用會耗費很多的棧空間,也就是內存,同時該過程較為耗時,因此其性能通常不及非遞歸版本。

那么我們該如何實現非遞歸的遍歷樹呢?

要解決這個問題,我們必須清楚的理解遞歸函數的調用過程。

從遞歸函數的調用過程可以看出,遞歸調用無非就是函數幀入棧出棧的過程,因此我們可以直接使用棧來模擬這個過程,只不過棧中保存的不是函數而是樹節點。

確定用棧來模擬遞歸調用這一點后,接下來我們就必須明確兩件事:

  1. 什么情況下入棧

  2. 什么情況下出棧

我們還是以先序遍歷為例來說明。

仔細觀察遞歸調用的過程,我們會發現這樣的規律:

  1. 不管三七二十一先把從根節點開始的所有左子樹節點放入棧中

  2. 查看棧頂元素,如果棧頂元素有右子樹那么右子樹入棧並以右子樹為新的根節點重復過程1直到棧空為止

現在我們可以回答這兩個問題了。

什么情況下入棧?

最開始時先把從根節點開始的所有左子樹節點放入棧中,第二步中如果棧頂有右子樹那么重復過程1,這兩種情況下會入棧。

那么什么情況下出棧呢?

當查看棧頂元素時實際上我們就可以直接pop掉棧頂元素了,這是和遞歸調用不同的一點,為什么呢?因為查看棧頂節點時我們可以確定一點事,那就是當前節點的左子樹一定已經處理完畢了,因此對於棧頂元素來說我們需要的僅僅是其右子樹的信息,拿到右子樹信息后棧頂節點就可以pop掉了。

因此上面的描述用代碼來表示就是:

void search(tree* root) {
    if(root == NULL)
        return ;
    stack<tree*>s;
    
    // 不管三七二十一先把從根節點開始的所有左子樹節點放入棧中
    while(root){
        s.push(root);
        root=root->left;
    }
    
    while(!s.empty()){
        // 查看棧頂元素,如果棧頂元素有右子樹那么右子樹入棧並重復過程1直到棧空為止
        tree* top = s.top();
        tree* t = top->right;
        s.pop();
   
        while(t){
            s.push(t);
            t = t->left;
        }
    }
    return r;
}

上述代碼是實現樹的三種非遞歸遍歷的基礎,請務必理解。

接下來就可以實現樹的三種非遞歸遍歷了。

實現二叉樹的非遞歸遍歷

有的同學可能已經注意到了,上一節中的代碼中沒有printf語句,如果讓你利用上面的代碼以先序遍歷方式打印節點該怎么實現呢?如果你真的已經理解了上述代碼那么就非常簡單了,對於先序遍歷來說,我們只需要在節點入棧之前打印出來就可以了:

void search_pre_order(tree* root) {
    if(root == NULL)
        return ;
    stack<tree*>s;
    
    // 不管三七二十一先把從根節點開始的所有左子樹節點放入棧中
    while(root){
        printf("%d ", root->value); // 節點入棧前打印 
        s.push(root);
        root=root->left;
    }
    
    while(!s.empty()){
        // 查看棧頂元素,如果棧頂元素有右子樹那么右子樹入棧並重復過程1直到棧空為止
        tree* top = s.top();
        tree* t = top->right;
        s.pop();
   
        while(t){
            printf("%d ", root->value); // 節點入棧前打印 
            s.push(t);
            t = t->left;
        }
    }
    return r;
}

那么對於中序遍歷呢?實際上也非常簡單,我們只需要在節點pop時打印就可以了:

void search_in_order(tree* root) {
    if(root == NULL)
        return ;
    stack<tree*>s;
    
    // 不管三七二十一先把從根節點開始的所有左子樹節點放入棧中
    while(root){
        s.push(root);
        root=root->left;
    }
    
    while(!s.empty()){
        // 查看棧頂元素,如果棧頂元素有右子樹那么右子樹入棧並重復過程1直到棧空為止
        tree* top = s.top();
        printf("%d ", top->value); // 節點pop時打印 
        tree* t = top->right;
        s.pop();
   
        while(t){
            s.push(t);
            t = t->left;
        }
    }
    return r;
}

對於后續遍歷呢?

后續遍歷相對復雜,原因就在於出棧的情況不一樣了

在先序和中序遍歷過程中,只要左子樹處理完畢實際上棧頂元素就可以出棧了,但是后續遍歷情況不同,什么是后續遍歷?只有左子樹和右子樹都遍歷完畢才可以處理當前節點,這是后續遍歷,那么我們該如何知道當前節點的左子樹和右子樹都處理完了呢?

顯然我們需要某種方法記錄下遍歷的過程,實際上我們只需要記錄下遍歷的前一個節點就足夠了。

如果我們知道了遍歷過程中的前一個節點,那么我們就可以做如下判斷了:

  1. 如果前一個節點是當前節點的右子樹,那么說明右子樹遍歷完畢可以pop了

  2. 如果前一個節點是當前節點的左子樹而且當前節點右子樹為空,那么說明可以pop了

  3. 如果當前節點的左子樹和右子樹都為空,也就是葉子節點那么說明可以pop了

這樣什么情況下出棧的問題就解決了,如果不符合這些情況就不能出棧。

只需要根據以上分析對代碼稍加修改就可以了:

void search_post_order(tree* root) {
    if(root == NULL)
        return ;
    stack<tree*>s;
    TreeNode* last=NULL; // 記錄遍歷的前一個節點
    
    // 不管三七二十一先把從根節點開始的所有左子樹節點放入棧中
    while(root){
        s.push(root);
        root=root->left;
    }
    
    while(!s.empty()){
        tree* top = s.top();
        if (top->left ==NULL && top->right == NULL ||   // 當前節點為葉子節點
            last==top->right ||                         // 前一個節點為當前節點的右子樹 
            top->right==NULL && last==top->left){ // 前一個節點為當前節點左子樹且右子樹為空
            printf("%d ", top->value);            // 節點pop時打印 
            last = top;                           // 記錄下前一個節點
            s.pop();
        } else {
            tree* t = top->right;
            while(t){
                s.push(t);
                t = t->left;
            }
        }
    }
    return r;
}

總結

樹的遞歸遍歷相對簡單且容易理解,但是遞歸調用實際上隱藏了相對復雜的遍歷過程,要想以非遞歸的方式來遍歷二叉樹就需要仔細理解遞歸調用過程。

寫在最后

歡迎大家關注我的公眾號【風平浪靜如碼】,海量Java相關文章,學習資料都會在里面更新,整理的資料也會放在里面。

覺得寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!


免責聲明!

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



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