原文鏈接:最近公共祖先問題
最近公共祖先(Least Common Ancestors)問題是面試中經常出現的一個問題,這種問題變種很多,解法也很多。最近公共祖先問題的定義如下:
對於有根樹T的兩個結點u、v,最近公共祖先LCA(T,u,v)表示一個結點x,滿足x是u、v的祖先且x的深度儘可能大。另一種理解方式是把T理解爲一個無向無環圖,而LCA(T,u,v)即u到v的最短路上深度最小的點。
例如,對於下面的樹,結點4和結點6的最近公共祖先LCA(T,4,6)爲結點2。
面試中LCA問題的擴展主要在於結點是否只包含父結點指針,對於同一棵樹是否進行多次LCA查詢。下面分別進行說明。
1.結點只包含父結點指針,只進行一次查詢
首先可以計算出結點u和v的深度d1和d2(由於只有parent指針,沿着parent指針一直向上移動即可計算出它的深度)。如果d1>d2,將u結點向上移動d1-d2步,如果d1<d2,將v結點向上移動d2-d1步,現在u結點和v結點在同一個深度了。下面只需要同時將u,v結點向上移動,直到它們相遇(到達同一個結點)爲止,相遇的結點即爲u,v結點的最小公共祖先。
int getDepth(TreeNode *node) { int d = 0; while (node) d++, node = node->parent; return d; } TreeNode *getLCA(TreeNode *node1, TreeNode *node2) { int d1 = getDepth(node1), d2 = getDepth(node2); if (d1 > d2) { swap(d1, d2); swap(node1, node2); } while (d1 < d2) d2--, node2 = node2->parent; while (node1 != node2) { node1 = node1->parent; node2 = node2->parent; } return node1; }
該算法時間複雜度爲O(h),空間複雜度爲O(1),其中h爲樹的高度。
2.結點只包含父結點指針,進行多次查詢
第一種算法每一次查詢的時間複雜度都是O(h),如果需要對同一棵樹進行多次查詢,有沒有更快的算法呢?觀察第一種算法,主要進行的操作是將某個結點u沿着parent指針向上移動n步,我們可以對樹進行一些預處理加速這個過程,這裏使用到了動態規劃的思想。
設P[i][j]表示結點i往上移動2^j步所到達的結點,P[i][j]可以通過以下遞推公式計算:
利用P數組可以快速的將結點i向上移動n步,方法是將n表示爲2進制數。比如n=6,二進制爲110,那麼利用P數組先向上移動4步(2^2),然後再繼續移動2步(2^1),即P[ P[i][2] ][1]。
預處理計算P數組代碼如下:
map<TreeNode*, int> nodeToId; map<int, TreeNode*> idToNode; const int MAXLOGN=20; //樹中最大結點數爲1<<20 int P[1 << MAXLOGN][MAXLOGN]; //allNodes存放樹中所有的結點 void preProcessTree(vector<TreeNode *> allNodes) { int n = allNodes.size(); // 初始化P中所有元素爲-1 for (int i = 0; i < n; i++) for (int j = 0; 1 << j < n; j++) P[i][j] = -1; for (int i = 0; i < n; i++) { nodeToId[allNodes[i]] = i; idToNode[i] = allNodes[i]; } // P[i][0]=parent(i) for (int i = 0; i < n; i++) P[i][0] = allNodes[i]->parent ? nodeToId[allNodes[i]->parent] : -1; // 計算P[i][j] for (int j = 1; 1 << j < n; j++) for (int i = 0; i < n; i++) if (P[i][j] != -1) P[i][j] = P[P[i][j - 1]][j - 1]; }
另外我們還需要預處理計算出每個結點的深度L[],預處理之後,查詢node1和node2的LCA算法如下。
TreeNode* getLCA(TreeNode *node1, TreeNode *node2, int L[]) { int id1 = nodeToId[node1], id2 = nodeToId[node2]; //如果node2的深度比node1深,那麼交換node1和node2 if (L[id1] < L[id2]) swap(id1, id2); //計算[log(L[id1])] int log; for (log = 1; 1 << log <= L[id1]; log++); log--; //將node1向上移動L[id1]-L[id2]步,使得node1和node2在同一深度上 for (int i = log; i >= 0; i--) if (L[id1] - (1 << i) >= L[id2]) id1 = P[id1][i]; if (id1 == id2) return idToNode[id1]; //使用P數組計算LCA(idToNode[id1], idToNode[id2]) for (i = log; i >= 0; i--) if (P[id1][i] != -1 && P[id1][i] != P[id2][i]) id1 = P[id1][i], id2 = P[id2][i]; return idToNode[id1]; }
時間複雜度分析:假設樹包含n個結點,由於P數組有nlogn個值需要計算,因此預處理的時間複雜度爲O(nlogn)。查詢兩個結點的LCA時,函數getLCA
中兩個循環最多執行2logn次,因此查詢的時間複雜度爲O(logn)。
3.結點包含兒子結點指針,只進行一次查詢
這裏我們只考慮二叉樹,樹中結點包含左右兒子結點指針。給定樹根結點T,以及樹中u,v結點,需要計算LCA(T,u,v)。可以採用遞歸的方法,對於結點node,如果在node左子樹或者右子樹中找到了LCA(u,v),那麼直接返回這個答案。否則如果node子樹同時包含了u,v結點,那麼node結點即爲LCA(u,v)。否則在當前node子樹中找不到LCA(u,v)。
struct TreeNode { TreeNode *left; TreeNode *right; }; //在子樹node中查找LCA(u,v),同時u,v在node子樹中的出現情況記錄到flag中 //如果沒找到LCA(u,v),返回NULL TreeNode *getLCAHelper(TreeNode *node, TreeNode *u, TreeNode *v, int &flag) { if (u == node && v == node) return node; int leftFlag = 0, rightFlag = 0; if (node->left != NULL) { ListNode *ret = getLCAHelper(node->left, u, v, leftFlag); if (!ret) return ret; } if (node->right != NULL) { ListNode *ret = getLCAHelper(node->right, u, v, rightFlag); if (!ret) return ret; } if (u == node) flag |= 1; //標記u在子樹node中 if (v == node) flag |= 2; //標記v在子樹node中 flag |= leftFlag; flag |= rightFlag; if (flag == 3) return node; //u,v都出現在node子樹中 return NULL; } //計算LCA(root, node1, node2) TreeNode *getLCA(TreeNode *root, TreeNode *node1, TreeNode *node2) { int flag = 0; return getLCAHelper(root, node1, node2, flag); }
時間複雜度分析:該遞歸算法最多訪問每個樹結點一次,因此時間複雜度爲O(n)。
4.結點包含兒子結點指針,進行多次查詢
這種情況同樣可以使用算法2來提高每次查詢的效率,預處理過程中先遍歷樹,記錄每個結點的深度和父親結點指針,然後計算P數組,查詢過程和算法2一樣。這樣,預處理的時間複雜度爲O(nlogn),查詢一次的時間複雜度爲O(logn)。
現在就去在線練習題庫練習:http://www.itint5.com/oj/#7