Git原理之最近公共祖先


讀完本文,你可以去力扣拿下如下題目:

236.二叉樹的最近公共祖先

-----------

如果說筆試的時候喜歡考各種動歸回溯的騷操作,面試其實最喜歡考比較經典的問題,難度不算太大,而且也比較實用。

上篇文章 四個命令玩轉 Git 寫了 Git 最常用的命令,沒有提分支合並,其實分支合並沒什么困難的,主要就是 mergerebase 兩種方式。本文就用 Git 的 rebase 工作方式引出一個經典的算法問題:最近公共祖先(Lowest Common Ancestor,簡稱 LCA)。

比如 git pull 這個命令,我們經常會用,它默認是使用 merge 方式將遠端別人的修改拉到本地;如果帶上參數 git pull -r,就會使用 rebase 的方式將遠端修改拉到本地。

這二者最直觀的區別就是:merge 方式合並的分支會有很多「分叉」,而 rebase 方式合並的分支就是一條直線。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

對於多人協作,merge 方式並不好,舉例來說,之前有很多朋友參加了在 GitHub 上的倉庫翻譯工作,GitHub 的 Pull Request 功能是使用 merge 方式,所以你看 fucking-algorithm 倉庫的 Git 歷史:

畫面看起來很炫酷,但實際上我們並不希望出現這種情形的。你想想,光是合並別人的代碼就這般群魔亂舞,如果說你本地還有多個開發分支,那畫面肯定更雜亂,雜亂就意味着很容易出問題,所以一般來說,實際工作中更推薦使用 rebase 方式合並代碼

那么問題來了,rebase 是如何將兩條不同的分支合並到同一條分支的呢:

上圖的情況是,我站在 dev 分支,使用 git rebase master,然后就會把 dev 接到 master 分支之上。Git 是這么做的:

首先,找到這兩條分支的最近公共祖先 LCA,然后從 master 節點開始,重演 LCAdev 幾個 commit 的修改,如果這些修改和 LCAmastercommit 有沖突,就會提示你手動解決沖突,最后的結果就是把 dev 的分支完全接到 master 上面。

那么,Git 是如何找到兩條不同分支的最近公共祖先的呢?這就是一個經典的算法問題了,下面來詳解。

二叉樹的最近公共祖先

這個問題可以在 LeetCode 上找到,看下題目:

函數的簽名如下:

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q);

root 節點確定了一棵二叉樹,pq 是這棵二叉樹上的兩個節點,讓你返回 p 節點和 q 節點的最近公共祖先節點。

我們前文 學習數據結構和算法的框架思維 就說過了,所有二叉樹的套路都是一樣的:

void traverse(TreeNode root) {
    // 前序遍歷
    traverse(root.left)
    // 中序遍歷
    traverse(root.right)
    // 后序遍歷
}

所以,只要看到二叉樹的問題,先把這個框架寫出來准沒問題:

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
}

現在我們思考如何添加一些細節,把框架改造成解法。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

labuladong 告訴你,遇到任何遞歸型的問題,無非就是靈魂三問

1、這個函數是干嘛的

2、這個函數參數中的變量是什么

3、得到函數的遞歸結果,你應該干什么

呵呵,看到這靈魂三問,你有沒有感覺到熟悉?本號的動態規划系列文章,篇篇都在說的動態規划套路,首先要明確的是什么?是不是要明確「定義」「狀態」「選擇」,這仨不就是上面的靈魂三問嗎?

下面我們就來看看如何回答這靈魂三問。

解法思路

首先看第一個問題,這個函數是干嘛的?或者說,你給我描述一下 lowestCommonAncestor 這個函數的「定義」吧。

描述:給該函數輸入三個參數 rootpq,它會返回一個節點。

情況 1,如果 pq 都在以 root 為根的樹中,函數返回的就是 pq 的最近公共祖先節點。

情況 2,那如果 pq 都不在以 root 為根的樹中怎么辦呢?函數理所當然地返回 null 唄。

情況 3,那如果 pq 只有一個存在於 root 為根的樹中呢?函數就會返回那個節點。

題目說了輸入的 pq 一定存在於以 root 為根的樹中,但是遞歸過程中,以上三種情況都有可能發生,所以說這里要定義清楚,后續這些定義都會在代碼中體現。

OK,第一個問題就解決了,把這個定義記在腦子里,無論發生什么,都不要懷疑這個定義的正確性,這是我們寫遞歸函數的基本素養。

然后來看第二個問題,這個函數的參數中,變量是什么?或者說,你描述一個這個函數的「狀態」吧。

描述:函數參數中的變量是 root,因為根據框架,lowestCommonAncestor(root) 會遞歸調用 root.leftroot.right;至於 pq,我們要求它倆的公共祖先,它倆肯定不會變化的。

第二個問題也解決了,你也可以理解這是「狀態轉移」,每次遞歸在做什么?不就是在把「以 root 為根」轉移成「以 root 的子節點為根」,不斷縮小問題規模嘛?

最后來看第三個問題,得到函數的遞歸結果,你該干嘛?或者說,得到遞歸調用的結果后,你做什么「選擇」?

這就像動態規划系列問題,怎么做選擇,需要觀察問題的性質,找規律。那么我們就得分析這個「最近公共祖先節點」有什么特點呢?剛才說了函數中的變量是 root 參數,所以這里都要圍繞 root 節點的情況來展開討論。

先想 base case,如果 root 為空,肯定得返回 null。如果 root 本身就是 p 或者 q,比如說 root 就是 p 節點吧,如果 q 存在於以 root 為根的樹中,顯然 root 就是最近公共祖先;即使 q 不存在於以 root 為根的樹中,按照情況 3 的定義,也應該返回 root 節點。

以上兩種情況的 base case 就可以把框架代碼填充一點了:

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    // 兩種情況的 base case
    if (root == null) return null;
    if (root == p || root == q) return root;
    
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
}

現在就要面臨真正的挑戰了,用遞歸調用的結果 leftright 來搞點事情。根據剛才第一個問題中對函數的定義,我們繼續分情況討論:

情況 1,如果 pq 都在以 root 為根的樹中,那么 leftright 一定分別是 pq(從 base case 看出來的)。

情況 2,如果 pq 都不在以 root 為根的樹中,直接返回 null

情況 3,如果 pq 只有一個存在於 root 為根的樹中,函數返回該節點。

明白了上面三點,可以直接看解法代碼了:

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    // base case
    if (root == null) return null;
    if (root == p || root == q) return root;
    
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    // 情況 1
    if (left != null && right != null) {
        return root;
    }
    // 情況 2
    if (left == null && right == null) {
        return null;
    }
    // 情況 3
    return left == null ? right : left;
}

對於情況 1,你肯定有疑問,leftright 非空,分別是 pq,可以說明 root 是它們的公共祖先,但能確定 root 就是「最近」公共祖先嗎?

這就是一個巧妙的地方了,因為這里是二叉樹的后序遍歷啊!前序遍歷可以理解為是從上往下,而后序遍歷是從下往上,就好比從 pq 出發往上走,第一次相交的節點就是這個 root,你說這是不是最近公共祖先呢?

綜上,二叉樹的最近公共祖先就計算出來了。

_____________

我的 在線電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 算法倉庫 已經獲得了 70k star,歡迎標星!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM