前言
二叉樹的遍歷可能大家都比較熟悉了,這篇文章主要介紹了三種二叉樹的遍歷方法——遞歸、迭代和莫里斯遍歷,他們各自有各自的特點。其中最重要的是莫里斯遍歷,相對於前兩種方法比較少見,只需要固定的空間就可以完成迭代遍歷。這篇文章將會結合動圖,帶你了解關於樹遍歷的知識。
簡介
我們通常希望通過訪問樹的每個節點來處理二叉樹,每次執行特定的操作,例如打印節點的內容、得到樹的所有節點的總和或者要找到最大的值。以某種順序訪問所有節點的過程稱為遍歷,僅遍歷樹中每個節點一次的遍歷稱為樹節點的枚舉。某些應用不需要以任何特定順序訪問節點,只要每個節點被精確訪問一次即可;有些應用,必須按保留某些關系的順序訪問節點。
線性數據結構(如數組、堆棧、隊列和鏈表)只有一種讀取數據的方法,但是像樹這樣的分層數據結構可以以不同的方式遍歷。
遍歷種類
根據我們遍歷樹的順序,我們把遍歷分成三種,分別是:
- 中序遍歷
- 前序遍歷
- 后序遍歷
這些遍歷方式和樹的結構有關。
中序遍歷
- 先遍歷左子樹
- 再遍歷父節點
- 最后遍歷右子樹
圖片中的中序遍歷結果應該是[4,2,5,1,6,3,7]
前序遍歷
- 先遍歷父節點
- 再遍歷左子樹
- 最后遍歷右子樹
圖片中的前序遍歷結果應該是[1,2,4,5,3,6,7]
后序遍歷
- 先遍歷左子樹
- 再遍歷右子樹
- 最后遍歷父節點
圖中的后序遍歷結果應該是[4,5,2,6,7,3,1]
代碼實現
首先定義樹的結構
public class TreeNode {
public int val;
public TreeNode left;
public TreeNode right;
public TreeNode(int x) { val = x; }
}
在代碼實現里面我們要返回按照特定遍歷順序依次遍歷的節點
中序實現
中序遞歸
按照中序遍歷的定義可以很容易用遞歸來實現
public List<Integer> inorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
List<Integer> res = new ArrayList<>(); // 保存最后的結果
inorderTraversal(root, res);
return res;
}
public void inorderTraversal(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
inorderTraversal(root.left, res); // 遍歷左子樹
res.add(root.val); // 遍歷父節點
inorderTraversal(root.right, res); // 遍歷右子樹
}
中序迭代
采用迭代的方法就有點復雜了,需要借助額外的數據結構——棧。
這個方法的思路是:
先從父結點遍歷左子節點,一直遍歷到不再存在左子節點,然后從棧頂開始檢查,對剛才遍歷的節點進行逆向遍歷,找到每一個節點的右子節點,如果這些右子節點有左節點就繼續壓入棧中(相當於下次遍歷要從這個右子節點的左子樹開始),繼續上面的過程。
整個相當於深度優先遍歷,從每個節點的左節點遍歷,遍歷父節點,最后遍歷右節點。棧的作用相當於記住了上次遍歷的位置,用來保存下次應該開始遍歷的節點。
public List<Integer> inorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
Stack<TreeNode> stack = new Stack<>();
List<Integer> res = new ArrayList<>(); // 遍歷結果
stack.push(root);
TreeNode cur = root.left;
while (!stack.isEmpty() || cur != null) {
while (cur != null) { // 先將所有的左節點的內容壓入棧中
stack.push(cur);
cur = cur.left;
}
cur = stack.pop(); // 出棧的時候進行遍歷
res.add(cur.val);
cur = cur.right; // 代表開始遍歷右子樹
}
return res;
}
中序莫里斯迭代
莫里斯遍歷不需要遞歸或者臨時的棧空間就可以完成遍歷,空間復雜度是常數。但是為了解決從子節點找到父節點的問題,需要臨時修改樹的結構,在遍歷完成之后復原成原來的樹結構。
整個遍歷的過程中只需要兩個指針——當前指針cur
和臨時前驅指針prev
,具體的過程如下
- 如果左子節點是空,錄入當前節點,當前指針
cur
指向右子節點 - 如果左子節點不是空,遍歷左子節點的最右側右子節點,找到最右側葉節點,在尋找的過程中可能出現兩種情況:
- 如果遍歷到的葉節點的右子節點是空,把葉節點的右子節點指向
cur
節點,cur
移向左子節點 - 如果遍歷到的葉節點的右子節點是
cur
節點,表示原來的葉節點到cur
節點連接已經存在,現在遍歷結束了,需要復原,置節點的右子節點為空,在錄入了cur
節點之后,cur
移到自己的右子節點
- 如果遍歷到的葉節點的右子節點是空,把葉節點的右子節點指向
- 重復上面兩步直到當前節點為空
其中最不好理解的是第二步,遍歷左子樹的右節點的過程中,只有當左子樹沒有建立到父節點的連接的時候,才能最后遍歷到盡頭,達到盡頭之后需要和父節點連接起來,當cur
遍歷到這個葉節點的時候才能回到正確的父節點的位置。
當當前節點cur
遍歷完了左子樹回到父節點的時候,多余的連接還是存在的,需要移除這個連接,而方法就是和建立連接一樣遍歷左子樹找到最右側節點,這個時候判斷的依據就不能是右節點為空,必須是左子節點的最右節點等於當前節點,相當於找到循環的起點,然后在這個地方切斷聯系。
代碼實現:
public List<Integer> inorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
TreeNode cur = root; // 記錄當前節點位置
List<Integer> res = new ArrayList<>();
while (cur != null) {
if (cur.left == null) { // 左節點為空,移到右子節點
res.add(cur.val);
cur = cur.right;
} else {
TreeNode prev = cur.left;
while (prev.right != null && prev.right != cur) { // 遍歷到左子樹的最右側節點
prev = prev.right;
}
if (prev.right == null) { // 建立返回父節點連接
prev.right = cur;
cur = cur.left;
} else { // 左子樹建立了連接,說明遍歷完了,可以拆除連接
res.add(cur.val); // 中序遍歷錄入當前節點
prev.right = null;
cur = cur.right;
}
}
}
return res;
}
時間復雜度分析:莫里斯遍歷的空間復雜度是常數,這個比較好理解,但是時間復雜度為什么是O(n)
呢?明明在代碼里面有個嵌套的循環可能會提高時間復雜度:
while (prev.right != null && prev.right != cur) { // 遍歷到左子樹的最右側節點
prev = prev.right;
}
可是如果在圖中模擬一下這個循環就會發現其實在尋找前驅節點的過程中,所有的節點其實最多只被遍歷了兩遍!比如對於節點1
,在尋找前驅節點的時候遍歷了2
和5
;當cur
從5
回到1
之后,又遍歷了一遍2
和5
,至此2
和5
在所有尋找前驅節點的過程中各遍歷了兩邊,而在尋找2..7
的前驅節點的時候,都沒有遍歷到2
和5
(除去了從2
和5
本身開始查找前驅節點時對自己的遍歷),所以2
和5
總體在前驅搜索的過程中只有兩次,加上他們自身的遍歷,也就只有3次。綜合來說,樹的每個節點的遍歷次數最多都是3次,所以時間復雜度是O(n)
級別的。
前序實現
前序遞歸
和中序的遞歸差不多,只有一行代碼的區別:
public List<Integer> preorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
List<Integer> res = new ArrayList<>(); // 保存最后的結果
preorderTraversal(root, res);
return res;
}
public void preorderTraversal(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
res.add(root.val); // 遍歷父節點 注意這行代碼提前了
preorderTraversal(root.left, res); // 遍歷左子樹
preorderTraversal(root.right, res); // 遍歷右子樹
}
前序迭代
直接根據中序迭代的方法,將記錄遍歷元素的時機改為了在入棧的時候就記錄,也就是將父節點計入數組的時間提前了。
public List<Integer> preorderTraversal3(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
Stack<TreeNode> stack = new Stack<>();
List<Integer> res = new ArrayList<>(); // 遍歷結果
stack.push(root);
TreeNode cur = root.left;
res.add(root.val); // 記錄根節點元素
while (!stack.isEmpty() || cur != null) {
while (cur != null) { // 先將所有的左節點的內容壓入棧中
stack.push(cur);
res.add(cur.val); // 這里在入棧的時候就要記錄遍歷的元素
cur = cur.left;
}
cur = stack.pop();
cur = cur.right; // 代表開始遍歷右子樹
}
return res;
}
前序莫里斯
和中序莫里斯遍歷的代碼也基本一樣,只不過當左子節點存在的時候,添加節點元素的位置從拆除多余連接的時候變成了建立連接的時候,也就是在移動cur
指針之前就得記錄節點,保證當前指向的節點是最先記錄的,左右子樹的節點要靠后,並且不能重復記錄元素。
public List<Integer> preorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
TreeNode cur = root; // 記錄當前節點位置
List<Integer> res = new ArrayList<>();
while (cur != null) {
if (cur.left == null) { // 左節點為空,移到右子節點
res.add(cur.val);
cur = cur.right;
} else {
TreeNode prev = cur.left;
while (prev.right != null && prev.right != cur) { // 遍歷到左子樹的最右側節點
prev = prev.right;
}
if (prev.right == null) { // 建立返回父節點連接
prev.right = cur;
res.add(cur.val); // 注意添加元素到數組的代碼位置移到了這里
cur = cur.left;
} else { // 左子樹建立了連接,說明遍歷完了,可以拆除連接
prev.right = null;
cur = cur.right;
}
}
}
return res;
}
后序實現
后序遞歸
后序遞歸和前中遞歸的實現差不多,只需要把錄入元素的時機放在遍歷左右子樹之后就行了
public List<Integer> postorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
List<Integer> res = new ArrayList<>(); // 保存最后的結果
postorderTraversal(root, res);
return res;
}
public void postorderTraversal(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
postorderTraversal(root.left, res); // 遍歷左子樹
postorderTraversal(root.right, res); // 遍歷右子樹
res.add(root.val); // 遍歷父節點
}
還有另外一種遞歸可能會對我們后序迭代算法略有啟發:我們可以通過將遍歷父節點操作放在最前面,然后交換遍歷左右子樹的順序,得到反轉的后序遍歷結果,最后反轉一下就能得到正確的遍歷結果。
public List<Integer> postorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
List<Integer> res = new ArrayList<>(); // 保存最后的結果
postorderTraversal(root, res);
Collections.reverse(res); // 反轉數組
return res;
}
public void postorderTraversal(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
res.add(root.val); // 遍歷父節點
postorderTraversal(root.right, res); // 遍歷右子樹
postorderTraversal(root.left, res); // 遍歷左子樹
}
后序迭代
后序迭代就比較巧妙了,利用上面講到的修改前序遍歷的遍歷左右子樹的順序,移植到迭代過程中的棧的操作,把原來的所有的right
改成left
,原來的left
改成right
public List<Integer> postorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
Stack<TreeNode> stack = new Stack<>();
List<Integer> res = new ArrayList<>(); // 遍歷結果
stack.push(root);
TreeNode cur = root.right;
res.add(root.val); // 添加根節點,反轉后變成最后元素
while (!stack.isEmpty() || cur != null) {
while (cur != null) { // 先將所有的右節點的內容壓入棧中
stack.push(cur);
res.add(cur.val); // 添加當前遍歷的節點
cur = cur.right;
}
cur = stack.pop();
cur = cur.left; // 代表開始遍歷左子樹
}
Collections.reverse(res); // 反轉最后的結果
return res;
}
后序莫里斯迭代
修改后序莫里斯迭代的思路其實和上面修改后序迭代的思路一樣
- 把前序莫里斯遍歷的代碼粘貼過來
- 把原來所有的
right
改成left
,把原來所有的left
改成right
- 返回結果之前反轉一下數組
這種后序迭代遍歷的核心思路都是通過交換前序遍歷中遍歷左右子樹的順序,達到完全逆轉后序遍歷的結果,最后反轉得到正確的結果。
public List<Integer> postorderTraversal(TreeNode root) {
if (root == null) {
return new ArrayList<>();
}
TreeNode cur = root; // 記錄當前節點位置
List<Integer> res = new ArrayList<>();
while (cur != null) {
if (cur.right == null) { // 右節點為空,移到左子節點
res.add(cur.val);
cur = cur.left;
} else {
TreeNode prev = cur.right;
while (prev.left != null && prev.left != cur) { // 遍歷右子樹的最左側節點
prev = prev.left;
}
if (prev.left == null) { // 建立返回父節點連接
prev.left = cur;
res.add(cur.val); // 添加元素到數組
cur = cur.right;
} else { // 右子樹建立了連接,說明遍歷完了,可以拆除連接
prev.left = null;
cur = cur.left;
}
}
}
Collections.reverse(res); // 最后要反轉數組得到最后的結果
return res;
}
總結
總的來說二叉樹的遍歷是非常重要也是非常基礎的知識,大部分人都能夠輕松的寫出遞歸的做法,遞歸的代碼是最簡潔明了容易理解的。
迭代的解決方法比較少見,需要額外的數據結構,循環的邏輯也不是那么容易理解,但是在面試或者OJ系統里面可能會出現的比較多。
關於莫里斯遍歷,可能大多數人都沒有聽說過這種巧妙的遍歷方法,需要修改樹的結構以降低空間開銷,同時在遍歷結束之后還要復原樹的結構。這種遍歷相對於上面的迭代更加難以理解,但是它只需要兩個變量就可以完成遍歷的特點令人影響深刻。
參考
Morris Traversal方法遍歷二叉樹(非遞歸,不用棧,O(1)空間)
What is Morris traversal?
二叉樹的前序、中序、后序遍歷—迭代方法
更多精彩內容請看我的個人博客