• 二叉树系列


    前言

    本篇是对二叉树系列中求最低公共祖先类题目的讨论。

    题目

    对于给定二叉树,输入两个树节点,求它们的最低公共祖先。

    思考:这其实并不单单是一道题目,解题的过程中,要先弄清楚这棵二叉树有没有一些特殊的性质,这些特殊性质可以便于我们使用最优的方式解题。

    传统二叉树的遍历,必须从跟节点开始,因此,思路肯定是从根节点找这两个节点了。但是,如果节点带有指向父节点的指针呢?这种情况下,我们完全就可以从这两个节点出发到根节点,免除了搜索的时间代价,毫无疑问会更快。

    那么如果没有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";
    }

    结语

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

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

  • 相关阅读:
    Asp.net文章内容分页
    JQuery文字不间断滚动
    .Net Core利用反射动态加载DLL类库的方法(解决类库不包含Nuget依赖包的问题)
    【Bug】远程登录导致WPF应用程序中的UserControl控件Loaded事件重复触发
    【原创】WPF TreeView带连接线样式的优化(WinFrom风格)
    DataGrid 字体垂直居中
    Elasticsearch.Net
    利用数学归纳法指导编写递归程序
    多种图像格式相互转换工具的开发(附源代码)
    油气大数据分析 第一章 软计算基础(第四、五、六节)
  • 原文地址:https://www.cnblogs.com/felixfang/p/3828915.html
Copyright © 2020-2023  润新知