1 数据结构类-最近公共祖先LCA问题

来源:互联网 发布:网络的拼音怎么写的 编辑:程序博客网 时间:2024/05/01 02:26

1.淘宝面试题:有一个一亿节点的树,现在已知两个点,找这两个点的共同的祖先。
这个题怎么做呢?


本题为一道开放性试题,所以可以随意一些。下文的第二道题目则不行。

本题类型 实现特定的数据结构的一些另外要求的功能。


方法1:修改原本数据结构的内部结构。


给出的较为简单的方法:可以考虑的方案是稍微改一下树的存储结构:

1 每个结点存储一个父节点指针和在父节点中的子节点编号(1~n),这样对所求的结点mn分别回溯到根就可以得到一个编号序列,将其逆序比较最大公共前缀找到的即为所求结点的编号

树结构的,可以考虑每个节点不仅记录父节点,还要额外记录一下当前层次。
两个节点先比较下层次,让层次大的先找父节点,直到层次相同了。
两个节点再同时找父节点,并比较。

方法2:利用算法完成。

见文末一起总结

2 腾讯面试题:

求二叉树的任意两个节点的最近公共祖先。

首先搞清楚到底什么是最近公共祖先。最近公共祖先简称LCA,所谓LCA,是当给定一个有根树T时,对于任意两个结点u、v,找到一个离根最远的结点x,使得x同时是u和v的祖先,x 便是u、v的最近公共祖先。

    举个例子,如针对下图所示的一棵普通的二叉树来讲:

    结点3和结点4的最近公共祖先是结点2,即LCA(3 4)=2 。在此,需要注意到当两个结点在同一棵子树上的情况,如结点3和结点2的最近公共祖先为2,即 LCA(3 2)=2。同理:LCA(5 6)=4,LCA(6 10)=1。

    明确了题意,咱们便来试着解决这个问题。一般文章的做法,可能是针对是否为二叉查找树分情况讨论,想必这也是一般人最先想到的思路。除此之外,还有所谓的Tarjan算法、倍增算法、以及转换为RMQ问题(求某段区间的极值)。

    下面,便来一一具体阐述这几种方法。

1.1、是二叉查找树

   在当这棵树是二叉查找树的情况下,如下图:

    那么从树根开始:

  • 如果当前结点t 大于结点u、v,说明u、v都在t 的左侧,所以它们的共同祖先必定在t 的左子树中,故从t 的左子树中继续查找;
  • 如果当前结点t 小于结点u、v,说明u、v都在t 的右侧,所以它们的共同祖先必定在t 的右子树中,故从t 的右子树中继续查找;
  • 如果当前结点t 满足 u <t < v,说明u和v分居在t 的两侧,故当前结点t 即为最近公共祖先;
  • 而如果u是v的祖先,那么返回u的父结点,同理,如果v是u的祖先,那么返回v的父结点。

  代码如下所示:

  1. //copyright@eriol 2011  
  2. //modified by July 2014  
  3. public int query(Node t, Node u, Node v) {    
  4.     int left = u.value;    
  5.     int right = v.value;    
  6.     Node parent = null;    
  7.   
  8.     //二叉查找树内,如果左结点大于右结点,不对,交换  
  9.     if (left > right) {    
  10.         int temp = left;    
  11.         left = right;    
  12.         right = temp;    
  13.     }    
  14.   
  15.     while (true) {    
  16.         //如果t小于u、v,往t的右子树中查找  
  17.         if (t.value < left) {    
  18.             parent = t;    
  19.             t = t.right;    
  20.   
  21.         //如果t大于u、v,往t的左子树中查找  
  22.         } else if (t.value > right) {    
  23.             parent = t;    
  24.             t = t.left;    
  25.         } else if (t.value == left || t.value == right) {    
  26.             return parent.value;    
  27.         } else {    
  28.             return t.value;    
  29.         }    
  30.     }    
  31. }    

若这棵树不是二叉查找树


1.暴力搜索(基本的遍历方法必须牢记)

来判断一个结点的子树中是不是包含了另外一个结点。这不是件很难的事,我们可以用递归的方法来实现:

/////////////////////////////////////////////////////////////////////////////////

// If the tree with head pHead has a node pNode, return true.

// Otherwise return false.

/////////////////////////////////////////////////////////////////////////////////

bool HasNode(TreeNode* pHead, TreeNode* pNode)

{

    if(pHead == pNode)

        return true;

 

    bool has = false;

 

    if(pHead->m_pLeft != NULL)

        has = HasNode(pHead->m_pLeft, pNode);

 

    if(!has && pHead->m_pRight != NULL)

        has = HasNode(pHead->m_pRight, pNode);

 

    return has;

}

我们可以从根结点开始,判断以当前结点为根的树中左右子树是不是包含我们要找的两个结点。如果两个结点都出现在它的左子树中,那最低的共同父结点也出现在它的左子树中。如果两个结点都出现在它的右子树中,那最低的共同父结点也出现在它的右子树中。如果两个结点一个出现在左子树中,一个出现在右子树中,那当前的结点就是最低的共同父结点。基于这个思路,我们可以写出如下代码:

/////////////////////////////////////////////////////////////////////////////////

// Find the last parent of pNode1 and pNode2 in a tree with head pHead

/////////////////////////////////////////////////////////////////////////////////

TreeNode* LastCommonParent_1(TreeNode* pHead, TreeNode* pNode1, TreeNode* pNode2)

