Git原理之最近公共祖先

Git原理之最近公共祖先

读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:

LeetCode 力扣 难度
1644. Lowest Common Ancestor of a Binary Tree IIopen in new window🔒 1644. 二叉树的最近公共祖先 IIopen in new window🔒 🟠
1650. Lowest Common Ancestor of a Binary Tree IIIopen in new window🔒 1650. 二叉树的最近公共祖先 IIIopen in new window🔒 🟠
1676. Lowest Common Ancestor of a Binary Tree IVopen in new window🔒 1676. 二叉树的最近公共祖先 IVopen in new window🔒 🟠
235. Lowest Common Ancestor of a Binary Search Treeopen in new window 235. 二叉搜索树的最近公共祖先open in new window 🟠
236. Lowest Common Ancestor of a Binary Treeopen in new window 236. 二叉树的最近公共祖先open in new window 🟠
- 剑指 Offer 68 - I. 二叉搜索树的最近公共祖先open in new window 🟢
- 剑指 Offer 68 - II. 二叉树的最近公共祖先open in new window 🟢

Info

在开头先打个广告,我的 手把手刷二叉树课程open in new window 按照公式和套路讲解了 150 道二叉树题目,只需一顿饭钱,就能手把手带你刷完二叉树分类的题目,迅速掌握递归思维,让你豁然开朗。我绝对有这个信心,信不信,可以等你看完我的二叉树算法系列文章再做评判。

如果说笔试的时候经常遇到各种动归回溯这类稍有难度的题目,那么面试会倾向于一些比较经典的问题,难度不算大,而且也比较实用。

本文就用 Git 引出一个经典的算法问题:最近公共祖先(Lowest Common Ancestor,简称 LCA)。

git pull 这个命令我们经常会用,它默认是使用 merge 方式将远端别人的修改拉到本地;如果带上参数 git pull -r,就会使用 rebase 的方式将远端修改拉到本地。

这二者最直观的区别就是:merge 方式合并的分支会看到很多「分叉」,而 rebase 方式合并的分支就是一条直线。但无论哪种方式,如果存在冲突,Git 都会检测出来并让你手动解决冲突。

那么问题来了,Git 是如何检测两条分支是否存在冲突的呢?

rebase 命令为例,比如下图的情况,我站在 dev 分支执行 git rebase master,然后 dev 就会接到 master 分支之上:

img

这个过程中,Git 是这么做的:

首先,找到这两条分支的最近公共祖先 LCA,然后从 master 节点开始,重演 LCAdev 几个 commit 的修改,如果这些修改和 LCAmastercommit 有冲突,就会提示你手动解决冲突,最后的结果就是把 dev 的分支完全接到 master 上面。

那么,Git 是如何找到两条不同分支的最近公共祖先的呢?这就是一个经典的算法问题了,下面我来由浅入深讲一讲。

🌟


🌟

#寻找一个元素

先不管最近公共祖先问题,我请你实现一个简单的算法:

给你输入一棵没有重复元素的二叉树根节点 root 和一个目标值 val,请你写一个函数寻找树中值为 val 的节点。

函数签名如下:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode find(TreeNode root, int val);

这个函数应该很容易实现对吧,比如我这样写代码:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

// 定义:在以 root 为根的二叉树中寻找值为 val 的节点
TreeNode find(TreeNode root, int val) {
// base case
if (root == null) {
return null;
}
// 看看 root.val 是不是要找的
if (root.val == val) {
return root;
}
// root 不是目标节点,那就去左子树找
TreeNode left = find(root.left, val);
if (left != null) {
return left;
}
// 左子树找不着,那就去左子树找
TreeNode right = find(root.right, val);
if (right != null) {
return right;
}
// 实在找不到了
return null;
}

这段代码应该不用我多解释了,我下面基于这段代码做一些简单的改写,请你分析一下我的改动会造成什么影响。

提示

如果你没读过前文 东哥带你刷二叉树(纲领篇),强烈建议阅读一下,理解二叉树前中后序遍历的奥义。

