二叉树系列 - 求两节点的最低公共祖先,例 剑指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";
}

 

 

结语

对于界定模糊的问题,比如二叉树,不同的二叉树对解法的影响是很大的,通过询问和沟通来对基于题目进行探讨,不单单能帮助解题,讨论的过程也是很愉快的~

对于一般二叉树,这里给了三种解法,这三种解法的数量级是一样的,具体哪个解法更优,得看具体情况了。

 

 

转载于:https://www.cnblogs.com/felixfang/p/3828915.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值