二叉树系列 - 求两节点的最低公共祖先,例 剑指Offer 50
前言
本篇是对二叉树系列中求最低公共祖先类题目的讨论。
题目
对于给定二叉树,输入两个树节点,求它们的最低公共祖先。
思考:这其实并不单单是一道题目,解题的过程中,要先弄清楚这棵二叉树有没有一些特殊的性质,这些特殊性质可以便于我们使用最优的方式解题。
传统二叉树的遍历,必须从跟节点开始,因此,思路肯定是从根节点找这两个节点了。但是,如果节点带有指向父节点的指针呢?这种情况下,我们完全就可以从这两个节点出发到根节点,免除了搜索的时间代价,毫无疑问会更快。
那么如果没有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"; }
结语
对于界定模糊的问题,比如二叉树,不同的二叉树对解法的影响是很大的,通过询问和沟通来对基于题目进行探讨,不单单能帮助解题,讨论的过程也是很愉快的~
对于一般二叉树,这里给了三种解法,这三种解法的数量级是一样的,具体哪个解法更优,得看具体情况了。
------------------------------------------------
Felix原创,转载请注明出处,感谢博客园!
posted on 2014-07-07 00:36 Felix Fang 阅读(9463) 评论(0) 编辑 收藏 举报