二叉樹的三種遍歷方式(遞迴、非遞迴和Morris遍歷)
二叉樹遍歷是二叉樹的最基本的操作,其實現方式主要有三種:
- 遞迴遍歷
- 非遞迴遍歷
- Morris遍歷
遞迴遍歷的實現非常容易,非遞迴實現需要用到棧。而Morris演算法可能很多人都不太熟悉,其強大之處在於只需要使用O(1)的空間就能實現對二叉樹O(n)時間的遍歷。
二叉樹結點的定義
每個二叉樹結點包括一個值以及左孩子和右孩子結點,其定義如下:
class TreeNode { public: int val; TreeNode *left, *right; TreeNode(int val) { this->val = val; this->left = this->right = NULL; } };
二叉樹的遍歷
二叉樹的遍歷,就是按照某條搜尋路徑訪問樹中的每一個結點,使得每個結點均被訪問一次,而且僅被訪問一次。常見的遍歷次序有:
- 先序遍歷:先訪問根結點,再訪問左子樹,最後訪問右子樹
- 中序遍歷:先訪問左子樹,再訪問根結點,最後訪問右子樹
- 後序遍歷:先訪問左子樹,再訪問右子樹,最後訪問根結點
下面介紹,二叉樹3種遍歷方式的實現。
遞迴遍歷
遞迴實現非常簡單,按照遍歷的次序,對當前結點分別呼叫左子樹和右子樹即可。
前序遍歷
void preOrder(TreeNode *root) { if(root == NULL) return; cout << root->val << endl; preOrder(root->left); preOrder(root->right); }
中序遍歷
void inOrder(TreeNode *root) { if(root == NULL) return; inOrder(root->left); cout <<root->val << endl; inOrder(root->right); }
後序遍歷
void postOrder(TreeNode *root) { if(root == NULL) return; postOrder(root->left); postOrder(root->right); cout << root->val << endl; }
複雜度分析
二叉樹遍歷的遞迴實現,每個結點只需遍歷一次,故時間複雜度為O(n)。而使用了遞迴,最差情況下遞迴呼叫的深度為O(n),所以空間複雜度為O(n)。
非遞迴遍歷
二叉樹遍歷的非遞迴實現,可以藉助棧。
前序遍歷
- 將根結點入棧;
- 每次從棧頂彈出一個結點,訪問該結點;
- 把當前結點的右孩子入棧;
- 把當前結點的左孩子入棧。
按照以上順序入棧,這樣出棧順序就與先序遍歷一樣:先根結點,再左子樹,最後右子樹。
void preOrder2(TreeNode *root) { if(root == NULL) return; stack<TreeNode *> stk; stk.push(root); while(!stk.empty()) { TreeNode *pNode = stk.top(); stk.pop(); cout << pNode->val << endl; if(pNode->right != NULL) stk.push(pNode->right); if(pNode->left != NULL) stk.push(pNode->left); } }
中序遍歷
- 初始化一個二叉樹結點pNode指向根結點;
- 若pNode非空,那麼就把pNode入棧,並把pNode變為其左孩子;(直到最左邊的結點)
- 若pNode為空,彈出棧頂的結點,並訪問該結點,將pNode指向其右孩子(訪問最左邊的結點,並遍歷其右子樹)
void inOrder2(TreeNode *root) { if(root == NULL) return; stack<TreeNode *> stk; TreeNode *pNode = root; while(pNode != NULL || !stk.empty()) { if(pNode != NULL) { stk.push(pNode); pNode = pNode->left; } else { pNode = stk.top(); stk.pop(); cout << pNode->val << endl; pNode = pNode->right; } } }
後序遍歷
- 設定兩個棧stk, stk2;
- 將根結點壓入第一個棧stk;
- 彈出stk棧頂的結點,並把該結點壓入第二個棧stk2;
- 將當前結點的左孩子和右孩子先後分別入棧stk;
- 當所有元素都壓入stk2後,依次彈出stk2的棧頂結點,並訪問之。
第一個棧的入棧順序是:根結點,左孩子和右孩子;於是,壓入第二個棧的順序是:根結點,右孩子和左孩子。因此,彈出的順序就是:左孩子,右孩子和根結點。
void postOrder2(TreeNode *root) { if(root == NULL) return; stack<TreeNode *> stk, stk2; stk.push(root); while(!stk.empty()) { TreeNode *pNode = stk.top(); stk.pop(); stk2.push(pNode); if(pNode->left != NULL) stk.push(pNode->left); if(pNode->right != NULL) stk.push(pNode->right); } while(!stk2.empty()) { cout << stk2.top()->val << endl; stk2.pop(); } }
另外,二叉樹的後序遍歷的非遞迴實現,也可以只使用一個棧來實現。
void postOrder2(TreeNode *root) { if(root == NULL) return; stack<TreeNode *> stk; stk.push(root); TreeNode *prev = NULL; while(!stk.empty()) { TreeNode *pNode = stk.top(); if(!prev || prev->left == pNode || prev->right == pNode) { // traverse down if(pNode->left) stk.push(pNode->left); else if(pNode->right) stk.push(pNode->right); /* else { cout << pNode->val << endl; stk.pop(); } */ } else if(pNode->left == prev) { // traverse up from left if(pNode->right) stk.push(pNode->right); } /* else if(pNode->right == prev) { // traverse up from right cout << pNode->val << endl; stk.pop(); } */ else { cout << pNode->val << endl; stk.pop(); } prev = pNode; } }
複雜度分析
二叉樹遍歷的非遞迴實現,每個結點只需遍歷一次,故時間複雜度為O(n)。而使用了棧,空間複雜度為二叉樹的高度,故空間複雜度為O(n)。
Morris遍歷
Morris遍歷演算法最神奇的地方就是,只需要常數的空間即可在O(n)時間內完成二叉樹的遍歷。O(1)空間進行遍歷困難之處在於在遍歷的子結點的時候如何重新返回其父節點?在Morris遍歷演算法中,通過修改葉子結點的左右空指標來指向其前驅或者後繼結點來實現的。
中序遍歷
- 如果當前結點pNode的左孩子為空,那麼輸出該結點,並把該結點的右孩子作為當前結點;
- 如果當前結點pNode的左孩子非空,那麼就找出該結點在中序遍歷中的前驅結點pPre
- 當第一次訪問該前驅結點pPre時,其右孩子必定為空,那麼就將其右孩子設定為當前結點,以便根據這個指標返回到當前結點pNode中,並將當前結點pNode設定為其左孩子;
- 當該前驅結點pPre的右孩子為當前結點,那麼就輸出當前結點,並把前驅結點的右孩子設定為空(恢復樹的結構),將當前結點更新為當前結點的右孩子
- 重複以上兩步,直到當前結點為空。
void inOrder3(TreeNode *root) { if(root == NULL) return; TreeNode *pNode = root; while(pNode != NULL) { if(pNode->left == NULL) { cout << pNode->val << endl; pNode = pNode->right; } else { TreeNode *pPre = pNode->left; while(pPre->right != NULL && pPre->right != pNode) { pPre = pPre->right; } if(pPre->right == NULL) { pPre->right = pNode; pNode = pNode->left; } else { pPre->right = NULL; cout << pNode->val << endl; pNode = pNode->right; } } } }
因為只使用了兩個輔助指標,所以空間複雜度為O(1)。對於時間複雜度,每次遍歷都需要找到其前驅的結點,而尋找前驅結點與樹的高度相關,那麼直覺上總的時間複雜度為O(nlogn)。其實,並不是每個結點都需要尋找其前驅結點,只有左子樹非空的結點才需要尋找其前驅,所有結點尋找前驅走過的路的總和至多為一棵樹的結點個數。因此,整個過程每條邊最多走兩次,一次使定位到該結點,另一次是尋找某個結點的前驅,所以時間複雜度為O(n)。
如以下一棵二叉樹。首先,訪問的是根結點F,其左孩子非空,所以需要先找到它的前驅結點(尋找路徑為B->D->E),將E的右指標指向F,然後當前結點為B。依然需要找到B的前驅結點A,將A的右指標指向B,並將當前結點設定為A。下一步,輸出A,並把當前結點設定為A的右孩子B。之後,會訪問到B的前驅結點A指向B,那麼令A的右指標為空,繼續遍歷B的右孩子。依次類推。
前序遍歷
與中序遍歷類似,區別僅僅是輸出的順序不同。
void preOrder3(TreeNode *root) { if(root == NULL) return; TreeNode *pNode = root; while(pNode) { if(pNode->left == NULL) { cout << pNode->val << endl; pNode = pNode->right; } else { TreeNode *pPre = pNode->left; while(pPre->right != NULL && pPre->right != pNode) pPre = pPre->right; if(pPre->right == NULL) { pPre->right = pNode; cout << pNode->val << endl; pNode = pNode->left; } else { pPre->right = NULL; pNode = pNode->right; } } } }
後序遍歷
- 先建立一個臨時結點dummy,並令其左孩子為根結點root,將當前結點設定為dummy;
- 如果當前結點的左孩子為空,則將其右孩子作為當前結點;
- 如果當前結點的左孩子不為空,則找到其在中序遍歷中的前驅結點
- 如果前驅結點的右孩子為空,將它的右孩子設定為當前結點,將當前結點更新為當前結點的左孩子;
- 如果前驅結點的右孩子為當前結點,倒序輸出從當前結點的左孩子到該前驅結點這條路徑上所有的結點。將前驅結點的右孩子設定為空,將當前結點更新為當前結點的右孩子。
- 重複以上過程,直到當前結點為空。
void reverse(TreeNode *p1, TreeNode *p2) { if(p1 == p2) return; TreeNode *x = p1; TreeNode *y = p1->right; while(true) { TreeNode *temp = y->right; y->right = x; x = y; y = temp; if(x == p2) break; } } void printReverse(TreeNode *p1, TreeNode *p2) { reverse(p1, p2); TreeNode *pNode = p2; while(true) { cout << pNode->val << endl; if(pNode == p1) break; pNode = pNode->right; } reverse(p2, p1); } void postOrder3(TreeNode *root) { if(root == NULL) return; TreeNode *dummy = new TreeNode(-1); dummy->left = root; TreeNode *pNode = dummy; while(pNode != NULL) { if(pNode->left == NULL) pNode = pNode->right; else { TreeNode *pPrev = pNode->left; while(pPrev->right != NULL && pPrev->right != pNode) pPrev = pPrev->right; if(pPrev->right == NULL) { pPrev->right = pNode; pNode = pNode->left; } else { printReverse(pNode->left, pPrev); pPrev->right = NULL; pNode = pNode->right; } } } }