面試常備題---二叉樹總結篇


      人生就像是一場長跑,有很多機會,但也得看我們是否能夠及時抓牢,就像下面這樣的代碼:

while(isRunning)
{
     if(...){...}
     else if(...){...}
     ...
     else{..}
}

      存在着太多的if...else if...else...,很多都是一閃而過,就看我們是否將isRunning時刻設置為true,一直不斷在跑,一直不斷在檢查條件是否滿足。就算條件達到了,有些人會選擇return或者將isRunning設置為false,主動退出循環,有些人選擇繼續跑下去,不斷追尋更高的目標。
      所以,如果我們一時看不到未來,請不斷跑下去,遲早會有某個條件滿足的,只要設置的條件是合理可達的。

      在實際編程中,樹是經常遇到的數據結構,但可惜的是,我們經常不知道該用樹了。實際情況就是,我們在避免過早使用數據結構,以防止引入不必要的復雜性。

      樹的邏輯非常簡單:除了根結點外,其他每個結點都只有一個父結點,除了葉結點外,其他所有結點都有一個或多個子結點。父結點和子結點間用指針鏈接。樹有很多種形式,最常見的是二叉樹,每個結點最多只有兩個子結點。

      二叉樹中最重要的操作就是遍歷,通常有中序遍歷,前序遍歷和后序遍歷,簡單一點講,這三種遍歷的區別就是根結點的遍歷順序問題,像是中序遍歷就是左,根,右,而前序遍歷是根,左,右,后序遍歷則是左,右,根。復雜一點的遍歷就是寬度優先遍歷:先訪問樹的第一層結點,再訪問樹的第二層結點...一直到最下面一層結點。在同一層結點中,從左到右的順序依次訪問。

     常見的二叉樹結點的定義如下:

struct BinaryTreeNode
{
    int m_nValue;
    BinaryTreeNode* m_pLeft;
    BinaryTreeNode* m_pRight;
};

      二叉樹中還有許多形式,像是二叉搜索樹,左子結點總是小於或等於根結點,而右子結點總是大於或等於根結點。另外兩種常見的形式就是堆和紅黑樹。堆分為最大堆和最小堆,在最大堆中,根結點的值最大,最小堆則相反。堆非常適合用於快速查找最值,像是堆排序,就是利用了這點。紅黑樹是把樹中的結點定義為紅和黑兩種顏色,並通過規則確保從根結點到葉結點的最長路徑的長度不超過最短路徑的兩倍。很多C++的STL都是基於紅黑樹實現的,像是set,multiset,map,multimap等數據結構。

題目一:輸入某二叉樹的前序遍歷和中序遍歷的結果,重建該二叉樹。

     在拿到這道題目的時候,我們首先明確一點,就是如何根據前序遍歷和中序遍歷來求出根結點。根結點對於二叉樹來說,至關重要,只有先確定根結點,我們才能確定其他結點。
     我們還是從一個測試用例開始。
     假設某個二叉樹的前序遍歷結果為{1, 2, 4, 7, 3, 5, 6, 8}, 中序遍歷的結果為{4, 7, 2, 1, 5, 3, 8, 6}。因為二叉樹的前序遍歷是根據:根,左,右的順序來,所以前序遍歷開頭的元素就是根結點,也就是說,1就是二叉樹的根結點。然后再看看中序遍歷:左, 根,右,前面已經確定了1就是根結點,那么1的左邊序列就是二叉樹左邊的元素,也就是說,{4, 7, 2}就是二叉樹左邊的元素,而{5, 3, 8, 6}就是二叉樹右邊的元素。
     二叉樹的特點就是任何非葉結點的結點都可以成為根結點,所以我們可以從上面得到的兩個序列中,按照之前的分析推敲出整個樹的結構,也就是采用遞歸的方法。
BinaryTreeNode* Construct(int* preorder, int* inorder, int length)
{
     if(preorder == NULL || inorder == NULL || length <= 0)
     {
           return NULL;
     }

     return ConstructCore(preorder, preorder + length - 1, inorder, inorder + length - 1);
}

