資料結構與演算法-二叉樹遍歷

BackSlash發表於2018-12-29
樹的遍歷是當且僅當訪問樹中每個節點一次的過程。遍歷可以解釋為把所有的節點放在一條線上,或者將樹線性化。
遍歷的定義只指定了一個條件:每個節點僅訪問一次,沒有指定這些節點的訪問順序。因此,節點有多少種排列方式,就有多少種遍歷方法。對於有n個節點的樹,共有n!個不同的遍歷方式。然而大多數遍歷方式是混亂的,很難從中找到規律,因此實現這樣的遍歷缺乏普遍性。經過前人總結,這裡介紹兩種遍歷方式,即廣度優先遍歷與深度優先遍歷。
  • 廣度優先遍歷
廣度優先遍歷是從根節點開始,向下逐層訪問每個節點。當使用佇列時,這種遍歷方式的實現相當直接。假設從上到下、從左到右進行廣度優先遍歷。在訪問了一個節點之後,它的子節點就放到佇列的末尾,然後訪問佇列頭部的節點。對於層次為n的節點,它的子節點位於第n+1層,如果將該節點的所有子節點都放到佇列的末尾,那麼,這些節點將在第n層的所有節點都訪問之後再訪問。
程式碼實現如下:
//廣度優先遍歷
void breadthFirst(Node* &bt){
	queue<Node*> que;
	Node* BT = bt;
	que.push(BT);
	while (!que.empty()){
		BT = que.front();
		que.pop();
		printf("%c", BT->data);
		if (BT->lchild)
			que.push(BT->lchild);
		if (BT->rchild)
			que.push(BT->rchild);
	}
}複製程式碼
廣度優先遍歷的邏輯比較簡單,這裡就不過多介紹了。
  • 深度優先遍歷
樹的深度優先遍歷相當有趣。深度優先遍歷將盡可能的向下進行,在遇到第一個轉折點時,向左或向右一步,然後再儘可能的向下發展。這一過程一直重複,直至訪問了所有節點為止。然而,這一定義並沒有清楚的指明什麼時候訪問節點:在沿著樹向下進行之前還是在折返之後呢?因此,深度優先遍歷有著幾種變種。
假如現在有個簡單的二叉樹:
資料結構與演算法-二叉樹遍歷
我們從左到右進行深度優先遍歷:
資料結構與演算法-二叉樹遍歷
你會發現其實每個節點遍歷到了3次:
資料結構與演算法-二叉樹遍歷
紅色的是第一次遍歷到的次序,藍色的是第二次遍歷到的次序,黑色的是第三次遍歷到的次序。這其實就是二叉樹從左到右的前序、中序、後序遍歷,從右到左也有三種遍歷,和上面遍歷的類似。前輩們對這三種遍歷方式進一步總結,將深度遍歷分解成3個任務:
  • V-訪問節點
  • L-遍歷左子樹
  • R-遍歷右子樹
前序遍歷其實是VLR,中序遍歷是LVR,後序遍歷是LRV
我們來討論深度遍歷的實現方法,毫無疑問,遞迴是非常簡單並且容易理解的方式,程式碼如下:
//從左向右前中後遞迴遍歷
void leftPreOrder(Node *bt)
{
	if (bt){
		printf("%c", bt->data);
		leftPreOrder(bt->lchild);
		leftPreOrder(bt->rchild);
	}
}

void leftInOrder(Node *bt)
{
	if (bt){
		leftInOrder(bt->lchild);
		printf("%c", bt->data);
		leftInOrder(bt->rchild);
	}
}

void leftPostOrder(Node *bt)
{
	if (bt){
		leftPostOrder(bt->lchild);
		leftPostOrder(bt->rchild);
		printf("%c", bt->data);
	}
}複製程式碼
可以看到三種遍歷方式的實現程式碼差別很小,其實差別就是V、L、R的順序不同,遞迴方式沒啥難點,這裡就不多講了。重點在於下面的非遞迴實現方式。
遞迴程式碼是有缺陷的,毫無疑問會給執行時棧帶來沉重的負擔,因為每次遞迴(呼叫函式)都會在執行時棧上開闢新的函式棧空間,如果二叉樹的高度比較大,程式執行可能直接爆掉棧空間,這是不能容忍的。因此,我們要實現非遞迴方式的二叉樹深度遍歷。
尋常的非遞迴遍歷方式實質上就是模擬上面動態圖中箭頭的轉移過程,在三種遍歷方式中前序遍歷是最簡單的,無論是哪一種遍歷方式,都需要藉助棧來實現。因為在深度遍歷中會有回溯的過程,這就要求程式可以記住以往遍歷過的節點,只有滿足這一點才能回溯成功,這就需要藉助棧來儲存遍歷節點的次序。下面是非遞迴遍歷方式的程式碼:
節點定義:

typedef struct BiNode{
  int data;
  struct BiNode * lchild;
  struct BiNode * rchild;
}BiNode,*BiTree;

前序遍歷:

void preOrderBiTree(BiNode * root){
	if(root == NULL)
		return;
	BiNode * node = root;
	stack<BiNode*> nodes;
	while(node || !nodes.empty()){
		while(node != NULL){
			nodes.push(node);
			printf("%d",node->data);
			node = node -> lchild;
		}
		node = nodes.top();//回溯到父節點
		nodes.pop();
		node = node -> rchild;
	}
}複製程式碼
可以看到,在將節點入棧之前已經訪問過節點,棧在這裡的作用只有回溯。
中序遍歷程式碼如下:
void inOrderBinaryTree(BiNode * root){
        if(root == NULL)
                return;
        BiNode * node = root;
        stack<BiNode*> nodes;
        while(node || !nodes.empty()){
                while(node != NULL){
                        nodes.push(node);
                        node = node ->lchild;
                }
                node = nodes.top();
                printf("%d ",node ->data);
                nodes.pop();
                node = node ->rchild;
        }
}複製程式碼
中序遍歷中訪問節點發生在出棧之前,棧在這裡的作用不僅僅是回溯,出棧還代表著第二次遍歷到該節點。後序遍歷更是需要程式實現V之前L和R操作已經完成,這就需要程式具有記憶功能。
後序遍歷程式碼如下:
void PostOrderS(Node *root) {
    Node *p = root, *r = NULL;
    stack<Node*> s;
    while (p!=NULL || !s.empty()) {
        if (p!=NULL) {//走到最左邊
            s.push(p);
            p = p->left;
        }
        else {
            p = s.top();
            if (p->right!=NULL && p->right != r)//右子樹存在,未被訪問
                p = p->right;
            else {
                s.pop();
                visit(p->val);
                r = p;//記錄最近訪問過的節點
                p = NULL;//節點訪問完後,重置p指標
            }
        }//else
    }//while
}複製程式碼
後序遍歷中最重要的邏輯是保證節點的左右子樹訪問之後再訪問該節點,程式中使用變數儲存了上次訪問的節點。如果節點i存在右子樹,並且上個訪問的節點是右子樹的根節點,才能訪問i節點。
毫無疑問,以上通過程式模擬前中後三種遍歷過程,就是我們常說的過程式程式碼,如果你在面試中遇到這類問題,情急之下不一定能寫的出來。究其原因在於思想不統一,前中後遍歷的三種程式碼實現方式是割裂開的。怎麼辦?筆者探究了一種實現方式,從思想上統一前中後三種遍歷過程。
首先用自然語言描述實現邏輯:
假如有以下二叉樹:
資料結構與演算法-二叉樹遍歷
我們來實現後序遍歷,後序遍歷的邏輯是LRV,也就是說,對於一顆二叉樹,我們希望首先遍歷左子樹,然後是右子樹,最後是根節點,放到棧裡就是這樣的:
資料結構與演算法-二叉樹遍歷
注意這裡,這裡看似只是壓入了三個節點,實質上已經將整棵樹壓入到棧裡了。因為13在這裡代表的不是一個節點,而是整棵左子樹,23也是代表著整棵右子樹。這裡就有疑問了,以這樣的方式入棧有個錘子用處,你告訴我怎麼訪問13這顆左子樹?如何解決這個矛盾點才是核心邏輯。我們可以繼續分解左子樹,就像這樣:
資料結構與演算法-二叉樹遍歷
可以看到,13代表的左子樹以LRV的方式分解成3部分。這時棧中存在兩個節點三顆樹,程式的下一步繼續彈出棧頂節點,如果是樹就繼續分解,如果是節點就代表著該節點是第一個可以訪問到的節點。
使用這種方式來遍歷二叉樹,前中後三種遍歷的差別只有一點,就是如何分解樹。前序是VLR,中序是LVR,後序是LRV,程式的其他部分沒有差別。
完整邏輯如下:
首先將根節點壓入棧,隨後彈出棧頂節點,發現是棵樹,分解該樹,按照指定的方式將左子樹、右子樹、根節點壓入棧中。隨後彈出棧頂節點,如果是棵樹就繼續分解,如果是節點就訪問該節點。程式的結束標誌是棧為空,代表整棵樹的節點都已經訪問完畢。
這種遍歷方式筆者稱之為分解遍歷,因為核心邏輯是如何分解棧中代表樹的節點。分解遍歷使得前中後非遞迴遍歷以統一的邏輯來處理。
分解遍歷要求棧中節點儲存額外的屬性,即該節點是樹還是結點,就是說我們需要一種方式知道彈出節點是樹還是結點。方法有很多,可以修改節點定義,新增一個額外的成員變數,或者棧中儲存字典,key是節點,value代表該節點是樹還是結點,或者修改棧,為每個節點儲存額外的屬性。筆者使用的方法是修改棧,為棧中每個節點儲存額外屬性。
參考程式碼如下:
/*二叉樹節點*/
typedef struct node
{
	char data;
	struct node *lchild, *rchild;
}Node;

/*二叉樹棧*/
typedef struct
{
	int top;//指向二叉樹節點指標的指標
	struct node* data[MAX];
	bool   isNode[MAX];
}SqStack;

void push(SqStack *&s, Node *bt, bool isNode = true)
{
	if (s == NULL)
               return;
	if (s->top == MAX - 1)
	{
		printf("棧滿,不能再入棧");
	}
	else
	{
		s->top++;
		s->data[s->top] = bt;
		s->isNode[s->top] = isNode;
	}
}

Node* pop(SqStack *&s)
{
	if (s->top == -1)
	{
		printf("棧空,不能出棧");
	}
	else
	{
		Node* temp;
		temp = s->data[s->top];
		s->top--;
		return temp;
	}
}

bool topIsNode(SqStack *&s){
	if (s->top != -1)
		return s->isNode[s->top];
	return false;
}

//從左向右前中後非遞迴遍歷
void leftPreOrder(Node *bt)
{
	Node *BT = bt;
	bool isNode = false;
	push(s, BT, isNode);
	while (!EmptyStack(s)){
		isNode = topIsNode(s);
		BT = pop(s);
		if (isNode){
			printf("%c", BT->data);
		}
		else{
			if (BT->rchild != NULL)
				push(s, BT->rchild, false);
			if (BT->lchild != NULL)
				push(s, BT->lchild, false);
			push(s, BT, true);
		}		
	}
}

void leftSimPreOrder(Node *bt)
{
	Node *BT = bt;
	push(s, BT);
	while (!EmptyStack(s)){
		BT = pop(s);
		printf("%c", BT->data);
		if (BT->rchild != NULL)
			push(s, BT->rchild);
		if (BT->lchild != NULL)
			push(s, BT->lchild);
	}
}

void leftInOrder(Node *bt)
{
	Node *BT = bt;
	bool isNode = false;
	push(s, BT, isNode);
	while (!EmptyStack(s)){
		isNode = topIsNode(s);
		BT = pop(s);
		if (isNode){
			printf("%c", BT->data);
		}
		else{
			if (BT->rchild != NULL)
				push(s, BT->rchild, false);
			push(s, BT, true);
			if (BT->lchild != NULL)
				push(s, BT->lchild, false);
		}
	}
}

void leftPostOrder(Node *bt)
{
	Node *BT = bt;
	bool isNode = false;
	push(s, BT, isNode);
	while (!EmptyStack(s)){
		isNode = topIsNode(s);
		BT = pop(s);
		if (isNode){
			printf("%c", BT->data);
		}
		else{
			push(s, BT, true);
			if (BT->rchild != NULL)
				push(s, BT->rchild, false);
			if (BT->lchild != NULL)
				push(s, BT->lchild, false);
		}
	}
}複製程式碼
參考程式碼中前中後非遞迴遍歷的邏輯幾乎一致,差別在於分解樹之後,以怎樣的方式入棧。而且對於前序遍歷,筆者進行了簡化。因為前序遍歷中,樹以VLR的方式分解,入棧之後,棧頂總是結點,因此可以省去判斷棧頂是樹還是結點的邏輯。
使用棧的二叉樹遍歷到此已經探究完畢,更多內容請看下一章節。

資料結構與演算法-二叉查詢樹

相關文章