dfs技巧

dfs技巧

  • dfs(root)
  • 单/双递归
  • 前后遍历
  • 虚拟节点
    • 【题目一】814. 二叉树剪枝
    • 【题目一】1325. 删除给定值的叶子节点
  • 边界
    • 搜索类
      • 空节点
    • 叶子节点
    • 构建类
      • 参数扩展的边界
        • 1008 题, 根据前序遍历构造二叉搜索树
      • 虚拟节点
  • 参数扩展大法
      • 783. 二叉搜索树节点最小距离
      • 1026. 节点与其祖先之间的最大差值
  • 返回元组/列表
    • 返回元祖
      • 865. 具有所有最深节点的最小子树
    • 返回数组
      • 1530.好叶子节点对的数量
  • 经典题目

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. 后继者

你可能感兴趣的:(#,刷题)