對二叉樹遍歷的思考

前言

在數據結構教材中的中序遍歷非遞歸形式過程中發現每次遍歷到葉子結點的時候,都會把NULL空結點放入棧中。

從而讓我思考這個空結點是否有必要放入棧中?

為了順理成章引出該問題。文章的前半部分講述中序遍歷的基本過程。
不想看的同學直接看最后一部分。想要加深對二叉樹遍歷的印象的同學推薦還是瀏覽一遍。

中序遍歷

遞歸形式

要求形式一:不加入空結點
① 先遍歷左結點
② 遍歷完中結點后,假如有右節點就遍歷右節點;沒有則返回上級。
按照要求可以寫出這樣的遞歸形式

InOrderTraverse(BiTree* tree)
{
  if(tree->lchild)//滿足要求①
    InOrderTraverse(tree->lchlid);
  Visit(tree->data);
  if(tree->rchild)//滿足要求②,其中一部分
    InOrderTraverse(tree->rchild);
//在函數結束后就會返回至上級調用該函數的地方
}

也可以這樣理解中序遍歷
要求形式二:加入空結點
① 如果該節點存在,就按要求遍歷左中右;不存在就返回。

InOrderTraverse(BiTree* tree)
{
  if(tree)
  {
    InOrderTraverse(tree->lchild);
    Visit(tree->data);
    InOrderTraverse(tree->rchild);
  }
}

非遞歸形式

依照遞歸形式的執行過程中遞歸工作棧的狀態變化模擬出非遞歸形式。

為了引出不加空結點的方法,先按照 要求二:加入空結點 的理解。
1、這里需要一個堆棧s,用來模擬每次遞歸過程中的工作棧。
2、用一個最外層的while循環表示每一個新結點的調用與返回。

因此代碼會是這樣

bool InOrderTraverse(BiTree* tree, TreeVisiter visit)
{
    std::stack<BiTree*> s;
    s.push(tree);

    //繼續分析 要求二方式的遞歸 中棧里面存的是什么的過程中,
    //會發現棧中存入的是每一個未被訪問過的結點和空結點。
    //所以棧未空時,表示樹遍歷結束
    while(!s.empty())
    {
      //...
    }
    return true;
}

步驟1、獲取棧頂結點,如果結點存在,就先遍歷左結點。

tree = s.top();
if(tree)
  {
    s.push(tree->lchild);//注意這里可以會把空結點存入棧中。
    continue;//開始下一個循環,因為將外層while循環定義為訪問每個新結點
  }

步驟2、如果結點不存在,則先退出空節點,再訪問該結點。最后訪問右孩子。

s.pop();
tree = s.top();//c++中top()是獲取棧頂,不退棧
s.pop();
if(!visit(tree->data)) return false;
s.push(tree->rchild);//這里的右孩子可能是空結點

結合后代碼就會變為如下

bool InOrderTraverse(BiTree* tree, TreeVisiter visit)
{
    std::stack<BiTree*> s;
    s.push(tree);
    while(!s.empty())
    {
//------------------------------
//可優化處
        tree = s.top();
        if(tree)
        {
          s.push(tree->lchild);//注意這里可以會把空結點存入棧中。
          continue;//開始下一個循環
        }
//------------------------------
        s.pop();
        if(!s.empty())
        {
            tree = s.top();
            s.pop();
            if(!visit(tree->data)) return false;
            s.push(tree->rchild);
        }
    }
    return true;
}

考慮整個while循環過程中,會發現在 可優化處 的地方可以用一個while稍微優化一下。

//訪問空結點代碼
bool InOrderTraverse(BiTree* tree, TreeVisiter visit)
{
    std::stack<BiTree*> s;
    s.push(tree);
    while(!s.empty())//外層while循環
    {
//------------------------------

        while((tree = s.top()) && tree)
            s.push(tree->lchild);
//------------------------------
        s.pop();//彈出空結點
        if(!s.empty())
        {
            tree = s.top();
            s.pop();
            if(!visit(tree->data)) return false;
            s.push(tree->rchild);
        }
    }
    return true;
}

假如優化成這樣的話,那么對外層while循環的定義也就和前邊的不一樣了。
不再是 “用一個最外層的while循環表示每一個新結點的調用與返回。”(也許你可能忘了)
就變成兩個定義了:

外層while循環新定義1:如果該結點存在,訪問當前結點的最左結點,再右移一位。

外層while循環新定義2:如果該結點為空結點,則返回至兄弟結點。(如果一直都是NULL,那么就一直返回)

定義1 如下圖的 紅線 部分
定義2 如下圖的 藍線 部分

中序遍歷過程圖

空結點是否有必要放入棧中?

問題1:為什么要彈出空結點?
看到代碼,給我的第一個反應是:很簡單呀,因為外層的while循環多放了一個空結點。
這是第一個原因。
當我嘗試在外層的while循環中加入空結點判斷,不加入空結點時,發現代碼就會陷入死循環。
因為該處彈出空結點并不僅僅是為了彈出外層的while循環中的空結點。
還為了右結點為空時返回至兄弟結點。

問題2:如果在每次入棧之前判斷一次是否為空結點會怎么樣?會不會減少入棧次數的同時減少判斷次數?

不訪問空結點

從圖像上看,遍歷的過程少了一些。主要是少了葉子個數的循環次數。看來 可能 是有效果的。
如果遵循上圖的訪問規則,外層while循環的定義很明顯地就不一樣了。

外層while循環在不訪問NULL結點的新定義:
訪問當前結點的最左結點,然后尋找下一個存在的合適結點(右孩子,否則是右兄弟,否則是右叔叔,否則是右爺爺,否則是右祖父,否則是......)

于是改寫了一下代碼。

//不訪問空結點代碼
bool InOrderTraverse_non_recursive_1(BiTree* tree, TreeVisiter visit)
{
    std::stack<BiTree*> s;
    s.push(tree);
    while(!s.empty())
    {
        tree = s.top();
        while(tree->lchild)
        {
            tree = tree->lchild;
            s.push(tree);
        }
        
        s.pop();
        
        if(!visit(tree->data)) return false;
        
        tree = tree->rchild;
        //尋找下一個存在的合適結點
        while(!tree)
        {
            if(s.empty())//在尋找的過程中可能是最后一個結點了
                return true;
            tree = s.top();
            s.pop();
            if(!visit(tree->data)) return false;
            tree = tree->rchild;
        }
        s.push(tree);
    }
    return true;
}

總結:

在加入棧之前判斷下該結點是否為空,只加入不為空的結點,在效率上較加入空結點會有一點點的改進。只是代碼長度可能會多那么幾行。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容