樹專題

StellaLiu螢窗小語發表於2020-12-06

一箇中心

樹的遍歷迭代寫法
其核心思想如下:
使用顏色標記節點的狀態,新節點為白色,已訪問的節點為灰色。
如果遇到的節點為白色,則將其標記為灰色,然後將其右子節點、自身、左子節點依次入棧。
如果遇到的節點為灰色,則將節點的值輸出。
使用這種方法實現的中序遍歷如下:

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

簡單總結一下,樹的題目一箇中心就是樹的遍歷。樹的遍歷分為兩種,分別是深度優先遍歷和廣度優先遍歷。關於樹的不同深度優先遍歷(前序,中序和後序遍歷)的迭代寫法是大多數人容易犯錯的地方,因此我介紹了一種統一三種遍歷的方法 - 二色標記法,這樣大家以後寫迭代的樹的前中後序遍歷就再也不用怕了。如果大家徹底熟悉了這種寫法,再去記憶和練習一次入棧甚至是 Morris 遍歷即可。
在這裡插入圖片描述

兩個基本點

上面提到了樹的遍歷有兩種基本方式,分別是深度優先遍歷(以下簡稱 DFS)和廣度優先遍歷(以下簡稱 BFS),這就是兩個基本點。這兩種遍歷方式下面又會細分幾種方式。比如 DFS 細分為前中後序遍歷, BFS 細分為帶層的和不帶層的。
而 BFS 適合求最短距離,這個和層次遍歷是不一樣的,很多人搞混。這裡強調一下,層次遍歷和 BFS 是完全不一樣的東西。
層次遍歷就是一層層遍歷樹,按照樹的層次順序進行訪問。
BFS 的核心在於求最短問題時候可以提前終止,這才是它的核心價值,層次遍歷是一種不需要提前終止的 BFS 的副產物。

深度優先遍歷

演算法流程

首先將根節點放入stack中。
從stack中取出第一個節點,並檢驗它是否為目標。如果找到所有的節點,則結束搜尋並回傳結果。否則將它某一個尚未檢驗過的直接子節點加入stack中。
重複步驟 2。 如果不存在未檢測過的直接子節點。將上一級節點加入stack中。 重複步驟 2。 重複步驟 4。
若stack為空,表示整張圖都檢查過了——亦即圖中沒有欲搜尋的目標。結束搜尋並回傳“找不到目標”。
這裡的 stack可以理解為自己實現的棧,也可以理解為呼叫棧。如果是呼叫棧的時候就是遞迴,如果是自己實現的棧的話就是迭代。
在這裡插入圖片描述

廣度優先遍歷

帶層資訊

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

不帶層資訊

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 比較適合找最短距離/路徑和某一個距離的目標。比如給定一個二叉樹,在樹的最後一行找到最左邊的值。,此題是力扣 513 的原題。這不就是求距離根節點最遠距離的目標麼? 一個 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.** **二叉樹中和為某一值的路徑

比如劍指 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
            # 選擇
            path.append(cur.val)
            # 遞迴左右子樹
            backtrack(nodes, path, cur.left, remain - cur.val)
            backtrack(nodes, path, cur.right, remain - cur.val)
            # 撤銷選擇
            path.pop(-1)
        ans = []
        # 入口,路徑,目標值全部傳進去,其中路徑和path都是擴充套件的引數
        backtrack(ans, [], root, target)
        return ans

1372. 二叉樹中的最長交錯路徑

再比如:**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],就表示的是如下的一顆二叉樹:

  1. 還有一種是給你描述一種場景,讓你構造一個符合條件的二叉樹

。這種題和上面的沒啥區別,套路簡直不要太像,比如 654. 最大二叉樹,我就不多說了,大家通過這道題練習一下就知道了。

  1. 動態構建二叉樹的 比如 894. 所有可能的滿二叉樹 ,對於這個題,直接 BFS 就好了。由於這種題很少,因此不做多的介紹。大家只要把最核心的掌握了,這種東西自然水到渠成。

二叉搜尋樹的構建

原因就在於二叉搜尋樹的根節點的值大於所有的左子樹的值,且小於所有的右子樹的值。因此我們可以根據這一特性去確定左右子樹的位置,經過這樣的轉換就和上面的普通二叉樹沒有啥區別了。比如
1008. 前序遍歷構造二叉搜尋樹

修改類

增加,刪除節點,或者是修改節點的值或者指向。

