二叉樹遍歷方法大全(包含莫里斯遍歷)


前言

二叉樹的遍歷可能大家都比較熟悉了,這篇文章主要介紹了三種二叉樹的遍歷方法——遞歸、迭代和莫里斯遍歷,他們各自有各自的特點。其中最重要的是莫里斯遍歷,相對於前兩種方法比較少見,只需要固定的空間就可以完成迭代遍歷。這篇文章將會結合動圖,帶你了解關於樹遍歷的知識。

簡介

我們通常希望通過訪問樹的每個節點來處理二叉樹,每次執行特定的操作,例如打印節點的內容、得到樹的所有節點的總和或者要找到最大的值。以某種順序訪問所有節點的過程稱為遍歷,僅遍歷樹中每個節點一次的遍歷稱為樹節點的枚舉。某些應用不需要以任何特定順序訪問節點,只要每個節點被精確訪問一次即可;有些應用,必須按保留某些關系的順序訪問節點。

線性數據結構(如數組、堆棧、隊列和鏈表)只有一種讀取數據的方法,但是像樹這樣的分層數據結構可以以不同的方式遍歷。

遍歷種類

根據我們遍歷樹的順序,我們把遍歷分成三種,分別是:

  • 中序遍歷
  • 前序遍歷
  • 后序遍歷

這些遍歷方式和樹的結構有關。

tree

中序遍歷

  1. 先遍歷左子樹
  2. 再遍歷父節點
  3. 最后遍歷右子樹

圖片中的中序遍歷結果應該是[4,2,5,1,6,3,7]

前序遍歷

  1. 先遍歷父節點
  2. 再遍歷左子樹
  3. 最后遍歷右子樹

圖片中的前序遍歷結果應該是[1,2,4,5,3,6,7]

后序遍歷

  1. 先遍歷左子樹
  2. 再遍歷右子樹
  3. 最后遍歷父節點

圖中的后序遍歷結果應該是[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);  // 遍歷右子樹
}

中序迭代

采用迭代的方法就有點復雜了,需要借助額外的數據結構——棧。

這個方法的思路是:

先從父結點遍歷左子節點,一直遍歷到不再存在左子節點,然后從棧頂開始檢查,對剛才遍歷的節點進行逆向遍歷,找到每一個節點的右子節點,如果這些右子節點有左節點就繼續壓入棧中(相當於下次遍歷要從這個右子節點的左子樹開始),繼續上面的過程。

整個相當於深度優先遍歷,從每個節點的左節點遍歷,遍歷父節點,最后遍歷右節點。棧的作用相當於記住了上次遍歷的位置,用來保存下次應該開始遍歷的節點。

inorder

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,具體的過程如下

  1. 如果左子節點是空,錄入當前節點,當前指針cur指向右子節點
  2. 如果左子節點不是空,遍歷左子節點的最右側右子節點,找到最右側葉節點,在尋找的過程中可能出現兩種情況:
    • 如果遍歷到的葉節點的右子節點是空,把葉節點的右子節點指向cur節點,cur移向左子節點
    • 如果遍歷到的葉節點的右子節點是cur節點,表示原來的葉節點到cur節點連接已經存在,現在遍歷結束了,需要復原,置節點的右子節點為空,在錄入了cur節點之后,cur移到自己的右子節點
  3. 重復上面兩步直到當前節點為空

其中最不好理解的是第二步,遍歷左子樹的右節點的過程中,只有當左子樹沒有建立到父節點的連接的時候,才能最后遍歷到盡頭,達到盡頭之后需要和父節點連接起來,當cur遍歷到這個葉節點的時候才能回到正確的父節點的位置。

當當前節點cur遍歷完了左子樹回到父節點的時候,多余的連接還是存在的,需要移除這個連接,而方法就是和建立連接一樣遍歷左子樹找到最右側節點,這個時候判斷的依據就不能是右節點為空,必須是左子節點的最右節點等於當前節點,相當於找到循環的起點,然后在這個地方切斷聯系。

morris

代碼實現:

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,在尋找前驅節點的時候遍歷了25;當cur5回到1之后,又遍歷了一遍25,至此25在所有尋找前驅節點的過程中各遍歷了兩邊,而在尋找2..7的前驅節點的時候,都沒有遍歷到25(除去了從25本身開始查找前驅節點時對自己的遍歷),所以25總體在前驅搜索的過程中只有兩次,加上他們自身的遍歷,也就只有3次。綜合來說,樹的每個節點的遍歷次數最多都是3次,所以時間復雜度是O(n)級別的。

traversal

前序實現

前序遞歸

和中序的遞歸差不多,只有一行代碼的區別:

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;
}

后序莫里斯迭代

修改后序莫里斯迭代的思路其實和上面修改后序迭代的思路一樣

  1. 把前序莫里斯遍歷的代碼粘貼過來
  2. 把原來所有的right改成left,把原來所有的left改成right
  3. 返回結果之前反轉一下數組

這種后序迭代遍歷的核心思路都是通過交換前序遍歷中遍歷左右子樹的順序,達到完全逆轉后序遍歷的結果,最后反轉得到正確的結果。

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?
二叉樹的前序、中序、后序遍歷—迭代方法

更多精彩內容請看我的個人博客


免責聲明!

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



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