首先,我修改一下 return 的位置:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode find(TreeNode root, int val) {
if (root == null) {
return null;
}
// 前序位置
if (root.val == val) {
return root;
}
// root 不是目标节点,去左右子树寻找
TreeNode left = find(root.left, val);
TreeNode right = find(root.right, val);
// 看看哪边找到了
return left != null ? left : right;
}

这段代码也可以达到目的,但是实际运行的效率会低一些,原因也很简单,如果你能够在左子树找到目标节点,还有没有必要去右子树找了?没有必要。但这段代码还是会去右子树找一圈,所以效率相对差一些。

更进一步,我把对 root.val 的判断从前序位置移动到后序位置:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode find(TreeNode root, int val) {
if (root == null) {
return null;
}
// 先去左右子树寻找
TreeNode left = find(root.left, val);
TreeNode right = find(root.right, val);
// 后序位置,看看 root 是不是目标节点
if (root.val == val) {
return root;
}
// root 不是目标节点,再去看看哪边的子树找到了
return left != null ? left : right;
}

这段代码相当于你先去左右子树找,然后才检查 root,依然可以到达目的,但是效率会进一步下降。因为这种写法必然会遍历二叉树的每一个节点

对于之前的解法,你在前序位置就检查 root,如果输入的二叉树根节点的值恰好就是目标值 val,那么函数直接结束了,其他的节点根本不用搜索。

但如果你在后序位置判断,那么就算根节点就是目标节点,你也要去左右子树遍历完所有节点才能判断出来。

最后,我再改一下题目,现在不让你找值为 val 的节点,而是寻找值为 val1 val2 的节点,函数签名如下:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode find(TreeNode root, int val1, int val2);

这和我们第一次实现的 find 函数基本上是一样的,而且你应该知道可以有多种写法,我选择这样写代码:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

// 定义:在以 root 为根的二叉树中寻找值为 val1 或 val2 的节点
TreeNode find(TreeNode root, int val1, int val2) {
// base case
if (root == null) {
return null;
}
// 前序位置,看看 root 是不是目标值
if (root.val == val1 || root.val == val2) {
return root;
}
// 去左右子树寻找
TreeNode left = find(root.left, val1, val2);
TreeNode right = find(root.right, val1, val2);
// 后序位置,已经知道左右子树是否存在目标值

return left != null ? left : right;
}

为什么要写这样一个奇怪的 find 函数呢?因为最近公共祖先系列问题的解法都是把这个函数作为框架的

下面一道一道题目来看。

#秒杀五道题目

先来看看力扣第 236 题「二叉树的最近公共祖先open in new window」:

给你输入一棵不含重复值的二叉树,以及存在于树中的两个节点 pq,请你计算 pq 的最近公共祖先节点。

后文我用 LCA(Lowest Common Ancestor)作为最近公共祖先节点的缩写。

比如输入这样一棵二叉树:

img

如果 p 是节点 6q 是节点 7,那么它俩的 LCA 就是节点 5

img

当然,pq 本身也可能是 LCA,比如这种情况 q 本身就是 LCA 节点:

img

两个节点的最近公共祖先其实就是这两个节点向根节点的「延长线」的交汇点,那么对于任意一个节点,它怎么才能知道自己是不是 pq 的最近公共祖先?

如果一个节点能够在它的左右子树中分别找到 pq,则该节点为 LCA 节点

这就要用到之前实现的 find 函数了,只需在后序位置添加一个判断逻辑,即可改造成寻找最近公共祖先的解法代码:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
return find(root, p.val, q.val);
}

// 在二叉树中寻找 val1 和 val2 的最近公共祖先节点
TreeNode find(TreeNode root, int val1, int val2) {
if (root == null) {
return null;
}
// 前序位置
if (root.val == val1 || root.val == val2) {
// 如果遇到目标值,直接返回
return root;
}
TreeNode left = find(root.left, val1, val2);
TreeNode right = find(root.right, val1, val2);
// 后序位置,已经知道左右子树是否存在目标值
if (left != null && right != null) {
// 当前节点是 LCA 节点
return root;
}

return left != null ? left : right;
}

find 函数的后序位置,如果发现 leftright 都非空,就说明当前节点是 LCA 节点,即解决了第一种情况:

