幾乎刷完了力扣全部的樹題,我發現了這些東西。。。

先上下本文的提綱,這個是我用 mindmap 畫的一個腦圖,以後我會繼續完善,將其餘專題逐步完善起來。javascript

你們也可使用 vscode blink-mind 打開源文件查看,裏面有一些筆記能夠點開查看。源文件能夠去個人公衆號《力扣加加》回覆腦圖獲取,之後腦圖也會持續更新更多內容。vscode 插件地址: https://marketplace.visualstu...

本系列包含如下專題:html

<!-- more -->java

一點絮叨

首先亮一下本文的主角 - 樹(個人化妝技術還行吧^\_^):node

樹標籤在 leetcode 一共有 175 道題。 爲了準備這個專題,我花了幾天時間將 leetcode 幾乎全部的樹題目都刷了一遍。python

除了 35 個上鎖的,1 個不能作的題(1628 題不知道爲啥作不了), 4 個標着樹的標籤但倒是圖的題目,其餘我都刷了一遍。經過集中刷這些題,我發現了一些有趣的信息,今天就分享給你們。git

食用指南

你們好,我是 lucifer。今天給你們帶來的是《樹》專題。另外爲了保持章節的聚焦性和實用性,省去了一些內容,好比哈夫曼樹,前綴樹,平衡二叉樹(紅黑樹等),二叉堆。這些內容相對來講實用性沒有那麼強,若是你們對這些內容也感興趣,能夠關注下個人倉庫 leetcode 算法題解,你們有想看的內容也能夠留言告訴我哦~github

另外要提早告知你們的是本文所講的不少內容都很依賴於遞歸。關於遞歸的練習我推薦你們把遞歸過程畫到紙上,手動代入幾回。等大腦熟悉了遞歸以後就不用這麼辛苦了。 實在懶得畫圖的同窗也能夠找一個可視化遞歸的網站,好比 https://recursion.now.sh/。 等你對遞歸有了必定的理解以後就仔細研究一下樹的各類遍歷方法,再把本文看完,最後把文章末尾的題目作一作,搞定個遞歸問題不大。面試

文章的後面《兩個基本點 - 深度優先遍歷》部分,對於如何練習樹的遍歷的遞歸思惟我也提出了一種方法

最後要強調的是,本文只是幫助你搞定樹題目的常見套路,但不是說樹的全部題目涉及的考點都講。好比樹狀 DP 這種不在本文的討論範圍,由於這種題更側重的是 DP,若是你不懂 DP 多半是作不出來的,你須要的是學完樹和 DP 以後再去學樹狀 DP。若是你對這些內容感興趣,能夠期待個人後續專題。算法

前言

提到樹你們更熟悉的是現實中的樹,而現實中的樹是這樣的:編程

而計算機中的樹實際上是現實中的樹的倒影。

計算機的數據結構是對現實世界物體間關係的一種抽象。好比家族的族譜,公司架構中的人員組織關係,電腦中的文件夾結構,html 渲染的 dom 結構等等,這些有層次關係的結構在計算機領域都叫作樹。

首先明確一下,樹實際上是一種邏輯結構。好比筆者平時寫複雜遞歸的時候,儘管筆者作的題目不是樹,也會畫一個遞歸樹幫助本身理解。

樹是一種重要的思惟工具

以最簡單的計算 fibonacci 數列爲例:

function fn(n) {
  if (n == 0 || n == 1) return n;

  return fn(n - 1) + fn(n - 2);
}

很明顯它的入參和返回值都不是樹,可是卻不影響咱們用樹的思惟去思考。

繼續回到上面的代碼,根據上面的代碼能夠畫出以下的遞歸樹。

其中樹的邊表示的是返回值,樹節點表示的是須要計算的值,即 fn(n)。

以計算 5 的 fibbonacci 爲例,過程大概是這樣的(動圖演示):

這其實就是一個樹的後序遍歷,你說樹(邏輯上的樹)是否是很重要?關於後序遍歷我們後面再講,如今你們知道是這麼回事就行。

你們也能夠去 這個網站 查看上面算法的單步執行效果。固然這個網站還有更多的算法的動畫演示。

上面的圖箭頭方向是爲了方便你們理解。其實箭頭方向變成向下的纔是真的樹結構。

廣義的樹真的頗有用,可是它範圍太大了。 本文所講的樹的題目是比較狹隘的樹,指的是輸入(參數)或者輸出(返回值)是樹結構的題目。

<!-- more -->

基本概念

樹的基本概念難度都不大,爲了節省篇幅,我這裏簡單過一下。對於你不熟悉的點,你們自行去查找一下相關資料。我相信你們也不是來看這些的,你們應該想看一些不同的東西,好比說一些作題的套路。

樹是一種非線性數據結構。樹結構的基本單位是節點。節點之間的連接,稱爲分支(branch)。節點與分支造成樹狀,結構的開端,稱爲根(root),或根結點。根節點以外的節點,稱爲子節點(child)。沒有連接到其餘子節點的節點,稱爲葉節點(leaf)。以下圖是一個典型的樹結構:

每一個節點能夠用如下數據結構來表示:

Node {
    value: any; // 當前節點的值
    children: Array<Node>; // 指向其兒子
}

其餘重要概念:

  • 樹的高度:節點到葉子節點的最大值就是其高度。
  • 樹的深度:高度和深度是相反的,高度是從下往上數,深度是從上往下。所以根節點的深度和葉子節點的高度是 0。
  • 樹的層:根開始定義,根爲第一層,根的孩子爲第二層。
  • 二叉樹,三叉樹,。。。 N 叉樹,由其子節點最多能夠有幾個決定,最多有 N 個就是 N 叉樹。

二叉樹

二叉樹是樹結構的一種,兩個叉就是說每一個節點最多只有兩個子節點,咱們習慣稱之爲左節點和右節點。

注意這個只是名字而已,並非實際位置上的左右

二叉樹也是咱們作算法題最多見的一種樹,所以咱們花大篇幅介紹它,你們也要花大量時間重點掌握。

二叉樹能夠用如下數據結構表示:

Node {
    value: any; // 當前節點的值
    left: Node | null; // 左兒子
    right: Node | null; // 右兒子
}

二叉樹分類

  • 徹底二叉樹
  • 滿二叉樹
  • 二叉搜索樹
  • 平衡二叉樹
  • 紅黑樹
  • 。。。

二叉樹的表示

  • 鏈表存儲
  • 數組存儲。很是適合徹底二叉樹

樹題難度幾何?

不少人以爲樹是一個很難的專題。實際上,只要你掌握了訣竅,它並沒那麼難。

從官方的難度標籤來看,樹的題目處於困難難度的一共是 14 道, 這其中還有 1 個標着樹的標籤可是倒是圖的題目,所以困難率是 13 / 175 ,也就是 7.4 % 左右。若是排除上鎖的 5 道,困難的只有 9 道。大多數困難題,相信你看完本節的內容,也能夠作出來。

從經過率來看,只有不到三分之一的題目平均經過率在 50% 如下,其餘(絕大多數的題目)經過率都是 50%以上。50% 是一個什麼概念呢?這其實很高了。舉個例子來講, BFS 的平均經過率差很少在 50%。 而你們認爲比較難的二分法和動態規劃的平均經過率差很少 40%。

你們不要對樹有壓力, 樹和鏈表同樣是相對容易的專題,今天 lucifer 給你們帶來了一個口訣一箇中心,兩個基本點,三種題型,四個重要概念,七個技巧,幫助你克服樹這個難關。

一箇中心

一箇中心指的是樹的遍歷。整個樹的專題只有一箇中心點,那就是樹的遍歷,你們務必緊緊記住。

無論是什麼題目,核心就是樹的遍歷,這是一切的基礎,不會樹的遍歷後面講的都是白搭。

其實樹的遍歷的本質就是去把樹裏邊兒的每一個元素都訪問一遍(任何數據結構的遍歷不都是如此麼?)。但怎麼訪問的?我不能直接訪問葉子節點啊,我必須得從根節點開始訪問,而後根據子節點指針訪問子節點,可是子節點有多個(二叉樹最多兩個)方向,因此又有了先訪問哪一個的問題,這形成了不一樣的遍歷方式。

左右子節點的訪問順序一般不重要,極個別狀況下會有一些微妙區別。好比說咱們想要訪問一棵樹的最左下角節點,那麼順序就會產生影響,但這種題目會比較少一點。

而遍歷不是目的,遍歷是爲了更好地作處理,這裏的處理包括搜索,修改樹等。樹雖然只能從根開始訪問,可是咱們能夠選擇在訪問完畢回來的時候作處理,仍是在訪問回來以前作處理,這兩種不一樣的方式就是後序遍歷先序遍歷

關於具體的遍歷,後面會給你們詳細講,如今只要知道這些遍歷是怎麼來的就好了。

而樹的遍歷又能夠分爲兩個基本類型,分別是深度優先遍歷和廣度優先遍歷。這兩種遍歷方式並非樹特有的,但卻伴隨樹的全部題目。值得注意的是,這兩種遍歷方式只是一種邏輯而已,所以理論能夠應用於任何數據結構,好比 365. 水壺問題 中,就能夠對水壺的狀態使用廣度優先遍歷,而水壺的狀態能夠用一個二元組來表示。

遺憾的是這道題的廣度優先遍歷解法在 LeetCode 上提交會超時

樹的遍歷迭代寫法

