如何在任意二叉树中找到两个节点的最近公共祖先?

190

这里的二叉树不一定是二叉搜索树。
可以将其结构表示为 -

struct node {
    int data;
    struct node *left;
    struct node *right;
};
我和我的朋友想到的最优解如下:
考虑这个二叉树

Binary Tree

其中序遍历结果为:8,4,9,2,5,1,6,3,7

后序遍历结果为:8,9,4,5,2,6,7,3,1

例如,如果我们要找到节点8和5的公共祖先,则需要在中序遍历时列出所有位于8和5之间的节点,而在此示例中,它们是[4,9,2]。然后我们检查此列表中最后出现在后序遍历中的节点,即2。因此,8和5的公共祖先是2。

我认为该算法的复杂度为O(n)(中序/后序遍历的O(n),其余步骤再次为O(n),因为它们仅是数组中的简单迭代)。但很有可能我错了。:-)

但这只是一个非常粗略的方法,我不确定是否有一些情况会失效。还有其他(可能更优)的解决方案吗?


6
出于好奇,这个有什么实际用途? - David Brunelle
19
LCA查询回答非常有用。LCA+后缀树=强大的字符串相关算法。 - Aryabhatta
44
当我问了类似的问题时,它被投票下降,并且有评论说这是一道面试题。这就是 Stack Overflow 的二元性吗? :( - some_other_guy
5
谢谢你的赞赏!感谢你在问题中提供了详细信息。 :) - amod
5
计算最近公共祖先(LCA)的一个实际应用是在呈现网页时,在计算适用于特定DOM元素的级联样式表(CSS)时,LCA是必要的计算。 - zc22
显示剩余7条评论
34个回答

109

root节点开始向下移动,如果找到任何一个节点,它的直接子节点是pq,那么这个节点就是LCA。 如果pq是节点的值,则返回该节点。否则,当pq之一是另一个的直接子节点时,将无法成功。

否则,如果在其右(或左)子树中找到具有p的节点和在其左(或右)子树中找到具有q的节点,则该节点是LCA。

修复后的代码如下:

treeNodePtr findLCA(treeNodePtr root, treeNodePtr p, treeNodePtr q) {

        // no root no LCA.
        if(!root) {
                return NULL;
        }

        // if either p or q is the root then root is LCA.
        if(root==p || root==q) {
                return root;
        } else {
                // get LCA of p and q in left subtree.
                treeNodePtr l=findLCA(root->left , p , q);

                // get LCA of p and q in right subtree.
                treeNodePtr r=findLCA(root->right , p, q);

                // if one of p or q is in leftsubtree and other is in right
                // then root it the LCA.
                if(l && r) {
                        return root;
                }
                // else if l is not null, l is LCA.
                else if(l) {
                        return l;
                } else {
                        return r;
                }
        }
}
下面的代码在其中一个元素是另一个元素的直接子元素时会失败。
treeNodePtr findLCA(treeNodePtr root, treeNodePtr p, treeNodePtr q) {

        // no root no LCA.
        if(!root) {
                return NULL;
        }

        // if either p or q is direct child of root then root is LCA.
        if(root->left==p || root->left==q || 
           root->right ==p || root->right ==q) {
                return root;
        } else {
                // get LCA of p and q in left subtree.
                treeNodePtr l=findLCA(root->left , p , q);

                // get LCA of p and q in right subtree.
                treeNodePtr r=findLCA(root->right , p, q);

                // if one of p or q is in leftsubtree and other is in right
                // then root it the LCA.
                if(l && r) {
                        return root;
                }
                // else if l is not null, l is LCA.
                else if(l) {
                        return l;
                } else {
                        return r;
                }
        }
}

代码展示


3
优雅的解决方案,但是root==p || root==q => return root的部分似乎过于乐观。如果实际上root是p/q,但另一个寻找的节点并不在树中怎么办? - Ian Durkan
16
我猜测当p或q的值不在二叉树中时,这段代码会失败。我的理解是否正确?比如说LCA(8,20),你的代码返回了8,但是20并不存在于该二叉树中。 - javaMan
3
这个解决方案的成本是多少?它是否有效?它似乎在找到p和q后仍然继续搜索。这是因为树中可能存在重复项,导致p和q不唯一,并且该算法需要找出所有符合条件的p和q。 - MikeB
3
@MikeB,这个解决方案一定是O(n),因为在最坏的情况下,你只遍历每个节点一次。Peter Lee,如果不使用父指针,这已经是你能做到的最有效率的方法了。你有更好的解决方案吗? - gsgx
8
第一个不完美的解决方案应该被删除,以免分散注意力。 - RomanKousta
显示剩余9条评论

74

Nick Johnson说如果没有父指针,O(n)时间复杂度算法是最好的。要查看该算法的简单递归版本,请参见Kinding's post中的代码,它以O(n)的时间运行。

但请记住,如果您的节点有父指针,则可能存在改进的算法。对于两个问题中的节点,请构造一个包含从根到节点的路径的列表,方法是从节点开始,并在前面插入父节点。

因此,对于您的示例中的8,您将获得(显示步骤):{4},{2, 4},{1,2,4}