img

find 函数的前序位置,如果找到一个值为 val1val2 的节点则直接返回,恰好解决了第二种情况:

img

因为题目说了 pq 一定存在于二叉树中(这点很重要),所以即便我们遇到 q 就直接返回,根本没遍历到 p,也依然可以断定 pq 底下,q 就是 LCA 节点。

这样,标准的最近公共祖先问题就解决了,接下来看看这个题目有什么变体。

比如力扣第 1676 题「二叉树的最近公共祖先 IVopen in new window」:

依然给你输入一棵不含重复值的二叉树,但这次不是给你输入 pq 两个节点了,而是给你输入一个包含若干节点的列表 nodes(这些节点都存在于二叉树中),让你算这些节点的最近公共祖先。

函数签名如下:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode lowestCommonAncestor(TreeNode root, TreeNode[] nodes);

比如还是这棵二叉树:

img

输入 nodes = [7,4,6],那么函数应该返回节点 5

看起来怪吓人的,实则解法逻辑是一样的,把刚才的代码逻辑稍加改造即可解决这道题:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode lowestCommonAncestor(TreeNode root, TreeNode[] nodes) {
// 将列表转化成哈希集合,便于判断元素是否存在
HashSet<Integer> values = new HashSet<>();
for (TreeNode node : nodes) {
values.add(node.val);
}

return find(root, values);
}

// 在二叉树中寻找 values 的最近公共祖先节点
TreeNode find(TreeNode root, HashSet<Integer> values) {
if (root == null) {
return null;
}
// 前序位置
if (values.contains(root.val)){
return root;
}

TreeNode left = find(root.left, values);
TreeNode right = find(root.right, values);
// 后序位置,已经知道左右子树是否存在目标值
if (left != null && right != null) {
// 当前节点是 LCA 节点
return root;
}

return left != null ? left : right;
}

有刚才的铺垫,你类比一下应该不难理解这个解法。

不过需要注意的是,这两道题的题目都明确告诉我们这些节点必定存在于二叉树中,如果没有这个前提条件,就需要修改代码了

比如力扣第 1644 题「二叉树的最近公共祖先 IIopen in new window」:

给你输入一棵不含重复值的二叉树的,以及两个节点 pq,如果 pq 不存在于树中,则返回空指针,否则的话返回 pq 的最近公共祖先节点。

在解决标准的最近公共祖先问题时,我们在 find 函数的前序位置有这样一段代码:

// 前序位置
if (root.val == val1 || root.val == val2) {
// 如果遇到目标值,直接返回
return root;
}

我也进行了解释,因为 pq 都存在于树中,所以这段代码恰好可以解决最近公共祖先的第二种情况:

img

但对于这道题来说,pq 不一定存在于树中,所以你不能遇到一个目标值就直接返回,而应该对二叉树进行完全搜索(遍历每一个节点),如果发现 pq 不存在于树中,那么是不存在 LCA 的。

回想我在文章开头分析的几种 find 函数的写法,哪种写法能够对二叉树进行完全搜索来着?

这种:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode find(TreeNode root, int val) {
if (root == null) {
return null;
}
// 先去左右子树寻找
TreeNode left = find(root.left, val);
TreeNode right = find(root.right, val);
// 后序位置,判断 root 是不是目标节点
if (root.val == val) {
return root;
}
// root 不是目标节点,再去看看哪边的子树找到了
return left != null ? left : right;
}

那么解决这道题也是类似的,我们只需要把前序位置的判断逻辑放到后序位置即可:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Solution {
// 用于记录 p 和 q 是否存在于二叉树中
boolean foundP = false, foundQ = false;

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
TreeNode res = find(root, p.val, q.val);
if (!foundP || !foundQ) {
return null;
}
// p 和 q 都存在二叉树中,才有公共祖先
return res;
}

