二叉树的公共最近祖先问题

171 阅读4分钟

写在前面

如果觉得写得好或者有所收获,记得点个关注和点个赞,不胜感激。
我之前写过一篇文章,叫做二叉树的相关算法集合,其实现在回过头来,感觉标题太大言不惭了,应该改成遍历算法集合,因为像今天遇到的这个问题,其实也是二叉树的相关算法,虽然解答的时候,实现了一种算法,但是,其实还有其他的一种算法思路,而且简洁明了,很值得学习,这里就记录讲解。

二叉搜索树的公共最近祖先问题

在这里插入图片描述

首先在正式讲解之前,我们先来讨论二叉搜索树的问题的解决方案。其实如果问题换成二叉搜索树的话,那么问题就简单很多了。二叉搜索树的性质如下:

  • 节点 N 左子树上的所有节点的值都小于等于节点 N 的值
  • 节点 N 右子树上的所有节点的值都大于等于节点 N 的值
  • 左子树和右子树也都是 二叉搜索树(BST)

所以,我们可以通过比较各节点之间的值,来判断最近公共祖先的节点。而最近公共祖先节点有如下三种情况:
在这里插入图片描述
我们发现,这三种情况其实很好解决,非常容易想到的就是通过递归来完成我们的方案。具体的算法思路如下:

  • 从根节点开始遍历树
  • 如果节点 p 和节点 q 都在右子树上,那么以右孩子为根节点继续 1 的操作
  • 如果节点 p 和节点 q 都在左子树上,那么以左孩子为根节点继续 1 的操作
  • 如果条件 2 和条件 3 都不成立,这就意味着我们已经找到节 p 和节点 q 的 LCA 了

有了上面的算法思路,我们很容易写出代码

public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if (p.val < root.val && q.val < root.val) {
        return lowestCommonAncestor(root.left, p, q);
    }else if (p.val > root.val && q.val > root.val) {
        return lowestCommonAncestor(root.right, p, q);
    }else return root;
}

当然,可以使用迭代来代替递归的解法,这里就不做深入的叙述了,有兴趣的可以自己去实现以下,我们主要来讲解更一般的情况,也就是二叉树的最近祖先问题。

二叉树的公共最近祖先问题

更一般的情况,也就是二叉树。对于普通的二叉树,我们其实第一直觉想到的就是,就是同时找到两个节点的路径,然后两条路在这里插入图片描述
径重合的分开处的节点,就是我们要找的最近公共祖先节点了。要完成这样的思路,我们可以非常暴力的通过递归,遍历记录两个节点的路径。不过我最开始想到的思路,是在这种暴力的基础上进行改进,可以简单的理解成剪枝了一下(直觉让我觉得直接暴力不是我想要的,嘿嘿嘿)。所以我就是用了类似前缀的思路,点到为止。我是通过一个哈希表,Key 为子节点,Value是节点,这样就可以记录路径关系,而不是简单的使用List 来记录(当然,使用List记录也可以,不过这样的话,还是需要使用递归来完成,所以我想使用迭代)。算法思路如下:

  • 从根节点开始遍历树。
  • 通过循环迭代,在找到 p 和 q 之前,将父指针存储在字典中。
  • 一旦我们找到了 p 和 q,我们就可以使用父指针字典获得 p 的所有祖先,并添加到一个称为祖先的集合中。
  • 同样,我们遍历节点 q 的祖先。如果祖先存在于为 p 设置的祖先中,这意味着这是 p 和 q 之间的第一个公共祖先(同时向上遍历),因此这是 LCA 节点。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    Deque<TreeNode> stack = new ArrayDeque<>();
    Map<TreeNode, TreeNode> parent = new HashMap<>();

    parent.put(root, null);
    stack.push(root);
    
    //通过一层一层的遍历,知道遍历到两个节点为止
    while (!parent.containsKey(p) || !parent.containsKey(q)) {
        TreeNode node = stack.pop();

        if (node.left != null) {
            parent.put(node.left, node);
            stack.push(node.left);
        }
        if (node.right != null) {
            parent.put(node.right, node);
            stack.push(node.right);
        }
    }

    Set<TreeNode> ancestors = new HashSet<>();

    while (p != null) {
        ancestors.add(p);
        p = parent.get(p);
    }

    while (!ancestors.contains(q))
        q = parent.get(q);
    return q;
}

