一文弄懂二叉樹的三種遍歷方式

高效能架構探索發表於2021-11-09

俗話說:學如逆水行舟,不進則退;心似平原走馬,易放難收。這句話對程式設計師而言,體會更深。這行已經越來越捲了,時刻準備著,?。 二叉樹,在面試中,已是必備的開胃菜。而在二叉樹相關的面試題目中,遍歷更是常考題目。本文將從二叉樹的遍歷角度入手,從遞迴和非遞迴角度來分析和講解二叉樹的遍歷。

遍歷

二叉樹的遍歷是指從根節點出發,按照某種次序依次訪問二叉樹中的所有節點,使每個節點被且僅被訪問一次。

二叉樹的遍歷,有「先序遍歷」「中序遍歷」以及「後續遍歷」三種。

圖一圖一

上面三種遍歷方式中的先序、中序以及後序三種方式,是父節點相對於子節點來說的。如果父節點先於子節點,那麼就是先序遍歷。如果子節點先於父節點,那麼就是後序遍歷。而對於子節點來說,如果先左節點,然後是父節點,然後再是右節點,那麼就是中序遍歷。

如【圖一】所示二叉樹。其三種遍歷結果如下:

先序遍歷: A->B->D->E->C->F->G

中序遍歷: D->B->E->A->F->C->G

後續遍歷: D->E->B->F->G->C->A

為了便於理解程式碼,我們先定義下樹的節點定義:

struct TreeNode {
  TreeNode *left;
  TreeNode *right;
  int val;
};

先序遍歷

定義:先訪問父節點,然後遍歷左子樹,最後遍歷右子樹。

遞迴

相信遞迴遍歷大家都會很容易寫出來,且bugfree。因為實現程式碼很簡單。

圖二 先序遍歷圖二 先序遍歷

在上圖【圖二】中,使用遞迴遍歷,就是將其左子樹和右子樹也當做一棵樹來進行處理。程式碼如下:

void PreOrder(TreeNode *root) {
   if (!root) {
     return;
   }
   
   // 遍歷根節點(此處僅為輸出,讀者也可以根據實際需要進行處理,比如儲存等)
   std::cout << root->val << std::endl;
   
   // 遍歷左子樹
   PreOrder(root->left);
   
   // 遍歷右子樹
   PreOrder(root->right);
}

非遞迴

在非遞迴操作中,我們仍然是按照先訪問根節點,然後遍歷左子樹,接著遍歷右子樹的方式。

圖三 先序遍歷圖三 先序遍歷

  1. 到達節點A,訪問節點A,開始遍歷A的左子樹
  2. 到達節點B,訪問節點B,開始遍歷B的左子樹
  3. 到達節點D,訪問節點D,因為節點D無子樹,因此節點D遍歷完成
    • 節點D遍歷完成,意味著節點B的左子樹遍歷完成,因此接著遍歷節點B的右子樹
  4. 到達節點E,訪問節點E。因為節點E無子樹,因此節點E遍歷完成
    • 節點E遍歷完成,意味著節點B的右子樹遍歷完成,也預示著節點B的子樹遍歷完成
    • 開始遍歷節點A的右子樹
  5. 到達節點C,訪問節點C。開始遍歷C的左子樹
  6. 到達節點F,訪問節點F。因為節點F無子樹,因此節點F遍歷完成
    • 節點F遍歷完成,意味著節點C的左子樹遍歷完成,因此開始遍歷節點C的右子樹
  7. 到的節點G,訪問節點G。因為節點G無子樹,因此節點G遍歷完成
    • 節點G遍歷完成,意味著節點C的右子樹遍歷完成,進而預示著節點C遍歷完成
    • 節點C遍歷完成,意味著節點A的右子樹遍歷完成,進而意味著節點A遍歷完成,因此以A為根節點的樹遍歷完成。

用非遞迴方式遍歷二叉樹,需要引入額外的資料結構棧(stack),其基本流程如下: 1、申請一個棧stack,然後將頭節點壓入stack中。

2、從stack中彈出棧頂節點,列印

3、將其右孩子節點(不為空的話)先壓入stack中

4、將其左孩子節點(不為空的話)壓入stack中。

5、不斷重複步驟2、3、4,直到stack為空,全部過程結束。

程式碼如下:

void PreOrder(TreeNode *root) {
  if (!root) {
    return;
  }
  
  std::stack<TreeNode*> s;
  s.push(root); // 步驟1
  
  while (!s.empty()) {
    auto t = s.top();
    s.pop();//出棧
    
    std::cout << t->val << std::endl; // 訪問節點
    if (t->right) {
      s.push(t->right); // 對應步驟3
    }
    
    if (t->left) {
      s.push(t->left); // 對應步驟4
    }
  }
}

中序遍歷

定義:先遍歷左子樹,訪問根節點,遍歷右子樹

遞迴

圖四 中序遍歷圖四 中序遍歷

在上圖【圖四】中,使用遞迴遍歷,就是將其左子樹和右子樹也當做一棵樹來進行處理。程式碼如下:

void InOrder(TreeNode *root) {
   if (!root) {
     return;
   }
   
  
   
   // 遍歷左子樹
   InOrder(root->left);
   
    // 遍歷根節點(此處僅為輸出,讀者也可以根據實際需要進行處理,比如儲存等)
   std::cout << root->val << std::endl;
   
   // 遍歷右子樹
   InOrder(root->right);
}

上述中序遍歷的遞迴程式碼,相比於先序遍歷,只是將訪問根節點的行為放在了遍歷左右子樹之間。

非遞迴

在非遞迴操作中,我們仍然是按照先遍歷左子樹,然後訪問根節點,最後遍歷右子樹的方式。