// 在二叉树中寻找 val1 和 val2 的最近公共祖先节点
TreeNode find(TreeNode root, int val1, int val2) {
if (root == null) {
return null;
}
TreeNode left = find(root.left, val1, val2);
TreeNode right = find(root.right, val1, val2);

// 后序位置,判断当前节点是不是 LCA 节点
if (left != null && right != null) {
return root;
}

// 后序位置,判断当前节点是不是目标值
if (root.val == val1 || root.val == val2) {
// 找到了,记录一下
if (root.val == val1) foundP = true;
if (root.val == val2) foundQ = true;
return root;
}

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

这样改造,对二叉树进行完全搜索,同时记录 pq 是否同时存在树中,从而满足题目的要求。

接下来,我们再变一变,如果让你在二叉搜索树中寻找 pq 的最近公共祖先,应该如何做呢?

提示

二叉搜索树相关的题目详解见 东哥带你刷二叉搜索树open in new window

看力扣第 235 题「二叉搜索树的最近公共祖先open in new window」:

给你输入一棵不含重复值的二叉搜索树,以及存在于树中的两个节点 pq,请你计算 pq 的最近公共祖先节点。

把之前的解法代码复制过来肯定也可以解决这道题,但没有用到 BST「左小右大」的性质,显然效率不是最高的。

在标准的最近公共祖先问题中,我们要在后序位置通过左右子树的搜索结果来判断当前节点是不是 LCA

TreeNode left = find(root.left, val1, val2);
TreeNode right = find(root.right, val1, val2);

// 后序位置,判断当前节点是不是 LCA 节点
if (left != null && right != null) {
return root;
}

但对于 BST 来说,根本不需要老老实实去遍历子树,由于 BST 左小右大的性质,将当前节点的值与 val1val2 作对比即可判断当前节点是不是 LCA

假设 val1 < val2,那么 val1 <= root.val <= val2 则说明当前节点就是 LCA;若 root.valval1 还小,则需要去值更大的右子树寻找 LCA;若 root.valval2 还大,则需要去值更小的左子树寻找 LCA

依据这个思路就可以写出解法代码:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 保证 val1 较小,val2 较大
int val1 = Math.min(p.val, q.val);
int val2 = Math.max(p.val, q.val);
return find(root, val1, val2);
}

// 在 BST 中寻找 val1 和 val2 的最近公共祖先节点
TreeNode find(TreeNode root, int val1, int val2) {
if (root == null) {
return null;
}
if (root.val > val2) {
// 当前节点太大,去左子树找
return find(root.left, val1, val2);
}
if (root.val < val1) {
// 当前节点太小,去右子树找
return find(root.right, val1, val2);
}
// val1 <= root.val <= val2
// 则当前节点就是最近公共祖先
return root;
}

再看最后一道最近公共祖先的题目吧,力扣第 1650 题「二叉树的最近公共祖先 IIIopen in new window」,这次输入的二叉树节点比较特殊,包含指向父节点的指针:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

class Node {
int val;
Node left;
Node right;
Node parent;
};

给你输入一棵存在于二叉树中的两个节点 pq,请你返回它们的最近公共祖先,函数签名如下:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

Node lowestCommonAncestor(Node p, Node q);

由于节点中包含父节点的指针,所以二叉树的根节点就没必要输入了。

这道题其实不是公共祖先的问题,而是单链表相交的问题,你把 parent 指针想象成单链表的 next 指针,题目就变成了:

给你输入两个单链表的头结点 pq,这两个单链表必然会相交,请你返回相交点。

img

我在前文 单链表的六大解题套路 中详细讲解过求链表交点的问题,具体思路在本文就不展开了,直接给出本题的解法代码:

java 🟢cpp 🤖python 🤖go 🤖javascript 🤖

Node lowestCommonAncestor(Node p, Node q) {
// 施展链表双指针技巧
Node a = p, b = q;
while (a != b) {
// a 走一步,如果走到根节点,转到 q 节点
if (a == null) a = q;
else a = a.parent;
// b 走一步,如果走到根节点,转到 p 节点
if (b == null) b = p;
else b = b.parent;
}
return a;
}

至此,5 道最近公共祖先的题目就全部讲完了,前 3 道题目从一个基本的 find 函数衍生出解法,后 2 道比较特殊,分别利用了 BST 和单链表相关的技巧,希望本文对你有启发。