前言
本篇是對二叉樹系列中求最低公共祖先類題目的討論。
題目
對於給定二叉樹,輸入兩個樹節點,求它們的最低公共祖先。
思考:這其實並不單單是一道題目,解題的過程中,要先弄清楚這棵二叉樹有沒有一些特殊的性質,這些特殊性質可以便於我們使用最優的方式解題。
傳統二叉樹的遍歷,必須從跟節點開始,因此,思路肯定是從根節點找這兩個節點了。但是,如果節點帶有指向父節點的指針呢?這種情況下,我們完全就可以從這兩個節點出發到根節點,免除了搜索的時間代價,毫無疑問會更快。
那么如果沒有parent指針,我們肯定只能從根節點開始搜索了,這個時候,如果二叉樹是二叉搜索樹,那么搜索效率是不是又可以大大提高呢?
遇到一道題目,特別是題意不夠明確的題目,我們完全可以和出題者進行探討。一方面,對需求的清晰分析和定義是程序員應有的素質,另一方面,對題目具體化的過程中,可以展現你對各宗情況的分析能力,以及基於二叉樹的各種數據結構(以此題為例)的熟悉程度。
特殊情況(一),節點帶有指向父節點的指針的二叉樹
如上面所言,當節點帶有parent指針時,可以方便的從給定節點遍歷到根節點,經過的路徑其實一條鏈表。因此,求最低公共祖先,就是求兩鏈表的第一個交點。
特殊情況(二),搜索二叉樹
如果節點沒有parent指針,或者給定的是兩個節點的數值而非節點地址,那么只有從根節點開始遍歷這一途了。但是,對於一些特殊性質的二叉樹,搜索效率是可以更高的,我們在解題前,不妨再問問面試官。
比如二叉搜索樹 BST,傳統的二叉樹要找一個節點,需要O(n)時間的深度搜索或者廣度搜索,但是BST卻只要O(logn)就可以,有了這一層便利,我們的思路就可以很簡潔。
(1) 如果給定的節點確定在二叉樹中,那么我們只要將這兩個節點值(a和b)和根節點(root->val)比較即可,如果root->val 的大小在a和b之間,或者root->val 和a b中的某一個相等,那最低公共祖先就是root了。否則,如果a b 都比(root->val)小,那繼續基於 root -> left 重復上述過程即可;如果a b 都比(root->val) 大,root -> right,遞歸實現。
(2) 如果是給定節點值,並且不能保證這兩個值在二叉樹中,那么唯一的變化就是:當root->val 的大小在a和b之間,或者root -> val 等於a或b 的情況出現時,我們不能斷定最低公共祖先就是root,需要在左(右) 枝繼續搜索 a或者b,找到才能斷定最低公共祖先就是root。遞歸過程的root都遵循這個規則。
傳統二叉樹,解法一,時間 2n ,空間 logn
對於普通的二叉樹,我們只能老老實實從根節點開始尋找兩節點了。這里我們假設題目是給定 兩個節點值而非節點地址,並且兩個節點值a, b可能都不在樹中。
本例以九度題目1509:樹中兩個結點的最低公共祖先為測試用例,如果找到最低公共祖先,返回其值,找不到則返回 "My God"。
樹節點結構為 TreeNode,所求函數為 FindCmmnAncstr。
struct TreeNode{ TreeNode *left; TreeNode *right; int val; TreeNode(int v): val(v), left(NULL), right(NULL){}; };
string FindCmmnAncstr(TreeNode* node, int a, int b){}
定義函數 FindCmmnAncstr,對於根節點root,我們用函數FindVal 在其左子樹中尋找 a 和 b 這兩個給定的值。如果都找到了,說明a和b的最低公共祖先肯定在左子樹,因此遞歸調用FindCmmnAncstr 處理 root -> left;如果都找不到,a和b的最低公共祖先如果存在,只有可能在右子樹。因此遞歸調用FindCmmnAncstr 處理 root -> right;如果a找到了,b沒找到,那么就在root -> right 中找b,找到了的話,最低公共祖先就是 root。
這種思路的需要空間復雜度O(logn),遞歸開銷。時間復雜度的數量級為O(n),因為函數FindVal的復雜度為O(k),k表示當前節點為根節點的子樹中節點數量,每進入一個子樹,理想情況下節點數減半;而FinalVal要被調用2*H次,H為樹的高度,約為logn。最壞情況下,就是每次找left 子樹的時候,兩個值都不在left子樹,因此right子樹繼續遞歸,時間 = 2(n/2 + n/(2*2) + n/(2*3) ... + n/(2*logn)) = 2n(1-(1/2)logn) < 2n
代碼,(注:待調試,未AC)
bool FindVal(TreeNode* node, int v){ if(!node) return false; if(node -> val == v) return true; return(FindVal(node -> left, v) || FindVal(node -> right, v)); } string FindCmmnAncstr(TreeNode* node, int a, int b){ if(!node) return "My God"; if(node -> val == a){ if(FindVal(node -> left, b) || FindVal(node -> right, b)) return convert(a); else return "My God"; } if(node -> val == b){ if(FindVal(node -> left, a) || FindVal(node -> right, a)) return convert(b); else return "My God"; } bool lefta = FindVal(node -> left, a); bool leftb = FindVal(node -> left, b); if(lefta && leftb) return FindCmmnAncstr(node -> left, a, b); if(!lefta && !leftb) return FindCmmnAncstr(node -> right, a, b); if(lefta){ if(FindVal(node -> right, b)) return convert(node -> val); else return "My God"; }else{ if(FindVal(node -> right, a)) return convert(node -> val); else return "My God"; } }
這里面定義了一個工具函數convert, 用來轉化int 為 string。
#include <string> #include <sstream> string convert(int v){ ostringstream convert; // stream used for the conversion convert << v; // insert the textual representation of 'Number' in the characters in the stream return convert.str(); // set 'Result' to the contents of the stream }
2014年11月底二刷,時間復雜度n,空間復雜度常數:
上面的解法最壞情況下每一個結點要被遍歷至少兩遍,因為深入到子樹里面繼續找公共祖先的時候,新的遞歸又要遍歷該子樹的結點,而該子樹的結點在上一輪遞歸中已經遍歷過了。
二叉樹類型的題目中如果遞歸安排的比較好的話,完全可以做到在每個結點只被遍歷一次的情況下解決問題。方法就是遞歸函數不但返回值作為中間結果,函數體本身也在利用子調用的結果計算最終解。
類似的題目和解法還有尋找二叉樹中的最長路徑。
對於這道題,定義遞歸函數int FindCmmnAncstrCore(TreeNode* node, int a, int b),返回int類型,用res表示返回值,如果node == null,res = 0;如果node -> val == b,那么res的末位bit上置1,如果node -> val == a,res的倒數第二位bit置1;接着在node為根的子樹中尋找a和b,將返回值或運算至res中。
res末兩位第一次變成"11"時,就是找到最低公共祖先的時候,保存下這個值作為最終返回值即可。這個時候后面的母函數調用如果再返回11,找到的只是公共祖先,而非最低公共祖先。
九度上AC的代碼。
#include <iostream> #include <string> #include <sstream> #include <vector> using namespace std; string CommonAnct = ""; struct TreeNode{ TreeNode *left; TreeNode *right; int val; TreeNode(int v): val(v), left(NULL), right(NULL){}; }; TreeNode* CreateTree(){ int v; cin >> v; if(v == 0) return NULL; TreeNode* root = new TreeNode(v); root -> left = CreateTree(); root -> right = CreateTree(); return root; } string convert(int v){ ostringstream convert; convert << v; return convert.str(); } int FindCmmnAncstrCore(TreeNode *node, int a, int b){ if(!node) return 0; if(CommonAnct.length() > 0) return 0; int res = 0; if(node -> val == a) res |= 2; if(node -> val == b) res |= 1; res |= (FindCmmnAncstrCore(node -> left, a, b) | FindCmmnAncstrCore(node -> right, a, b)); if(res == 3 && CommonAnct.length() <= 0) CommonAnct = convert(node -> val); return res; } string FindCmmnAncstr(TreeNode* node, int a, int b){ CommonAnct = ""; FindCmmnAncstrCore(node, a, b); if(CommonAnct.length() == 0) return "My God"; return CommonAnct; } int main(){ int testNumber = 0; while(cin >> testNumber){ for(int i = 0; i < testNumber; ++i){ TreeNode* root = CreateTree(); int a, b; cin >> a >> b; cout << FindCmmnAncstr(root, a, b) << endl; } } return 0; }
傳統二叉樹,解法二,時間 n ,空間 3logn
上一例的解法在於有節點被重復遍歷,導致時間復雜度的升高。
為了避免節點被重復遍歷,我們可以將找到a和b后所經過的節點路徑存儲下來,然后比較兩條路徑,找出相同的部分即可。
這種思路更簡潔,更直觀,時間上保證了每個節點最多被訪問一次。缺點是空間上需要額外開辟兩個 logn 數量級的空間存儲路徑,時間上多出了比較路徑所消耗的時間。
代碼,已AC,輸入處理部分見上面的代碼。
bool FindVal(TreeNode* node, int v, vector<int> &path){ if(!node) return false; path.push_back(node -> val); if(node -> val == v) return true; if(FindVal(node -> left, v, path) || FindVal(node -> right, v, path)) return true; path.pop_back(); return false; } string FindCmmnAncstr2(TreeNode* node, int a, int b){ if(!node) return "My God"; vector<int> path1; //尋找a的經過路徑 FindVal(node, a, path1); vector<int> path2; //尋找b的經過路徑 FindVal(node, b, path2); vector<int>::iterator it1 = path1.begin(); vector<int>::iterator it2 = path2.begin(); int acstor = 0; for(; it1 < path1.end() && it2 < path2.end() && (*it1) == (*it2); acstor = *it2, ++it1, ++it2); return (acstor > 0 ? convert(acstor) : "My God"); }
傳統二叉樹,解法三,時間 3n,空間2logn
這個解法比較難以想到,參考了 GoCalf的這篇博文,他給出了python的偽代碼,我基於他的思路給出了具體的在C++上的實現。
我們不再用遞歸來尋找節點,而是改用自己的棧。並且,在使用這個棧對二叉樹進行前序遍歷的時候,對遍歷方式稍稍進行修改。
一般使用棧對二叉樹進行preorder traversal 前序遍歷,過程是這樣的,遍歷方式(1):
st.push(root) while(!st.empty()){ TreeNode* node = st.top(); st.pop(); //Do something to node. if(node->right) st.push(node->right); //注意是右子樹先進棧 if(node->left) st.push(node->left); }
我們將過程稍微更改下,遍歷方式(2):
st.push(root) while(!st.empty()){ TreeNode* node = st.top(); //Do something to node. if(node -> left){ st.push(node -> left); node -> left = NULL; }else{ st.pop(); if(node -> right) st.push(node -> right); } }
改動的后果是什么?
遍歷的順序依然不會改變,如果在//Do something 部分添加輸出 node -> val,輸出結果依然是前序遍歷。但是,變化的是棧內部的節點!
原來的遍歷方式(1)中,通過st.top()獲得棧頂節點node后,node就會彈出,轉而壓入其左右孩子。新遍歷方式中,node的左子樹遍歷完成后,在遍歷右子樹之前,node才會被彈出,然后壓入右孩子。
直觀的效果就是,假設A為root,經過如下路徑找到了值為a的節點H,在遍歷方式(1)中,stack里存的是什么?自棧底到棧頂,依次應該是A的右孩子,B的右孩子,D的右孩子,G的右孩子。
在遍歷方式(2)里,棧里存的是什么?自棧底到棧頂,依次應該是A,B,D,G,H。
A / B / C \ D / E \ F \ G / H
接下來我們要找值為b的節點,我們可以發現a 和 b 如果存在最低公共祖先,這個最低公共祖先必然是A,B,D,G,H中的最低節點。更好的是,此時A,B,D,G,H依然按順序排列在棧中。因此我們只要繼續尋找b節點,找到后,此時棧中A,B,D,G,H 這五個節點中依然被保留着的最低節點,就是最低公共祖先。
下面的問題時:尋找b節點時,我們改用什么樣的遍歷方式呢?像上面第二種一樣的遍歷方式嗎?如果這樣,試想如果b在H的右子樹上,因為a在H上,那么此時最低公共祖先應該是H。但是依照第二種遍歷方式,在H的右子樹上找到b時,H已經被彈出棧外了。
因此,繼續尋找b的過程中,我們需要做到遍歷node右子樹時,node依然保留在棧中,因此,我們再次將遍歷方式作調整:
遍歷方式(3)
while(!st.empty()){ TreeNode* node = st.back(); //Do something to node if(node -> left){ st.push(node -> left); node -> left = NULL; }else if(node -> right){ st.push(node -> right); node -> right = NULL; }else{ st.pop_back(); } }
這樣做,使得只有當node的左右子樹都完成了遍歷,node才會被pop出。當然,代價是節點訪問上出現了重復。這種遍歷方式其實像極了回溯。除葉節點外,每個節點需要被訪問3次。
這種算法的最壞情況,是在根節點就找到了a,接着使用上面的遍歷方式(3)開始找b,於是時間上花了 3n的時間。
其實算法有改進的空間,改進的思路是:
這種算法的核心在於:我們找到a后,此時棧中的元素從棧頂到棧底 是優先級逐漸降低的 “最低公共祖先”候選人。尋找b時,可以采用遍歷方式(1),然后記錄下找到b后最近被pop出的候選人,這個候選人就是最低公共祖先。這樣,找b的時間代價因為采用了 遍歷方式(1) 的緣故,成了n。
基於未改進思路,在九度上AC的代碼為
string FindCmmnAncstr(TreeNode* node, int a, int b){ if(!node) return "My God"; vector<TreeNode*> st; map<TreeNode*, int> m; int another = 0; st.push_back(node); TreeNode *n = NULL; while(!st.empty()){//尋找第一個數的過程 n = st.back(); if(n -> val == a || n -> val == b){ another = (n -> val == a) ? b : a; break; } if(n -> left){ st.push_back(n -> left); n -> left = NULL; }else{ st.pop_back(); if(n -> right) st.push_back(n -> right); } } if(st.empty()) return "My God"; vector<TreeNode*>::iterator it = st.begin(); for(; it < st.end(); ++it) m[*it] = 1; //m用來標記此時在棧中的“最低公共祖先”候選人 while(!st.empty()){ //尋找另一個書another的過程 n = st.back(); if(n -> val == another) break; if(n -> left){ st.push_back(n -> left); n -> left = NULL; }else if(n -> right){ st.push_back(n -> right); n -> right = NULL; }else{ st.pop_back(); } } while(!st.empty()){ //從上到下遍歷棧,找到第一個被標記為“候選人”的節點就是最低公共祖先。 if(m.find(st.back()) != m.end()) return convert((st.back()) -> val); st.pop_back(); } return "My God"; }
結語
對於界定模糊的問題,比如二叉樹,不同的二叉樹對解法的影響是很大的,通過詢問和溝通來對基於題目進行探討,不單單能幫助解題,討論的過程也是很愉快的~
對於一般二叉樹,這里給了三種解法,這三種解法的數量級是一樣的,具體哪個解法更優,得看具體情況了。