BinaryTreeNode* ConstructCore(int* startPreorder, int* endPreorder, int* startInorder, int* endInorder)
{
     int rootValue = startPreorder[0];
     BinaryTreeNode* root = new BinaryTreeNode();
     root->m_nValue = rootValue;
     root->m_pLeft = root->m_pRight = NULL;

     if(startPreorder == endPreorder)
    {
          if(startInorder == endInorder && *startPreorder == *startInorder)
          {
                return root;
          }
          else
          {
                throw std :: exception("Invalid input.");
           }
    }

    int* rootInorder = startInorder;
    while(rootInorder <= endInorder && *rootInorder != rootValue)
    {
        ++rootInorder;
    }

    if(rootInorder == endInorder && *rootInorder != rootValue)
    {
         throw std :: exception("Invalid input.");
    }

    int leftLength = rootInorder - startInorder;
    int* leftPreorderEnd = startPreorder + leftLength;
    if(leftLength > 0)
    {
         root->m_pLeft = ConstructCore(startPreorder + 1, leftPreorderEnd, startInorder, rootInorder - 1);
    }
    if(leftLength < endPreorder - startPreorder)
   {
         root->m_pRight = ConstructCore(leftPreorderEnd + 1, endPreorder, rootInorder + 1, endInorder);
   }

   return root;
}

題目二:輸入兩棵二叉樹A和B,判斷B是不是A的子結構。

     要確定B是不是A的子結構,我們可以先在A中找到B的根結點,然后再看看這個根結點下面的左右結點是否和B相同。也就是說,我們首先要遍歷二叉樹A。

     二叉樹特別適合使用遞歸的方式,這道題也不例外:
bool DoesTree1HaveTree2(BinaryTreeNode* pRoot1, BinaryTreeNode* pRoot2)
{
     if(pRoot2 == NULL)
    {
         return true;
    }

    if(pRoot1 == NULL)
    {
         return false;
    }

    if(pRoot1->m_nVlaue != pRoot2->m_nValue)
   {
         return false;
   }

   return DoesTree1HaveTree2(pRoot->m_pLeft, pRoot2->m_pLeft) && DoesTree1HaveTree2(pRoot1->m_pRight, pRoot2->m_pRight);
}

     解決二叉樹的編程問題,需要注意的有兩方面:魯棒性和簡潔性。因為二叉樹涉及到大量的指針操作,所以每次使用指針的時候我們都必須提醒自己:是否有空指針的危險。

     簡潔性對於二叉樹問題求解非常重要,因為大量的指針操作非常容易出現問題,就算我們已經足夠小心了,但最好就是通過使用遞歸來讓代碼更加簡潔,這樣就可以少寫幾個指針,少犯點錯誤。
題目三:輸入一個二叉樹,輸出它的鏡像。
     所謂的二叉樹鏡像,其實也就是將一個結點的左右子結點交換,就像我們平時照鏡子一樣。
     根據這樣的原理,我們知道,這是一個前序遍歷二叉樹,然后在找到左右子結點后將它們進行交換的過程。
void MirrorRecursively(BinaryTreeNode* pNode)
{
     if(pNode == NULL) || (pNode->m_pLeft == NULL && pNode->m_pRight))
    {
         return;
    }

    BinaryTreeNode* pTemp = pNode->m_pLeft;
    pNode->m_pLeft = pNode->m_pRight;
    pNode->m_pRight = pTemp;

    if(pNode->m_pLeft)
    {
          MirrorRecursively(pNode->m_pLeft);
    }

   if(pNode->m_pRight)
   {
         MirrorRecursively(pNode->m_pRight);
   }
}

題目四:從上到下打印二叉樹的每個結點,同一層的結點按照從左到右的順序。

     這就是寬度優先遍歷,代碼如下:
