dfs技巧
dfs技巧
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)
前後遍歷
和連結串列一樣, 要掌握樹的前後序,也只需要記住一句話就好了。那就是如果是前序遍歷,那麼你可以想象上面的節點都處理好了,怎麼處理的不用管。相應地如果是後序遍歷,那麼你可以想象下面的樹都處理好了,怎麼處理的不用管。這句話的正確性也是毋庸置疑。
前後序對連結串列來說比較直觀。對於樹來說,其實更形象地說應該是自頂向下或者自底向上。自頂向下和自底向上在演算法上是不同的,不同的寫法有時候對應不同的書寫難度。比如 https://leetcode-cn.com/problems/sum-root-to-leaf-numbers/,這種題目就適合通過引數擴充套件 + 前序來完成。
關於引數擴充套件的技巧,我們在後面展開。
自頂向下就是在每個遞迴層級,首先訪問節點來計算一些值,並在遞迴呼叫函式時將這些值傳遞到子節點,一般是通過引數傳到子樹中。
自底向上是另一種常見的遞迴方法,首先對所有子節點遞迴地呼叫函式,然後根據返回值和根節點本身的值得到答案。
關於前後序的思維技巧,可以參考我的這個文章 的前後序部分。
大多數樹的題使用後序遍歷比較簡單,並且大多需要依賴左右子樹的返回值。比如 1448. 統計二叉樹中好節點的數目
不多的問題需要前序遍歷,而前序遍歷通常要結合引數擴充套件技巧。比如 1022. 從根到葉的二進位制數之和
如果你能使用引數和節點本身的值來決定什麼應該是傳遞給它子節點的引數,那就用前序遍歷。
如果對於樹中的任意一個節點,如果你知道它子節點的答案,你能計算出當前節點的答案,那就用後序遍歷。
如果遇到二叉搜尋樹則考慮中序遍歷
虛擬節點
【題目一】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% 以上的題目邊界就兩種情況。 樹的題目絕大多樹又是搜尋類,你想想掌握這兩種情況多重要。
空節點
虛擬碼:
def dfs(root):
if not root: print('是空節點,你需要返回合適的值')
# your code here`
葉子節點
虛擬碼:
def dfs(root):
if not root: print('是空節點,你需要返回合適的值')
if not root.left and not root.right: print('是葉子節點,你需要返回合適的值')
構建類
相比於搜尋類, 構建就比較麻煩了。我總結了兩個常見的邊界。
引數擴充套件的邊界
1008 題, 根據前序遍歷構造二叉搜尋樹
比如 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])
虛擬節點
除了搜尋類的技巧可以用於構建類外,也可以考慮用我上面的講的虛擬節點。
引數擴充套件大法
引數擴充套件這個技巧非常好用,一旦掌握你會愛不釋手。
如果不考慮引數擴充套件, 一個最簡單的 dfs 通常是下面這樣:
def dfs(root):
# do something
而有時候,我們需要 dfs 攜帶更多的有用資訊。典型的有以下三種情況:
攜帶父親或者爺爺的資訊。
def dfs(root, parent):
if not root: return
dfs(root.left, root)
dfs(root.right, root)
攜帶路徑資訊,可以是路徑和或者具體的路徑陣列等。
路徑和:
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. 求和路徑 來練練手。
以上幾個模板都很常見,類似的場景還有很多。總之當你需要傳遞額外資訊給子節點(關鍵字是子節點)的時候,請務必掌握這種技巧。這也解釋了為啥引數擴充套件經常用於前序遍歷。
- 二叉搜尋樹的搜尋題大多數都需要擴充套件參考,甚至怎麼擴充套件都是固定的。
二叉搜尋樹的搜尋總是將最大值和最小值通過引數傳遞到左右子樹,類似 dfs(root, lower, upper),然後在遞迴過程更新最大和最小值即可。這裡需要注意的是 (lower, upper) 是的一個左右都開放的區間。
比如有一個題783. 二叉搜尋樹節點最小距離是求二叉搜尋樹的最小差值的絕對值。當然這道題也可以用我們前面提到的二叉搜尋樹的中序遍歷的結果是一個有序陣列這個性質來做。只需要一次遍歷,最小差一定出現在相鄰的兩個節點之間。
這裡我用另外一種方法,該方法就是擴充套件引數大法中的 左右邊界法。
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 的祖先。
使用類似上面的套路輕鬆求解。
1026. 節點與其祖先之間的最大差值
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 返回深度,我們通過比較左右子樹的深度來定位答案(最深的節點位置)。
程式碼:
865. 具有所有最深節點的最小子樹
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.好葉子節點對的數量
一個典型的題目是 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. 所有可能的滿二叉樹 也是一樣的套路,大家用上面的知識練下手吧~
經典題目
劍指 Offer 55 - I. 二叉樹的深度
劍指 Offer 34. 二叉樹中和為某一值的路徑
101. 對稱二叉樹
226. 翻轉二叉樹
543. 二叉樹的直徑
662. 二叉樹最大寬度
971. 翻轉二叉樹以匹配先序遍歷
987. 二叉樹的垂序遍歷
863. 二叉樹中所有距離為 K 的結點
面試題 04.06. 後繼者
相關文章
- DFS
- dfs序
- DFS樹
- 深搜dfs
- Tempter of the Bone(DFS)
- DAG bfs + dfs 126,
- 樹的DFS序
- Prime Ring Problem (dfs)
- DFS序例題+感受
- DFS入門筆記筆記
- 圖的dfs_euler
- DFS演算法原理演算法
- DFS實現拓撲排序排序
- DFS剪枝最佳化策略
- 關於元素排列的DFS
- DFS(深度優先搜尋)
- 【題目整理】dfs入門
- 9*9的數獨(dfs)
- P1219 八皇后(dfs)
- bzoj4500: 矩陣(dfs)矩陣
- dfs的return時機問題
- dfs時間複雜度分析時間複雜度
- 聊聊演算法——BFS和DFS演算法
- 數獨問題(DFS+回溯)
- HDU1427速算24點(dfs)
- HDU - 2553 N皇后問題(DFS)
- HDU 1427-速算24點(DFS)
- 求樹的直徑(BFS/DFS)
- 有向圖的拓撲排序——DFS排序
- 藍橋杯-迷宮(BFS+DFS)
- P1101 單詞方陣【DFS】
- 模板題目:DFS (130. Surrounded Regions)
- Codefroces 1328E Tree Querie(dfs序)
- L2-007 家庭房產【DFS】
- L2-020 功夫傳人【DFS】
- P1433 吃乳酪 (dfs+剪枝)
- hdu 6446 Tree and Permutation(dfs+思維)
- P1019 單詞接龍(dfs)