非遞歸遍歷二叉樹

1.先序遍歷

從遞歸說起

  1. void preOrder(TNode* root)
  2. {
  3.     if (root != NULL)
  4.     {
  5.         Visit(root);
  6.         preOrder(root->left);
  7.         preOrder(root->right);
  8.     }
  9. }

遞歸算法非常的簡單。先訪問跟節點,然後訪問左節點,再訪問右節點。如果不用遞歸,那該怎麼做呢?仔細看一下遞歸程序,就會發現,其實每次都是走樹的左分支(left),直到左子樹爲空,然後開始從遞歸的最深處返回,然後開始恢復遞歸現場,訪問右子樹。

其實過程很簡單:一直往左走 root->left->left->left...->null,由於是先序遍歷,因此一遇到節點,便需要立即訪問;由於一直走到最左邊後,需要逐步返回到父節點訪問右節點,因此必須有一個措施能夠對節點序列回溯。有兩個辦法:
1.用棧記憶:在訪問途中將依次遇到的節點保存下來。由於節點出現次序與恢復次序是反序的,因此是一個先進後出結構,需要用棧。
使用棧記憶的實現有兩個版本。第一個版本是模擬遞歸的實現效果,跟LX討論的,第二個版本是直接模擬遞歸。
2.節點增加指向父節點的指針:通過指向父節點的指針來回溯(後來發現還要需要增加一個訪問標誌,來指示節點是否已經被訪問,不知道可不可以不用標誌直接實現回溯?想了一下,如果不用這個標誌位,回溯的過程會繁瑣很多。暫時沒有更好的辦法。)

(還有其他辦法可以回溯麼?)
這3個算法僞代碼如下,沒有測試過。

先序遍歷僞代碼:非遞歸版本,用棧實現,版本1

  1. // 先序遍歷僞代碼:非遞歸版本,用棧實現,版本1
  2. void preOrder1(TNode* root)
  3. {
  4.     Stack S;
  5.     while ((root != NULL) || !S.empty())
  6.     {
  7.         if (root != NULL)
  8.         {
  9.             Visit(root);
  10.             S.push(root);       // 先序就體現在這裏了,先訪問,再入棧
  11.             root = root->left;  // 依次訪問左子樹
  12.         }
  13.         else
  14.         {
  15.             root = S.pop();     // 回溯至父親節點
  16.             root = root->right;
  17.         }
  18.     }
  19. }

preOrder1每次都將遇到的節點壓入棧,當左子樹遍歷完畢後才從棧中彈出最後一個訪問的節點,訪問其右子樹。在同一層中,不可能同時有兩個節點壓入棧,因此棧的大小空間爲O(h),h爲二叉樹高度。時間方面,每個節點都被壓入棧一次,彈出棧一次,訪問一次,複雜度爲O(n)

 

 先序遍歷僞代碼:非遞歸版本,用棧實現,版本2
  1. // 先序遍歷僞代碼:非遞歸版本,用棧實現,版本2
  2. void preOrder2(TNode* root)
  3. {
  4.     if ( root != NULL)
  5.     {
  6.         Stack S;
  7.         S.push(root);
  8.         while (!S.empty())
  9.         {
  10.             TNode* node = S.pop(); 
  11.             Visit(node);          // 先訪問根節點,然後根節點就無需入棧了
  12.             S.push(node->right);  // 先push的是右節點,再是左節點
  13.             S.push(node->left);
  14.         }
  15.     }
  16. }

preOrder2每次將節點壓入棧,然後彈出,壓右子樹,再壓入左子樹,在遍歷過程中,遍歷序列的右節點依次被存入棧,左節點逐次被訪問。同一時刻,棧中元素爲m-1個右節點和1個最左節點,最高爲h。所以空間也爲O(h);每個節點同樣被壓棧一次,彈棧一次,訪問一次,時間複雜度O(n)


先序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針

  1. // 先序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針
  2. void preOrder3(TNode* root)
  3. {
  4.     while ( root != NULL ) // 回溯到根節點時爲NULL,退出
  5.     {
  6.         if( !root->bVisited )
  7.         {   // 判定是否已被訪問
  8.             Visit(root);
  9.             root->bVisited = true;
  10.         }
  11.         if ( root->left != NULL && !root->left->bVisited )      // 訪問左子樹
  12.         {
  13.             root = root->left;
  14.         }
  15.         else if( root->right != NULL && !root->right->bVisited ) // 訪問右子樹
  16.         {
  17.             root = root->right;
  18.         }
  19.         else   // 回溯
  20.         {
  21.             root = root->parent;
  22.         }
  23.     }
  24. }