void PrintFromTopToBottom(BinaryTreeNode* pTreeRoot)
{
     if(!pTreeRoot)
     {
         return;
     }

     std :: deque<BinaryTreeNode*> dequeTreeNode;

     dequeTreeNode.push_back(pTreeRoot);

     while(dequeTreeNode.size())
     {
          BinaryTreeNode* pNode = dequeTreeNode.front();
          dequeTreeNode.pop_front();

          printf("%d ", pNode->m_nValue);
   
          if(pNode->m_pLeft)
          {
               dequeTreeNode.push_back(pNode->m_pLeft);
          }

          if(pNode->m_pRight)
          {
               dequeTreeNode.push_back(pNode->m_pRight);
          }
     }
}
     這種題目是最常見的,就算是后序遍歷這種常見的遍歷算法,要想完全寫好代碼還是不容易的:  
bool VerifySquenceOfBST(int sequence[], int length)
{
     if(sequence == NULL || length <= 0)
     {
           return false;
     }

     int root = sequence[length - 1];
     
     int i = 0;
     for(; i < length; ++i)
     {
          if(sequence[i] > root)
          {
                break;
          }
     }

     int j = i;
     for(; j < length; ++j)
    {
         if(sequence[j] < root)
         {
               return false;
         }
    }

    bool left = true;
    if(i > 0)
    {
         left = VerifySequenceOfBST(sequence, i);
    }

    bool right = true;
    if(i < length - 1)
    {
         right = VerifySequenceOfBST(sequence + i, length - i - 1);
    }

    return left && right;
}

     結合二叉搜索樹的特點,再加上遞歸,這個代碼不難實現。
     利用二叉樹的遍歷算法,我們能夠做很多事情,像是這道題目:

void FindPath(BinaryTreeNode* pRoot, int expectedSum)
{
      if(pRoot == NULL)
      {
           return;
      }

      std :: vector<int> path;
      int currentSum = 0;
      FindPath(pRoot, expectedSum, path, currentSum);
}

void FindPath(BinaryTreeNode* pRoot, int expectedSum., std :: vector<int>& path, int& currentSum)
{
     currentSum += pRoot->m_nValue;
     path.push_back(pRoot->m_nValue);
     
     bool isLeft = pRoot->m_pLeft == NULL && pRoot->m_pRight == NULL;
     if(currentSum == expectedSum && isLeft)
     {
           printf("A path is found: ");
           std :: Vector<int> :: iterator iter = path.begin();
           for(; iter != path.end(); ++iter)
           {
                printf("%d\t", *iter);
           }
      printf("\n");
     }

    if(pRoot->m_pLeft != NULL)
    {
           FindPath(pRoot->m_pLeft, expectedSum, path, currentSum);
    }
    if(pRoot->m_pRight != NULL)
    {
          FindPath(pRoot->m_pRight, expectedSum, path, currentSum);
    }

    currentSum -= pRoot->m_nValue;
    path.pop_back();
}

     上面的代碼只要仔細看一下,就會發現很嚴謹,像是我們在傳遞一個容器色時候,一般都是傳遞它的引用,這是為了防止傳參的時候的副本復制,但是引用的作用並不僅僅如此,像是接下來的參數currentSum之所以是int&,是因為我們希望該值能夠在函數遞歸調用的時候被改變,如果不是這樣,離開該函數后,currentSum就會變為原值,因為它只是原本的currentSum的一個副本。
      這里我們並不使用STL中的stack,而是采用vector的原因就是stack只能取得棧頂的元素。

BinaryTreeNode* Convert(BinaryTreeNode* pRootOfTree)
{
     BinaryTreeNode* pLastNodeInList = NULL;
     ConvertNode(pRootOfTree, &pLastNodeInList);

     BinaryTreeNode* pHeadOfList = pLastNodeInList;
     while(pHeadOfList != NULL && pHeadOfList->m_pLeft != NULL)
     {
          pHeadOfList = pHeadOfList->m_pLeft;
     }

     return pHeadOfList;
}