不少小朋友表示二叉樹前中後序的遞歸寫法沒問題,可是迭代就寫不出來,問我有什麼好的方法沒有。

這裏就給你們介紹一種寫迭代遍歷樹的實操技巧,統一三種樹的遍歷方式,包你不會錯,這個方法叫作雙色標記法。 若是你會了這個技巧,那麼你平時練習大可只用遞歸。而後面試的時候,真的要求用迭代或者是對性能有特別要求的那種題目,那你就用個人方法套就好了,下面我來詳細講一下這種方法。

咱們知道垃圾回收算法中,有一種算法叫三色標記法。 即:

  • 用白色表示還沒有訪問
  • 灰色表示還沒有徹底訪問子節點
  • 黑色表示子節點所有訪問

那麼咱們能夠模仿其思想,使用雙色標記法來統一三種遍歷。

其核心思想以下:

  • 使用顏色標記節點的狀態,新節點爲白色,已訪問的節點爲灰色。
  • 若是遇到的節點爲白色,則將其標記爲灰色,而後將其右子節點、自身、左子節點依次入棧。
  • 若是遇到的節點爲灰色,則將節點的值輸出。

使用這種方法實現的中序遍歷以下:

class Solution:
    def inorderTraversal(self, root: TreeNode) -> List[int]:
        WHITE, GRAY = 0, 1
        res = []
        stack = [(WHITE, root)]
        while stack:
            color, node = stack.pop()
            if node is None: continue
            if color == WHITE:
                stack.append((WHITE, node.right))
                stack.append((GRAY, node))
                stack.append((WHITE, node.left))
            else:
                res.append(node.val)
        return res

能夠看出,實現上 WHITE 就表示的是遞歸中的第一次進入過程,Gray 則表示遞歸中的從葉子節點返回的過程。 所以這種迭代的寫法更接近遞歸寫法的本質。

如要實現前序、後序遍歷,也只須要調整左右子節點的入棧順序便可,其餘部分是無需作任何變化


(前中後序遍歷只須要調整這三句話的位置便可)

能夠看出使用三色標記法,其寫法相似遞歸的形式,所以便於記憶和書寫。

有的同窗可能會說,這裏的每個節點都會入棧出棧兩次,相比普通的迭代入棧和出棧次數整整加了一倍,這性能能夠接受麼?我要說的是這種時間和空間的增長僅僅是常數項的增長,大多數狀況並不會都程序形成太大的影響。 除了有時候比賽會比較噁心人,會卡常(卡常是指經過計算機原理相關的、與理論複雜度無關的方法對代碼運行速度進行優化)。反過來看,你們寫的代碼大多數是遞歸,要知道遞歸因爲內存棧的開銷,性能一般比這裏的二色標記法更差纔對, 那爲啥不用一次入棧的迭代呢?更極端一點,爲啥你們不都用 morris 遍歷 呢?

morris 遍歷 是能夠在常數的空間複雜度完成樹的遍歷的一種算法。

我認爲在大多數狀況下,你們對這種細小的差別能夠不用太關注。另外若是這種遍歷方式徹底掌握了,再根據遞歸的思想去寫一次入棧的迭代也不是難事。 無非就是調用函數的時候入棧,函數 return 時候出棧罷了。更多二叉樹遍歷的內容,你們也能夠訪問我以前寫的專題《二叉樹的遍歷》

小結

簡單總結一下,樹的題目一箇中心就是樹的遍歷。樹的遍歷分爲兩種,分別是深度優先遍歷和廣度優先遍歷。關於樹的不一樣深度優先遍歷(前序,中序和後序遍歷)的迭代寫法是大多數人容易犯錯的地方,所以我介紹了一種統一三種遍歷的方法 - 二色標記法,這樣你們之後寫迭代的樹的前中後序遍歷就不再用怕了。若是你們完全熟悉了這種寫法,再去記憶和練習一次入棧甚至是 Morris 遍歷便可。

其實用一次入棧和出棧的迭代實現遞歸也很簡單,無非就是仍是用遞歸思想,只不過你把遞歸體放到循環裏邊而已。你們能夠在熟悉遞歸以後再回頭看看就容易理解了。樹的深度遍歷的遞歸技巧,咱們會在後面的《兩個基本點》部分講解。

兩個基本點

上面提到了樹的遍歷有兩種基本方式,分別是深度優先遍歷(如下簡稱 DFS)和廣度優先遍歷(如下簡稱 BFS),這就是兩個基本點。這兩種遍歷方式下面又會細分幾種方式。好比 DFS 細分爲前中後序遍歷, BFS 細分爲帶層的和不帶層的

DFS 適合作一些暴力枚舉的題目,DFS 若是藉助函數調用棧,則能夠輕鬆地使用遞歸來實現。

BFS 不是 層次遍歷

而 BFS 適合求最短距離,這個和層次遍歷是不同的,不少人搞混。這裏強調一下,層次遍歷和 BFS 是徹底不同的東西。

層次遍歷就是一層層遍歷樹,按照樹的層次順序進行訪問。


(層次遍歷圖示)

BFS 的核心在於求最短問題時候能夠提早終止,這纔是它的核心價值,層次遍歷是一種不須要提早終止的 BFS 的副產物。這個提早終止不一樣於 DFS 的剪枝的提早終止,而是找到最近目標的提早終止。好比我要找距離最近的目標節點,BFS 找到目標節點就能夠直接返回。而 DFS 要窮舉全部可能才能找到最近的,這纔是 BFS 的核心價值。實際上,咱們也可使用 DFS 實現層次遍歷的效果,藉助於遞歸,代碼甚至會更簡單。

若是找到任意一個知足條件的節點就行了,沒必要最近的,那麼 DFS 和 BFS 沒有太大差異。同時爲了書寫簡單,我一般會選擇 DFS。

以上就是兩種遍歷方式的簡單介紹,下面咱們對二者進行一個詳細的講解。

深度優先遍歷

深度優先搜索算法(英語:Depth-First-Search,DFS)是一種用於遍歷樹或圖的算法。沿着樹的深度遍歷樹的節點,儘量深的搜索樹的分支。當節點 v 的所在邊都己被探尋過,搜索將回溯到發現節點 v 的那條邊的起始節點。這一過程一直進行到已發現從源節點可達的全部節點爲止。若是還存在未被發現的節點,則選擇其中一個做爲源節點並重復以上過程,整個進程反覆進行直到全部節點都被訪問爲止,屬於盲目搜索

深度優先搜索是圖論中的經典算法,利用深度優先搜索算法能夠產生目標圖的相應拓撲排序表,利用拓撲排序表能夠方便的解決不少相關的圖論問題,如最大路徑問題等等。因發明「深度優先搜索算法」,約翰 · 霍普克洛夫特與羅伯特 · 塔揚在 1986 年共同得到計算機領域的最高獎:圖靈獎。

截止目前(2020-02-21),深度優先遍歷在 LeetCode 中的題目是 129 道。在 LeetCode 中的題型絕對是超級大戶了。而對於樹的題目,咱們基本上均可以使用 DFS 來解決,甚至咱們能夠基於 DFS 來作層次遍歷,並且因爲 DFS 能夠基於遞歸去作,所以算法會更簡潔。 在對性能有很高要求的場合,我建議你使用迭代,不然儘可能使用遞歸,不只寫起來簡單快速,還不容易出錯。

DFS 圖解:

binary-tree-traversal-dfs

(圖片來自 https://github.com/trekhleb/j...

算法流程

  1. 首先將根節點放入stack中。
  2. stack中取出第一個節點,並檢驗它是否爲目標。若是找到全部的節點,則結束搜尋並回傳結果。不然將它某一個還沒有檢驗過的直接子節點加入stack中。
  3. 重複步驟 2。
  4. 若是不存在未檢測過的直接子節點。將上一級節點加入stack中。
    重複步驟 2。
  5. 重複步驟 4。
  6. stack爲空,表示整張圖都檢查過了——亦即圖中沒有欲搜尋的目標。結束搜尋並回傳「找不到目標」。

這裏的 stack 能夠理解爲本身實現的棧,也能夠理解爲調用棧。若是是調用棧的時候就是遞歸,若是是本身實現的棧的話就是迭代。

算法模板

一個典型的通用的 DFS 模板多是這樣的:

const visited = {}
function dfs(i) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }

    visited[i] = true // 將當前狀態標爲已搜索
    for (根據i能到達的下個狀態j) {
        if (!visited[j]) { // 若是狀態j沒有被搜索過
            dfs(j)
        }
    }
}

上面的 visited 是爲了防止因爲環的存在形成的死循環的。 而咱們知道樹是不存在環的,所以樹的題目大多數不須要 visited,除非你對樹的結構作了修改,好比就左子樹的 left 指針指向自身,此時會有環。再好比 138. 複製帶隨機指針的鏈表 這道題須要記錄已經複製的節點,這些須要記錄 visited 信息的樹的題目少之又少

所以一個樹的 DFS 更可能是:

function dfs(root) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }
    for (const child of root.children) {
        dfs(child)
    }
}

而幾乎全部的題目幾乎都是二叉樹,所以下面這個模板更常見。

function dfs(root) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }
    dfs(root.left)
    dfs(root.right)
}

而咱們不一樣的題目除了 if (知足特定條件部分不一樣以外),還會寫一些特有的邏輯,這些邏輯寫的位置不一樣,效果也大相徑庭。那麼位置不一樣會有什麼影響,何時應該寫哪裏呢?接下來,咱們就聊聊兩種常見的 DFS 方式。