修改指標的題目一般不難,比如 116. 填充每個節點的下一個右側節點指標,這不就是 BFS 的時候順便記錄一下上一次訪問的同層節點,然後增加一個指標不就行了麼?關於 BFS ,套用我的帶層的 BFS 模板就搞定了。
增加和刪除的題目一般稍微複雜,比如 450. 刪除二叉搜尋樹中的節點 和 669. 修剪二叉搜尋樹。西法我教你兩個套路,面對這種問題就不帶怕的。那就是後續遍歷 + 虛擬節點,

實際工程中,我們也可以不刪除節點,而是給節點做一個標記,表示已經被刪除了,這叫做軟刪除。

另外一種是為了方便計算,自己加了一個指標。
比如 863. 二叉樹中所有距離為 K 的結點 通過修改樹的節點類,增加一個指向父節點的引用 parent,問題就轉化為距離目標節點一定距離的問題了,此時可是用我上面講的帶層的 BFS 模板解決。

四個重要概念

二叉搜尋樹

若左子樹不空,則左子樹上所有節點的值均小於它的根節點的值; 若右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;
左、右子樹也分別為二叉排序樹; 沒有鍵值相等的節點。 對於一個二叉查詢樹,常規操作有插入,查詢,刪除,找父節點,求最大值,求最小值。

中序遍歷是有序的
另外二叉查詢樹有一個性質,這個性質對於做題很多幫助,那就是: 二叉搜尋樹的中序遍歷的結果是一個有序陣列。 比如 98. 驗證二叉搜尋樹 就可以直接中序遍歷,並一邊遍歷一邊判斷遍歷結果是否是單調遞增的,如果不是則提前返回 False 即可。
再比如 99. 恢復二叉搜尋樹,官方難度為困難。題目大意是給你二叉搜尋樹的根節點 root ,該樹中的兩個節點被錯誤地交換。請在不改變其結構的情況下,恢復這棵樹。 *我們可以先中序遍歷發現不是遞增的節點,他們就是被錯誤交換的節點,然後交換恢復即可。這道題難點就在於一點,即錯誤交換可能錯誤交換了中序遍歷的相鄰節點或者中序遍歷的非相鄰節點,這是兩種 case,需要分別討論 *。

完全二叉樹

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

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

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

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

我們可以給完全二叉樹編號,這樣父子之間就可以通過編號輕鬆求出。比如我給所有節點從左到右從上到下依次從 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

這個題目雖然並不是完全二叉樹的題目,但是卻和完全二叉樹很像,有借鑑完全二叉樹的地方。

路徑

124.二叉樹中的最大路徑和

雖然是困難難度,但是搞清楚概念的話,和簡單難度沒啥區別。 接下來,我們就以這道題講解一下。

這道題的題目是
給定一個非空二叉樹,返回其最大路徑和。路徑的概念是:一條從樹中任意節點出發,沿父節點-子節點連線,達到任意節點的序列。該路徑至少包含一個節點,且不一定經過根節點

個描述我會想到大概率是要麼全域性記錄最大值,要麼雙遞迴。 如果使用雙遞迴,那麼複雜度就是
,實際上,子樹的路徑和計算出來了,可以推匯出父節點的最大路徑和,因此如果使用雙遞迴會有重複計算。一個可行的方式是記憶化遞迴。
如果使用全域性記錄最大值,只需要在遞迴的時候 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
# Definition for a binary tree node.
# class TreeNode(object):
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution(object):
    def maxPathSum(self, root):
        """
        :type root: TreeNode
        :rtype: int
        """
        # 思路:遞迴求解,遞迴求解以某一個結點為起始結點的最大路徑和,
        # 它等於當前結點的值+max(以左子結點為起始結點的最大路徑和,以右子結點為起始結點的最大路徑和);
        # 在遞迴的過程中,更新最大的路徑和的值,它等於當前結點的值+左子結點的最大路徑和+右子結點的最大路徑和
        self.res = float('-inf')
        self.dfs(root)
        return self.res

    def dfs(self, root):
        if not root:
            return 0
        left = self.dfs(root.left)
        right = self.dfs(root.right)
        left = left if left > 0 else 0
        right = right if right > 0 else 0
        self.res = max(self.res, left + root.val + right)
        return max(left, right) + root.val

類似題目 113. 路徑總和 I

距離

和路徑類似,距離也是一個相似且頻繁出現的一個考點,並且二者都是搜尋類題目的考點。原因就在於最短路徑就是距離,而樹的最短路徑就是邊的數目。
這兩個題練習一下,碰到距離的題目基本就穩了。
834.樹中距離之和
863.二叉樹中所有距離為 K 的結點

相關文章