对于您要查询的另一个节点执行相同的操作,结果为(未显示步骤):{1, 2}

现在比较您制作的两个列表,查找列表不同的第一个元素或其中一个列表的最后一个元素,以先出现的为准。

该算法需要O(h)时间,其中h是树的高度。在最坏的情况下,O(h)等价于O(n),但如果树是平衡的,则仅为O(log(n))。它还需要O(h)空间。可以使用仅使用常量空间的改进版本,其代码显示在CEGRD's post中。


无论树是如何构建的,如果这将是您在树上执行许多次而在中间不更改它的操作,则有其他算法可用,需要O(n) [线性]时间准备,但然后查找任何一对只需O(1) [常数]时间。有关这些算法的参考,请参阅维基百科上的最近公共祖先问题页面。(原链接由Jason提供)

1
如果给定了父节点指针,那么这将完成任务。树中的节点就像我在问题中给出的结构一样-只有左/右子节点指针,没有父节点指针。如果没有可用的父节点指针,而且树不是二叉搜索树,只是一个二叉树,是否有 O(log(n)) 的解决方案? - Siddhant
2
如果您没有特定的方法来寻找父节点和给定节点之间的路径,那么平均需要O(n)时间来查找。这将使得无法在O(log(n))时间内完成。然而,如果您要在树中多次执行此操作且在其间不更改树结构,则具有O(n)的一次成本和O(1)对查找可能是最好的选择。否则,如果有可能的话,请添加父指针。它可以使许多潜在算法更快,但我相信它不会改变任何现有算法的顺序。希望这能帮助到您。 - Kevin Cathcart
1
这种方法可以使用O(1)内存完成--请参见Artelius(和其他人)在https://dev59.com/hnI-5IYBdhLWcg3w9thw的解决方案。 - Tom Sirgedas
@Tom:确实,这将限制基于列表的算法的内存复杂度为O(1)。显然,这意味着需要遍历整个树两次来获取节点深度以查找共同祖先。对于拥有父指针而不进行O(n)预计算的情况,O(h) 时间和 O(1) 空间显然是最优的。 - Kevin Cathcart
复杂度O(h)是如何计算的?这不是二叉搜索树,因此为了找到从根节点到叶子节点的路径,我们必须在左右子树中遍历。因此,它不能是O(log n)。 - ALBI
1
@ALBI 如果树是平衡的,则O(h)仅为O(log(n))。对于任何树,无论是二叉还是非二叉,如果您有父节点指针,则可以通过跟随父节点指针最多h次来在O(h)时间内确定从叶到根的路径。这将给您从叶子到根的路径。如果将路径存储为堆栈,则迭代堆栈将为您提供从根到叶子的路径。如果缺少父节点指针,并且树没有特殊结构,则找到从根到叶子的路径需要O(n)时间。 - Kevin Cathcart

51

以下是JAVA的工作代码

public static Node LCA(Node root, Node a, Node b) {
   if (root == null) {
       return null;
   }

   // If the root is one of a or b, then it is the LCA
   if (root == a || root == b) {
       return root;
   }

   Node left = LCA(root.left, a, b);
   Node right = LCA(root.right, a, b);

   // If both nodes lie in left or right then their LCA is in left or right,
   // Otherwise root is their LCA
   if (left != null && right != null) {
      return root;
   }

   return (left != null) ? left : right; 
}

4
当一个节点不存在于树中时,这并不起作用。 - Pratik Khadloya
如果给定的树是二叉搜索树,你会优化你的代码吗? - Mona Jalal
1
如果根节点是a或b中的一个,则它就是LCA,但这并不一定正确。此时你所知道的是,你不需要检查任何子节点来找到LCA。这是因为我们可以稍后检查根节点的父节点,看看两个分支上是否都有匹配项(LCA是父节点),或者只有其中一个分支有匹配项(在这种情况下,该分支可能是LCA,或者更大的祖先可能是LCA)。 - andresp

30

到目前为止给出的答案使用递归或存储路径在内存中等方式。

这两种方法可能会因为树的深度太大而失败。

以下是我对此问题的看法。当我们检查两个节点的深度(距离根的距离)时,如果它们相等,那么我们可以安全地从两个节点向上移动,直到共同祖先。如果其中一个深度更大,则我们应该从深层节点向上移动,同时保持在另一个节点。

以下是代码:

