讀完本文,你可以去力扣拿下如下題目:
-----------
如果說筆試的時候喜歡考各種動歸回溯的騷操作,面試其實最喜歡考比較經典的問題,難度不算太大,而且也比較實用。
上篇文章 四個命令玩轉 Git 寫了 Git 最常用的命令,沒有提分支合並,其實分支合並沒什么困難的,主要就是 merge
和 rebase
兩種方式。本文就用 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
節點開始,重演 LCA
到 dev
幾個 commit
的修改,如果這些修改和 LCA
到 master
的 commit
有沖突,就會提示你手動解決沖突,最后的結果就是把 dev
的分支完全接到 master
上面。
那么,Git 是如何找到兩條不同分支的最近公共祖先的呢?這就是一個經典的算法問題了,下面來詳解。
二叉樹的最近公共祖先
這個問題可以在 LeetCode 上找到,看下題目:
函數的簽名如下:
TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q);
root
節點確定了一棵二叉樹,p
和 q
是這棵二叉樹上的兩個節點,讓你返回 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
這個函數的「定義」吧。
描述:給該函數輸入三個參數 root
,p
,q
,它會返回一個節點。
情況 1,如果 p
和 q
都在以 root
為根的樹中,函數返回的就是 p
和 q
的最近公共祖先節點。
情況 2,那如果 p
和 q
都不在以 root
為根的樹中怎么辦呢?函數理所當然地返回 null
唄。
情況 3,那如果 p
和 q
只有一個存在於 root
為根的樹中呢?函數就會返回那個節點。
題目說了輸入的 p
和 q
一定存在於以 root
為根的樹中,但是遞歸過程中,以上三種情況都有可能發生,所以說這里要定義清楚,后續這些定義都會在代碼中體現。
OK,第一個問題就解決了,把這個定義記在腦子里,無論發生什么,都不要懷疑這個定義的正確性,這是我們寫遞歸函數的基本素養。
然后來看第二個問題,這個函數的參數中,變量是什么?或者說,你描述一個這個函數的「狀態」吧。
描述:函數參數中的變量是 root
,因為根據框架,lowestCommonAncestor(root)
會遞歸調用 root.left
和 root.right
;至於 p
和 q
,我們要求它倆的公共祖先,它倆肯定不會變化的。
第二個問題也解決了,你也可以理解這是「狀態轉移」,每次遞歸在做什么?不就是在把「以 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);
}
現在就要面臨真正的挑戰了,用遞歸調用的結果 left
和 right
來搞點事情。根據剛才第一個問題中對函數的定義,我們繼續分情況討論:
情況 1,如果 p
和 q
都在以 root
為根的樹中,那么 left
和 right
一定分別是 p
和 q
(從 base case 看出來的)。
情況 2,如果 p
和 q
都不在以 root
為根的樹中,直接返回 null
。
情況 3,如果 p
和 q
只有一個存在於 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,你肯定有疑問,left
和 right
非空,分別是 p
和 q
,可以說明 root
是它們的公共祖先,但能確定 root
就是「最近」公共祖先嗎?
這就是一個巧妙的地方了,因為這里是二叉樹的后序遍歷啊!前序遍歷可以理解為是從上往下,而后序遍歷是從下往上,就好比從 p
和 q
出發往上走,第一次相交的節點就是這個 root
,你說這是不是最近公共祖先呢?
綜上,二叉樹的最近公共祖先就計算出來了。
_____________
我的 在線電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 算法倉庫 已經獲得了 70k star,歡迎標星!