不一樣的二叉樹非遞歸遍歷

本篇將討論模擬系統棧的二叉樹非遞歸遍歷,並由此討論一般化的將遞歸程序改爲非遞歸程序的方式。

二叉樹的非遞歸遍歷

就以中序遍歷爲例,下面是大家都熟悉的前序遍歷遞歸版本代碼框架:

void inOrder(TreeNode *root){
    if(root == NULL) return;
    inOrder(root->left);
    visit(root);
    inOrder(root->right);
}

計算機執行遞歸程序從操作系統角度看實際上還是一個函數調用另一個函數,只不過調用的函數是這個程序本身而已,所以需要在調用前記錄當前程序執行到哪裏,以及當前程序的變量狀態,將這些信息壓入系統棧,這樣當遞歸調用返回時就能繼續執行當前應該執行的邏輯。
下面我們將按照模擬系統棧的方式將其改爲非遞歸形式,所謂模擬系統棧,就是模擬計算機執行遞歸程序時的過程,使用的是自己定義的棧來記錄需要的信息。
第一步:爲程序分段
爲什麼要分段,是因爲遞歸程序執行到不同階段需要保存相應的信息以方便在遞歸返回時繼續執行當前程序,我們想要明白應該保存哪些位置的信息最好方式就是對程序分段。

void inOrder(TreeNode *root){
    // pos: 0
    if(root == NULL) return;
    inOrder(root->left);
    // pos: 1
    visit(root);
    inOrder(root->right);
    // pos: 2
}

爲遞歸程序分段的原則是程序會從哪裏開始和哪裏可能會發生遞歸函數的調用,可以看到我們爲程序標註了pos0,pos1,pos2的分段,因爲該程序執行時會從pos0進入,然後到pos1前的inorder(root->left)發生了一次調用遞歸函數,所以在遞歸調用前要將當前信息保存到棧中;同理在pos2前的inorder(root->right)發生了一次調用,所以在調用之前要將信息保存到棧中。
第二步:確定要保存的信息
明白了在哪些位置保存信息到棧中接下來就是看我們需要保存什麼信息了,對於二叉樹遍歷其實就是程序當前執行到的位置當前正在訪問的節點兩個信息,這兩個信息唯一確定了遞歸函數狀態。
於是給出非遞歸代碼:

struct state {
    // 首先定義狀態結構體(當然使用數組也是可以的)
    int pos; // 記錄狀態:程序執行到的位置
    TreeNode* node; // 記錄狀態:當前訪問的節點
};
void inorder(TreeNode* root) {
    stack<state>s;
    s.push({0, root});
    while (s.size()) {
        auto a = s.top();
        s.pop();
        if (a.pos == 0) {
            if (a.node == NULL) continue; 
            a.pos = 1;
            s.push(a);
            s.push({ 0, a.node->left });
        }
        else if (a.pos == 1) {
            visit(a.node);
            a.pos = 2; s.push(a); 
            s.push({ 0, a.node->right });
        }
        else if (a.pos == 2) continue;
    }
    return;
}

注意到在pos2之後其實沒有新的邏輯了,所以程序可以簡化一些,將pos2信息的入棧代碼部分刪去(實際中爲了邏輯清晰最好不刪):

void inorder(TreeNode* root) {
    stack<state>s;
    s.push({0, root});
    while (s.size()) {
        auto a = s.top();
        s.pop();
        if (a.pos == 0) {
            if (a.node == NULL) continue; 
            a.pos = 1;
            s.push(a);
            s.push({ 0, a.node->left });
        }
        else if (a.pos == 1) {
            visit(a.node);
            s.push({ 0, a.node->right });
        }
    }
    return;
}

好了,這就是模擬系統棧的非遞歸代碼。
這樣做有什麼好處呢,一個好處是非遞歸程序相對於遞歸程序都具有的好處,遞歸程序在執行過程中中間信息是保存在系統棧中的,系統棧大小有限,遞歸層次太深會引起棧溢出,但非遞歸程序可以一定程度上避免這個問題,另外遞歸程序返回時有額外開銷,時間效率實際也略低於非遞歸程序;另一個好處是針對代碼本身的,模擬系統棧幫助我們明白了遞歸程序的本質,這會讓我們寫出更結構化的非遞歸代碼,怎麼理解這一點呢?還記得數據結構課本里的二叉樹遍歷的普通的非遞歸代碼嗎,前序和中序非遞歸的邏輯差不太多,但是對於非遞歸後序遍歷代碼卻變的複雜一些,這樣就要用兩套模式來記憶和理解二叉樹的非遞歸。但是基於模擬系統棧理解了遞歸的本質,用該方式來書寫非遞歸代碼就只需要一套模式,後序遍歷分段:

void postorder(TreeNode* root) {
    // pos0
    if (root == NULL) return;
    postorder(root->left);
    // pos1
    postorder(root->right);
    // pos2
    visit(root);
}

於是後序遍歷的非遞歸版本:

