1.淘寶面試題:有一個一億節點的樹,現在已知兩個點,找這兩個點的共同的祖先。
這個題怎麼做呢?
本題爲一道開放性試題,所以可以隨意一些。下文的第二道題目則不行。
本題類型 實現特定的數據結構的一些另外要求的功能。
方法1:修改原本數據結構的內部結構。
給出的較爲簡單的方法:可以考慮的方案是稍微改一下樹的存儲結構:
1 每個結點存儲一個父節點指針和在父節點中的子節點編號(1~n),這樣對所求的結點mn分別回溯到根就可以得到一個編號序列,將其逆序比較最大公共前綴找到的即爲所求結點的編號
2 樹結構的,可以考慮每個節點不僅記錄父節點,還要額外記錄一下當前層次。
兩個節點先比較下層次,讓層次大的先找父節點,直到層次相同了。
兩個節點再同時找父節點,並比較。
方法2:利用算法完成。
2 騰訊面試題:
求二叉樹的任意兩個節點的最近公共祖先。
首先搞清楚到底什麼是最近公共祖先。最近公共祖先簡稱LCA,所謂LCA,是當給定一個有根樹T時,對於任意兩個結點u、v,找到一個離根最遠的結點x,使得x同時是u和v的祖先,x 便是u、v的最近公共祖先。
舉個例子,如針對下圖所示的一棵普通的二叉樹來講:
結點3和結點4的最近公共祖先是結點2,即LCA(3 4)=2 。在此,需要注意到當兩個結點在同一棵子樹上的情況,如結點3和結點2的最近公共祖先爲2,即 LCA(3 2)=2。同理:LCA(5 6)=4,LCA(6 10)=1。
明確了題意,咱們便來試着解決這個問題。一般文章的做法,可能是針對是否爲二叉查找樹分情況討論,想必這也是一般人最先想到的思路。除此之外,還有所謂的Tarjan算法、倍增算法、以及轉換爲RMQ問題(求某段區間的極值)。
下面,便來一一具體闡述這幾種方法。
1.1、是二叉查找樹
在當這棵樹是二叉查找樹的情況下,如下圖:
那麼從樹根開始:
- 如果當前結點t 大於結點u、v,說明u、v都在t 的左側,所以它們的共同祖先必定在t 的左子樹中,故從t 的左子樹中繼續查找;
- 如果當前結點t 小於結點u、v,說明u、v都在t 的右側,所以它們的共同祖先必定在t 的右子樹中,故從t 的右子樹中繼續查找;
- 如果當前結點t 滿足 u <t < v,說明u和v分居在t 的兩側,故當前結點t 即爲最近公共祖先;
- 而如果u是v的祖先,那麼返回u的父結點,同理,如果v是u的祖先,那麼返回v的父結點。
代碼如下所示:
- //copyright@eriol 2011
- //modified by July 2014
- public int query(Node t, Node u, Node v) {
- int left = u.value;
- int right = v.value;
- Node parent = null;
- //二叉查找樹內,如果左結點大於右結點,不對,交換
- if (left > right) {
- int temp = left;
- left = right;
- right = temp;
- }
- while (true) {
- //如果t小於u、v,往t的右子樹中查找
- if (t.value < left) {
- parent = t;
- t = t.right;
- //如果t大於u、v,往t的左子樹中查找
- } else if (t.value > right) {
- parent = t;
- t = t.left;
- } else if (t.value == left || t.value == right) {
- return parent.value;
- } else {
- return t.value;
- }
- }
- }
若這棵樹不是二叉查找樹
1.暴力搜索(基本的遍歷方法必須牢記)
來判斷一個結點的子樹中是不是包含了另外一個結點。這不是件很難的事,我們可以用遞歸的方法來實現:
/////////////////////////////////////////////////////////////////////////////////
// If the tree with head pHead has a node pNode, return true.
// Otherwise return false.
/////////////////////////////////////////////////////////////////////////////////
bool HasNode(TreeNode* pHead, TreeNode* pNode)
{
if(pHead == pNode)
return true;
bool has = false;
if(pHead->m_pLeft != NULL)
has = HasNode(pHead->m_pLeft, pNode);
if(!has && pHead->m_pRight != NULL)
has = HasNode(pHead->m_pRight, pNode);
return has;
}
我們可以從根結點開始,判斷以當前結點爲根的樹中左右子樹是不是包含我們要找的兩個結點。如果兩個結點都出現在它的左子樹中,那最低的共同父結點也出現在它的左子樹中。如果兩個結點都出現在它的右子樹中,那最低的共同父結點也出現在它的右子樹中。如果兩個結點一個出現在左子樹中,一個出現在右子樹中,那當前的結點就是最低的共同父結點。基於這個思路,我們可以寫出如下代碼:
/////////////////////////////////////////////////////////////////////////////////
// Find the last parent of pNode1 and pNode2 in a tree with head pHead
/////////////////////////////////////////////////////////////////////////////////
TreeNode* LastCommonParent_1(TreeNode* pHead, TreeNode* pNode1, TreeNode* pNode2)
{
if(pHead == NULL || pNode1 == NULL || pNode2 == NULL)
return NULL;
// check whether left child has pNode1 and pNode2
bool leftHasNode1 = false;
bool leftHasNode2 = false;
if(pHead->m_pLeft != NULL)
{
leftHasNode1 = HasNode(pHead->m_pLeft, pNode1);
leftHasNode2 = HasNode(pHead->m_pLeft, pNode2);
}
if(leftHasNode1 && leftHasNode2)
{
if(pHead->m_pLeft == pNode1 || pHead->m_pLeft == pNode2)
return pHead;
return LastCommonParent_1(pHead->m_pLeft, pNode1, pNode2);
}
// check whether right child has pNode1 and pNode2
bool rightHasNode1 = false;
bool rightHasNode2 = false;
if(pHead->m_pRight != NULL)
{
if(!leftHasNode1)
rightHasNode1 = HasNode(pHead->m_pRight, pNode1);
if(!leftHasNode2)
rightHasNode2 = HasNode(pHead->m_pRight, pNode2);
}
if(rightHasNode1 && rightHasNode2)
{
if(pHead->m_pRight == pNode1 || pHead->m_pRight == pNode2)
return pHead;
return LastCommonParent_1(pHead->m_pRight, pNode1, pNode2);
}
if((leftHasNode1 && rightHasNode2)
|| (leftHasNode2 && rightHasNode1))
return pHead;
return NULL;
}
接着我們來分析一下這個方法的效率。函數HasNode的本質就是遍歷一棵樹,其時間複雜度是O(n)(n是樹中結點的數目)。由於我們根結點開始,要對每個結點調用函數HasNode。因此總的時間複雜度是O(n2)。
我們仔細分析上述代碼,不難發現我們判斷以一個結點爲根的樹是否含有某個結點時,需要遍歷樹的每個結點。接下來我們判斷左子結點或者右結點爲根的樹中是否含有要找結點,仍然需要遍歷。第二次遍歷的操作其實在前面的第一次遍歷都做過了。由於存在重複的遍歷,本方法在時間效率上肯定不是最好的。
方法二 我們可以把問題轉化爲求兩個鏈表的共同結點。
/////////////////////////////////////////////////////////////////////////////////
// Get the path form pHead and pNode in a tree with head pHead
/////////////////////////////////////////////////////////////////////////////////
bool GetNodePath(TreeNode* pHead, TreeNode* pNode, std::list<TreeNode*>& path)
{
if(pHead == pNode)
return true;
path.push_back(pHead);
bool found = false;
if(pHead->m_pLeft != NULL)
found = GetNodePath(pHead->m_pLeft, pNode, path);
if(!found && pHead->m_pRight)
found = GetNodePath(pHead->m_pRight, pNode, path);
if(!found)
path.pop_back();
return found;
}
由於這個路徑是從跟結點開始的。最低的共同父結點就是路徑中的最後一個共同結點:
/////////////////////////////////////////////////////////////////////////////////
// Get the last common Node in two lists: path1 and path2
/////////////////////////////////////////////////////////////////////////////////
TreeNode* LastCommonNode
(
const std::list<TreeNode*>& path1,
const std::list<TreeNode*>& path2
)
{
std::list<TreeNode*>::const_iterator iterator1 = path1.begin();
std::list<TreeNode*>::const_iterator iterator2 = path2.begin();
TreeNode* pLast = NULL;
while(iterator1 != path1.end() && iterator2 != path2.end())
{
if(*iterator1 == *iterator2)
pLast = *iterator1;
iterator1++;
iterator2++; (個人認爲這裏有問題?????)
}
return pLast;
}
有了前面兩個子函數之後,求兩個結點的最低共同父結點就很容易了。我們先求出從根結點出發到兩個結點的兩條路徑,再求出兩條路徑的最後一個共同結點。代碼如下:
/////////////////////////////////////////////////////////////////////////////////
// Find the last parent of pNode1 and pNode2 in a tree with head pHead
/////////////////////////////////////////////////////////////////////////////////
TreeNode* LastCommonParent_2(TreeNode* pHead, TreeNode* pNode1, TreeNode* pNode2)
{
if(pHead == NULL || pNode1 == NULL || pNode2 == NULL)
return NULL;
std::list<TreeNode*> path1;
GetNodePath(pHead, pNode1, path1);
std::list<TreeNode*> path2;
GetNodePath(pHead, pNode2, path2);
return LastCommonNode(path1, path2);
}
這種思路的時間複雜度是O(n),時間效率要比第一種方法好很多。但同時我們也要注意到,這種思路需要兩個鏈表來保存路徑,空間效率比不上第一個方法。
方法三 Tarjan算法
Tarjan算法的原理是dfs + 並查集,它每次把兩個結點對的最近公共祖先的查詢保存起來,然後dfs 更新一次。如此,利用並查集優越的時空複雜度,此算法的時間複雜度可以縮小至O(n+Q),其中,n爲數據規模,Q爲詢問個數。
方法四 轉換爲RMQ問題
3.1、什麼是RMQ問題
RMQ,全稱爲Range Minimum Query,顧名思義,則是區間最值查詢,它被用來在數組中查找兩個指定索引中最小值的位置。即RMQ相當於給定數組A[0, N-1],找出給定的兩個索引如 i、j 間的最小值的位置。
假設一個算法預處理時間爲 f(n),查詢時間爲g(n),那麼這個算法複雜度的標記爲<f(n), g(n)>。我們將用RMQA(i, j) 來表示數組A 中索引i 和 j 之間最小值的位置。 u和v的離樹T根結點最遠的公共祖先用LCA T(u, v)表示。
如下圖所示,RMQA(2,7 )則表示求數組A中從A[2]~A[7]這段區間中的最小值:
很顯然,從上圖中,我們可以看出最小值是A[3] = 1,所以也就不難得出最小值的索引值RMQA(2,7) = 3。