兩種常見分類

前序遍歷和後序遍歷是最多見的兩種 DFS 方式。而另一種遍歷方式 (中序遍歷)通常用於平衡二叉樹,這個咱們後面的四個重要概念部分再講。

前序遍歷

若是你的代碼大概是這麼寫的(注意主要邏輯的位置):

function dfs(root) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }
    // 主要邏輯
    dfs(root.left)
    dfs(root.right)
}

那麼此時咱們稱爲前序遍歷。

後續遍歷

而若是你的代碼大概是這麼寫的(注意主要邏輯的位置):

function dfs(root) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }
    dfs(root.left)
    dfs(root.right)
    // 主要邏輯
}

那麼此時咱們稱爲後序遍歷。

值得注意的是, 咱們有時也會會寫出這樣的代碼:

function dfs(root) {
    if (知足特定條件){
        // 返回結果 or 退出搜索空間
    }
    // 作一些事
    dfs(root.left)
    dfs(root.right)
    // 作另外的事
}

如上代碼,咱們在進入和退出左右子樹的時候分別執行了一些代碼。那麼這個時候,是前序遍歷仍是後續遍歷呢?實際上,這屬於混合遍歷了。不過咱們這裏只考慮主邏輯的位置,關鍵詞是主邏輯

若是代碼主邏輯在左右子樹以前執行,那麼就是前序遍歷。若是代碼主邏輯在左右子樹以後執行,那麼就是後序遍歷。關於更詳細的內容, 我會在七個技巧 中的先後遍歷部分講解,你們先留個印象,知道有着兩種方式就好。

遞歸遍歷的學習技巧

上面的《一箇中心》部分,給你們介紹了一種乾貨技巧《雙色遍歷》統一三種遍歷的迭代寫法。 而樹的遍歷的遞歸的寫法其實大多數人都沒問題。爲何遞歸寫的沒問題,用棧寫迭代就有問題呢? 本質上其實仍是對遞歸的理解不夠。那 lucifer 今天給你們介紹一種練習遞歸的技巧。其實文章開頭也提到了,那就是畫圖 + 手動代入。有的同窗不知道怎麼畫,這裏我拋磚引玉分享一下我學習遞歸的畫法。

好比咱們要前序遍歷一棵這樣的樹:

1
   / \
  2   3
     / \
    4   5

圖畫的還算比較清楚, 就很少解釋了。你們遇到題目多畫幾回這樣的遞歸圖,慢慢就對遞歸有感受了。

廣度優先遍歷

樹的遍歷的兩種方式分別是 DFS 和 BFS,剛纔的 DFS 咱們簡單過了一下前序和後序遍歷,對它們有了一個簡單印象。這一小節,咱們來看下樹的另一種遍歷方式 - BFS。

BFS 也是圖論中算法的一種,不一樣於 DFS, BFS 採用橫向搜索的方式,在數據結構上一般採用隊列結構。 注意,DFS 咱們藉助的是棧來完成,而這裏藉助的是隊列。

BFS 比較適合找最短距離/路徑某一個距離的目標。好比給定一個二叉樹,在樹的最後一行找到最左邊的值。 ,此題是力扣 513 的原題。這不就是求距離根節點最遠距離的目標麼? 一個 BFS 模板就解決了。

BFS 圖解:

binary-tree-traversal-bfs