不记录路径思路

当然,我们上面的第一直觉就是我们通过记录两个节点的路径,再来找公共祖先,但是其实我们在遍历的过程中,就可以通过记录当前祖先的方式,找到公共祖先。不过和上面的思路的遍历方式区别的地方在于,上面的思路是通过层次遍历,而这种方式是通过深度遍历。可能这样说比较抽象,我们这里使用图来理解,图是直接引用LeetCode的图。

我们通过栈来实现深度遍历,当我们遇到第一个节点的时候,我们就记录该节点为公共祖先节点,然后继续遍历。
在这里插入图片描述
当记录的公共祖先节点被弹出栈时,我们就要把公共节点移动到当前栈顶的节点,如图所示。
在这里插入图片描述
继续弹出
在这里插入图片描述
当遇到第二个节点的时候,公共祖先的节点就被找到了。
在这里插入图片描述

private static int BOTH_PENDING = 2;
private static int LEFT_DONE = 1;
private static int BOTH_DONE = 0;

public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {

    Stack<Pair<TreeNode, Integer>> stack = new Stack<Pair<TreeNode, Integer>>();
    stack.push(new Pair<TreeNode, Integer>(root, Solution.BOTH_PENDING));

    boolean one_node_found = false;
    TreeNode LCA = null;
    TreeNode child_node = null;

    while (!stack.isEmpty()) {

        Pair<TreeNode, Integer> top = stack.peek();
        TreeNode parent_node = top.getKey();
        int parent_state = top.getValue();

        if (parent_state != Solution.BOTH_DONE) {
            if (parent_state == Solution.BOTH_PENDING) {
                if (parent_node == p || parent_node == q) {
                    if (one_node_found) {
                        return LCA;
                    } else {
                        one_node_found = true;
                        LCA = stack.peek().getKey();
                    }
                }
                child_node = parent_node.left;
            } else {
                child_node = parent_node.right;
            }
            stack.pop();
            stack.push(new Pair<TreeNode, Integer>(parent_node, parent_state - 1));

            if (child_node != null) {
                stack.push(new Pair<TreeNode, Integer>(child_node, Solution.BOTH_PENDING));
            }
        } else {
            if (LCA == stack.pop().getKey() && one_node_found) {
                LCA = stack.peek().getKey();
            }
        }
    }
    return null;
}

递归思路

其实这里为啥把递归放最后,是因为这里要讲的递归思路的一种巧妙方式,我觉得很值得学习。

  • 从根节点开始遍历树。
  • 如果当前节点本身是 p 或 q 中的一个,我们会将变量 mid 标记为 true,并继续搜索左右分支中的另一个节点。
  • 如果左分支或右分支中的任何一个返回 true,则表示在下面找到了两个节点中的一个。
  • 如果在遍历的任何点上,left、right 或者 mid 三个标记中的任意两个变为 true,这意味着我们找到了节点 p 和 q 的最近公共祖先。
private TreeNode res;
public boolean recurseTree(TreeNode currentNode, TreeNode p, TreeNode q) {
    if (currentNode == null) return false;

    int left = recurseTree(currentNode.left, p, q) ? 1 : 0;
    int right = recurseTree(currentNode.right, p, q) ? 1 : 0;

    int mid = 0;
    if (currentNode.val == p.val || currentNode.val == q.val) mid = 1;

    if (left + right + mid >= 2) {
        res = currentNode;
        return false;
    } else if (left + right + mid > 0) {
        return true;
    } else return false;
}

public TreeNode lowestCommonAncestorS1(TreeNode root, TreeNode p, TreeNode q) {
    recurseTree(root, p, q);
    return res;
}