前言
二叉樹的前序遍歷,中序遍歷,後序遍歷是面試中常常考察的基本演算法,關於它的概念這裡不再贅述了,還不瞭解的同學可以去翻翻LeetCode的解釋。
這裡,我個人對這三個遍歷順序理解是:前
中
後
這三個詞是針對根節點的訪問順序而言的,即前序就是根節點在最前根->左->右
,中序是根節點在中間左->根->右
,後序是根節點在最後左->右->根
。
無論哪種遍歷順序,用遞迴總是最容易實現的,也是最沒有含金量的。但我們至少要保證能信手捏來地把遞迴寫出來,在此基礎上,再掌握非遞迴的方式。
在二叉樹的順序遍歷中,常常會發生先遇到的節點到後面再訪問的情況,這和先進後出的棧
的結構很相似,因此在非遞迴的實現方法中,我們最常使用的資料結構就是棧
。
前序遍歷
前序遍歷(題目見這裡)是三種遍歷順序中最簡單的一種,因為根
節點是最先訪問的,而我們在訪問一個樹的時候最先遇到的就是根節點。
遞迴法
遞迴的方法很容易實現,也很容易理解:我們先訪問根節點,然後遞迴訪問左子樹,再遞迴訪問右子樹,即實現了根->左->右
的訪問順序,因為使用的是遞迴方法,所以每一個子樹都實現了這樣的順序。
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
preorderHelper(root, result);
return result;
}
private void preorderHelper(TreeNode root, List<Integer> result) {
if (root == null) return;
result.add(root.val); // 訪問根節點
preorderHelper(root.left, result); // 遞迴遍歷左子樹
preorderHelper(root.right, result); //遞迴遍歷右子樹
}
}
迭代法
在迭代法中,我們使用棧來實現。由於出棧順序和入棧順序相反,所以每次新增節點的時候先新增右節點,再新增左節點。這樣在下一輪訪問子樹的時候,就會先訪問左子樹,再訪問右子樹:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
if (root == null) return result;
Stack<TreeNode> toVisit = new Stack<>();
toVisit.push(root);
TreeNode cur;
while (!toVisit.isEmpty()) {
cur = toVisit.pop();
result.add(cur.val); // 訪問根節點
if (cur.right != null) toVisit.push(cur.right); // 右節點入棧
if (cur.left != null) toVisit.push(cur.left); // 左節點入棧
}
return result;
}
}
中序遍歷
中序遍歷(題目見這裡)相對前序遍歷要複雜一點,因為我們說過,在二叉樹的訪問中,最先遇到的是根節點,但是在中序遍歷中,最先訪問的不是根節點,而是左節點。(當然,這裡說複雜是針對非遞迴方法而言的,遞迴方法都是很簡單的。)
遞迴法
無論對於哪種方式,遞迴的方法總是很容易實現的,也是很符合直覺的。對於中序遍歷,就是先訪問左子樹,再訪問根節點,再訪問右子樹,即 左->根->右
:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
inorderHelper(root, result);
return result;
}
private void inorderHelper(TreeNode root, List<Integer> result) {
if(root == null) return;
inorderHelper(root.left, result); // 遞迴遍歷左子樹
result.add(root.val); // 訪問根節點
inorderHelper(root.right, result); // 遞迴遍歷右子樹
}
}
大家可以對比它和前序遍歷的遞迴實現,二者僅僅是在節點的訪問順序上有差別,程式碼框架完全一致。
迭代法
中序遍歷的迭代法要稍微複雜一點,因為最先遇到的根節點不是最先訪問的,我們需要先訪問左子樹,再回退到根節點,再訪問根節點的右子樹,這裡的一個難點是從左子樹回退到根節點的操作,雖然可以用棧來實現回退,但是要注意在出棧時儲存根節點的引用,因為我們還需要通過根節點來訪問右子樹:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> toVisit = new Stack<>();
TreeNode cur = root;
while (cur != null || !toVisit.isEmpty()) {
while (cur != null) {
toVisit.push(cur); // 新增根節點
cur = cur.left; // 迴圈新增左節點
}
cur = toVisit.pop(); // 當前棧頂已經是最底層的左節點了,取出棧頂元素,訪問該節點
result.add(cur.val);
cur = cur.right; // 新增右節點
}
return result;
}
}
這裡:
while (cur != null) {
toVisit.push(cur);
cur = cur.left;
}
↑這一部分實現了遞迴新增左節點的作用。
cur = toVisit.pop();
result.add(cur.val);
cur = cur.right;
↑這一部分實現了對根節點的遍歷,同時將指標指向了右子樹,在下輪中遍歷右子樹。
在看這部分程式碼中,腦海中要有一個概念:當前樹的根節點的左節點,是它的左子樹的根節點。因此從不同的層次上看,左節點也是根節點。另外,LeetCode上也提供了關於中序遍歷的動態圖的演示,感興趣的讀者可以去看一看。
後序遍歷
後序遍歷(題目見這裡)是三種遍歷方法中最難的,與中序遍歷相比,雖然都是先訪問左子樹,但是在回退到根節點的時候,後序遍歷不會立即訪問根節點,而是先訪問根節點的右子樹,這裡要小心的處理入棧出棧的順序。(當然,這裡說複雜是針對非遞迴方法而言的,遞迴方法都是很簡單的。)
遞迴法
無論對於哪種方式,遞迴的方法總是很容易實現的,也是很符合直覺的。對於後序遍歷,就是先訪問左子樹,再訪問右子樹,再訪問根節點,即 左->右->根
:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
postorderHelper(root, result);
return result;
}
private void postorderHelper(TreeNode root, List<Integer> result) {
if (root == null) return;
postorderHelper(root.left, result); // 遍歷左子樹
postorderHelper(root.right, result); // 遍歷右子樹
result.add(root.val); // 訪問根節點
}
}
與前序遍歷和後序遍歷相比,程式碼結構完全一致,差別僅僅是遞迴函式的呼叫順序。
迭代法
前面說過,與中序遍歷不同的是,後序遍歷在訪問完左子樹向上回退到根節點的時候不是立馬訪問根節點的,而是得先去訪問右子樹,訪問完右子樹後在回退到根節點,因此,在迭代過程中要複雜一點:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> toVisit = new Stack<>();
TreeNode cur = root;
TreeNode pre = null;
while (cur != null || !toVisit.isEmpty()) {
while (cur != null) {
toVisit.push(cur); // 新增根節點
cur = cur.left; // 遞迴新增左節點
}
cur = toVisit.peek(); // 已經訪問到最左的節點了
//在不存在右節點或者右節點已經訪問過的情況下,訪問根節點
if (cur.right == null || cur.right == pre) {
toVisit.pop();
result.add(cur.val);
pre = cur;
cur = null;
} else {
cur = cur.right; // 右節點還沒有訪問過就先訪問右節點
}
}
return result;
}
}
這裡尤其注意後續遍歷和中序遍歷中對於從最左側節點向上回退時的處理:
在後序遍歷中,我們首先使用的是:
cur = toVisit.peek();
注意,這裡使用的是peek
而不是pop
,這是因為我們需要首先去訪問右節點,下面的:
if (cur.right == null || cur.right == pre)
就是用來判斷是否存在右節點,或者右節點是否已經訪問過了,如果右節點已經訪問過了,則接下來的操作就和中序遍歷的情況差不多了,所不同的是,這裡多了兩步:
pre = cur;
cur = null;
這兩步的目的都是為了在下一輪遍歷中不再訪問自己,cur = null
很好理解,因為我們必須在一輪結束後改變cur的值,以新增下一個節點,所以它和cur = cur.right
一樣,目的都是指向下一個待遍歷的節點,只是在這裡,右節點已經訪問過了,則以當前節點為根節點的整個子樹都已經訪問過了,接下來應該回退到當前節點的父節點,而當前節點的父節點已經在棧裡了,所以我們並沒有新的節點要新增,直接將cur
設為null即可。
pre = cur
的目的有點類似於將當前節點標記為已訪問,它是和if條件中的cur.right == pre
配合使用的。注意這裡的兩個cur
指的不是同一個節點。我們假設當前節點為C
,當前節點的父節點為A
,而C是A的右孩子,則當前cur是C,但在一輪中,cur將變成A,則:
A
/ \
B C (pre)
-
pre = cur
就是pre = C
-
if (cur.right == null || cur.right == pre)
就是if (A.right == null || A.right == pre)
這裡,由於A是有右節點的,它的右節點就是C,所以A.right == null
不成立。但是C節點我們在上一輪已經訪問過了,所以這裡為了防止進入else語句重複新增節點,我們多加了一個A.right == pre
條件,它表示A的右節點已經訪問過了,我們得以進入if語句內,直接訪問A節點。
雙棧法
前面我們說過,前序遍歷之所以最簡單,是因為遍歷過程中最先遇到的根節點是最先訪問的,而在後序遍歷中,最先遇到的根節點是最後訪問的,所以導致了上面的迭代法非常複雜,那有沒有辦法簡化一下呢?其實是有的。
大家仔細觀察一下後序遍歷的順序左->右->根
,根節點在最後,要是能像前序遍歷一樣把它放在最前面就好了,怎麼辦呢?一個最簡單的方法就是倒個序,即將左->右->根
倒序成根->右->左
,這樣不就和前序遍歷的根->左->右
差不多了嗎?而因為棧本身就是後進先出的,是天然的倒序工具,因此,我們只需要再用一個棧將輸出順序反過來即可,由此,雙棧法應運而生,它的思路是:
- 用一個棧實現
根->右->左
的遍歷 - 用另一個棧將遍歷順序反過來,使之變成
左->右->根
下面我們來看實現:
首先,在最開始的前序遍歷中,我們已經實現了遞迴方式的根->左->右
的遍歷,如下:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
if (root == null) return result;
Stack<TreeNode> toVisit = new Stack<>();
toVisit.push(root);
TreeNode cur;
while (!toVisit.isEmpty()) {
cur = toVisit.pop();
result.add(cur.val); // 訪問根節點
if (cur.right != null) toVisit.push(cur.right); // 右節點入棧
if (cur.left != null) toVisit.push(cur.left); // 左節點入棧
}
return result;
}
}
那麼要實現根->右->左
的遍歷,只需要交換左右節點的入棧順序即可,即:
(程式碼中將與前序遍歷相同的程式碼部分註釋起來了,好讓大家能直觀地看到不同點,下同)
//class Solution {
// public List<Integer> preorderTraversal(TreeNode root) {
// List<Integer> result = new LinkedList<>();
// if (root == null) return result;
//
// Stack<TreeNode> toVisit = new Stack<>();
// toVisit.push(root);
// TreeNode cur;
//
// while (!toVisit.isEmpty()) {
// cur = toVisit.pop();
// result.add(cur.val); // 訪問根節點
if (cur.left != null) toVisit.push(cur.left); // 左節點入棧
if (cur.right != null) toVisit.push(cur.right); // 右節點入棧
// }
// return result;
// }
//}
至此,我們完成了第一步,接下來是第二步,用另一個棧來反序:
//class Solution {
// public List<Integer> postorderTraversal(TreeNode root) {
// List<Integer> result = new LinkedList<>();
// if (root == null) return result;
//
// Stack<TreeNode> toVisit = new Stack<>();
Stack<TreeNode> reversedStack = new Stack<>();
// toVisit.push(root);
// TreeNode cur;
//
// while (!toVisit.isEmpty()) {
// cur = toVisit.pop();
reversedStack.push(cur); // result.add(cur.val);
// if (cur.left != null) toVisit.push(cur.left); // 左節點入棧
// if (cur.right != null) toVisit.push(cur.right); // 右節點入棧
// }
//
while (!reversedStack.isEmpty()) {
cur = reversedStack.pop();
result.add(cur.val);
}
// return result;
// }
//}
可見,反序只是將原來直接新增到結果中的值先新增到一個棧中,最後再將該棧中的元素全部出棧即可。
至此,我們就實現了雙棧法的後序遍歷,是不是變的和前序遍歷一樣簡單了呢?
雙棧法的簡化——使用Deque
上面我們介紹的雙棧法雖然簡化了迭代法,但是它額外使用了一個棧,並且需要在最後將反序棧中的元素再一個個出棧,新增到結果集中,顯得比較笨重,不夠優雅,我們下面就來試著簡化一下。
既然最後需要逆序輸出,除了用額外的棧來實現,我們還可以用連結串列本身來實現——即,每次新增元素時都新增到連結串列的頭部,這樣,連結串列本身就成為了一個棧,在java中,LinkedList
本身就已經實現了Deque
介面,因此,它也可以當做雙端佇列,則,上面的程式碼可以簡化成:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
LinkedList<Integer> result = new LinkedList<>();
if (root == null) return result;
Stack<TreeNode> toVisit = new Stack<>();
toVisit.push(root);
TreeNode cur;
while (!toVisit.isEmpty()) {
cur = toVisit.pop();
result.addFirst(cur.val);
if (cur.left != null) toVisit.push(cur.left);
if (cur.right != null) toVisit.push(cur.right);
}
return result;
}
}
如果你拿它和前序遍歷的迭代法的程式碼對比可以發現,它們唯一的不同就在於這三行:
result.addFirst(cur.val);
if (cur.left != null) toVisit.push(cur.left);
if (cur.right != null) toVisit.push(cur.right);
這裡要注意,addFirst
方法是將值新增到連結串列的開頭。
Morris遍歷法
前面我們多次說過,在二叉樹的訪問中,我們最先遇到的是樹的根節點,因此,前序遍歷方法非常簡單,因為它本身就是先去訪問根節點,即根->左->右
。而在後序遍歷中,為了簡化問題,我們出於同樣的考慮,將後續遍歷左->右->根
的順序先倒置成根->右->左
,使得後續遍歷中也先去訪問根節點,這樣就將後序遍歷變得和前序遍歷一樣簡單了,所以目前來看,反倒是中序遍歷左->根->右
變成最不直觀的了。
那麼有沒有辦法像轉變後序遍歷一樣,將中序遍歷也轉變成先訪問根節點呢?似乎不太容易,因為中序遍歷的根節點是在中間訪問的,無論正過來倒過去,都無法最先訪問。
當然,萬事不是絕對的,如果我們的二叉樹是一個偏向二叉樹,每一個子樹都沒有左節點呢?那麼就有:
-
左->根->右
=>根->右
這樣我們就能先訪問根節點了。當然,這自然是個極端的例子,因為正常情況下二叉樹都不會長這樣。但是,這為我們提供了一個思路——既然二叉樹不長這樣,我們可以把它轉換成這樣,這也就是Morris遍歷法所做的事情。
那麼怎麼轉換呢,我們知道,中序遍歷需要先去遍歷左子樹,而左子樹中也要按左->根->右
的順序去遍歷,所以整個樹的根節點必然是接在左子樹的最後一個右節點的後面去遍歷,所以,Morris遍歷法的演算法虛擬碼如下:
current = root;
while(current != null) {
if(current沒有左節點) {
訪問current的值
current = current.right
}
else {
在current的左子樹中找到最靠右的節點(rightmost node)
將current接在這個rightmost node下面,作為它的右子樹
current = current.left
}
}
這個虛擬碼看上去有點抽象,我們來看一個例子,這個例子來源於LeetCode:
現在有這麼一棵二叉樹:
1
/ \
2 3
/ \ /
4 5 6
我們要對它進行中序遍歷,需要將它轉換成一個只有右節點的偏向樹,按照Morris演算法,首先1
是根節點,它是現在的current
,它存在一個左子樹:
2
/ \
4 5
按照演算法,我們需要找到這個左子樹最靠右的節點,在這裡就是5
,接下來就將current作為這個節點的右子樹,即:
2
/ \
4 5
\
1
\
3
/
6
然後令current為原來根節點的左節點,則此時的current變成了2,則新的current還是存在左節點,在這裡就是4,我們按照同樣的步驟再將當前的current接在它的左子樹的最右節點下面,這裡左子樹只有一個節點4,所以我們直接作為該節點的右孩子即可:
4
\
2
\
5
\
1
\
3
/
6
到這裡,4就沒有左子樹了,則我們進入if語句中,訪問當前節點的值,再指向它的右子樹。這樣一路訪問到3這個節點,我們發現它是有左子樹6的,我們再按之前的方式,將3接在6的右子樹上,最後完成遍歷。
所以,綜上看下來,Morris演算法的目的就是消滅左子樹,如果根節點存在左子樹,就將根節點作為左子樹的最右節點的右孩子,這是因為中序遍歷中,對於根節點的訪問,一定是在訪問完左子樹之後的,而左子樹的最右節點就是左子樹訪問的最後一個節點,因為大家都按照左->中->右
的順序來遍歷。
有了對上面的過程的理解以及虛擬碼,我們再來寫程式碼就很容易了:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
TreeNode cur = root;
while (cur != null) {
if (cur.left == null) {
result.add(cur.val);
cur = cur.right;
} else {
TreeNode rightmost = cur.left;
while (rightmost.right != null) {
rightmost = rightmost.right; // 尋找左子樹的最右節點
}
rightmost.right = cur; // 當前節點作為左子樹的最右節點的右孩子
TreeNode oldRoot = cur;
cur = cur.left; // 將左子樹作為新的頂層節點
oldRoot.left = null; // 消除左子樹,防止出現無限迴圈
}
}
return result;
}
}
這裡一定要注意oldRoot.left = null
,這一步的目的就是消除左子樹,同時它也能防止無限迴圈的出現,一定不要忘記這一步。
綜上,你可以把Morris演算法理解成不斷將左節點作為新的頂層節點從而消滅左子樹的過程,即實現了:
-
左->根->右
=>根->右
的轉變。
其實,如果你再倒回去看我們之前中序遍歷的迭代法的做法:
while (cur != null || !toVisit.isEmpty()) {
while (cur != null) {
toVisit.push(cur); // 新增根節點
cur = cur.left; // 迴圈新增左節點
}
cur = toVisit.pop(); // 當前棧頂已經是最底層的左節點了,取出棧頂元素,訪問該節點
result.add(cur.val);
cur = cur.right; // 新增右節點
}
這裡,不斷新增左節點的做法也有點將左->根->右
轉變成 根->右
的意思,因為以最左的那個左節點為根節點的樹可不就是隻剩下根->右
了嘛,然後我們就安心地訪問根節點,再去訪問它的右節點了,只是在下一輪右節點的訪問中,我們還是要不斷地新增左節點,以實現“消滅”左節點的目的。可見,事實上,思想都是相通的。
最後,這裡有一點特別值得一提的是,在Morris演算法中,我們並沒有使用到棧,因為我們已經將整個樹調整成其訪問順序恰好和遍歷順序一致的偏向樹了,所以相比之前使用棧的演算法,這種演算法更節約空間。
複雜度分析
前面我們分析了前序,中序,後序遍歷的各種方法,但是並沒有去分析它們的複雜度,這裡我們一起來看一下:
首先對於時間複雜度,由於樹的每一個節點我們都是要去遍歷的,所以它是難以優化的,都是O(n),對於Morris演算法,這個複雜度的計算要稍微複雜一點,但是可以證明,它同樣是O(n)。
對於空間複雜度,對遞迴方法而言,最壞的空間複雜度是O(n),平均空間複雜度是O(log(n))。對於普通的迭代法而言,由於我們使用到了棧,其時間複雜度和空間複雜度一致,都是O(n),對於Morris演算法,由於我們並沒有使用到棧,只使用到臨時變數,因此其空間複雜度是O(1)。
總結
本文介紹了關於二叉樹的前序,中序,後序遍歷的遞迴和迭代兩個版本的演算法,同時對於後序遍歷的簡化版本及中序遍歷的Morris演算法做出瞭解釋和說明,其實Morris演算法的思想同樣可以應用在前序遍歷和後序遍歷上,只是筆者認為前序遍歷和後序遍歷經過簡化後已經足夠簡單,這裡並沒有給出,不然大有探討“茴香豆的茴字有多少種寫法”的嫌疑。
二叉樹的遍歷中重要的是理解節點的遍歷順序和訪問順序之間的關係,我們在上面的非遞迴演算法中多次提到,由於最先訪問的到的是樹的根節點,所以很多優化都是將訪問順序轉換成先訪問根節點來做的,理解了這一點再去看那些“玄乎”但是能work的程式碼,就不會覺得摸不著頭腦了。
(完)