(圖片來自 https://github.com/trekhleb/j...

算法流程

  1. 首先將根節點放入隊列中。
  2. 從隊列中取出第一個節點,並檢驗它是否爲目標。

    • 若是找到目標,則結束搜索並回傳結果。
    • 不然將它全部還沒有檢驗過的直接子節點加入隊列中。
  3. 若隊列爲空,表示整張圖都檢查過了——亦即圖中沒有欲搜索的目標。結束搜索並回傳「找不到目標」。
  4. 重複步驟 2。

算法模板

const visited = {}
function bfs() {
    let q = new Queue()
    q.push(初始狀態)
    while(q.length) {
        let i = q.pop()
        if (visited[i]) continue
        if (i 是咱們要找的目標) return 結果
        for (i的可抵達狀態j) {
            if (j 合法) {
                q.push(j)
            }
        }
    }
    return 沒找到
}

兩種常見分類

BFS 我目前使用的模板就兩種,這兩個模板能夠解決全部的樹的 BFS 問題。

前面我提到了「BFS 比較適合找最短距離/路徑某一個距離的目標」。 若是我須要求的是最短距離/路徑,我是不關心我走到第幾步的,這個時候但是用不標記層的目標。而若是我須要求距離某個節點距離等於 k 的全部節點,這個時候第幾步這個信息就值得被記錄了。

小於 k 或者 大於 k 也是同理。
標記層

一個常見的 BFS 模板,代入題目只須要根據題目微調便可。

class Solution:
    def bfs(k):
        # 使用雙端隊列,而不是數組。由於數組從頭部刪除元素的時間複雜度爲 N,雙端隊列的底層實現實際上是鏈表。
        queue = collections.deque([root])
        # 記錄層數
        steps = 0
        # 須要返回的節點
        ans = []
        # 隊列不空,生命不止!
        while queue:
            size = len(queue)
            # 遍歷當前層的全部節點
            for _ in range(size):
                node = queue.popleft()
                if (step == k) ans.append(node)
                if node.right:
                    queue.append(node.right)
                if node.left:
                    queue.append(node.left)
            # 遍歷完當前層全部的節點後 steps + 1
            steps += 1
        return ans
不標記層

不帶層的模板更簡單,所以你們其實只須要掌握帶層信息的目標就夠了。

一個常見的 BFS 模板,代入題目只須要根據題目微調便可。

class Solution:
    def bfs(k):
        # 使用雙端隊列,而不是數組。由於數組從頭部刪除元素的時間複雜度爲 N,雙端隊列的底層實現實際上是鏈表。
        queue = collections.deque([root])
        # 隊列不空,生命不止!
        while queue:
            node = queue.popleft()
            # 因爲沒有記錄 steps,所以咱們確定是不須要根據層的信息去判斷的。不然就用帶層的模板了。
            if (node 是咱們要找到的) return node
            if node.right:
                queue.append(node.right)
            if node.left:
                queue.append(node.left)
        return -1

以上就是 BFS 的兩種基本方式,即帶層和不帶層,具體使用哪一種看題目是否須要根據層信息作判斷便可。

小結

樹的遍歷是後面全部內容的基礎,而樹的遍歷的兩種方式 DFS 和 BFS 到這裏就簡單告一段落,如今你們只要知道 DFS 和 BFS 分別有兩種常見的方式就夠了,後面我會給你們詳細補充。

三種題型

樹的題目就三種類型,分別是:搜索類,構建類和修改類,而這三類題型的比例也是逐漸下降的,即搜索類的題目最多,其次是構建類,最後是修改類。這一點和鏈表有很大的不一樣,鏈表更多的是修改類。

接下來,lucifer 給你們逐一講解這三種題型。

搜索類

搜索類的題目是樹的題目的絕對大頭。而搜索類只有兩種解法,那就是 DFS 和 BFS,下面分別介紹。

幾乎全部的搜索類題目均可以方便地使用遞歸來實現,關於遞歸的技巧會在七個技巧中的單/雙遞歸部分講解。還有一小部分使用遞歸很差實現,咱們可使用 BFS,藉助隊列輕鬆實現,好比最經典的是求二叉樹任意兩點的距離,樹的距離其實就是最短距離,所以能夠用 BFS 模板解決。這也是爲啥我說DFS 和 BFS是樹的題目的兩個基本點的緣由。

全部搜索類的題目只要把握三個核心點,即開始點結束點目標便可。

DFS 搜索

DFS 搜索類的基本套路就是從入口開始作 dfs,而後在 dfs 內部判斷是不是結束點,這個結束點一般是葉子節點空節點,關於結束這個話題咱們放在七個技巧中的邊界部分介紹,若是目標是一個基本值(好比數字)直接返回或者使用一個全局變量記錄便可,若是是一個數組,則能夠經過擴展參數的技巧來完成,關於擴展參數,會在七個技巧中的參數擴展部分介紹。 這基本就是搜索問題的所有了,當你讀完後面的七個技巧,回頭再回來看這個會更清晰。

套路模板:

# 其中 path 是樹的路徑, 若是須要就帶上,不須要就不帶
def dfs(root, path):
    # 空節點
    if not root: return
    # 葉子節點
    if not root.left and not root.right: return
    path.append(root)
    # 邏輯能夠寫這裏,此時是前序遍歷
    dfs(root.left)
    dfs(root.right)
    # 須要彈出,否則會錯誤計算。
    # 好比對於以下樹:
    """
              5
             / \
            4   8
           /   / \
          11  13  4
         /  \    / \
        7    2  5   1
    """
    # 若是不 pop,那麼 5 -> 4 -> 11 -> 2 這條路徑會變成 5 -> 4 -> 11 -> 7 -> 2,其 7 被錯誤地添加到了 path

    path.pop()
    # 邏輯也能夠寫這裏,此時是後序遍歷

    return 你想返回的數據

好比劍指 Offer 34. 二叉樹中和爲某一值的路徑 這道題,題目是:輸入一棵二叉樹和一個整數,打印出二叉樹中節點值的和爲輸入整數的全部路徑。從樹的根節點開始往下一直到葉節點所通過的節點造成一條路徑。 這不就是從根節點開始,到葉子節點結束的全部路徑搜索出來,挑選出和爲目標值的路徑麼?這裏的開始點是根節點, 結束點是葉子節點,目標就是路徑。

對於求這種知足特定和的題目,咱們均可以方便地使用前序遍歷 + 參數擴展的形式,關於這個,我會在七個技巧中的先後序部分展開。

因爲須要找到全部的路徑,而不只僅是一條,所以這裏適合使用回溯暴力枚舉。關於回溯,能夠參考個人 回溯專題
class Solution:
    def pathSum(self, root: TreeNode, target: int) -> List[List[int]]:
        def backtrack(nodes, path, cur, remain):
            # 空節點
            if not cur: return
            # 葉子節點
            if cur and not cur.left and not cur.right:
                if remain == cur.val:
                    res.append((path + [cur.val]).copy())
                return
            # 選擇
            tepathmp.append(cur.val)
            # 遞歸左右子樹
            backtrack(nodes, path, cur.left, remain - cur.val)
            backtrack(nodes, path, cur.right, remain - cur.val)
            # 撤銷選擇
            path.pop(-1)
        ans = []
        # 入口,路徑,目標值所有傳進去,其中路徑和path都是擴展的參數
        dfs(ans, [], root, target, )
        return ans

再好比:1372. 二叉樹中的最長交錯路徑,題目描述:

給你一棵以 root 爲根的二叉樹,二叉樹中的交錯路徑定義以下:

選擇二叉樹中 任意 節點和一個方向(左或者右)。
若是前進方向爲右,那麼移動到當前節點的的右子節點,不然移動到它的左子節點。
改變前進方向:左變右或者右變左。
重複第二步和第三步,直到你在樹中沒法繼續移動。
交錯路徑的長度定義爲:訪問過的節點數目 - 1(單個節點的路徑長度爲 0 )。

請你返回給定樹中最長 交錯路徑 的長度。

好比:

此時須要返回 3
解釋:藍色節點爲樹中最長交錯路徑(右 -> 左 -> 右)。

這不就是從任意節點開始,到任意節點結束的全部交錯路徑所有搜索出來,挑選出最長的麼?這裏的開始點是樹中的任意節點,結束點也是任意節點,目標就是最長的交錯路徑。

對於入口是任意節點的題目,咱們均可以方便地使用雙遞歸來完成,關於這個,我會在七個技巧中的單/雙遞歸部分展開。

對於這種交錯類的題目,一個好用的技巧是使用 -1 和 1 來記錄方向,這樣咱們就能夠經過乘以 -1 獲得另一個方向。

886. 可能的二分法785. 判斷二分圖 都用了這個技巧。

用代碼表示就是:

next_direction = cur_direction * - 1

這裏咱們使用雙遞歸便可解決。 若是題目限定了只從根節點開始,那就能夠用單遞歸解決了。值得注意的是,這裏內部遞歸須要 cache 一下 , 否則容易由於重複計算致使超時。

個人代碼是 Python,這裏的 lru_cache 就是一個緩存,你們可使用本身語言的字典模擬實現。
class Solution:
    @lru_cache(None)
    def dfs(self, root, dir):
        if not root:
            return 0
        if dir == -1:
            return int(root.left != None) + self.dfs(root.left, dir * -1)
        return int(root.right != None) + self.dfs(root.right, dir * -1)

    def longestZigZag(self, root: TreeNode) -> int:
        if not root:
            return 0
        return max(self.dfs(root, 1), self.dfs(root, -1), self.longestZigZag(root.left), self.longestZigZag(root.right))

這個代碼不懂不要緊,你們只有知道搜索類題目的大方向便可,具體作法咱們後面會介紹,你們留個印象就行。更多的題目以及這些技巧的詳細使用方式放在七個技巧部分展開。

BFS 搜索

這種類型相比 DFS,題目數量明顯下降,套路也少不少。題目大可能是求距離,套用我上面的兩種 BFS 模板基本均可以輕鬆解決,這個很少介紹了。

構建類

除了搜索類,另一個大頭是構建類。構建類又分爲兩種:普通二叉樹的構建和二叉搜索樹的構建。

普通二叉樹的構建

而普通二叉樹的構建又分爲三種:

  1. 給你兩種 DFS 的遍歷的結果數組,讓你構建出原始的樹結構。好比根據先序遍歷和後序遍歷的數組,構造原始二叉樹。這種題我在構造二叉樹系列 系列裏講的很清楚了,你們能夠去看看。
這種題目假設輸入的遍歷的序列中都不含重複的數字,想一想這是爲何。
  1. 給你一個 BFS 的遍歷的結果數組,讓你構建出原始的樹結構。

最經典的就是 劍指 Offer 37. 序列化二叉樹。咱們知道力扣的全部的樹表示都是使用數字來表示的,而這個數組就是一棵樹的層次遍歷結果,部分葉子節點的子節點(空節點)也會被打印。好比:[1,2,3,null,null,4,5],就表示的是以下的一顆二叉樹:

咱們是如何根據這樣的一個層次遍歷結果構造出原始二叉樹的呢?這其實就屬於構造二叉樹的內容,這個類型目前力扣就這一道題。這道題若是你完全理解 BFS,那麼就難不倒你。

  1. 還有一種是給你描述一種場景,讓你構造一個符合條件的二叉樹。這種題和上面的沒啥區別,套路簡直不要太像,好比 654. 最大二叉樹,我就很少說了,你們經過這道題練習一下就知道了。

除了這種靜態構建,還有一種很很罕見的動態構建二叉樹的,好比 894. 全部可能的滿二叉樹 ,對於這個題,直接 BFS 就行了。因爲這種題不多,所以不作多的介紹。你們只要把最核心的掌握了,這種東西天然水到渠成。

二叉搜索樹的構建

普通二叉樹沒法根據一種序列重構的緣由是隻知道根節點,沒法區分左右子樹。若是是二叉搜索樹,那麼就有可能根據一種遍歷序列構造出來。 緣由就在於二叉搜索樹的根節點的值大於全部的左子樹的值,且小於全部的右子樹的值。所以咱們能夠根據這一特性去肯定左右子樹的位置,通過這樣的轉換就和上面的普通二叉樹沒有啥區別了。好比 1008. 前序遍歷構造二叉搜索樹

修改類

上面介紹了兩種常見的題型:搜索類和構建類。還有一種比例相對比較小的題目類型是修改類。

固然修改類的題目也是要基於搜索算法的,不找到目標怎麼刪呢?

修改類的題目有兩種基本類型。

題目要求的修改

一種是題目讓你增長,刪除節點,或者是修改節點的值或者指向。

修改指針的題目通常不難,好比 116. 填充每一個節點的下一個右側節點指針,這不就是 BFS 的時候順便記錄一下上一次訪問的同層節點,而後增長一個指針不就好了麼?關於 BFS ,套用個人帶層的 BFS 模板就搞定了。

增長和刪除的題目通常稍微複雜,好比 450. 刪除二叉搜索樹中的節點669. 修剪二叉搜索樹。西法我教你兩個套路,面對這種問題就不帶怕的。那就是後續遍歷 + 虛擬節點,這兩個技巧一樣放在後面的七個技巧部分講解。是否是對七個技巧很期待?^\_^

實際工程中,咱們也能夠不刪除節點,而是給節點作一個標記,表示已經被刪除了,這叫作軟刪除。

算法須要,本身修改

另一種是爲了方便計算,本身加了一個指針。

好比 863. 二叉樹中全部距離爲 K 的結點 經過修改樹的節點類,增長一個指向父節點的引用 parent,問題就轉化爲距離目標節點必定距離的問題了,此時但是用我上面講的帶層的 BFS 模板解決。

動態語言能夠直接加屬性(好比上面的 parent),而靜態語言是不容許的,所以你須要增長一個新的類定義。不過你也可使用字典來實現, key 是 node 引用, value 是你想記錄的東西,好比這裏的 parent 節點。

好比對於 Java 來講,咱們能夠:

class Solution {
    Map<TreeNode, TreeNode> parent;
    public void dfs(TreeNode node, TreeNode parent) {
        if (node != null) {
            parent.put(node, parent);
            dfs(node.left, node);
            dfs(node.right, node);
        }
    }
}

簡單回顧一下這一小節的知識。

接下來是作樹的題目不得不知的四個重要概念。

四個重要概念

二叉搜索樹

二叉搜索樹(Binary Search Tree),亦稱二叉查找樹。

二叉搜索樹具備下列性質的二叉樹:

  • 若左子樹不空,則左子樹上全部節點的值均小於它的根節點的值;
  • 若右子樹不空,則右子樹上全部節點的值均大於它的根節點的值;
  • 左、右子樹也分別爲二叉排序樹;
  • 沒有鍵值相等的節點。

對於一個二叉查找樹,常規操做有插入,查找,刪除,找父節點,求最大值,求最小值。

天生適合查找

二叉查找樹,之因此叫查找樹就是由於其很是適合查找。

舉個例子,以下一顆二叉查找樹,咱們想找節點值小於且最接近 58 的節點,搜索的流程如圖所示:

bst
(圖片來自 https://www.geeksforgeeks.org...

能夠看出每次向下走,都會排除了一個分支,若是一顆二叉搜索樹同時也是一顆二叉平衡樹的話,那麼其搜索過程時間複雜度就是 $O(logN)$。實際上,平衡二叉搜索樹的查找和有序數組的二分查找本質都是同樣的,只是數據的存儲方式不一樣罷了。那爲何有了有序數組二分,還須要二叉搜索樹呢?緣由在於樹的結構對於動態數據比較優友好,好比數據是頻繁變更的,好比常常添加和刪除,那麼就可使用二叉搜索樹。理論上添加和刪除的時間複雜度都是 $O(h)$,其中 h 爲樹的高度,若是是一顆平衡二叉搜索樹,那麼時間複雜度就是 $O(logN)$。而數組的添加和刪除的時間複雜度爲 $O(N)$,其中 N 爲數組長度。

方便搜索,是二叉搜索樹核心的設計初衷。不讓查找算法時間複雜度退化到線性是平衡二叉樹的初衷

咱們平時說的二分不少是數組的二分,由於數組能夠隨機訪問嘛。不過這種二分實在太狹義了,二分的本質是將問題規模縮小到一半,所以二分和數據結構沒有本質關係,可是不一樣的數據結構卻給二分賦予了不一樣的色彩。好比跳錶就是鏈表的二分,二叉搜索樹就是樹的二分等。隨着你們對算法和數據結構的瞭解的加深,會發現更多有意思的東西^\_^

中序遍歷是有序的

另外二叉查找樹有一個性質,這個性質對於作題不少幫助,那就是: 二叉搜索樹的中序遍歷的結果是一個有序數組。 好比 98. 驗證二叉搜索樹 就能夠直接中序遍歷,並一邊遍歷一邊判斷遍歷結果是不是單調遞增的,若是不是則提早返回 False 便可。

再好比 99. 恢復二叉搜索樹,官方難度爲困難。題目大意是給你二叉搜索樹的根節點 root ,該樹中的兩個節點被錯誤地交換。請在不改變其結構的狀況下,恢復這棵樹。 咱們能夠先中序遍歷發現不是遞增的節點,他們就是被錯誤交換的節點,而後交換恢復便可。這道題難點就在於一點,即錯誤交換可能錯誤交換了中序遍歷的相鄰節點或者中序遍歷的非相鄰節點,這是兩種 case,須要分別討論。

相似的題目不少,再也不贅述。你們若是碰到二叉搜索樹的搜索類題目,必定先想下能不能利用這個性質來作。

徹底二叉樹

一棵深度爲 k 的有 n 個結點的二叉樹,對樹中的結點按從上至下、從左到右的順序進行編號,若是編號爲 i(1≤i≤n)的結點與滿二叉樹中編號爲 i 的結點在二叉樹中的位置相同,則這棵二叉樹稱爲徹底二叉樹。

以下就是一顆徹底二叉樹:

直接考察徹底二叉樹的題目雖然很少,貌似只有一道 222. 徹底二叉樹的節點個數(二分可解),可是理解徹底二叉樹對你作題其實幫助很大。

如上圖,是一顆普通的二叉樹。若是我將其中的空節點補充徹底,那麼它就是一顆徹底二叉樹了。

這有什麼用呢?這頗有用!我總結了兩個用處:

  1. 咱們能夠給徹底二叉樹編號,這樣父子之間就能夠經過編號輕鬆求出。好比我給全部節點從左到右從上到下依次從 1 開始編號。那麼已知一個節點的編號是 i,那麼其左子節點就是 2 i,右子節點就是 2 1 + 1,父節點就是 (i + 1) / 2。

熟悉二叉堆的同窗可能發現了,這就是用數組實現的二叉堆,其實二叉堆就是徹底二叉樹的一個應用

有的同窗會說,」可是不少題目都不是徹底二叉樹呀,那不是用不上了麼?「其實否則,咱們只要想象它存在便可,咱們將空節點腦補上去不就能夠了?好比 662. 二叉樹最大寬度。題目描述:

給定一個二叉樹,編寫一個函數來獲取這個樹的最大寬度。樹的寬度是全部層中的最大寬度。這個二叉樹與滿二叉樹(full binary tree)結構相同,但一些節點爲空。

每一層的寬度被定義爲兩個端點(該層最左和最右的非空節點,兩端點間的null節點也計入長度)之間的長度。

示例 1:

輸入:

           1
         /   \
        3     2
       / \     \
      5   3     9

輸出: 4
解釋: 最大值出如今樹的第 3 層,寬度爲 4 (5,3,null,9)。

很簡單,一個帶層的 BFS 模板便可搞定,簡直就是默寫題。不過這裏須要注意兩點:

  • 入隊的時候除了要將普通節點入隊,還要空節點入隊。
  • 出隊的時候除了入隊節點自己,還要將節點的位置信息入隊,即下方代碼的 pos。

參考代碼:

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def widthOfBinaryTree(self, root: TreeNode) -> int:
        q = collections.deque([(root, 0)])
        steps = 0
        cur_depth = leftmost = ans = 0

        while q:
            for _ in range(len(q)):
                node, pos = q.popleft()
                if node:
                    # 節點編號關關係是否是用上了?
                    q.append((node.left, pos * 2))
                    q.append((node.right, pos * 2 + 1))
                    # 邏輯開始
                    if cur_depth != steps:
                        cur_depth = steps
                        leftmost = pos
                    ans = max(ans, pos - leftmost + 1)
                    # 邏輯結束
            steps += 1
        return ans

再好比劍指 Offer 37. 序列化二叉樹。若是我將一個二叉樹的徹底二叉樹形式序列化,而後經過 BFS 反序列化,這不就是力扣官方序列化樹的方式麼?好比:

1
   / \
  2   3
     / \
    4   5

序列化爲 "[1,2,3,null,null,4,5]"。 這不就是我剛剛畫的徹底二叉樹麼?就是將一個普通的二叉樹硬生生當成徹底二叉樹用了。

其實這並非序列化成了徹底二叉樹,下面會糾正。

將一顆普通樹序列化爲徹底二叉樹很簡單,只要將空節點當成普通節點入隊處理便可。代碼:

class Codec:

    def serialize(self, root):
        q = collections.deque([root])
        ans = ''
        while q:
            cur = q.popleft()
            if cur:
                ans += str(cur.val) + ','
                q.append(cur.left)
                q.append(cur.right)
            else:
                # 除了這裏不同,其餘和普通的不記錄層的 BFS 沒區別
                ans += 'null,'
        # 末尾會多一個逗號,咱們去掉它。
        return ans[:-1]

細心的同窗可能會發現,我上面的代碼其實並非將樹序列化成了徹底二叉樹,這個咱們稍後就會講到。另外後面多餘的空節點也一併序列化了。這實際上是能夠優化的,優化的方式也很簡單,那就是去除末尾的 null 便可。

你只要完全理解我剛纔講的咱們能夠給徹底二叉樹編號,這樣父子之間就能夠經過編號輕鬆求出。好比我給全部節點從左到右從上到下依次從 1 開始編號。那麼已知一個節點的編號是 i,那麼其左子節點就是 2 * i,右子節點就是 2 * 1 + 1,父節點就是 (i + 1) / 2。 這句話,那麼反序列化對你就不是難事。

若是我用一個箭頭表示節點的父子關係,箭頭指向節點的兩個子節點,那麼大概是這樣的:

咱們剛纔提到了:

  • 1 號節點的兩個子節點的 2 號 和 3 號。
  • 2 號節點的兩個子節點的 4 號 和 5 號。
  • 。。。
  • i 號節點的兩個子節點的 2 * i 號 和 2 * 1 + 1 號。

此時你可能會寫出相似這樣的代碼:

def deserialize(self, data):
        if data == 'null': return None
        nodes = data.split(',')
        root = TreeNode(nodes[0])
        # 從一號開始編號,編號信息一塊兒入隊
        q = collections.deque([(root, 1)])
        while q:
            cur, i = q.popleft()
            # 2 * i 是左節點,而 2 * i 編號對應的實際上是索引爲 2 * i - 1 的元素, 右節點同理。
            if 2 * i - 1 < len(nodes): lv = nodes[2 * i - 1]
            if 2 * i < len(nodes): rv = nodes[2 * i]
            if lv != 'null':
                l = TreeNode(lv)
                # 將左節點和 它的編號 2 * i 入隊
                q.append((l, 2 * i))
                cur.left = l
            if rv != 'null':
                r = TreeNode(rv)
                # 將右節點和 它的編號 2 * i + 1 入隊
                q.append((r, 2 * i + 1))
                cur.right = r

        return root

可是上面的代碼是不對的,由於咱們序列化的時候其實不是徹底二叉樹,這也是上面我埋下的伏筆。所以遇到相似這樣的 case 就會掛:

這也是我前面說」上面代碼的序列化並非一顆徹底二叉樹「的緣由。

其實這個很好解決, 核心仍是上面我畫的那種圖:

其實咱們能夠:

  • 用三個指針分別指向數組第一項,第二項和第三項(若是存在的話),這裏用 p1,p2,p3 來標記,分別表示當前處理的節點,當前處理的節點的左子節點和當前處理的節點的右子節點。
  • p1 每次移動一位,p2 和 p3 每次移動兩位。
  • p1.left = p2; p1.right = p3。
  • 持續上面的步驟直到 p1 移動到最後。

所以代碼就不難寫出了。反序列化代碼以下:

def deserialize(self, data):
    if data == 'null': return None
    nodes = data.split(',')
    root = TreeNode(nodes[0])
    q = collections.deque([root])
    i = 0
    while q and i < len(nodes) - 2:
        cur = q.popleft()
        lv = nodes[i + 1]
        rv = nodes[i + 2]
        i += 2
        if lv != 'null':
            l = TreeNode(lv)
            q.append(l)
            cur.left = l
        if rv != 'null':
            r = TreeNode(rv)
            q.append(r)
            cur.right = r

    return root

這個題目雖然並非徹底二叉樹的題目,可是卻和徹底二叉樹很像,有借鑑徹底二叉樹的地方。

路徑

關於路徑這個概念,leetcode 真的挺喜歡考察的,不信你本身去 leetcode 官網搜索一下路徑,看有多少題。樹的路徑這種題目的變種不少,算是一種經典的考點了。

要明白路徑的概念,以及如何解決這種題,只須要看一個題目就行了 124.二叉樹中的最大路徑和,雖然是困難難度,可是搞清楚概念的話,和簡單難度沒啥區別。 接下來,咱們就以這道題講解一下。

這道題的題目是 給定一個非空二叉樹,返回其最大路徑和。路徑的概念是:一條從樹中任意節點出發,沿父節點-子節點鏈接,達到任意節點的序列。該路徑至少包含一個節點,且不必定通過根節點。這聽起來真的不容易理解,力扣給的 demo 我也沒搞懂,這裏我本身畫了幾個圖來給你們解釋一下這個概念。

首先是官網給的兩個例子:

接着是我本身畫的一個例子:

如圖紅色的部分是最大路徑上的節點。

能夠看出:

  • 路徑能夠由一個節點作成,能夠由兩個節點組成,也能夠由三個節點組成等等,可是必須連續。
  • 路徑必須是」直來直去「的,不能拐。 好比上圖的路徑的左下角是 3,就不能是 2,由於若是是 2 就拐了。

咱們繼續回到 124 題。題目說是 」從任意節點出發.......「 看完這個描述我會想到大機率是要麼全局記錄最大值,要麼雙遞歸。

  • 若是使用雙遞歸,那麼複雜度就是 $O(N^2)$,實際上,子樹的路徑和計算出來了,能夠推導出父節點的最大路徑和,所以若是使用雙遞歸會有重複計算。一個可行的方式是記憶化遞歸。
  • 若是使用全局記錄最大值,只須要在遞歸的時候 return 當前的一條邊(上面提了不能拐),並在函數內部計算以當前節點出發的最大路徑和,並更新全局最大值便可。 這裏的核心實際上是 return 較大的一條邊,由於較小的邊不多是答案。

這裏我選擇使用第二種方法。

代碼:

class Solution:
    ans = float('-inf')
    def maxPathSum(self, root: TreeNode) -> int:
        def dfs(node):
            if not node: return 0
            l = dfs(node.left)
            r = dfs(node.right)
            # 選擇當前的節點,並選擇左右兩邊,固然左右兩邊也能夠不選。必要時更新全局最大值
            self.ans = max(self.ans, max(l,0) + max(r, 0) + node.val)
            # 只返回一邊,所以咱們挑大的返回。固然左右兩邊也能夠不選
            return max(l, r, 0) + node.val
        dfs(root)
        return self.ans
相似題目 113. 路徑總和 I

距離

和路徑相似,距離也是一個類似且頻繁出現的一個考點,而且兩者都是搜索類題目的考點。緣由就在於最短路徑就是距離,而樹的最短路徑就是邊的數目。

這兩個題練習一下,碰到距離的題目基本就穩了。

七個技巧

上面數次提到了七個技巧,相信你們已經火燒眉毛想要看看這七個技巧了吧。那就讓我拿出本章壓箱底的內容吧~

注意,這七個技巧所有是基於 dfs 的,bfs 掌握了模板就行,基本沒有什麼技巧可言。

認真學習的小夥伴能夠發現了, 上面的內容只有二叉樹的迭代寫法(雙色標記法)兩個 BFS 模板 具備實操性,其餘大可能是戰略思想上的。算法思想當然重要,可是要結合具體實踐落地纔能有實踐價值,才能讓咱們把知識消化成本身的。而這一節滿滿的全是實用乾貨ヽ( ̄ ω  ̄( ̄ ω  ̄〃)ゝ。

dfs(root)

第一個技巧,也是最容易掌握的一個技巧。咱們寫力扣的樹題目的時候,函數的入參全都是叫 root。而這個技巧是說,咱們在寫 dfs 函數的時候,要將函數中表示當前節點的形參寫成 root。即:

def dfs(root):
    # your code

而以前我一直習慣寫成 node,即:

def dfs(node):
    # your code

可能有的同窗想問:」 這有什麼關係麼?「。我總結了兩個緣由。

第一個緣由是:之前 dfs 的形參寫的是 node, 而我常常誤寫成 root,致使出錯(這個錯誤並不會拋錯,所以不是特別容易發現)。自從換成了 root 就沒有發生這樣的問題了。

第二個緣由是:這樣寫至關於把 root 當成是 current 指針來用了。最開始 current 指針指向 root,而後不斷修改指向樹的其它節點。這樣就概念就簡化了,只有一個當前指針的概念。若是使用 node,就是當前指針 + root 指針兩個概念了。

(一開始 current 就是 root)

(後面 current 不斷改變。具體如何改變,取決於你的搜索算法,是 dfs 仍是 bfs 等)

單/雙遞歸

上面的技巧稍顯簡單,可是卻有用。這裏介紹一個稍微難一點的技巧,也更加有用。

咱們知道遞歸是一個頗有用的編程技巧,靈活使用遞歸,可使本身的代碼更加簡潔,簡潔意味着代碼不容易出錯,即便出錯了,也能及時發現問題並修復。

樹的題目大多數均可以用遞歸輕鬆地解決。若是一個遞歸不行,那麼來兩個。(至今沒見過三遞歸或更多遞歸)

單遞歸你們寫的比較多了,其實本篇文章的大部分遞歸都是單遞歸。 那何時須要兩個遞歸呢?其實我上面已經提到了,那就是若是題目有相似,任意節點開始 xxxx 或者全部 xxx這樣的說法,就能夠考慮使用雙遞歸。可是若是遞歸中有重複計算,則可使用雙遞歸 + 記憶化 或者直接單遞歸。

好比 面試題 04.12. 求和路徑,再好比 563.二叉樹的坡度 這兩道題的題目說法均可以考慮使用雙遞歸求解。

雙遞歸的基本套路就是一個主遞歸函數和一個內部遞歸函數。主遞歸函數負責計算以某一個節點開始的 xxxx,內部遞歸函數負責計算 xxxx,這樣就實現了以全部節點開始的 xxxx

其中 xxx 能夠替換成任何題目描述,好比路徑和等

一個典型的加法雙遞歸是這樣的:

def dfs_inner(root):
    # 這裏寫你的邏輯,就是前序遍歷
    dfs_inner(root.left)
    dfs_inner(root.right)
    # 或者在這裏寫你的邏輯,那就是後序遍歷
def dfs_main(root):
    return dfs_inner(root) + dfs_main(root.left) + dfs_main(root.right)

你們能夠用個人模板去套一下上面兩道題試試。

先後遍歷

前面個人鏈表專題也提到了先後序遍歷。因爲鏈表只有一個 next 指針,所以只有兩種遍歷。而二叉樹有兩個指針,所以常見的遍歷有三個,除了先後序,還有一箇中序。而中序除了二叉搜索樹,其餘地方用的並很少。

和鏈表同樣, 要掌握樹的先後序,也只須要記住一句話就行了。那就是若是是前序遍歷,那麼你能夠想象上面的節點都處理好了,怎麼處理的不用管。相應地若是是後序遍歷,那麼你能夠想象下面的樹都處理好了,怎麼處理的不用管。這句話的正確性也是毋庸置疑。

先後序對鏈表來講比較直觀。對於樹來講,其實更形象地說應該是自頂向下或者自底向上。自頂向下和自底向上在算法上是不一樣的,不一樣的寫法有時候對應不一樣的書寫難度。好比 https://leetcode-cn.com/problems/sum-root-to-leaf-numbers/,這種題目就適合經過參數擴展 + 前序來完成。

關於參數擴展的技巧,咱們在後面展開。
  • 自頂向下就是在每一個遞歸層級,首先訪問節點來計算一些值,並在遞歸調用函數時將這些值傳遞到子節點,通常是經過參數傳到子樹中。
  • 自底向上是另外一種常見的遞歸方法,首先對全部子節點遞歸地調用函數,而後根據返回值根節點自己的值獲得答案。

關於先後序的思惟技巧,能夠參考個人這個文章先後序部分

總結下個人經驗:

  • 大多數樹的題使用後序遍歷比較簡單,而且大多須要依賴左右子樹的返回值。好比 1448. 統計二叉樹中好節點的數目
  • 很少的問題須要前序遍歷,而前序遍歷一般要結合參數擴展技巧。好比 1022. 從根到葉的二進制數之和
  • 若是你能使用參數和節點自己的值來決定什麼應該是傳遞給它子節點的參數,那就用前序遍歷。
  • 若是對於樹中的任意一個節點,若是你知道它子節點的答案,你能計算出當前節點的答案,那就用後序遍歷。
  • 若是遇到二叉搜索樹則考慮中序遍歷

虛擬節點

是的!不只僅鏈表有虛擬節點的技巧,樹也是同樣。關於這點你們可能比較容易忽視。

回憶一下鏈表的虛擬指針的技巧,咱們一般在何時纔會使用?

  • 其中一種狀況是鏈表的頭會被修改。這個時候一般須要一個虛擬指針來作新的頭指針,這樣就不須要考慮第一個指針的問題了(由於此時第一個指針變成了咱們的虛擬指針,而虛擬指針是不用參與題目運算的)。樹也是同樣,當你須要對樹的頭節點(在樹中咱們稱之爲根節點)進行修改的時候, 就能夠考慮使用虛擬指針的技巧了。
  • 另一種是題目須要返回樹中間的某個節點(不是返回根節點)。實際上也可藉助虛擬節點。因爲我上面提到的指針的操做,實際上,你能夠新建一個虛擬頭,而後讓虛擬頭在恰當的時候(恰好指向須要返回的節點)斷開鏈接,這樣咱們就能夠返回虛擬頭的 next 就 ok 了。

更多關於虛擬指針的技巧能夠參考這個文章虛擬頭部分

下面就力扣中的兩道題來看一下。

【題目一】814. 二叉樹剪枝

題目描述:

給定二叉樹根結點 root ,此外樹的每一個結點的值要麼是 0,要麼是 1。

返回移除了全部不包含 1 的子樹的原二叉樹。

( 節點 X 的子樹爲 X 自己,以及全部 X 的後代。)

示例1:
輸入: [1,null,0,0,1]
輸出: [1,null,0,null,1]

解釋:
只有紅色節點知足條件「全部不包含 1 的子樹」。
右圖爲返回的答案。

示例2:
輸入: [1,0,1,0,0,0,1]
輸出: [1,null,1,null,1]

示例3:
輸入: [1,1,0,1,1,0,1,0]
輸出: [1,1,0,1,1,null,1]

說明:

給定的二叉樹最多有 100 個節點。
每一個節點的值只會爲 0 或 1 。

根據題目描述不難看出, 咱們的根節點可能會被整個移除掉。這就是我上面說的根節點被修改的狀況。 這個時候,咱們只要新建一個虛擬節點當作新的根節點,就不須要考慮這個問題了。

此時的代碼是這樣的:

var pruneTree = function (root) {
  function dfs(root) {
    // do something
  }
  ans = new TreeNode(-1);
  ans.left = root;
  dfs(ans);
  return ans.left;
};

接下來,只須要完善 dfs 框架便可。 dfs 框架也很容易,咱們只須要將子樹和爲 0 的節點移除便可,而計算子樹和是一個難度爲 easy 的題目,只須要後序遍歷一次並收集值便可。

計算子樹和的代碼以下:

function dfs(root) {
  if (!root) return 0;
  const l = dfs(root.left);
  const r = dfs(root.right);
  return root.val + l + r;
}

有了上面的鋪墊,最終代碼就不難寫出了。

完整代碼(JS):

var pruneTree = function (root) {
  function dfs(root) {
    if (!root) return 0;
    const l = dfs(root.left);
    const r = dfs(root.right);
    if (l == 0) root.left = null;
    if (r == 0) root.right = null;
    return root.val + l + r;
  }
  ans = new TreeNode(-1);
  ans.left = root;
  dfs(ans);
  return ans.left;
};

【題目一】1325. 刪除給定值的葉子節點

題目描述:

給你一棵以 root 爲根的二叉樹和一個整數 target ,請你刪除全部值爲 target 的 葉子節點 。

注意,一旦刪除值爲 target 的葉子節點,它的父節點就可能變成葉子節點;若是新葉子節點的值剛好也是 target ,那麼這個節點也應該被刪除。

也就是說,你須要重複此過程直到不能繼續刪除。

 

示例 1:

輸入:root = [1,2,3,2,null,2,4], target = 2
輸出:[1,null,3,null,4]
解釋:
上面左邊的圖中,綠色節點爲葉子節點,且它們的值與 target 相同(同爲 2 ),它們會被刪除,獲得中間的圖。
有一個新的節點變成了葉子節點且它的值與 target 相同,因此將再次進行刪除,從而獲得最右邊的圖。
示例 2:

輸入:root = [1,3,3,3,2], target = 3
輸出:[1,3,null,null,2]
示例 3:

輸入:root = [1,2,null,2,null,2], target = 2
輸出:[1]
解釋:每一步都刪除一個綠色的葉子節點(值爲 2)。
示例 4:

輸入:root = [1,1,1], target = 1
輸出:[]
示例 5:

輸入:root = [1,2,3], target = 1
輸出:[1,2,3]
 

提示:

1 <= target <= 1000
每一棵樹最多有 3000 個節點。
每個節點值的範圍是 [1, 1000] 。

和上面題目相似,這道題的根節點也可能被刪除,所以這裏咱們採起和上面題目相似的技巧。

因爲題目說明了一旦刪除值爲  target  的葉子節點,它的父節點就可能變成葉子節點;若是新葉子節點的值剛好也是  target ,那麼這個節點也應該被刪除。也就是說,你須要重複此過程直到不能繼續刪除。 所以這裏使用後序遍歷會比較容易,由於形象地看上面的描述過程你會發現這是一個自底向上的過程,而自底向上一般用後序遍歷。

上面的題目,咱們能夠根據子節點的返回值決定是否刪除子節點。而這道題是根據左右子樹是否爲空,刪除本身,關鍵字是本身。而樹的刪除和鏈表刪除相似,樹的刪除須要父節點,所以這裏的技巧和鏈表相似,記錄一下當前節點的父節點便可,並經過參數擴展向下傳遞。至此,咱們的代碼大概是:

class Solution:
    def removeLeafNodes(self, root: TreeNode, target: int) -> TreeNode:
        # 單鏈表只有一個 next 指針,而二叉樹有兩個指針 left 和 right,所以要記錄一下當前節點是其父節點的哪一個孩子
        def dfs(node, parent, is_left=True):
            # do something
        ans = TreeNode(-1)
        ans.left = root
        dfs(root, ans)
        return ans.left

有了上面的鋪墊,最終代碼就不難寫出了。

完整代碼(Python):

class Solution:
    def removeLeafNodes(self, root: TreeNode, target: int) -> TreeNode:
        def dfs(node, parent, is_left=True):
            if not node: return
            dfs(node.left, node, True)
            dfs(node.right, node, False)
            if node.val == target and parent and not node.left and not node.right:
                if is_left: parent.left = None
                else: parent.right = None
        ans = TreeNode(-1)
        ans.left = root
        dfs(root, ans)
        return ans.left

邊界

發現本身總是邊界考慮不到,首先要知道這是正常的,人類的本能。 你們要克服這種本能, 只有多作,慢慢就能克服。 就像改一個壞習慣同樣,除了堅持,一個有用的技巧是獎勵和懲罰,我也用過這個技巧。

上面我介紹了樹的三種題型。對於不一樣的題型其實邊界考慮的側重點也是不同的,下面咱們展開聊聊。

搜索類

搜索類的題目,樹的邊界其實比較簡單。 90% 以上的題目邊界就兩種狀況。

樹的題目絕大多樹又是搜索類,你想一想掌握這兩種狀況多重要。
  1. 空節點

僞代碼:

def dfs(root):
    if not root: print('是空節點,你須要返回合適的值')
    # your code here`
  1. 葉子節點

僞代碼:

def dfs(root):
    if not root: print('是空節點,你須要返回合適的值')
    if not root.left and not root.right: print('是葉子節點,你須要返回合適的值')
# your code here`

一張圖總結一下:

通過這樣的處理,後面的代碼基本都不須要判空了。

構建類

相比於搜索類, 構建就比較麻煩了。我總結了兩個常見的邊界。

  1. 參數擴展的邊界

好比 1008 題, 根據前序遍歷構造二叉搜索樹。我就少考慮的邊界。

def bstFromPreorder(self, preorder: List[int]) -> TreeNode:
    def dfs(start, end):
        if start > end:
            return None
        if start == end:
            return TreeNode(preorder[start])
        root = TreeNode(preorder[start])
        mid = -1
        for i in range(start + 1, end + 1):
            if preorder[i] > preorder[start]:
                mid = i
                break
        if mid == -1:
            return None

        root.left = dfs(start + 1, mid - 1)
        root.right = dfs(mid, end)
        return root

    return dfs(0, len(preorder) - 1)

注意上面的代碼沒有判斷 start == end 的狀況,加下面這個判斷就行了。

if start == end: return TreeNode(preorder[start])
  1. 虛擬節點

除了搜索類的技巧能夠用於構建類外,也能夠考慮用我上面的講的虛擬節點。

參數擴展大法

參數擴展這個技巧很是好用,一旦掌握你會愛不釋手。

若是不考慮參數擴展, 一個最簡單的 dfs 一般是下面這樣:

def dfs(root):
    # do something

而有時候,咱們須要 dfs 攜帶更多的有用信息。典型的有如下三種狀況:

  1. 攜帶父親或者爺爺的信息。
def dfs(root, parent):
    if not root: return
    dfs(root.left, root)
    dfs(root.right, root)
  1. 攜帶路徑信息,能夠是路徑和或者具體的路徑數組等。

路徑和:

def dfs(root, path_sum):
    if not root:
        # 這裏能夠拿到根到葉子的路徑和
        return path_sum
    dfs(root.left, path_sum + root.val)
    dfs(root.right, path_sum + root.val)

路徑:

def dfs(root, path):
    if not root:
        # 這裏能夠拿到根到葉子的路徑
        return path
    path.append(root.val)
    dfs(root.left, path)
    dfs(root.right, path)
    # 撤銷
    path.pop()

學會了這個技巧,你們能夠用 面試題 04.12. 求和路徑 來練練手。

以上幾個模板都很常見,相似的場景還有不少。總之當你須要傳遞額外信息給子節點(關鍵字是子節點)的時候,請務必掌握這種技巧。這也解釋了爲啥參數擴展常常用於前序遍歷。

  1. 二叉搜索樹的搜索題大多數都須要擴展參考,甚至怎麼擴展都是固定的。

二叉搜索樹的搜索老是將最大值和最小值經過參數傳遞到左右子樹,相似 dfs(root, lower, upper),而後在遞歸過程更新最大和最小值便可。這裏須要注意的是 (lower, upper) 是的一個左右都開放的區間。

好比有一個題783. 二叉搜索樹節點最小距離是求二叉搜索樹的最小差值的絕對值。固然這道題也能夠用咱們前面提到的二叉搜索樹的中序遍歷的結果是一個有序數組這個性質來作。只須要一次遍歷,最小差必定出如今相鄰的兩個節點之間。

這裏我用另一種方法,該方法就是擴展參數大法中的 左右邊界法。

class Solution:
def minDiffInBST(self, root):
    def dfs(node, lower, upper):
        if not node:
            return upper - lower
        left = dfs(node.left, lower, node.val)
        right = dfs(node.right, node.val, upper)
        # 要麼在左,要麼在右,不可能橫跨(由於是 BST)
        return min(left, right)
    return dfs(root, float('-inf'), float('inf')

其實這個技巧不只適用二叉搜索樹,也但是適用在別的樹,好比 1026. 節點與其祖先之間的最大差值,題目大意是:給定二叉樹的根節點  root,找出存在於 不一樣 節點  A 和  B  之間的最大值 V,其中  V = |A.val - B.val|,且  A  是  B  的祖先。

使用相似上面的套路輕鬆求解。

class Solution:
def maxAncestorDiff(self, root: TreeNode) -> int:
    def dfs(root, lower, upper):
        if not root:
            return upper - lower
        # 要麼在左,要麼在右,要麼橫跨。
        return max(dfs(root.left, min(root.val, lower), max(root.val, upper)), dfs(root.right, min(root.val, lower), max(root.val, upper)))
    return dfs(root, float('inf'), float('-inf'))

返回元組/列表

一般,咱們的 dfs 函數的返回值是一個單值。而有時候爲了方便計算,咱們會返回一個數組或者元祖。

對於個數固定狀況,咱們通常使用元組,固然返回數組也是同樣的。

這個技巧和參數擴展有殊途同歸之妙,只不過一個做用於函數參數,一個做用於函數返回值。

返回元祖

返回元組的狀況還算比較常見。好比 865. 具備全部最深節點的最小子樹,一個簡單的想法是 dfs 返回深度,咱們經過比較左右子樹的深度來定位答案(最深的節點位置)。

代碼:

class Solution:
    def subtreeWithAllDeepest(self, root: TreeNode) -> int:
        def dfs(node, d):
            if not node: return d
            l_d = dfs(node.left, d + 1)
            r_d = dfs(node.right, d + 1)
            if l_d >= r_d: return l_d
            return r_d
        return dfs(root, -1)

可是題目要求返回的是樹節點的引用啊,這個時候應該考慮返回元祖,即除了返回深度,也要把節點給返回

class Solution:
    def subtreeWithAllDeepest(self, root: TreeNode) -> TreeNode:
        def dfs(node, d):
            if not node: return (node, d)
            l, l_d = dfs(node.left, d + 1)
            r, r_d = dfs(node.right, d + 1)
            if l_d == r_d: return (node, l_d)
            if l_d > r_d: return (l, l_d)
            return (r, r_d)
        return dfs(root, -1)[0]

返回數組

dfs 返回數組比較少見。即便題目要求返回數組,咱們也一般是聲明一個數組,在 dfs 過程不斷 push,最終返回這個數組。而不會選擇返回一個數組。絕大多數狀況下,返回數組是用於計算笛卡爾積。所以你須要用到笛卡爾積的時候,考慮使用返回數組的方式。

通常來講,若是須要使用笛卡爾積的狀況仍是比較容易看出的。另一個不太準確的技巧是,若是題目有」全部可能「,」全部狀況「,能夠考慮使用此技巧。

一個典型的題目是 1530.好葉子節點對的數量

題目描述:

給你二叉樹的根節點 root 和一個整數 distance 。

若是二叉樹中兩個葉節點之間的 最短路徑長度 小於或者等於 distance ,那它們就能夠構成一組 好葉子節點對 。

返回樹中 好葉子節點對的數量 。

 

示例 1:

 



輸入:root = [1,2,3,null,4], distance = 3
輸出:1
解釋:樹的葉節點是 3 和 4 ,它們之間的最短路徑的長度是 3 。這是惟一的好葉子節點對。
示例 2:

輸入:root = [1,2,3,4,5,6,7], distance = 3
輸出:2
解釋:好葉子節點對爲 [4,5] 和 [6,7] ,最短路徑長度都是 2 。可是葉子節點對 [4,6] 不知足要求,由於它們之間的最短路徑長度爲 4 。
示例 3:

輸入:root = [7,1,4,6,null,5,3,null,null,null,null,null,2], distance = 3
輸出:1
解釋:惟一的好葉子節點對是 [2,5] 。
示例 4:

輸入:root = [100], distance = 1
輸出:0
示例 5:

輸入:root = [1,1,1], distance = 2
輸出:1
 

提示:

tree 的節點數在 [1, 2^10] 範圍內。
每一個節點的值都在 [1, 100] 之間。
1 <= distance <= 10

上面咱們學習了路徑的概念,在這道題又用上了。

其實兩個葉子節點的最短路徑(距離)能夠用其最近的公共祖先來輔助計算。即兩個葉子節點的最短路徑 = 其中一個葉子節點到最近公共祖先的距離 + 另一個葉子節點到最近公共祖先的距離

所以咱們能夠定義 dfs(root),其功能是計算以 root 做爲出發點,到其各個葉子節點的距離。 若是其子節點有 8 個葉子節點,那麼就返回一個長度爲 8 的數組, 數組每一項的值就是其到對應葉子節點的距離。

若是子樹的結果計算出來了,那麼父節點只須要把子樹的每一項加 1 便可。這點不難理解,由於父到各個葉子節點的距離就是父節點到子節點的距離(1) + 子節點到各個葉子節點的距離

由上面的推導可知須要先計算子樹的信息,所以咱們選擇前序遍歷。

完整代碼(Python):

class Solution:
    def countPairs(self, root: TreeNode, distance: int) -> int:
        self.ans = 0

        def dfs(root):
            if not root:
                return []
            if not root.left and not root.right:
                return [0]
            ls = [l + 1 for l in dfs(root.left)]
            rs = [r + 1 for r in dfs(root.right)]
            # 笛卡爾積
            for l in ls:
                for r in rs:
                    if l + r <= distance:
                        self.ans += 1
            return ls + rs
        dfs(root)
        return self.ans

894. 全部可能的滿二叉樹 也是同樣的套路,你們用上面的知識練下手吧~

經典題目

推薦你們先把本文提到的題目都作一遍,而後用本文學到的知識作一下下面十道練習題,檢驗一下本身的學習成果吧!

總結

樹的題目一種中心點就是遍歷,這是搜索問題和修改問題的基礎。

而遍歷從大的方向分爲廣度優先遍歷和深度優先遍歷,這就是咱們的兩個基本點。兩個基本點能夠進一步細分,好比廣度優先遍歷有帶層信息的和不帶層信息的(其實只要會帶層信息的就夠了)。深度優先遍歷常見的是前序和後序,中序多用於二叉搜索樹,由於二叉搜索樹的中序遍歷是嚴格遞增的數組。

樹的題目從大的方向上來看就三種,一種是搜索類,這類題目最多,這種題目緊緊把握開始點,結束點 和 目標便可。構建類型的題目我以前的專題以及講過了,一句話歸納就是根據一種遍歷結果肯定根節點位置,根據另一種遍歷結果(若是是二叉搜索樹就不須要了)肯定左右子樹。修改類題目很少,這種問題邊界須要特殊考慮,這是和搜索問題的本質區別,可使用虛擬節點技巧。另外搜索問題,若是返回值不是根節點也能夠考慮虛擬節點。

樹有四個比較重要的對作題幫助很大的概念,分別是徹底二叉樹,二叉搜索樹,路徑和距離,這裏面相關的題目推薦你們好好作一下,都很經典。

最後我給你們介紹了七種乾貨技巧,不少技巧都說明了在什麼狀況下可使用。好很差用你本身去找幾個題目試試就知道了。

以上就是樹專題的所有內容了。你們對此有何見解,歡迎給我留言,我有時間都會一一查看回答。更多算法套路能夠訪問個人 LeetCode 題解倉庫:https://github.com/azl3979858... 。 目前已經 38K star 啦。你們也能夠關注個人公衆號《力扣加加》帶你啃下算法這塊硬骨頭。

我整理的 1000 多頁的電子書已經開發下載了,你們能夠去個人公衆號《力扣加加》後臺回覆電子書獲取。

相關文章
相關標籤/搜索