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

前言

二叉樹的前序遍歷,中序遍歷,後序遍歷是面試中常常考察的基本算法,關於它的概念這裏不再贅述了,還不瞭解的同學可以去翻翻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節點。

雙棧法

前面我們說過,前序遍歷之所以最簡單,是因爲遍歷過程中最先遇到的根節點是最先訪問的,而在後序遍歷中,最先遇到的根節點是最後訪問的,所以導致了上面的迭代法非常複雜,那有沒有辦法簡化一下呢?其實是有的。

大家仔細觀察一下後序遍歷的順序左->右->根,根節點在最後,要是能像前序遍歷一樣把它放在最前面就好了,怎麼辦呢?一個最簡單的方法就是倒個序,即將左->右->根倒序成根->右->左,這樣不就和前序遍歷的根->左->右差不多了嗎?而因爲棧本身就是後進先出的,是天然的倒序工具,因此,我們只需要再用一個棧將輸出順序反過來即可,由此,雙棧法應運而生,它的思路是:

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

(完)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章