1. 程式人生 > 實用技巧 >二叉樹的非遞迴遍歷演算法

二叉樹的非遞迴遍歷演算法

我們都知道,二叉樹常見的操作之一就是遍歷,leetcode上很多對二叉樹的操作都是基於遍歷基礎之上的。

二叉樹的遍歷操作分為深度優先遍歷和廣度優先遍歷,深度優先遍歷包括前序、中序、後序三種遍歷方式;廣度優先遍歷又叫層序遍歷。

這兩種遍歷方式都是非常重要的,樹的層序遍歷一般是沒法通過遞迴來實現,其遍歷過程需要藉助一個佇列來實現,本質上就是圖的BFS的一種特例,可以用來求二叉樹的高度、寬度等資訊。

我們今天要講的是二叉樹的前、中、後序遍歷的非遞迴演算法。

二叉樹前、中、後序遍歷的遞迴演算法形式上上是非常簡潔明瞭的,只要你熟練使用遞迴這一方法,應該都能寫出來。

而非遞迴演算法則顯得不那麼容易。但是非遞迴演算法也非常重要,有一些題用非遞迴演算法解會簡單一點。

其實,我覺得我們人類的思維就是典型的非遞迴形式的,所以,要想實現一個非遞迴演算法,其實就是把我們腦中的求解過程轉換成相應的程式碼。

前序非遞迴

例如,對於下面這棵二叉樹,我們要對其進行非遞迴前序遍歷,該如何做?

前序遍歷,就是先訪問根節點,再訪問左子樹,再訪問右子樹,左子樹和右子樹的訪問過程也是這樣遞迴進行。

那麼我們一開始是依次訪問1、2、4、6。到了6的時候我們為什麼要停下來呢?因為這時候我們發現,6的左子樹為空,相當於它的左子樹已經訪問完了,按照前序遍歷的定義,這個時候我們應該訪問其右子樹,我們發現6的右子樹也為空,相當於它的右子樹訪問完了。

這個時候我們該怎麼辦呢?

沒錯,這個時候說明以6為根節點的子樹都已經訪問完了,我們應該往上回溯,回到6的父親結點。

從這裡,我們發現了幾個問題:

  • 我們每個結點都要經過3次
    • 第一次是從該結點的根結點往下走到該結點的時候經過的
    • 第二次是從其左子樹返回到該結點時經過的
    • 第三次是從其右子樹返回到該結點時經過的。
  • 前序遍歷一開始是不斷地往左下角方向走,每經過一個結點就訪問它,直到到達最左下角位置停下來。
  • 每當從左孩子返回到根結點的時候,需要判斷一下右子樹是不是為空,不為空的話才去訪問右子樹。
  • 由於存在回溯,所以訪問過程需要用到棧,用來記錄我們依次經過的結點。

初步思考之後,我們可以寫出如下程式碼:

vector<int> preorderTraversal(TreeNode* root) {
    vector<int> res;
    stack<TreeNode*> s;
    TreeNode* p = root;
    if (p) {
        res.push_back(p->val);
        s.push(p);
    }
    while (!s.empty()) {
        p = s.top();
        while (p->left) {
            res.push_back(p->left->val);
            s.push(p->left);
            p = p->left;
        }

        if (p->right) {
            res.push_back(p->right->val);
            s.push(p->right);
            p = p->right;
        }
        else {
            p = s.top(); s.pop();
        }
    }

    return res;
}

res陣列是用來記錄我們訪問序列的,p指向我們當前經過的結點,s用來記錄我們走過的結點序列。

這個程式碼乍一看好像和我們剛才思考的過程是一樣的,但是你一執行就會出現問題,首先,我們來看一下這個迴圈

p = s.top();
while (p->left) {
    res.push_back(p->left->val);
    s.push(p->left);
    p = p->left;
}

我們上面講過,二叉樹中每個結點都會經過三次,我們這裡一直在往左下角方向走,沒走一步記錄一個結點到棧裡面,並且訪問該結點。

但是,你有沒有發現,還是針對我們之前那棵樹:

當我們訪問完6回到4的時候,這個迴圈條件同樣又成立了,它又會重複一次往左下角走,這並不是我們想要的,那麼怎麼辦?

你可以想一下,這種情況是什麼條件才會觸發的?

沒錯,就是我們剛從左子樹返回的時候,言外之意就是說,我們上一個訪問的結點是左孩子。所以為了避免這種情況發生,我們必須記錄一下上一個訪問過的結點是什麼?

為了我們新加一個pre指標,指向上一個訪問過的結點,這個結點需要我們每走一步都更新一次。

同時我們會發現,當我們從右子樹返回的時候,我們也不能進入上面那個迴圈。

比如對於之前那個圖,當我們從7返回到4的時候,我們不應該再次往4的左孩子方向走。

還有一個類似的問題,就是我們往右孩子方向走不僅僅需要右孩子非空,而且需要保證上一個訪問過的結點不是右孩子。

於是,引入這個pre指標之後,我們之前的程式碼可以改成下面這樣的

vector<int> preorderTraversal(TreeNode* root) {
    vector<int> res;
    stack<TreeNode*> s;
    TreeNode* p = root, * pre = p;
    if (p) {
        res.push_back(p->val);
        s.push(p);
    }
    while (!s.empty()) {
        p = s.top();
        while (p->left && p->left != pre && p->right != pre) {//沒有從左邊返回,也沒有從右邊返回
            res.push_back(p->left->val);
            s.push(p->left);
            pre = p;
            p = p->left;
        }

        if (p->right && p->right != pre) {
            res.push_back(p->right->val);
            s.push(p->right);
            pre = p;
            p = p->right;
        }
        else {
            p = s.top(); s.pop();
            pre = p;
        }
    }

    return res;
}