圖五 中序遍歷圖五 中序遍歷

  1. 到達節點A,節點A有左子樹,遍歷節點A的左子樹
  2. 到達節點B,節點B有左子樹,遍歷節點B的左子樹
  3. 到達節點D,節點D無子樹,訪問D節點
    • 由於D無子樹,意味著B的左子樹遍歷完成,那麼就回到B節點
  4. 訪問B節點,遍歷B節點的右子樹
  5. 到達節點E,節點E無子樹,訪問節點E
    • E節點遍歷完成,意味著以B為根的子樹遍歷完成,回到A節點
  6. 到達A節點,訪問A節點,遍歷A節點的右子樹
  7. 到達C節點,遍歷C節點的左子樹
  8. 到達F節點,因為F節點無子樹,因此訪問F節點。
    • 由於F節點無子樹,意味著C節點的左子樹遍歷完成,回到C節點
  9. 到達C節點,訪問C節點,遍歷C的右子樹
  10. 到達節點G,由於G無子樹,因為訪問節點G
  • G節點遍歷完成,意味著C節點的右子樹遍歷完成,進而意味著A節點的右子樹遍歷完成,從意味著以A節點為根的二叉樹遍歷完成。

中序遍歷,同樣需要額外的輔助資料結構棧。

  1. 將根節點放入棧 2、如果根節點有左子樹,則將左子樹的根節點放入棧 3、重複步驟1和2.繼續遍歷左子樹 4、從棧中彈出節點,進行訪問,然後遍歷右子樹(重複步驟1和2) 5、如果棧為空,則遍歷完成

程式碼如下:

void InOrder(TreeNode *root) {
  if (!root) {
    return;
  }
  
  std::stack<TreeNode*> s;
  auto p = root;
  
  while (!s.empty() || p) {
    if (p) { // 步驟1和2
      s.push(p);
      p = p->left;
    } else { // 步驟4
      auto t = s.top();
      std::cout << t->val << std::endl;
      p = t->right;
    }
  }
}

後續遍歷

定義:先遍歷左子樹,再遍歷右子樹,最後訪問根節點

遞迴

圖六 後序遍歷圖六 後序遍歷

void PostOrder(TreeNode *root) {
   if (!root) {
     return;
   }
   
   // 遍歷左子樹
   PostOrder(root->left);
   
   // 遍歷右子樹
   PostOrder(root->right);
   
    // 遍歷根節點(此處僅為輸出,讀者也可以根據實際需要進行處理,比如儲存等)
   std::cout << root->val << std::endl;
}

上面就是後續遍歷的遞迴寫法,比較寫先序遍歷、中序遍歷以及後續遍歷三者的遞迴遍歷寫法,大部分程式碼是一樣的,唯一的區別就是「訪問根節點的程式碼位置」不一樣:

先序遍歷:「先訪問根節點,然後遍歷左子樹,最後遍歷右子樹」 中序遍歷:「先遍歷左子樹,然後訪問根節點,最後遍歷右子樹」 後序遍歷:「先遍歷左子樹,然後遍歷右子樹,最後訪問根節點」

非遞迴

在非遞迴操作中,我們仍然是按照先遍歷左子樹,然後訪問根節點,最後遍歷右子樹的方式。

圖七 後續遍歷圖七 後續遍歷

  1. 到達節點A,遍歷A的左子樹
  2. 到達節點B,遍歷B的左子樹
  3. 到達節點D,由於D無子樹,則訪問節點D
    • 節點D無子樹,意味著節點B的左子樹遍歷完成,接著遍歷B的右子樹
  4. 到達節點E,由於E無子樹,則訪問節點E
    • 節點E無子樹,意味著節點B的右子樹遍歷完成,接回到節點B
  5. 訪問節點B,回到節點B的根節點A
  6. 到達節點A,訪問節點A的右子樹
  7. 到達節點C,遍歷節點C的左子樹
  8. 到達節點F,由於節點F無子樹,因此訪問節點F
    • 節點F訪問完成,意味著C節點的左子樹遍歷完成,因此回到節點C
  9. 到達節點C,遍歷節點C的右子樹
  10. 到達節點G,由於節點G無子樹,因為訪問節點G
  • 節點G訪問完成,意味著C節點的右子樹遍歷完成,回到節點C
  1. 到達節點C,訪問節點C
  • 節點C遍歷完成,意味著節點A的右子樹遍歷完成,回到節點A
  1. 節點A的右子樹遍歷完成,訪問節點A

用非遞迴方式遍歷二叉樹,需要引入額外的資料結構棧(stack),其基本流程如下: 1、申請兩個棧stack,然後將頭節點壓入指定stack中。

2、從stack中彈出棧頂節點,放入另外一個棧中

3、將其左孩子節點(不為空的話)先壓入stack中

4、將其右孩子節點(不為空的話)壓入stack中。

5、不斷重複步驟2、3、4,直到stack為空。

6、重複訪問另外一個棧,直至棧空

void PostOrder(TreeNode *root) {
  if (!root) {
    return;
  }
  
  std::stack<TreeNode*> s1;
  std::stack<TreeNode*> s2;
  
  s1.push(root);
  
  while (!s1.empty()) {
    auto t = s1.top();
    s1.pop();
    s2.push(t);
    
    if (t->left) {
      s1.push(t->left);
    }
    
    if (t->right) {
      s1.push(t->right);
    }
  }
  
  while (!s2.empty()) {
    auto t = s2.top();
    s2.pop();
    std::cout << t->val << std::endl;
  }
}

結語

對於二叉樹來說,所謂的遍歷,是指沿著某條路線依次訪問每個節點,且均只做一次訪問。二叉樹的遍歷,是面試中常面演算法之一,一定要把其弄懂,必要的時候,需要背誦,乃至做到肌肉記憶。

相關文章