{

    if(pHead == NULL || pNode1 == NULL || pNode2 == NULL)

        return NULL;

 

    // check whether left child has pNode1 and pNode2

    bool leftHasNode1 = false;

    bool leftHasNode2 = false;

    if(pHead->m_pLeft != NULL)

    {

        leftHasNode1 = HasNode(pHead->m_pLeft, pNode1);

        leftHasNode2 = HasNode(pHead->m_pLeft, pNode2);

    }

 

    if(leftHasNode1 && leftHasNode2)

    {

        if(pHead->m_pLeft == pNode1 || pHead->m_pLeft == pNode2)

            return pHead;

 

        return LastCommonParent_1(pHead->m_pLeft, pNode1, pNode2);

    }

 

    // check whether right child has pNode1 and pNode2

    bool rightHasNode1 = false;

    bool rightHasNode2 = false;

    if(pHead->m_pRight != NULL)

    {

        if(!leftHasNode1)

            rightHasNode1 = HasNode(pHead->m_pRight, pNode1);

        if(!leftHasNode2)

            rightHasNode2 = HasNode(pHead->m_pRight, pNode2);

    }

 

    if(rightHasNode1 && rightHasNode2)

    {

        if(pHead->m_pRight == pNode1 || pHead->m_pRight == pNode2)

            return pHead;

 

        return LastCommonParent_1(pHead->m_pRight, pNode1, pNode2);

    }

 

    if((leftHasNode1 && rightHasNode2)

        || (leftHasNode2 && rightHasNode1))

        return pHead;

 

    return NULL;

}

接着我们来分析一下这个方法的效率。函数HasNode的本质就是遍历一棵树,其时间复杂度是O(n)n是树中结点的数目)。由于我们根结点开始,要对每个结点调用函数HasNode。因此总的时间复杂度是O(n2)

我们仔细分析上述代码,不难发现我们判断以一个结点为根的树是否含有某个结点时,需要遍历树的每个结点。接下来我们判断左子结点或者右结点为根的树中是否含有要找结点,仍然需要遍历。第二次遍历的操作其实在前面的第一次遍历都做过了。由于存在重复的遍历,本方法在时间效率上肯定不是最好的。


方法二  我们可以把问题转化为求两个链表的共同结点。

/////////////////////////////////////////////////////////////////////////////////

// Get the path form pHead and pNode in a tree with head pHead

/////////////////////////////////////////////////////////////////////////////////

bool GetNodePath(TreeNode* pHead, TreeNode* pNode, std::list<TreeNode*>& path)

{

    if(pHead == pNode)

        return true;

 

    path.push_back(pHead);

 

    bool found = false;

    if(pHead->m_pLeft != NULL)

        found = GetNodePath(pHead->m_pLeft, pNode, path);

    if(!found && pHead->m_pRight)

        found = GetNodePath(pHead->m_pRight, pNode, path);

 

    if(!found)

        path.pop_back();

 

    return found;

}

由于这个路径是从跟结点开始的。最低的共同父结点就是路径中的最后一个共同结点:

/////////////////////////////////////////////////////////////////////////////////

// Get the last common Node in two lists: path1 and path2

/////////////////////////////////////////////////////////////////////////////////

TreeNode* LastCommonNode

(

    const std::list<TreeNode*>& path1,

    const std::list<TreeNode*>& path2

)

{

    std::list<TreeNode*>::const_iterator iterator1 = path1.begin();

    std::list<TreeNode*>::const_iterator iterator2 = path2.begin();

   

    TreeNode* pLast = NULL;

 

    while(iterator1 != path1.end() && iterator2 != path2.end())

    {

        if(*iterator1 == *iterator2)

            pLast = *iterator1;

 

        iterator1++;

        iterator2++;                                                       (个人认为这里有问题?????)

    }

 

    return pLast;

}

有了前面两个子函数之后,求两个结点的最低共同父结点就很容易了。我们先求出从根结点出发到两个结点的两条路径,再求出两条路径的最后一个共同结点。代码如下:

/////////////////////////////////////////////////////////////////////////////////

// Find the last parent of pNode1 and pNode2 in a tree with head pHead

/////////////////////////////////////////////////////////////////////////////////

TreeNode* LastCommonParent_2(TreeNode* pHead, TreeNode* pNode1, TreeNode* pNode2)

{

    if(pHead == NULL || pNode1 == NULL || pNode2 == NULL)

        return NULL;

 

    std::list<TreeNode*> path1;

    GetNodePath(pHead, pNode1, path1);

 

    std::list<TreeNode*> path2;

    GetNodePath(pHead, pNode2, path2);

 

    return LastCommonNode(path1, path2);

}

这种思路的时间复杂度是O(n),时间效率要比第一种方法好很多。但同时我们也要注意到,这种思路需要两个链表来保存路径,空间效率比不上第一个方法。


方法三 Tarjan算法

Tarjan算法的原理是dfs + 并查集,它每次把两个结点对的最近公共祖先的查询保存起来,然后dfs 更新一次。如此,利用并查集优越的时空复杂度,此算法的时间复杂度可以缩小至O(n+Q),其中,n为数据规模,Q为询问个数。

方法四  转换为RMQ问题

3.1、什么是RMQ问题
RMQ,全称为Range Minimum Query,顾名思义,则是区间最值查询,它被用来在数组中查找两个指定索引中最小值的位置。即RMQ相当于给定数组A[0, N-1],找出给定的两个索引如 i、j 间的最小值的位置。
假设一个算法预处理时间为 f(n),查询时间为g(n),那么这个算法复杂度的标记为<f(n), g(n)>。我们将用RMQA(i, j) 来表示数组A 中索引i 和 j 之间最小值的位置。 u和v的离树T根结点最远的公共祖先用LCA T(u, v)表示。

    如下图所示,RMQA(2,7 )则表示求数组A中从A[2]~A[7]这段区间中的最小值:


    很显然,从上图中,我们可以看出最小值是A[3] = 1,所以也就不难得出最小值的索引值RMQA(2,7) = 3。


未完 待补充




0 0