這次我們的程式碼終於沒有任何問題了。

講完二叉樹的前序非遞迴演算法,我們再講講中序和後序非遞迴演算法。其實理解了前序,我們只需要做一些微小的修改就行,整體過程還是類似的。

中序非遞迴

還是以我們上面那棵樹為例,我們來講講中序遍歷過程

中序遍歷,就是先訪問左子樹,再訪問根節點,最後訪問右子樹,左子樹和右子樹的訪問過程也是這樣遞迴進行。

那麼我們一開始訪問的是哪個元素呢?

沒錯,由於我們先是對左子樹進行遞迴,所以我們一開始訪問的應該是最左下角位置的元素

這個和前序是有一點區別的,前序是往左下角方向走,每走一步訪問一個結點。中序是先一下子走到左下角,然後再訪問最左下角位置的元素。

訪問完6,接下來該怎麼辦?沒錯因為6的左子樹一定為空,否則它就不可能是第一個被訪問的,這時候我們應該判斷一下6的右子樹是不是為空,如果右子樹不為空的話,我們要對它的右子樹重複剛才的過程。

對於上面那棵樹,它的右子樹是空的,於是相當於以6為根結點的子樹都已經訪問完了,這個時候我們應該回到6的父親結點,並且訪問其父親結點。

然後重複剛才的過程。

整個過程程式碼如下:

vector<int> inorderTraversal(TreeNode* root) {
    vector<int> res;

    stack<TreeNode*> s;
    TreeNode* p = root, * pre = p;
    if (p) s.push(p);
    while (!s.empty()) {
        p = s.top();
        while (p->left && p->left != pre && p->right != pre) {//沒有從左邊返回,也沒有從右邊返回
            s.push(p->left);
            pre = p;
            p = p->left;
        }

        if (p->right && p->right != pre) {
            if (p->left == pre) res.push_back(p->val);

            s.push(p->right);
            pre = p;
            p = p->right;
        }
        else {
            p = s.top(); s.pop();
            if (p->right != pre) res.push_back(p->val);
            pre = p;
        }
    }

    return res;
}

可以和前面的前序遍歷做個對比,我們發現,有以下幾個區別:

  • 對於下面這個迴圈,也就是在我們往左下角方向走的時候,我們並沒有訪問中途的結點
//中序
while (p->left && p->left != pre && p->right != pre) {//沒有從左邊返回,也沒有從右邊返回
    s.push(p->left);
    pre = p;
    p = p->left;
}

前序遍歷過程我們是走一步,就訪問一個(訪問的過程就是把該結點的值新增到res陣列中去)

//前序
while (p->left && p->left != pre && p->right != pre) {//沒有從左邊返回,也沒有從右邊返回
    res.push_back(p->left->val);
    s.push(p->left);
    pre = p;
    p = p->left;
}
  • 對於下面的程式碼,對應的是我們往右子樹方向走的時候,如果我們是從左子樹回到當前結點,然後當前結點的右子樹不為空,我們應該先訪問當前結點,再往它的右子樹方向走
if (p->right && p->right != pre) {
    if (p->left == pre) res.push_back(p->val);

    s.push(p->right);
    pre = p;
    p = p->right;
}

-對於下面的程式碼,對應的是我們走到了最左下角位置的時候,我們需要確認到達這個位置時,上一個訪問過的結點不是右子樹,因為從右子樹返回的時候,我們當前結點是早就訪問過的,中序遍歷根結點訪問順序是優先於右子樹。

else {
    p = s.top(); s.pop();
    if (p->right != pre) res.push_back(p->val);
    pre = p;
}

整體來看,我們相比前序遍歷只是在結點訪問順序上做了一些修改,整體程式碼邏輯是基本一樣的。

後序非遞迴

後序遍歷,是先訪問左子樹,再訪問右子樹,最後訪問根結點,整體邏輯和上面前序以及中序都是一樣的,我就直接解釋一下後續非遞迴的程式碼了

vector<int> postorderTraversal(TreeNode* root) {
    vector<int> res;

    stack<TreeNode*> s;
    TreeNode* p = root, * pre = p;
    if (p) s.push(p);
    while (!s.empty()) {
        p = s.top();
        while (p->left && p->left != pre && p->right != pre) {//沒有從左邊返回,也沒有從右邊返回
            s.push(p->left);
            pre = p;
            p = p->left;
        }

        if (p->right && p->right != pre) {
            s.push(p->right);
            pre = p;
            p = p->right;
        }
        else {
            p = s.top(); s.pop();
            pre = p;
            res.push_back(p->val);
        }
    }

    return res;
}

由於是最後訪問根結點,所以後序遍歷的程式碼在形式上會更加簡單。

我們想一想,在後序遍歷過程中,什麼時候才會輸出一個結點,那麼必然是訪問完一個結點的左子樹和右子樹之後才會訪問該結點。那麼當訪問完一個結點的左右子樹,我們上面的程式碼其實就是進入到了那個

else {
    p = s.top(); s.pop();
    pre = p;
    res.push_back(p->val);
}

這個我就不多解釋了,搞不清楚的可以去自己除錯一下。


對於我們上面那棵樹,我們的三種遍歷序列依次為: