1.先序遍歷
從遞歸說起
- void preOrder(TNode* root)
- {
- if (root != NULL)
- {
- Visit(root);
- preOrder(root->left);
- preOrder(root->right);
- }
- }
遞歸算法非常的簡單。先訪問跟節點,然後訪問左節點,再訪問右節點。如果不用遞歸,那該怎麼做呢?仔細看一下遞歸程序,就會發現,其實每次都是走樹的左分支(left),直到左子樹爲空,然後開始從遞歸的最深處返回,然後開始恢復遞歸現場,訪問右子樹。
其實過程很簡單:一直往左走 root->left->left->left...->null,由於是先序遍歷,因此一遇到節點,便需要立即訪問;由於一直走到最左邊後,需要逐步返回到父節點訪問右節點,因此必須有一個措施能夠對節點序列回溯。有兩個辦法:
1.用棧記憶:在訪問途中將依次遇到的節點保存下來。由於節點出現次序與恢復次序是反序的,因此是一個先進後出結構,需要用棧。
使用棧記憶的實現有兩個版本。第一個版本是模擬遞歸的實現效果,跟LX討論的,第二個版本是直接模擬遞歸。
2.節點增加指向父節點的指針:通過指向父節點的指針來回溯(後來發現還要需要增加一個訪問標誌,來指示節點是否已經被訪問,不知道可不可以不用標誌直接實現回溯?想了一下,如果不用這個標誌位,回溯的過程會繁瑣很多。暫時沒有更好的辦法。)
(還有其他辦法可以回溯麼?)
這3個算法僞代碼如下,沒有測試過。
先序遍歷僞代碼:非遞歸版本,用棧實現,版本1
- // 先序遍歷僞代碼:非遞歸版本,用棧實現,版本1
- void preOrder1(TNode* root)
- {
- Stack S;
- while ((root != NULL) || !S.empty())
- {
- if (root != NULL)
- {
- Visit(root);
- S.push(root); // 先序就體現在這裏了,先訪問,再入棧
- root = root->left; // 依次訪問左子樹
- }
- else
- {
- root = S.pop(); // 回溯至父親節點
- root = root->right;
- }
- }
- }
preOrder1每次都將遇到的節點壓入棧,當左子樹遍歷完畢後才從棧中彈出最後一個訪問的節點,訪問其右子樹。在同一層中,不可能同時有兩個節點壓入棧,因此棧的大小空間爲O(h),h爲二叉樹高度。時間方面,每個節點都被壓入棧一次,彈出棧一次,訪問一次,複雜度爲O(n)
先序遍歷僞代碼:非遞歸版本,用棧實現,版本2
- // 先序遍歷僞代碼:非遞歸版本,用棧實現,版本2
- void preOrder2(TNode* root)
- {
- if ( root != NULL)
- {
- Stack S;
- S.push(root);
- while (!S.empty())
- {
- TNode* node = S.pop();
- Visit(node); // 先訪問根節點,然後根節點就無需入棧了
- S.push(node->right); // 先push的是右節點,再是左節點
- S.push(node->left);
- }
- }
- }
preOrder2每次將節點壓入棧,然後彈出,壓右子樹,再壓入左子樹,在遍歷過程中,遍歷序列的右節點依次被存入棧,左節點逐次被訪問。同一時刻,棧中元素爲m-1個右節點和1個最左節點,最高爲h。所以空間也爲O(h);每個節點同樣被壓棧一次,彈棧一次,訪問一次,時間複雜度O(n)
先序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針
- // 先序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針
- void preOrder3(TNode* root)
- {
- while ( root != NULL ) // 回溯到根節點時爲NULL,退出
- {
- if( !root->bVisited )
- { // 判定是否已被訪問
- Visit(root);
- root->bVisited = true;
- }
- if ( root->left != NULL && !root->left->bVisited ) // 訪問左子樹
- {
- root = root->left;
- }
- else if( root->right != NULL && !root->right->bVisited ) // 訪問右子樹
- {
- root = root->right;
- }
- else // 回溯
- {
- root = root->parent;
- }
- }
- }
preOrder3的關鍵在於回溯。爲了回溯增加指向父親節點的指針,以及是否已經訪問的標誌位,對比preOrder1與preOrder2,但增加的空間複雜度爲O(n)。時間方面,每個節點被訪問一次。但是,當由葉子節點跳到下一個要訪問的節點時,需要先回溯至父親節點,再判斷是否存在沒有被訪問過的右子樹,如果沒有,則繼續回溯,直至找到一顆沒有被訪問過的右子樹,這個過程需要很多的時間。每個葉子節點的回溯需要O(h)時間複雜度,葉子節點最多爲(2^(h-1)),因此回溯花費的上限爲O(h*(2^(h-1))。這個上限應該可以縮小。preOrder3唯一的好處是不需要額外的數據結構-棧。
2.中序遍歷
根據上面的先序遍歷,可以類似的構造出中序遍歷的三種方式。仔細想一下,只有第一種方法改過來時最方便的。需要的改動僅僅調換一下節點訪問的次序,先序是先訪問,再入棧;而中序則是先入棧,彈棧後再訪問。僞代碼如下。時間複雜度與空間複雜度同先序一致。
- // 中序遍歷僞代碼:非遞歸版本,用棧實現,版本1
- void InOrder1(TNode* root)
- {
- Stack S;
- while ( root != NULL || !S.empty() )
- {
- while( root != NULL ) // 左子樹入棧
- {
- S.push(root);
- root = root->left;
- }
- if ( !S.empty() )
- {
- root = S.pop();
- Visit(root->data); // 訪問根結點
- root = root->right; // 通過下一次循環實現右子樹遍歷
- }
- }
- }
第二個用棧的版本卻並不樂觀。preOrder2能夠很好的執行的原因是,將左右節點壓入棧後,根節點就再也用不着了;而中序和後序卻不一樣,左右節點入棧後,根節點後面還需要訪問。因此三個節點都要入棧,而且入棧的先後順序必須爲:右節點,根節點,左節點。但是,當入棧以後,根節點與其左右子樹的節點就分不清楚了。因此必須引入一個標誌位,表示 是否已經將該節點的左右子樹入棧了。每次入棧時,根節點標誌位爲true,左右子樹標誌位爲false。
僞代碼如下:
- // 中序遍歷僞代碼:非遞歸版本,用棧實現,版本2
- void InOrder2(TNode* root)
- {
- Stack S;
- if( root != NULL )
- {
- S.push(root);
- }
- while ( !S.empty() )
- {
- TNode* node = S.pop();
- if ( node->bPushed )
- { // 如果標識位爲true,則表示其左右子樹都已經入棧,那麼現在就需要訪問該節點了
- Visit(node);
- }
- else
- { // 左右子樹尚未入棧,則依次將 右節點,根節點,左節點 入棧
- if ( node->right != NULL )
- {
- node->right->bPushed = false; // 左右子樹均設置爲false
- S.push(node->right);
- }
- node->bPushed = true; // 根節點標誌位爲true
- S.push(node);
- if ( node->left != NULL )
- {
- node->left->bPushed = false;
- S.push(node->left);
- }
- }
- }
- }
對比先序遍歷,這個算法需要額外的增加O(n)的標誌位空間。另外,棧空間也擴大,因爲每次壓棧的時候都壓入根節點與左右節點,因此棧空間爲O(n)。時間複雜度方面,每個節點壓棧兩次,作爲子節點壓棧一次,作爲根節點壓棧一次,彈棧也是兩次。因此無論從哪個方面講,這個方法效率都不及InOrder1。
至於不用棧來實現中序遍歷。頭暈了,暫時不想了。後面再來完善。還有後序遍歷,貌似更復雜。對了,還有個層序遍歷。再寫一篇吧。頭都大了。
9.8續
中序遍歷的第三個非遞歸版本:採用指向父節點的指針回溯。這個與先序遍歷是非常類似的,不同之處在於,先序遍歷只要一遇到節點,那麼沒有被訪問那麼立即訪問,訪問完畢後嘗試向左走,如果左孩子補課訪問,則嘗試右邊走,如果左右皆不可訪問,則回溯;中序遍歷是先嚐試向左走,一直到左邊不通後訪問當前節點,然後嘗試向右走,右邊不通,則回溯。(這裏不通的意思是:節點不爲空,且沒有被訪問過)
- // 中序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針
- void InOrder3(TNode* root)
- {
- while ( root != NULL ) // 回溯到根節點時爲NULL,退出
- {
- while ( root->left != NULL && !root->left->bVisited )
- { // 沿左子樹向下搜索當前子樹尚未訪問的最左節點
- root = root->left;
- }
- if ( !root->bVisited )
- { // 訪問尚未訪問的最左節點
- Visit(root);
- root->bVisited=true;
- }
- if ( root->right != NULL && !root->right->bVisited )
- { // 遍歷當前節點的右子樹
- root = root->right;
- }
- else
- { // 回溯至父節點
- root = root->parent;
- }
- }
- }
這個算法時間複雜度與空間複雜度與第3個先序遍歷的版本是一樣的。
3.後序遍歷
從直覺上來說,後序遍歷對比中序遍歷難度要增大很多。因爲中序遍歷節點序列有一點的連續性,而後續遍歷則感覺有一定的跳躍性。先左,再右,最後才中間節點;訪問左子樹後,需要跳轉到右子樹,右子樹訪問完畢了再回溯至根節點並訪問之。這種序列的不連續造成實現前面先序與中序類似的第1個與第3個版本比較困難。但是按照第2個思想,直接來模擬遞歸還是非常容易的。如下:
- // 後序遍歷僞代碼:非遞歸版本,用棧實現
- void PostOrder(TNode* root)
- {
- Stack S;
- if( root != NULL )
- {
- S.push(root);
- }
- while ( !S.empty() )
- {
- TNode* node = S.pop();
- if ( node->bPushed )
- { // 如果標識位爲true,則表示其左右子樹都已經入棧,那麼現在就需要訪問該節點了
- Visit(node);
- }
- else
- { // 左右子樹尚未入棧,則依次將 右節點,左節點,根節點 入棧
- if ( node->right != NULL )
- {
- node->right->bPushed = false; // 左右子樹均設置爲false
- S.push(node->right);
- }
- if ( node->left != NULL )
- {
- node->left->bPushed = false;
- S.push(node->left);
- }
- node->bPushed = true; // 根節點標誌位爲true
- S.push(node);
- }
- }
- }
和中序遍歷的第2個版本比較,僅僅只是把左孩子入棧和根節點入棧順序調換一下;這種差別就跟遞歸版本的中序與後序一樣。
4.層序遍歷
這個很簡單,就不說老。
- // 層序遍歷僞代碼:非遞歸版本,用隊列完成
- void LevelOrder(TNode *root)
- {
- Queue Q;
- Q.push(root);
- while (!Q.empty())
- {
- node = Q.front(); // 取出隊首值並訪問
- Visit(node);
- if (NULL != node->left) // 左孩子入隊
- {
- Q.push(node->left);
- }
- if (NULL != node->right) // 右孩子入隊
- {
- Q.push(node->right);
- }
- }
- }
小結一下:
用棧來實現比增加指向父節點指針回溯更方便;
採用第一個思想,就是跟蹤指針移動 用棧保存中間結果的實現方式,先序與中序難度一致,後序很困難。先序與中序只需要修改一下訪問的位置即可。
採用第二個思想,直接用棧來模擬遞歸,先序非常簡單;而中序與後序難度一致。先序簡單是因爲節點可以直接訪問,訪問完畢後無需記錄。而中序與後序時,節點在彈棧後還不能立即訪問,還需要等其他節點訪問完畢後才能訪問,因此節點需要設置標誌位來判定,因此需要額外的O(n)空間。