void postorder(TreeNode* root) {
    stack<state>s;
    s.push({0,root});
    while (s.size()) {
        auto a = s.top();
        s.pop();
        if (a.pos == 0) {
            if (a.node == NULL) continue; 
            a.pos = 1;
            s.push(a);
            s.push({ 0,a.node->left });
        }
        else if (a.pos == 1) {
            a.pos = 2; s.push(a); 
            s.push({ 0, a.node->right });
        }
        else if (a.pos == 2) visit(a.node);
    }
    return;
}

相應的前序非遞歸版本:

void preorder(TreeNode* root) {
    stack<state>s;
    s.push({0,root});
    while (s.size()) {
        auto a = s.top();
        s.pop();
        if (a.pos == 0) {
            if (a.node == NULL) continue; 
            visit(a.node);
            a.pos = 1;
            s.push(a);
            s.push({ 0,a.node->left });
        }
        else if (a.pos == 1) {
            a.pos = 2; s.push(a); 
            s.push({ 0, a.node->right });
        }
        else if (a.pos == 2) continue;
    }
    return;
}

可以看到,在模擬系統棧的方式下,前中後序的非遞歸遍歷僅僅取決於要實現訪問的邏輯所在的位置,代碼在形式上完全統一,妙啊!

另一個例子

以一個具體問題爲例,再來看看如何模擬系統棧實現遞歸到非遞歸程序的轉化:
題目:組合型枚舉
從 1~n 這 n 個整數中隨機選出 m 個,輸出所有可能的選擇方案。
輸入格式
兩個整數 n,m ,在同一行用空格隔開。
輸出格式
按照從小到大的順序輸出所有方案,每行1個。
首先,同一行內的數升序排列,相鄰兩個數用一個空格隔開。
其次,對於兩個不同的行,對應下標的數一一比較,字典序較小的排在前面(例如1 3 5 7排在1 3 6 8前面)。
數據範圍
n>0 , 0≤m≤n , n+(n−m)≤25
輸入樣例:

5 3

輸出樣例:

1 2 3 
1 2 4 
1 2 5 
1 3 4 
1 3 5 
1 4 5 
2 3 4 
2 3 5 
2 4 5 
3 4 5 

思考題:如果要求使用非遞歸方法,該怎麼做呢?
思路分析和代碼:
實現這樣的枚舉本身並不太難,結合位運算,用state來表示是否選擇某個數,初值爲1<<n,第i位爲1表示選擇該位對應的數,爲0表示不選。那麼當state的二進制表示中有m個1時,即是一個組合。於是給出遞歸版本代碼:

# include<iostream>
# include<stack>
using namespace std;
int n, m;
void dfs(int u, int count, int state) {
    // 當前考慮第u個
    if (count + n - u < m)return;//當前往後都選上也不夠m個
    if (count == m) {
        for (int i = 0; i < n; i++)
            if (state & (1 << i))
                cout << i + 1 << " ";
        cout << endl;
        return;
    }
    dfs(u + 1, count + 1, state | (1 << u));//用u,state第u位置1
    dfs(u + 1, count, state); // 不用u
}
int main(){
    cin >> n >> m;
    dfs(0, 0, 0);
    return 0;
}

對遞歸代碼分段:

void dfs(int u, int count, int state) {
    // 0
    if (count + n - u < m)return;//當前往後都選上也不夠m個
    if (count == m) {
        for (int i = 0; i < n; i++)
            if (state & (1 << i))
                cout << i + 1 << " ";
        cout << endl;
        return;
    }
    dfs(u + 1, count + 1, state | (1 << u));//用u,state第u位置1
    // 1
    dfs(u + 1, count, state); // 不用u
    // 2
}

給出非遞歸代碼:

# include<iostream>
# include<stack>
using namespace std;
int n, m;
// 棧模擬遞歸
struct State {
    int pos, u, count, state;
};
int main() {
    cin >> n >> m;
    stack<State>stk;
    stk.push({ 0,0,0,0 });
    while (stk.size()) {
        auto a = stk.top();
        stk.pop();
        if (a.pos == 0) {
            if (a.count + n - a.u < m)continue;
            if (a.count == m) {
                for (int i = 0; i < n; i++)
                    if ((a.state >> i) & 1)
                        cout << i + 1 << " ";
                cout << endl;
                continue;
            }
            a.pos = 1; 
            stk.push(a);// 原狀態保存
            stk.push({ 0,a.u + 1,a.count + 1,a.state | (1 << a.u) });// 加入新狀態
        }
        else if (a.pos == 1) {
            a.pos = 2; stk.push(a);// 本行代碼可以省略,因pos=2時什麼都沒做,如果pos=2有其它操作不可省略
            stk.push({0,a.u+1,a.count,a.state});
        }
        else continue;
    }
    return 0;
}

最後總結將遞歸程序改爲非遞歸的步驟:

    1. 對程序分段,明確會在哪些位置發生調用
    1. 明確要保存的信息,一般是程序執行到的位置和程序傳入的參數
    1. 逐語句翻譯遞歸程序
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章