preOrder3的關鍵在於回溯。爲了回溯增加指向父親節點的指針,以及是否已經訪問的標誌位,對比preOrder1與preOrder2,但增加的空間複雜度爲O(n)。時間方面,每個節點被訪問一次。但是,當由葉子節點跳到下一個要訪問的節點時,需要先回溯至父親節點,再判斷是否存在沒有被訪問過的右子樹,如果沒有,則繼續回溯,直至找到一顆沒有被訪問過的右子樹,這個過程需要很多的時間。每個葉子節點的回溯需要O(h)時間複雜度,葉子節點最多爲(2^(h-1)),因此回溯花費的上限爲O(h*(2^(h-1))。這個上限應該可以縮小。preOrder3唯一的好處是不需要額外的數據結構-棧。

 

2.中序遍歷
根據上面的先序遍歷,可以類似的構造出中序遍歷的三種方式。仔細想一下,只有第一種方法改過來時最方便的。需要的改動僅僅調換一下節點訪問的次序,先序是先訪問,再入棧;而中序則是先入棧,彈棧後再訪問。僞代碼如下。時間複雜度與空間複雜度同先序一致。

  1. // 中序遍歷僞代碼:非遞歸版本,用棧實現,版本1
  2. void InOrder1(TNode* root)
  3. {
  4.     Stack S;
  5.     while ( root != NULL || !S.empty() )
  6.     {
  7.         while( root != NULL )   // 左子樹入棧
  8.         {
  9.             S.push(root);
  10.             root = root->left;
  11.         }
  12.         if ( !S.empty() )
  13.         {
  14.             root = S.pop();
  15.             Visit(root->data);   // 訪問根結點
  16.             root = root->right;  // 通過下一次循環實現右子樹遍歷
  17.         }
  18.     }
  19. }

第二個用棧的版本卻並不樂觀。preOrder2能夠很好的執行的原因是,將左右節點壓入棧後,根節點就再也用不着了;而中序和後序卻不一樣,左右節點入棧後,根節點後面還需要訪問。因此三個節點都要入棧,而且入棧的先後順序必須爲:右節點,根節點,左節點。但是,當入棧以後,根節點與其左右子樹的節點就分不清楚了。因此必須引入一個標誌位,表示 是否已經將該節點的左右子樹入棧了。每次入棧時,根節點標誌位爲true,左右子樹標誌位爲false。
僞代碼如下:

  1. // 中序遍歷僞代碼:非遞歸版本,用棧實現,版本2
  2. void InOrder2(TNode* root)
  3. {
  4.     Stack S;
  5.     if( root != NULL )
  6.     {
  7.         S.push(root);
  8.     }
  9.     while ( !S.empty() )
  10.     {
  11.         TNode* node = S.pop(); 
  12.         if ( node->bPushed )
  13.         {   // 如果標識位爲true,則表示其左右子樹都已經入棧,那麼現在就需要訪問該節點了
  14.             Visit(node);        
  15.         }
  16.         else
  17.         {   // 左右子樹尚未入棧,則依次將 右節點,根節點,左節點 入棧
  18.             if ( node->right != NULL )
  19.             {
  20.                 node->right->bPushed = false// 左右子樹均設置爲false
  21.                 S.push(node->right);
  22.             }
  23.             node->bPushed = true;  // 根節點標誌位爲true
  24.             S.push(node);
  25.             if ( node->left != NULL )
  26.             {
  27.                 node->left->bPushed = false;
  28.                 S.push(node->left);
  29.             }
  30.         }
  31.     }
  32. }

對比先序遍歷,這個算法需要額外的增加O(n)的標誌位空間。另外,棧空間也擴大,因爲每次壓棧的時候都壓入根節點與左右節點,因此棧空間爲O(n)。時間複雜度方面,每個節點壓棧兩次,作爲子節點壓棧一次,作爲根節點壓棧一次,彈棧也是兩次。因此無論從哪個方面講,這個方法效率都不及InOrder1。

 

至於不用棧來實現中序遍歷。頭暈了,暫時不想了。後面再來完善。還有後序遍歷,貌似更復雜。對了,還有個層序遍歷。再寫一篇吧。頭都大了。

 

9.8續

中序遍歷的第三個非遞歸版本:採用指向父節點的指針回溯。這個與先序遍歷是非常類似的,不同之處在於,先序遍歷只要一遇到節點,那麼沒有被訪問那麼立即訪問,訪問完畢後嘗試向左走,如果左孩子補課訪問,則嘗試右邊走,如果左右皆不可訪問,則回溯;中序遍歷是先嚐試向左走,一直到左邊不通後訪問當前節點,然後嘗試向右走,右邊不通,則回溯。(這裏不通的意思是:節點不爲空,且沒有被訪問過)

  1. // 中序遍歷僞代碼:非遞歸版本,不用棧,增加指向父節點的指針
  2. void InOrder3(TNode* root)
  3. {
  4.     while ( root != NULL ) // 回溯到根節點時爲NULL,退出
  5.     {
  6.         while ( root->left != NULL && !root->left->bVisited )
  7.         {                  // 沿左子樹向下搜索當前子樹尚未訪問的最左節點           
  8.             root = root->left;
  9.         }
  10.         if ( !root->bVisited )
  11.         {                  // 訪問尚未訪問的最左節點
  12.             Visit(root);
  13.             root->bVisited=true;
  14.         }
  15.         if ( root->right != NULL && !root->right->bVisited )
  16.         {                  // 遍歷當前節點的右子樹  
  17.             root = root->right;
  18.         }
  19.         else
  20.         {                 // 回溯至父節點
  21.             root = root->parent;
  22.         }
  23.     }
  24. }

這個算法時間複雜度與空間複雜度與第3個先序遍歷的版本是一樣的。

 

3.後序遍歷

從直覺上來說,後序遍歷對比中序遍歷難度要增大很多。因爲中序遍歷節點序列有一點的連續性,而後續遍歷則感覺有一定的跳躍性。先左,再右,最後才中間節點;訪問左子樹後,需要跳轉到右子樹,右子樹訪問完畢了再回溯至根節點並訪問之。這種序列的不連續造成實現前面先序與中序類似的第1個與第3個版本比較困難。但是按照第2個思想,直接來模擬遞歸還是非常容易的。如下:

  1. // 後序遍歷僞代碼:非遞歸版本,用棧實現
  2. void PostOrder(TNode* root)
  3. {
  4.     Stack S;
  5.     if( root != NULL )
  6.     {
  7.         S.push(root);
  8.     }
  9.     while ( !S.empty() )
  10.     {
  11.         TNode* node = S.pop(); 
  12.         if ( node->bPushed )
  13.         {   // 如果標識位爲true,則表示其左右子樹都已經入棧,那麼現在就需要訪問該節點了
  14.             Visit(node);        
  15.         }
  16.         else
  17.         {   // 左右子樹尚未入棧,則依次將 右節點,左節點,根節點 入棧
  18.             if ( node->right != NULL )
  19.             {
  20.                 node->right->bPushed = false// 左右子樹均設置爲false
  21.                 S.push(node->right);
  22.             }
  23.             if ( node->left != NULL )
  24.             {
  25.                 node->left->bPushed = false;
  26.                 S.push(node->left);
  27.             }
  28.             node->bPushed = true;            // 根節點標誌位爲true
  29.             S.push(node);
  30.         }
  31.     }
  32. }

和中序遍歷的第2個版本比較,僅僅只是把左孩子入棧和根節點入棧順序調換一下;這種差別就跟遞歸版本的中序與後序一樣。

 

4.層序遍歷

這個很簡單,就不說老。

  1. // 層序遍歷僞代碼:非遞歸版本,用隊列完成
  2. void LevelOrder(TNode *root)
  3. {
  4.     Queue Q;
  5.     Q.push(root);
  6.     while (!Q.empty())
  7.     {
  8.         node = Q.front();        // 取出隊首值並訪問
  9.         Visit(node);
  10.         if (NULL != node->left)  // 左孩子入隊
  11.         {          
  12.             Q.push(node->left);    
  13.         }
  14.         if (NULL != node->right) // 右孩子入隊
  15.         {
  16.             Q.push(node->right);
  17.         }
  18.     }
  19. }

小結一下:

用棧來實現比增加指向父節點指針回溯更方便;

採用第一個思想,就是跟蹤指針移動 用棧保存中間結果的實現方式,先序與中序難度一致,後序很困難。先序與中序只需要修改一下訪問的位置即可。

採用第二個思想,直接用棧來模擬遞歸,先序非常簡單;而中序與後序難度一致。先序簡單是因爲節點可以直接訪問,訪問完畢後無需記錄。而中序與後序時,節點在彈棧後還不能立即訪問,還需要等其他節點訪問完畢後才能訪問,因此節點需要設置標誌位來判定,因此需要額外的O(n)空間。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章