二叉樹的前序,中序,後序遍歷方法總結

前言

二叉樹的前序遍歷,中序遍歷,後序遍歷是面試中經常考察的基本算法,關於它的概念這裏再也不贅述了,還不瞭解的同窗能夠去翻翻LeetCode的解釋java

這裏,我我的對這三個遍歷順序理解是: 這三個詞是針對根節點的訪問順序而言的,即前序就是根節點在最前根->左->右,中序是根節點在中間左->根->右,後序是根節點在最後左->右->根node

不管哪一種遍歷順序,用遞歸老是最容易實現的,也是最沒有含金量的。但咱們至少要保證能信手捏來地把遞歸寫出來,在此基礎上,再掌握非遞歸的方式。面試

在二叉樹的順序遍歷中,經常會發生先遇到的節點到後面再訪問的狀況,這和先進後出的的結構很類似,所以在非遞歸的實現方法中,咱們最常使用的數據結構就是算法

前序遍歷

前序遍歷(題目見這裏)是三種遍歷順序中最簡單的一種,由於節點是最早訪問的,而咱們在訪問一個樹的時候最早遇到的就是根節點。數據結構

遞歸法

遞歸的方法很容易實現,也很容易理解:咱們先訪問根節點,而後遞歸訪問左子樹,再遞歸訪問右子樹,即實現了根->左->右的訪問順序,由於使用的是遞歸方法,因此每個子樹都實現了這樣的順序。框架

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

中序遍歷

中序遍歷(題目見這裏)相對前序遍歷要複雜一點,由於咱們說過,在二叉樹的訪問中,最早遇到的是根節點,可是在中序遍歷中,最早訪問的不是根節點,而是左節點。(固然,這裏說複雜是針對非遞歸方法而言的,遞歸方法都是很簡單的。)工具

遞歸法

不管對於哪一種方式,遞歸的方法老是很容易實現的,也是很符合直覺的。對於中序遍歷,就是先訪問左子樹,再訪問根節點,再訪問右子樹,即 左->根->右post

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節點。

雙棧法

前面咱們說過,前序遍歷之因此最簡單,是由於遍歷過程當中最早遇到的根節點是最早訪問的,而在後序遍歷中,最早遇到的根節點是最後訪問的,因此致使了上面的迭代法很是複雜,那有沒有辦法簡化一下呢?實際上是有的。

你們仔細觀察一下後序遍歷的順序左->右->根,根節點在最後,要是能像前序遍歷同樣把它放在最前面就行了,怎麼辦呢?一個最簡單的方法就是倒個序,即將左->右->根倒序成根->右->左,這樣不就和前序遍歷的根->左->右差很少了嗎?而由於棧自己就是後進先出的,是自然的倒序工具,所以,咱們只須要再用一個棧將輸出順序反過來便可,由此,雙棧法應運而生,它的思路是:

  1. 用一個棧實現 根->右->左 的遍歷
  2. 用另外一個棧將遍歷順序反過來,使之變成 左->右->根

下面咱們來看實現:

首先,在最開始的前序遍歷中,咱們已經實現了遞歸方式的根->左->右的遍歷,以下:

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的代碼,就不會以爲摸不着頭腦了。

(完)

相關文章
相關標籤/搜索