詳解二叉樹的非遞迴遍歷
前言
對於二叉樹的遞迴遍歷比較簡單,所以本文主要討論的是非遞迴版。其中,中序遍歷的非遞迴寫法最簡單,後序遍歷最難。
節點的定義:
//Binary Tree Node
typedef struct node
{
int data;
struct node* lchild; //左孩子
struct node* rchild; //右孩子
}BTNode;
首先,有一點是明確的:非遞迴寫法一定會用到棧,這個應該不用太多的解釋。我們先看中序遍歷:
中序遍歷
分析
中序遍歷的遞迴定義:先左子樹,後根節點,再右子樹。如何寫非遞迴程式碼呢?一句話:讓程式碼跟著思維走。我們的思維是什麼?思維就是中序遍歷的路徑。假設,你面前有一棵二叉樹,現要求你寫出它的中序遍歷序列。如果你對中序遍歷理解透徹的話,你肯定先找到左子樹的最下邊的節點。那麼下面的程式碼就是理所當然的:
中序程式碼段(i)
BTNode* p = root; //p指向樹根
stack<BTNode*> s; //STL中的棧
//一直遍歷到左子樹最下邊,邊遍歷邊儲存根節點到棧中
while (p)
{
s.push(p);
p = p->lchild;
}
儲存一路走過的根節點的理由是:中序遍歷的需要,遍歷完左子樹後,需要藉助根節點進入右子樹。程式碼走到這裡,指標p為空,此時無非兩種情況:
說明:
①、上圖中只給出了必要的節點和邊,其它的邊和節點與討論無關,不必畫出。
②、你可能認為圖a中最近儲存節點算不得是根節點。如果你看過樹、二叉樹基礎,使用擴充二叉樹的概念,就可以解釋。總之,不用糾結這個沒有意義問題。
③、整個二叉樹只有一個根節點的情況可以劃到圖a。
仔細想想,二叉樹的左子樹,最下邊是不是上圖兩種情況?不管怎樣,此時都要出棧,並訪問該節點。這個節點就是中序序列的第一個節點。根據我們的思維,程式碼應該是這樣:
p = s.top();
s.pop();
cout << p->data;
我們的思維接著走,兩圖情形不同得區別對待:
1.圖a中訪問的是一個左孩子,按中序遍歷順序,接下來應訪問它的根節點。也就是圖a中的另一個節點,高興的是它已被儲存在棧中。我們只需這樣的程式碼和上一步一樣的程式碼:
p = s.top();
s.pop();
cout << p->data;
左孩子和根都訪問完了,接著就是右孩子了,對吧。接下來只需一句程式碼:p=p->rchild;在右子樹中,又會新一輪的程式碼段(i)、程式碼段(ii)……直到棧空且p空。
2.再看圖b,由於沒有左孩子,根節點就是中序序列中第一個,然後直接是進入右子樹:p=p->rchild;在右子樹中,又會新一輪的程式碼段(i)、程式碼段(ii)……直到棧空且p空。
思維到這裡,似乎很不清晰,真的要區分嗎?根據圖a接下來的程式碼段(ii)這樣的:
p = s.top();
s.pop();
cout << p->data;
p = s.top();
s.pop();
cout << p->data;
p = p->rchild;
根據圖b,程式碼段(ii)又是這樣的:
p = s.top();
s.pop();
cout << p->data;
p = p->rchild;
我們可小結下:遍歷過程是個迴圈,並且按程式碼段(i)、程式碼段(ii)構成一次迴圈體,迴圈直到棧空且p空為止。
不同的處理方法很讓人抓狂,可統一處理嗎?真的是可以的!回顧擴充二叉樹,是不是每個節點都可以看成是根節點呢?那麼,程式碼只需統一寫成圖b的這種形式。也就是說程式碼段(ii)統一是這樣的:
中序程式碼段(ii)
p = s.top();
s.pop();
cout << p->data;
p = p->rchild;
口說無憑,得經的過理論檢驗。
圖a的程式碼段(ii)也可寫成圖b的理由是:由於是葉子節點,p=-=p->rchild;之後p肯定為空。為空,還需經過新一輪的程式碼段(i)嗎?顯然不需。(因為不滿足迴圈條件)那就直接進入程式碼段(ii)。看!最後還是一樣的吧。還是連續出棧兩次。看到這裡,要仔細想想哦!相信你一定會明白的。
這時寫出遍歷迴圈體就不難了:
BTNode* p = root;
stack<BTNode*> s;
while (!s.empty() || p)
{
//程式碼段(i)一直遍歷到左子樹最下邊,邊遍歷邊儲存根節點到棧中
while (p)
{
s.push(p);
p = p->lchild;
}
//程式碼段(ii)當p為空時,說明已經到達左子樹最下邊,這時需要出棧了
if (!s.empty())
{
p = s.top();
s.pop();
cout << setw(4) << p->data;
//進入右子樹,開始新的一輪左子樹遍歷(這是遞迴的自我實現)
p = p->rchild;
}
}
仔細想想,上述程式碼是不是根據我們的思維走向而寫出來的呢?再加上邊界條件的檢測,中序遍歷非遞迴形式的完整程式碼是這樣的:
中序遍歷程式碼一
//中序遍歷
void InOrderWithoutRecursion1(BTNode* root)
{
//空樹
if (root == NULL)
return;
//樹非空
BTNode* p = root;
stack<BTNode*> s;
while (!s.empty() || p)
{
//一直遍歷到左子樹最下邊,邊遍歷邊儲存根節點到棧中
while (p)
{
s.push(p);
p = p->lchild;
}
//當p為空時,說明已經到達左子樹最下邊,這時需要出棧了
if (!s.empty())
{
p = s.top();
s.pop();
cout << setw(4) << p->data;
//進入右子樹,開始新的一輪左子樹遍歷(這是遞迴的自我實現)
p = p->rchild;
}
}
}
中序遍歷程式碼二
//中序遍歷
void InOrderWithoutRecursion2(BTNode* root)
{
//空樹
if (root == NULL)
return;
//樹非空
BTNode* p = root;
stack<BTNode*> s;
while (!s.empty() || p)
{
if (p)
{
s.push(p);
p = p->lchild;
}
else
{
p = s.top();
s.pop();
cout << setw(4) << p->data;
p = p->rchild;
}
}
}
前序遍歷
分析
前序遍歷的遞迴定義:先根節點,後左子樹,再右子樹。
首先,我們遍歷左子樹,邊遍歷邊列印,並把根節點存入棧中,以後需藉助這些節點進入右子樹開啟新一輪的迴圈。還得重複一句:所有的節點都可看做是根節點。根據思維走向,寫出程式碼段(i):
前序程式碼段(i)
//邊遍歷邊列印,並存入棧中,以後需要藉助這些根節點(不要懷疑這種說法哦)進入右子樹
while (p)
{
cout << setw(4) << p->data;
s.push(p);
p = p->lchild;
}
接下來就是:出棧,根據棧頂節點進入右子樹。
前序程式碼段(ii)
//當p為空時,說明根和左子樹都遍歷完了,該進入右子樹了
if (!s.empty())
{
p = s.top();
s.pop();
p = p->rchild;
}
同樣地,程式碼段(i)(ii)構成了一次完整的迴圈體。至此,不難寫出完整的前序遍歷的非遞迴寫法。
前序遍歷程式碼一
void PreOrderWithoutRecursion1(BTNode* root)
{
if (root == NULL)
return;
BTNode* p = root;
stack<BTNode*> s;
while (!s.empty() || p)
{
//邊遍歷邊列印,並存入棧中,以後需要藉助這些根節點(不要懷疑這種說法哦)進入右子樹
while (p)
{
cout << setw(4) << p->data;
s.push(p);
p = p->lchild;
}
//當p為空時,說明根和左子樹都遍歷完了,該進入右子樹了
if (!s.empty())
{
p = s.top();
s.pop();
p = p->rchild;
}
}
cout << endl;
}
下面給出,本質是一樣的另一段程式碼:
前序遍歷程式碼二
//前序遍歷
void PreOrderWithoutRecursion2(BTNode* root)
{
if (root == NULL)
return;
BTNode* p = root;
stack<BTNode*> s;
while (!s.empty() || p)
{
if (p)
{
cout << setw(4) << p->data;
s.push(p);
p = p->lchild;
}
else
{
p = s.top();
s.pop();
p = p->rchild;
}
}
cout << endl;
}
在二叉樹中使用的是這樣的寫法,略有差別,本質上也是一樣的:
前序遍歷程式碼三
void PreOrderWithoutRecursion3(BTNode* root)
{
if (root == NULL)
return;
stack<BTNode*> s;
BTNode* p = root;
s.push(root);
while (!s.empty()) //迴圈結束條件與前兩種不一樣
{
//這句表明p在迴圈中總是非空的
cout << setw(4) << p->data;
/*
棧的特點:先進後出
先被訪問的根節點的右子樹後被訪問
*/
if (p->rchild)
s.push(p->rchild);
if (p->lchild)
p = p->lchild;
else
{//左子樹訪問完了,訪問右子樹
p = s.top();
s.pop();
}
}
cout << endl;
}
最後進入最難的後序遍歷:
後序遍歷
分析
後序遍歷遞迴定義:先左子樹,後右子樹,再根節點。
後序遍歷的難點在於:需要判斷上次訪問的節點是位於左子樹,還是右子樹。若是位於左子樹,則需跳過根節點,先進入右子樹,再回頭訪問根節點;若是位於右子樹,則直接訪問根節點。直接看程式碼,程式碼中有詳細的註釋。
後序遍歷程式碼一
//後序遍歷
void PostOrderWithoutRecursion(BTNode* root)
{
if (root == NULL)
return;
stack<BTNode*> s;
//pCur:當前訪問節點,pLastVisit:上次訪問節點
BTNode* pCur, *pLastVisit;
pCur = root;
pLastVisit = NULL;
//先把pCur移動到左子樹最下邊
while (pCur)
{
s.push(pCur);
pCur = pCur->lchild;
}
while (!s.empty())
{
//走到這裡,pCur都是空,並已經遍歷到左子樹底端(看成擴充二叉樹,則空,亦是某棵樹的左孩子)
pCur = s.top();
s.pop();
//一個根節點被訪問的前提是:無右子樹或右子樹已被訪問過
if (pCur->rchild == NULL || pCur->rchild == pLastVisit)
{
cout << setw(4) << pCur->data;
//修改最近被訪問的節點
pLastVisit = pCur;
}
/*這裡的else語句可換成帶條件的else if:
else if (pCur->lchild == pLastVisit)//若左子樹剛被訪問過,則需先進入右子樹(根節點需再次入棧)
因為:上面的條件沒通過就一定是下面的條件滿足。仔細想想!
*/
else
{
//根節點再次入棧
s.push(pCur);
//進入右子樹,且可肯定右子樹一定不為空
pCur = pCur->rchild;
while (pCur)
{
s.push(pCur);
pCur = pCur->lchild;
}
}
}
cout << endl;
}
下面給出另一種思路下的程式碼。它的想法是:給每個節點附加一個標記(left,right)。如果該節點的左子樹已被訪問過則置標記為left;若右子樹被訪問過,則置標記為right。顯然,只有當節點的標記位是right時,才可訪問該節點;否則,必須先進入它的右子樹。詳細細節看程式碼中的註釋。
後序遍歷程式碼二
//定義列舉型別:Tag
enum Tag{left,right};
//自定義新的型別,把二叉樹節點和標記封裝在一起
typedef struct
{
BTNode* node;
Tag tag;
}TagNode;
//後序遍歷
void PostOrderWithoutRecursion2(BTNode* root)
{
if (root == NULL)
return;
stack<TagNode> s;
TagNode tagnode;
BTNode* p = root;
while (!s.empty() || p)
{
while (p)
{
tagnode.node = p;
//該節點的左子樹被訪問過
tagnode.tag = Tag::left;
s.push(tagnode);
p = p->lchild;
}
tagnode = s.top();
s.pop();
//左子樹被訪問過,則還需進入右子樹
if (tagnode.tag == Tag::left)
{
//置換標記
tagnode.tag = Tag::right;
//再次入棧
s.push(tagnode);
p = tagnode.node;
//進入右子樹
p = p->rchild;
}
else//右子樹已被訪問過,則可訪問當前節點
{
cout << setw(4) << (tagnode.node)->data;
//置空,再次出棧(這一步是理解的難點)
p = NULL;
}
}
cout << endl;
}
總結
在非遞迴版本中的幾個要點:
①、我們需要理解三種遍歷方法的思想(以跟節點為基準);
②、所有的節點都可看做是父節點(葉子節點可看做是兩個孩子為空的父節點);
③ 、利用堆疊來儲存中間值,來實現非遞迴版本。