void ConvertNode(BinaryTreeNode* pNode, BinaryTreeNode** pLastNodeInList)
{
     if(pNode == NULL)
     {
          return;
     }

     BinaryTreeNode* pCurrent = pNode;

     if(pCurrent->m_pLeft != NULL)
    {
          ConvertNode(pCurrentNode->m_pLeft, pLastNodeInList);
    }
    
    pCurrent->m_pLeft = *pLastNodeInList;
    if(*pLastNodeInList != NULL)
    {
        (*pLastNodeInList)->m_pRight = pCurrent;
    }

    *pLastNodeInList = pCurrent;
    
    if(pCurrent->m_pRight != NULL)
    {
         ConvertNode(pCurrent->m_pRight, pLastNodeInList);
    }
}

      二叉樹不僅可以看成鏈表,也可以看成圖。

struct Node
{
     Node* pLeft;
     Node* pRight;
     int nMaxKLeft;
     int nMaxRight;
     char cValue;
};

int nMaxLen = 0;

void FindMaxLen(Node* pRoot)
{
     if(pRoot == NULL)
     {
          return;
     }

     if(pRoot->pLeft == NULL)
     {
          pRoot->nMaxLeft = 0;
     }

     if(pRoot->pRight == NULL)
    {
          pRoot->nMaxRight = 0;
    }

    if(pRoot->pLeft != NULL)
    {
          FindMaxLen(pRoot->pLeft);
    }

    if(pRoot->pRight != NULL)
    {
          FindMaxLen(pRoot->pRight);
    }

    if(pRoot->pLeft != NULL)
    {
          int nTempMax = 0;
          if(pRoot->pLeft->nMaxLeft > pRoot->pLeft->nMaxRight)
          {
                nTempMax = pRoot->pLeft->nMaxLeft;
          }
          else
          {
                nTempMax = pRoot->pLeft->nMaxRight;
          }

          pRoot->nMaxLeft = nTempMax + 1;
    }

    if(pRoot->pRight != NULL)
    {
          int nTempMax = 0;
          if(pRoot->pRight->nMaxLeft > pRoot->pRight->nMaxRight)
          {
               nTempMax = pRoot->pRight->nMaxLeft;
          }
           else
          {
               nTempMax = pRoot->pRight->nMaxRight;
          }
          pRoot->nMaxRight = nTempMax + 1;
    }

    if(pRoot->nMaxLeft + pRoot->nMaxRight > nMaxLeft)
   {
          nMaxLen = pRoot->nMaxLeft + pRoot->nMaxRight;
   }
}
     二叉樹的深度也是一個重要的考點。
題目九:輸入一棵二叉樹的根結點,求該樹的深度。
     所謂的深度,指的就是從根結點到葉結點依次經過的結點(含根,葉結點)形成樹的一條路徑,最長路徑的長度為樹的深度。
     表面上好像需要遍歷整棵樹,以便知道那部分的結點最多,但實際上根本就不需要這樣做。如果一棵樹只有一個結點,它的深度為1,如果結點只有左子樹而沒有右子樹,那么樹的深度應該是其左子樹的深度加1,同樣如果結點只有右子樹而沒有左子樹,那么樹的深度應該是其右子樹的深度加1.如果既有左子樹又有右子樹,那么該樹的深度就是其左右子樹深度的較大值加1。
     代碼如下:
int TreeDepth(BinaryTreeNode* pRoot)
{
    if(pRoot == NULL)
    {
          return 0;
    }

    int nLeft = TreeDepth(pRoot->m_pLeft);
    int nRight = TreeDepth(pRoot->m_pRight);

    return nLeft > nRight ? nLeft + 1 : nRight + 1;
}

     在這道題的基礎上,我們還可以增加難度:
題目十:輸入一棵二叉樹的根結點,判斷該樹是否是平衡二叉樹。如果某二叉樹中任意結點的左右子樹的深度相差不超過1,那么它就是一棵平衡二叉樹。

     這不難,我們可以在前面代碼的基礎上,在每次得到左右子樹的深度的時候進行一次判斷就可以,但是時間效率不高,和數組遍歷一樣,從前面開始不行,那么就從后面開始。

     后序遍歷的好處就是在我們遍歷到一個結點前就已經遍歷了它的左右子樹,只要在遍歷每個結點的時候記錄它的深度就可以了。

bool IsBalanced(BinaryTreeNode* pRoot)
{
int depth = 0;
return IsBalanced(pRoot, &depth);
}