findLowestCommonAncestor(v,w):
  depth_vv = depth(v);
  depth_ww = depth(w);

  vv = v; 
  ww = w;

  while( depth_vv != depth_ww ) {
    if ( depth_vv > depth_ww ) {
      vv = parent(vv);
      depth_vv--;
    else {
      ww = parent(ww);
      depth_ww--;
    }
  }

  while( vv != ww ) {
    vv = parent(vv);
    ww = parent(ww);
  }

  return vv;    

该算法的时间复杂度为:O(n)。算法的空间复杂度为:O(1)。

关于深度的计算,我们可以先记住定义:如果v是根,则depth(v) = 0;否则,depth(v) = depth(parent(v)) + 1。我们可以按照以下方法计算深度:

depth(v):
  int d = 0;
  vv = v;
  while ( vv is not root ) {
    vv = parent(vv);
    d++;
  }
  return d;

6
二叉树通常没有指向父节点的引用。添加父节点引用可以轻松完成,但我认为它需要 O(n) 的额外空间。 - John Kurlak
这个解决方案中有一个微妙的假设。如果一个节点是另一个节点的直接或间接父节点(即,深度较大的节点在以深度较浅的节点为根的树中),则此解决方案将返回深度较浅节点的父节点作为结果。根据您如何定义最近公共祖先,这可能不是您想要的。某些定义将要求深度较浅节点本身是父节点。在这种情况下,您需要跟踪哪个是深度较浅的节点并返回该节点。 - Srikanth

9
这可以在以下网址找到:- http://goursaha.freeoda.com/DataStructure/LowestCommonAncestor.html
 tree_node_type *LowestCommonAncestor(
 tree_node_type *root , tree_node_type *p , tree_node_type *q)
 {
     tree_node_type *l , *r , *temp;
     if(root==NULL)
     {
        return NULL;
     }

    if(root->left==p || root->left==q || root->right ==p || root->right ==q)
    {
        return root;
    }
    else
    {
        l=LowestCommonAncestor(root->left , p , q);
        r=LowestCommonAncestor(root->right , p, q);

        if(l!=NULL && r!=NULL)
        {
            return root;
        }
        else
        {
        temp = (l!=NULL)?l:r;
        return temp;
        }
    }
}

请问一下,如果树中存在p但完全不存在q,您的代码会如何表现?同样地,如果p和q都不存在,会怎么样呢?谢谢! - Trying
时间复杂度的大O是什么?我认为它是O(n*log(n)),太慢了。 - Peter Lee

9

这有点取决于你的二叉树结构。假设你有一种方法可以在给定根节点的情况下找到所需的叶节点-只需将其应用于两个值,直到所选择的分支分歧。

如果没有办法在给定根节点的情况下找到所需的叶节点,则你唯一的解决方案-无论是正常操作还是查找最后一个公共节点-都是对树进行暴力搜索。


7

查找两个节点的共同祖先:

  • 使用二分查找在树中查找给定的节点 Node1,并将此过程中访问的所有节点保存在一个数组中,称为 A1。时间 - O(logn),空间 - O(logn)
  • 使用二分查找在树中查找给定的 Node2,并将此过程中访问的所有节点保存在一个数组中,称为 A2。时间 - O(logn),空间 - O(logn)
  • 如果 A1 列表或 A2 列表为空,则其中一个节点不存在,因此不存在共同祖先。
  • 如果 A1 列表和 A2 列表都非空,则查看列表,直到找到不匹配的节点。一旦找到这样的节点,那么该节点之前的节点就是共同祖先。

这适用于二叉搜索树。


2
他明确指出树不一定是二叉搜索树。 - Peter Lee
@Peter Lee - 上述逻辑即使对于任何二叉树也可以简单更改而适用。不是对给定节点进行二分查找,而是应用线性查找(即任何遍历但两种情况应该相同)。当然,运行时间将是O(n)而不是O(logn)。实际上,当父指针不可用时,此算法是最强大的算法。许多人提供的递归算法(如'codaddict')在给定节点之一不属于树时将无法工作。 - KGhatak

7

5

3
下面的递归算法对于平衡二叉树的运行时间为O(log N)。如果传入getLCA()函数的任何一个节点与根节点相同,则根节点将成为LCA,无需执行任何递归操作。
测试用例: [1] 节点n1和n2都在树中,并且位于其父节点的两侧。 [2] n1或n2是根节点,则LCA是根节点。 [3] 只有n1或n2在树中,LCA将是树根的左子树的根节点,或者LCA将是树根的右子树的根节点。
[4] n1和n2都不在树中,没有LCA。 [5] n1和n2都在一条直线上,LCA将是n1或n2中离树根最近的节点。
//find the search node below root
bool findNode(node* root, node* search)
{
    //base case
    if(root == NULL)
        return false;

    if(root->val == search->val)
        return true;

    //search for the node in the left and right subtrees, if found in either return true
    return (findNode(root->left, search) || findNode(root->right, search));
}

//returns the LCA, n1 & n2 are the 2 nodes for which we are
//establishing the LCA for
node* getLCA(node* root, node* n1, node* n2)
{
    //base case
    if(root == NULL)
        return NULL;

    //If 1 of the nodes is the root then the root is the LCA
    //no need to recurse.
    if(n1 == root || n2 == root)
        return root;

    //check on which side of the root n1 and n2 reside
    bool n1OnLeft = findNode(root->left, n1);
    bool n2OnLeft = findNode(root->left, n2);

    //n1 & n2 are on different sides of the root, so root is the LCA
    if(n1OnLeft != n2OnLeft)
        return root;

    //if both n1 & n2 are on the left of the root traverse left sub tree only
    //to find the node where n1 & n2 diverge otherwise traverse right subtree
    if(n1OnLeft)
        return getLCA(root->left, n1, n2);
    else
        return getLCA(root->right, n1, n2);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接