在上一篇中,我們了解了樹的基本概念以及二叉樹的基本特點和代碼實現,還用遞歸的方式對二叉樹的三種遍歷算法進行了代碼實現。但是,由於遞歸需要系統堆棧,所以空間消耗要比非遞歸代碼要大很多。而且,如果遞歸深度太大,可能系統撐不住。因此,我們使用非遞歸(這里主要是循環,循環方法比遞歸方法快, 因為循環避免了一系列函數調用和返回中所涉及到的參數傳遞和返回值的額外開銷)來重新實現一遍各種遍歷算法,再對二叉樹的另外一種特殊的遍歷—層次遍歷進行實現,最后再了解一下特殊的二叉樹—二叉查找樹。
一、遞歸與循環的區別及比較
1.1 遞歸為何很慢?
大家都知道遞歸的實現是通過調用函數本身,函數調用的時候,每次調用時要做地址保存,參數傳遞等,這是通過一個遞歸工作棧實現的。具體是每次調用函數本身要保存的內容包括:局部變量、形參、調用函數地址、返回值。那么,如果遞歸調用 N 次,就要分配 N*局部變量、N*形參、N*調用函數地址、N*返回值,這勢必是影響效率的。
關於系統棧和用戶棧:
①系統棧(也叫核心棧、內核棧)是內存中屬於操作系統空間的一塊區域,其主要用途為: (1)保存中斷現場,對於嵌套中斷,被中斷程序的現場信息依次壓入系統棧,中斷返回時逆序彈出; (2)保存操作系統子程序間相互調用的參數、返回值、返回點以及子程序(函數)的局部變量。
②用戶棧是用戶進程空間中的一塊區域,用於保存用戶進程的子程序間相互調用的參數、返回值、返回點以及子程序(函數)的局部變量。
我們編寫的遞歸程序屬於用戶程序,因此使用的是用戶棧。
1.2 循環會快些嗎?
遞歸與循環是兩種不同的解決問題的典型思路。當然也並不是說循環效率就一定比遞歸高,遞歸和循環是兩碼事,遞歸帶有棧操作,循環則不一定,兩個概念不是一個層次,不同場景做不同的嘗試。
(1)遞歸算法:
①優點:代碼簡潔、清晰,並且容易驗證正確性。
②缺點:它的運行需要較多次數的函數調用,如果調用層數比較深,需要增加額外的堆棧處理(還有可能出現堆棧溢出的情況),比如參數傳遞需要壓棧等操作,會對執行效率有一定影響。但是,對於某些問題,如果不使用遞歸,那將是極端難看的代碼。
(2)循環算法:
①優點:速度快,結構簡單。
②缺點:並不能解決所有的問題。有的問題適合使用遞歸而不是循環。但是如果使用循環並不困難的話,最好使用循環。
(3)遞歸與循環的對比總結:
①一般遞歸調用可以處理的算法,也通過循環去解決常需要額外的低效處理。
②現在的編譯器在經過優化后,對於多次調用的函數處理會有非常好的效率優化,效率未必低於循環。
③遞歸和循環兩者完全可以互換。如果用到遞歸的地方可以很方便使用循環替換,而不影響程序的閱讀,那么替換成遞歸往往是好的。(例如:求階乘的遞歸實現與循環實現。)
二、二叉樹的非遞歸遍歷實現
2.1 前序遍歷的非遞歸實現

// Method01:前序遍歷 public void PreOrderNoRecurise(Node<T> node) { if (node == null) { return; } // 根->左->右 Stack<Node<T>> stack = new Stack<Node<T>>(); stack.Push(node); Node<T> tempNode = null; while (stack.Count > 0) { // 1.遍歷根節點 tempNode = stack.Pop(); Console.Write(tempNode.data); // 2.右子樹壓棧 if (tempNode.rchild != null) { stack.Push(tempNode.rchild); } // 3.左子樹壓棧(目的:保證下一個出棧的是左子樹的節點) if (tempNode.lchild != null) { stack.Push(tempNode.lchild); } } }
在該方法中,利用了棧的先進后出的特性,首先遍歷顯示根節點,然后將右子樹(注意是右子樹不是左子樹)壓棧,最后將左子樹壓棧。由於最后時將左子樹節點壓棧,所以下一次首先出棧的應該是左子樹的根節點,也就保證了先序遍歷的規則。
2.2 中序遍歷的非遞歸實現

public void MidOrderNoRecurise(Node<T> node) { if (node == null) { return; } // 左->根->右 Stack<Node<T>> stack = new Stack<Node<T>>(); Node<T> tempNode = node; while (tempNode != null || stack.Count > 0) { // 1.依次將所有左子樹節點壓棧 while(tempNode != null) { stack.Push(tempNode); tempNode = tempNode.lchild; } // 2.出棧遍歷節點 tempNode = stack.Pop(); Console.Write(tempNode.data); // 3.左子樹遍歷結束則跳轉到右子樹 tempNode = tempNode.rchild; } }
在該方法中,首先將根節點所有的左子樹節點壓棧,然后一一出棧,每當出棧一個元素后,便將其右子樹節點壓棧。這樣就可以實現首先出棧的永遠是棧中的左子樹節點,然后是根節點,最后時右子樹節點,也就可以保證中序遍歷的規則。
2.3 后序遍歷的非遞歸實現

public void PostOrderNoRecurise(Node<T> node) { if (root == null) { return; } // 兩個棧:一個存儲,一個輸出 Stack<Node<T>> stackIn = new Stack<Node<T>>(); Stack<Node<T>> stackOut = new Stack<Node<T>>(); Node<T> currentNode = null; // 根節點首先壓棧 stackIn.Push(node); // 左->右->根 while (stackIn.Count > 0) { currentNode = stackIn.Pop(); stackOut.Push(currentNode); // 左子樹壓棧 if (currentNode.lchild != null) { stackIn.Push(currentNode.lchild); } // 右子樹壓棧 if (currentNode.rchild != null) { stackIn.Push(currentNode.rchild); } } while (stackOut.Count > 0) { // 依次遍歷各節點 Node<T> outNode = stackOut.Pop(); Console.Write(outNode.data); } }
在該方法中,使用了兩個棧來輔助,其中一個stackIn作為中間存儲起到過渡作用,而另一個stackOut則作為最后的輸出結果進行遍歷顯示。眾所周知,棧的特性使LIFO(后進先出),那么stackIn在進行存儲過渡時,先按照根節點->左孩子->右孩子的順序依次壓棧,那么其出棧順序就是右孩子->左孩子->根節點。而每當循環一次就會從stackIn中出棧一個元素,並壓入stackOut中,那么這時stackOut中的出棧順序則變成了左孩子->右孩子->根節點的順序,也就符合了后序遍歷的規則。
2.4 層次遍歷的實現

public void LevelOrder(Node<T> node) { if (root == null) { return; } Queue<Node<T>> queueNodes = new Queue<Node<T>>(); queueNodes.Enqueue(node); Node<T> tempNode = null; // 利用隊列先進先出的特性存儲節點並輸出 while (queueNodes.Count > 0) { tempNode = queueNodes.Dequeue(); Console.Write(tempNode.data); if (tempNode.lchild != null) { queueNodes.Enqueue(tempNode.lchild); } if (tempNode.rchild != null) { queueNodes.Enqueue(tempNode.rchild); } } }
在該方法中,使用了一個隊列來輔助實現,隊列是遵循FIFO(先進先出)的,與棧剛好相反,所以,我們這里只需要按照根節點->左孩子->右孩子的入隊順序依次入隊,輸出時就可以符合根節點->左孩子->右孩子的規則了。
2.5 各種非遞歸遍歷的測試
上面我們實現了非遞歸方式的遍歷算法,這里我們對其進行一個簡單的測試。跟上一篇相同首先創建一棵如下圖所示的二叉樹,然后調用非遞歸版的先序、中序、后序以及層次遍歷方法查看遍歷結果。
(1)測試代碼:

static void MyBinaryTreeBasicTest() { // 構造一顆二叉樹,根節點為"A" MyBinaryTree<string> bTree = new MyBinaryTree<string>("A"); Node<string> rootNode = bTree.Root; // 向根節點"A"插入左孩子節點"B"和右孩子節點"C" bTree.InsertLeft(rootNode, "B"); bTree.InsertRight(rootNode, "C"); // 向節點"B"插入左孩子節點"D"和右孩子節點"E" Node<string> nodeB = rootNode.lchild; bTree.InsertLeft(nodeB, "D"); bTree.InsertRight(nodeB, "E"); // 向節點"C"插入右孩子節點"F" Node<string> nodeC = rootNode.rchild; bTree.InsertRight(nodeC, "F"); // 計算二叉樹目前的深度 Console.WriteLine("The depth of the tree : {0}", bTree.GetDepth(bTree.Root)); // 前序遍歷 Console.WriteLine("---------PreOrder---------"); bTree.PreOrder(bTree.Root); // 中序遍歷 Console.WriteLine(); Console.WriteLine("---------MidOrder---------"); bTree.MidOrder(bTree.Root); // 后序遍歷 Console.WriteLine(); Console.WriteLine("---------PostOrder---------"); bTree.PostOrder(bTree.Root); Console.WriteLine(); // 前序遍歷(非遞歸) Console.WriteLine("---------PreOrderNoRecurise---------"); bTree.PreOrderNoRecurise(bTree.Root); // 中序遍歷(非遞歸) Console.WriteLine(); Console.WriteLine("---------MidOrderNoRecurise---------"); bTree.MidOrderNoRecurise(bTree.Root); // 后序遍歷(非遞歸) Console.WriteLine(); Console.WriteLine("---------PostOrderNoRecurise---------"); bTree.PostOrderNoRecurise(bTree.Root); Console.WriteLine(); // 層次遍歷 Console.WriteLine("---------LevelOrderNoRecurise---------"); bTree.LevelOrder(bTree.Root); }
(2)運行結果:
三、二叉查找樹又是什么鬼?
二叉查找樹(Binary Search Tree)又稱二叉排序樹(Binary Sort Tree),亦稱二叉搜索樹。它具有以下幾個性質:
(1)若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值;
(2)若右子樹不空,則右子樹上所有結點的值均大於或等於它的根結點的值;
(3)左、右子樹也分別為二叉排序樹;
(4)沒有鍵值相等的節點。
對於二叉查找樹,我們只需要進行一次中序遍歷便可以得到一個排序后的遍歷結果。
四、二叉查找樹的實現
4.1 新節點的插入
二叉查找樹的插入過程大致為以下幾個步驟:
Step1.若當前的二叉查找樹為空,則插入的元素為根節點;
--> Step2.若插入的元素值小於根節點值,則將元素插入到左子樹中;
--> Step3.若插入的元素值不小於根節點值,則將元素插入到右子樹中。

public void InsertNode(int data) { Node newNode = new Node(); newNode.data = data; if (this.root == null) { this.root = newNode; } else { Node currentNode = this.root; Node parentNode = null; while(currentNode != null) { parentNode = currentNode; if(currentNode.data < data) { currentNode = currentNode.rchild; } else { currentNode = currentNode.lchild; } } if(parentNode.data < data) { // 若插入的元素值小於根節點值,則將元素插入到左子樹中 parentNode.rchild = newNode; } else { // 若插入的元素值不小於根節點值,則將元素插入到右子樹中 parentNode.lchild = newNode; } } }
對如上圖所示的二叉查找樹進行構造:
MyBinarySearchTree bst = new MyBinarySearchTree(8); bst.InsertNode(3); bst.InsertNode(10); bst.InsertNode(1); bst.InsertNode(6); bst.InsertNode(14); bst.InsertNode(4); bst.InsertNode(7); bst.InsertNode(13); Console.WriteLine("----------LevelOrder----------"); bst.LevelOrder(bst.Root);
層次遍歷的顯示結果如下圖所示:
4.2 老節點的移除
二叉查找樹的刪除過程相比插入過程要復雜一些,這里主要分三種情況進行處理:
Scene1.節點p為葉子節點:直接刪除該節點,再修改其父節點的指針(注意分是根節點和不是根節點),如圖(a);
Scene2.節點p為單支節點(即只有左子樹或右子樹):讓p的子樹與p的父親節點相連,再刪除p即可;(注意分是根節點和不是根節點兩種情況),如圖b;
Scene3.節點p的左子樹和右子樹均不為空:首先找到p的后繼y,因為y一定沒有左子樹,所以可以刪除y,並讓y的父親節點成為y的右子樹的父親節點,並用y的值代替p的值;或者可以先找到p的前驅x,x一定沒有右子樹,所以可以刪除x,並讓x的父親節點成為y的左子樹的父親節點。如圖c。
通過代碼實現如下:

public void RemoveNode(int key) { Node current = null, parent = null; // 定位節點位置 current = FindNode(key); // 沒找到data為key的節點 if (current == null) { Console.WriteLine("沒有找到data為{0}的節點!", key); return; } #region 1.如果該節點是葉子節點 if (current.lchild == null && current.rchild == null) // 如果該節點是葉子節點 { if (current == this.root) // 如果該節點為根節點 { this.root = null; } else if (parent.lchild == current) // 如果該節點為左孩子節點 { parent.lchild = null; } else if (parent.rchild == current) // 如果該節點為右孩子節點 { parent.rchild = null; } } #endregion #region 2.如果該節點是單支節點 else if (current.lchild == null || current.rchild == null) // 如果該節點是單支節點 (只有一個左孩子節點或者一個右孩子節點) { if (current == this.root) // 如果該節點為根節點 { if (current.lchild == null) { this.root = current.rchild; } else { this.root = current.lchild; } } else { if (parent.lchild == current && current.lchild != null) // p是q的左孩子且p有左孩子 { parent.lchild = current.lchild; } else if (parent.lchild == current && current.rchild != null) // p是q的左孩子且p有右孩子 { parent.rchild = current.rchild; } else if (parent.rchild == current && current.lchild != null) // p是q的右孩子且p有左孩子 { parent.rchild = current.lchild; } else // p是q的右孩子且p有右孩子 { parent.rchild = current.rchild; } } } #endregion #region 3.如果該節點的左右子樹均不為空 else // 如果該節點的左右子樹均不為空 { Node t = current; Node s = current.lchild; // 從p的左子節點開始 // 找到p的前驅,即p左子樹中值最大的節點 while(s.rchild != null) { t = s; s = s.rchild; } current.data = s.data; // 把節點s的值賦給p if (t == current) { current.lchild = s.lchild; } else { current.rchild = s.rchild; } } #endregion } // 根據Key查找某個節點 public Node FindNode(int key) { Node currentNode = this.root; while (currentNode != null && currentNode.data != key) { if (currentNode.data < key) { currentNode = currentNode.rchild; } else if (currentNode.data > key) { currentNode = currentNode.lchild; } else { break; } } return currentNode; }
在上面的示例中移除既有左孩子又有右孩子的節點6后的層次遍歷結果如下圖所示:
附件下載
本文所實現的C#版二叉樹的代碼:http://pan.baidu.com/s/1gdjKwKF
參考資料
(1)程傑,《大話數據結構》
(2)陳廣,《數據結構(C#語言描述)》
(3)段恩澤,《數據結構(C#語言版)》
(4)VincentCZW,《遞歸的效率問題以及與循環的比較》
(5)HelloWord,《循環與遞歸的區別》
(6)愛也玲瓏,《二叉查找樹—插入、刪除與查找》