bool
IsBalanced(BinaryTreeNode* pRoot, int* pDepth) { if(pRoot == NULL) { *pDepth = 0; return true; } int left, right; if(IsBalanced(pRoot->m_pLeft, &left) && IsBalanced(pRoot->m_pRight, &right)) { int diff = left - right; if(diff <= 1 && diff >= -1) { *path = 1 + (left > right ? left : right); return true; } } return false; }

     上面的題目都是顯式的指定二叉樹,但實際中的編程可不是這樣,像是下面這道:

題目十一:輸入n個整數,找出其中最小的k個數。

      第一眼的想法肯定是利用數組來求解。

      我們可以對這些數字進行排序,排序后位於最前面的k個數字就是最小的k個數,這種思路的時間復雜度是O(Nlog2N),前提就是使用快速排序。

      在之前的數組總結中,我們曾經提及過Partition這個函數,這里同樣也可以使用:

void GetLeastNumbers(int* input, int n, int* output, int k)
{
      if(input == NULL || output == NULL || k > n || n <= 0 || k <= 0)
      {
           return;
      }

      int start = 0;
      int end = n - 1;
      int index = Partition(input, n, start, end);
      while(index != k - 1)
      {
           if(index > k - 1)
           {
                  end = index - 1;
                  index = Partition(input, n, start, end);
           }
           else
           {
                   start = index + 1;
                   index = Partition(input, n, start, end);
           }        
       }

        for(int i = 0; i < k; ++i)
        {
              output[i] = input[i];
        }
}

     這種算法的局限就是我們需要修改輸入的數組,因為函數Partition會調整數組中數字的順序。
     我們可以先創建一個大小為k的數據容器來存儲最小的k個數字,然后每次從輸入的n個整數中讀入一個數,如果容器中已有的數字少於k個,則直接把這次讀入的整數放入容器中,如果容器中已有k個數字,也就是容器已滿,此時我們不能再插入新的數字而只能替換已有的數字。找出已有的k個數中的最大值,然后拿這次待插入的整數和最大值進行比較。如果待插入的值比當前已有的最大值還要大,那么這個數字不可能是最小的k個整數之一,於是我們可以拋棄這個整數。

     因此當容器滿了之后,我們要做3件事情:一是在k個整數中找到最大數;二是有可能在這個容器中刪除最大數;三是有可能要插入一個新的數字。如果用一個二叉樹來實現這個容器,那么我們可能在O(log2K)時間內實現這些操作,所以對於n個輸入數字而言,總的時間效率就是O(Nlog2K)。

     因為都需要找到k個整數中的最大數字,我們很容易想到用最大堆。在最大堆中,根結點的值總是大於它的子樹中的任意結點的值。於是我么每次都可以在O(1)得到已有的k個數字中的最大值,但需要O(log2K)時間完成刪除和插入操作。

     自己從頭到尾實現一個紅黑樹是需要一定的代碼,我們可以利用現成的基於紅黑樹的容器:

typedef multiset<int, greater<int>> intSet;
typedef multiset<int, greater<int>> :: iterator setIterator;

void GetLeastNumbers(const vector<int>& data, intSet& leastNumbers, int k)
{
    leastNumbers.clear();
   
    if(k < 1 || data.size() < k)
    {
          return;
     }

     vector<int> :: const_iterator iter = data.begin();
     for(; iter != data.end(); ++iter)
     {
           if((leastNumbers.size()) > k)
           {
                 leastNumbers.insert(*iter);
           }
           else
           {
                 setIterator iterGreatest = leastNumbers.begin();

                 if(*iter < *(leastNumbers.begin())
                 {
                      leastNumbers.erase(iterGreastest);
                      leastNumbers.insert(*iter);
                 }
           }
     }
}

      這種算法的時間復雜度是O(N),比起第一種是慢了,但是它不需要修改原有的數據,而且非常適合海量數據的輸入,因為內存大小是有限的,我們根本不可能一次性存入數組中,所以我們只能從輔助空間中每次讀入一個數字,再進行判斷。

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM