LeetCode-Tree篇总结

文章目录

  • 一、前言
  • 二、基础
    • 1. 树节点的定义。
    • 2. 深度优先遍历的递归写法
    • 3. 深度优先遍历的迭代写法
    • 4. 广度优先遍历的迭代写法
  • 三、深度优先遍历问题
    • 1. 树的深度问题
    • 2. 树的路径或叶子节点问题
    • 3. 二叉搜索树或中序遍历问题
  • 四、广度优先遍历问题
  • 五、二叉树的重建问题
  • 六、总结
  • 参考文献

一、前言

作为正式好好刷题的开始,考虑到树相关的题目一般而言较为模板化,递归的代码一般也比较简洁,而且个人也比较擅长这个方面,因此,决定先从这个部分开始。这篇博客主要是记录一些思路,并不会讲解太多题目。

二、基础

个人认为,树的大部分题目其实都可以归结为一个遍历问题,树的遍历广义上分为两种:深度优先遍历(depth first search,dfs)以及广度优先遍历(breadth first search,bfs),其中深度优先遍历又可以细分为三种:前序遍历、中序遍历以及后序遍历,这几种遍历方式的具体细节这里就不展开介绍了。
这几种遍历方式是树相关题目的基础,需要牢固掌握,可以刷一下以下几道基础题,巩固一下基础。
144. 二叉树的前序遍历
94. 二叉树的中序遍历
145. 二叉树的后序遍历
102. 二叉树的层序遍历

以上几道题是非常基础的简单遍历题,实现的时候最好能实现递归以及迭代两种方法。
下面也附上个人觉得非常好用的递归以及迭代的模板。

1. 树节点的定义。

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

2. 深度优先遍历的递归写法

class Solution:
    def preorderTraversal(self, root: TreeNode) -> List[int]:
        def dfs(cur):
            if not cur:
                return
            
            # 前序递归
            res.append(cur.val)
            dfs(cur.left)
            dfs(cur.right)
            
            # # 中序递归
            # dfs(cur.left)
            # res.append(cur.val)
            # dfs(cur.right)
            
            # # 后序递归
            # dfs(cur.left)
            # dfs(cur.right)
            # res.append(cur.val) 
                 
        res = []
        dfs(root)
        return res

从以上代码不难看出,深度优先遍历的三种方式整体流程是类似的,唯一的区别就是res.append(cur.val)这个“遍历”操作的位置不同。

下面有几个问题需要强调:

  • 递归问题:递归问题有个原则个人觉得十分好用,那就是永远只处理当前元素的情况,剩下来的问题交给递归子问题来解决。比如说树的构建问题,对于当前root节点来说,我们只需要做两件事:1. 根据节点值构建出当前root节点;2. 将root节点与左子树和右子树连接起来,而左子树和右子树的创建,则交给递归去完成,我们只需要专注于当前root节点的节点值如何获取以及左子树、右子树的递归子问题条件的相应变化即可
  • 树的“遍历”操作:对于树的问题,关键点就是抽象出这里所谓的“遍历”到底是个什么操作。对于上面的代码来说,“遍历”就是将当前节点的值添加到一个list中,不同的题目实际上就是这里的“遍历”操作不太相同,后续会通过一些题目代码进行具体说明。这个“遍历”抽象同样适用于迭代操作。

3. 深度优先遍历的迭代写法

class Solution:
    def preorderTraversal(self, root: TreeNode) -> List[int]:
        if not root:
            return []
        res = []
        stack = [root]
        while stack:
            cur = stack.pop()
            res.append(cur.val)  # 遍历操作
            if cur.right:  # 由于栈为后入先出,因此,左右孩子的入栈顺序需要注意
                stack.append(cur.right)
                # 添加其他代码
            if cur.left:
                stack.append(cur.left)
                # 添加其他代码
        return res
        
        # 后序迭代 将前序迭代进栈顺序稍作修改,最后得到的结果反转
        # while stack:
        #     cur = stack.pop()
        #     if cur.left:
        #         stack.append(cur.left)
        #     if cur.right:
        #         stack.append(cur.right)
        #     res.append(cur.val)  # 遍历操作
        # return res[::-1]  # 反转结果
        
class Solution:
    def inorderTraversal(self, root: TreeNode) -> List[int]:
        res = []
        stack = []
        cur = root
        while stack or cur:
            while cur:
                stack.append(cur)
                cur = cur.left
            cur = stack.pop()
            res.append(cur.val)
            cur = cur.right
        return res

以上代码,尤其是前序遍历的非递归写法是很多问题的基础模板,根据“遍历”操作的不同,一般将相应的res.append(cur.val)换成对应的“遍历”操作,同时在相应的stack.append(cur.left)以及stack.append(cur.right)位置处增加其他代码即可。

前序(后序)以及中序代码中都使用了stack数据结构,但其在初始化的时候有一点不同,有时我们可能会记不起来栈初始化的正确形式,这里可以做一下简单分析,从而有助于记忆。两个初始化区别的原因在于两种遍历顺序中根节点是否是最先处理的节点。

  • 首先,我们先思考一下为什么需要用栈?
    在前序遍历(后序遍历类似)中,当我们访问完当前节点以及当前节点的左孩子后,此时,需要回溯,去访问当前节点的右孩子;但我们无法直接从左孩子去查找到右孩子,因此,我们需要用一个数据结构去保存我们之前访问过的节点,而这个数据结构需要满足后入先出的特点,不难看出,这就是一个,通过栈,我们即可返回到当前节点,再通过当前节点去访问其右孩子,从而完成遍历过程;而在中序遍历中也是类似的,访问完左孩子后,还需要栈去弹出当前节点,从而完成后续遍历。
  • 前序遍历(后序遍历)与中序遍历对于根节点的处理顺序又有什么不同呢?
    在前序遍历(后序遍历)中,根节点是需要第一个访问的,也就是首先需要处理的,因此,我们在初始化就入栈,出栈时就对当前节点进行“遍历”操作,即可实现根节点优先访问的结果;而对于中序遍历,根节点不是第一个需要访问的,之所以将根节点入栈,只是为了保存一下而已,因此,我们初始化时不需要将其入栈。

考虑到以上思考过程就不难记住两种初始化方式了。

4. 广度优先遍历的迭代写法

class Solution:
    def levelOrder(self, root: TreeNode) -> List[List[int]]:
        if not root:
            return []
        cur, res = [root], []
        while cur:
            lay, layval = [], []
            for node in cur:
                layval.append(node.val)
                if node.left:
                    lay.append(node.left)
                if node.right:
                    lay.append(node.right)
            cur = lay
            res.append(layval)
        return res

广度优先遍历一般使用队列来实现,上述代码通过for循环去实现类似队列的popleft的操作,个人觉得更加优雅,也更容易记忆。同样,这里的初始化也需要加入根节点,因为根节点需要优先处理。

三、深度优先遍历问题

树的大部分题目基本都可以用深度优先遍历来完成,而深度优先遍历中一般前序遍历使用得较多,整体框架大致类似,一般都是在所谓的“遍历”操作上有所差别。

1. 树的深度问题

例如104. 二叉树的最大深度一题,该题的递归以及迭代解法为:

# 递归
class Solution:
    def maxDepth(self, root: TreeNode) -> int:
        if not root:
            return 0
        return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))  # 根 左孩子 右孩子


# 迭代
class Solution:
    def maxDepth(self, root: TreeNode) -> int:
        if not root:
            return 0
        
        max_depth = 1
        stack = [root, 0]
        while stack:
            depth = stack.pop()
            node = stack.pop()
            if not node.left and not node.right:
                max_depth = max(max_depth, depth + 1)  # 遍历操作
            if node.right:  # 右孩子
                stack.append(node.right)
                stack.append(depth + 1)
            if node.left:  # 左孩子
                stack.append(node.left)
                stack.append(depth + 1)
        return max_depth

这道题就是一道套用前序遍历模板的题目。对比上述代码与标准前序遍历模板,主要差异就是遍历操作的差异,迭代代码中还使用了栈去保存每个节点高度的做法,也是树的高度问题中常见的方法
还有一些关于树的高度的问题,例如:110. 平衡二叉树、111. 二叉树的最小深度。

2. 树的路径或叶子节点问题

例如112. 路径总和一题,该题的递归以及迭代解法为:

# 递归
class Solution:
    def hasPathSum(self, root: TreeNode, sum: int) -> bool:
        def dfs(root, cur_sum):
            if not root:
                return False
            if not root.left and not root.right and cur_sum + root.val == sum:  # 递归过程中仅对当前root节点进行处理
                return True
            return dfs(root.left, cur_sum + root.val) or dfs(root.right, cur_sum + root.val)  # 子问题交给左右孩子递归解决,但同时需要修改相应的条件使其成为一个正确的子问题
        return dfs(root, 0)


# 迭代
class Solution:
    def hasPathSum(self, root: TreeNode, sum: int) -> bool:
        if not root:
            return False
        
        stack = [root, 0]
        while stack:
            cur_sum = stack.pop()
            node = stack.pop()
            if not node.left and not node.right and cur_sum + node.val == sum:  # 遍历操作
                return True
            if node.right:
                stack.append(node.right)
                stack.append(cur_sum + node.val)
            if node.left:
                stack.append(node.left)
                stack.append(cur_sum + node.val)
            
        return False

这道题的整体代码和104. 二叉树的最大深度是较为类似的,都是dfs并且使用栈存储节点以及节点对应的某种状态(当前高度、当前路径和),当然额外的状态也可以不止一种,例如对于113. 路径总和 II就可以再添加一个path的路径状态,具体代码如下:

# 递归
class Solution:
    def pathSum(self, root: TreeNode, sum: int) -> List[List[int]]:
        def dfs(root, path, cur_sum):
            if not root:
                return
            if not root.left and not root.right and cur_sum + root.val == sum:
                path.append(root.val)
                paths.append(path)
                return
            if root.left:
                dfs(root.left, path + [root.val], cur_sum + root.val)
            if root.right:
                dfs(root.right, path + [root.val], cur_sum + root.val)
        
        paths = []
        dfs(root, [], 0)
        return paths


# 迭代
class Solution:
    def pathSum(self, root: TreeNode, sum: int) -> List[List[int]]:
        if not root:
            return []
        
        stack = [root, [], 0]  # 存储当前节点node以及额外两种状态:path和cur_cum
        paths = []
        while stack:
            cur_sum = stack.pop()
            path = stack.pop()
            node = stack.pop()
            if not node.left and not node.right and cur_sum + node.val == sum:
                path.append(node.val)
                paths.append(path)
                continue
            if node.right:
                stack.append(node.right)
                stack.append(path + [node.val])
                stack.append(cur_sum + node.val)
            if node.left:
                stack.append(node.left)
                stack.append(path + [node.val])
                stack.append(cur_sum + node.val)
            
        return paths

类似的题目还有129. 求根到叶子节点数字之和、257. 二叉树的所有路径

3. 二叉搜索树或中序遍历问题

二叉搜索树的定义为:二叉查找树(Binary Search Tree),(又:二叉搜索树,二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。

二叉搜索树的中序遍历序列为有序序列。因此,很多二叉搜索树问题都涉及到中序遍历问题。

例如对于98. 验证二叉搜索树一题,该题的递归以及迭代解法为:

class Solution:
    def isValidBST(self, root):
        def BFS(root, left, right):
            if root is None:
                return True
            if left < root.val < right:
                return BFS(root.left, left, root.val) and BFS(root.right, root.val, right)
            else:
                return False
        
        return BFS(root, -float('inf'), float('inf'))


class Solution:
    def isValidBST(self, root):
        stack, inorder = [], float('-inf')
        while stack or root:
            while root:
                stack.append(root)
                root = root.left
            root = stack.pop()
            # 如果中序遍历得到的节点的值小于等于前一个 inorder,说明不是二叉搜索树
            if root.val <= inorder:
                return False
            inorder = root.val
            root = root.right
        return True

需要注意的是,该题的递归写法采用的是BFS,也就是先判断当前节点是否满足要求,再去判断左右节点是否满足要求,如果当前节点不满足要求了,则直接返回结果,这样可以剪枝,提前结束递归过程。
而该题的迭代解法就是比较经典的中序遍历模板了。由于二叉搜索树的中序遍历为有序数组,因此,我们可以检查该二叉搜索树的中序遍历是否满足该约束,不满足则说明不是二叉搜索树,直到最后,即可完成判断。
类似的还有99. 恢复二叉搜索树,这道题的解法为:

class Solution:
    def recoverTree(self, root: TreeNode) -> None:
        """
        Do not return anything, modify root in-place instead.
        """
        firstNode = None
        secondNode = None
        pre = TreeNode(-sys.maxsize)
        
        stack = []
        p = root
        while p or stack:
            while p:
                stack.append(p)
                p = p.left
            p = stack.pop()
            if not firstNode and pre.val > p.val:
                firstNode = pre
            if firstNode and pre.val > p.val:
                secondNode = p
            pre = p
            p = p.right
        firstNode.val, secondNode.val = secondNode.val, firstNode.val

这道题也是利用二叉搜索树中序遍历序列为有序序列这一特性,通过一次中序遍历去找到不符合要求的两个节点的值,最后将两个节点的值进行交换即可。
这里需要注意的是,题目要求in_place的操作,这种一般都是通过交换来实现。

树的遍历过程倘若用迭代实现,是可以在遍历过程中加入很多其他操作来实现不同功能的。
例如对于173. 二叉搜索树迭代器这道题,有两种思路:第一种对树进行一次完整中序遍历,并将结果保存,后续需要时取出来即可,但这种方式的问题在于,每次新建这个迭代器时,都会先完成一次完整的中序遍历,但我们新建这个迭代器的目的只是为了接着取元素而已,并不一定就要完整遍历这颗树;因此,我们可以使用第二种思路,将中序遍历的递归代码进行拆解,迭代器的迭代过程与中序遍历的遍历过程融合起来,迭代一次,然后遍历一个元素,这样可以省去大量的遍历时间,具体代码如下:

# 完整中序遍历
class BSTIterator:
    def __init__(self, root: TreeNode):
        self.data = []
        self.index = 0
        stack = []
        node = root
        while stack or node:
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()  # 当前行与下一行为实际上的遍历操作
            self.data.append(node.val)
            node = node.right
    
    def next(self) -> int:
        data = self.data[self.index]
        self.index += 1
        return data
    
    def hasNext(self) -> bool:
        return self.index < len(self.data)


class BSTIterator:
    def __init__(self, root: TreeNode):
        self.stack = []
        self._leftmost_inorder(root)
    
    def _leftmost_inorder(self, root):  # 在下次迭代前,准备好数据
        while root:
            self.stack.append(root)
            root = root.left
    
    def next(self) -> int:
        topmost_node = self.stack.pop()  # 拆分遍历过程
        if topmost_node.right:
            self._leftmost_inorder(topmost_node.right)
        return topmost_node.val
    
    def hasNext(self) -> bool:
        return len(self.stack) > 0

类似的还有一些题目,例如:108. 将有序数组转换为二叉搜索树、109. 有序链表转换二叉搜索树 、230. 二叉搜索树中第K小的元素、235. 二叉搜索树的最近公共祖先等。

四、广度优先遍历问题

一般大部分的题目都是可以通过深度遍历较为高效地解决,但是也有些树的题目具有较为明显的层次特征,这时使用广度优先遍历能更高效地解决相关问题。

例如对于101. 对称二叉树来说,递归以及迭代解法为:

# 递归
class Solution:
    def isSymmetric(self, root: TreeNode) -> bool:
        if not root:
            return True
        
        return self._is_symmetric(root.left, root.right)
    
    def _is_symmetric(self, left, right):
        if (not left) and (not right):
            return True
        if (not left) or (not right):
            return False
        
        return left.val == right.val and self._is_symmetric(left.left, right.right) and self._is_symmetric(left.right,
                                                                                                           right.left)


# 迭代
# 层序遍历
class Solution:
    def isSymmetric(self, root: TreeNode) -> bool:
        if not root:
            return True
        queue = deque([root.left, root.right])
        
        while queue:
            next_level = deque()
            while queue:
                left = queue.popleft()
                right = queue.pop()
                if (not left) and (not right):
                    continue
                if (not left) or (not right) or left.val != right.val:
                    return False
                next_level.appendleft(left.right)
                next_level.appendleft(left.left)
                next_level.append(right.left)
                next_level.append(right.right)
            queue = next_level
        return True


class Solution:
    def isSymmetric(self, root: TreeNode) -> bool:
        if not root:
            return True
        queue = deque([root.left, root.right])
        
        while queue:
            next_level = deque()
            i = 0
            j = len(queue) - 1
            while i < j:
                left = queue[i]
                right = queue[j]
                if (not left) and (not right):
                    i += 1
                    j -= 1
                    continue
                if (not left) or (not right) or left.val != right.val:
                    return False
                next_level.appendleft(left.right)
                next_level.appendleft(left.left)
                next_level.append(right.left)
                next_level.append(right.right)
                i += 1
                j -= 1
            queue = next_level
        return True

这道题的递归算法需要注意的是比较的对称位置的节点。
而迭代算法两个写法的思想都是一样的——在层序遍历的每一层遍历时,同时从左右两边开始对称遍历,并比较对称节点的值是否相等,整体框架还是层序遍历的框架。
103. 二叉树的锯齿形层次遍历、107. 二叉树的层次遍历 II也都是类似的层序遍历的改版题目,主要是在每个层访问时有略微差异。
还有些题目就是专门对每个层做文章,例如116. 填充每个节点的下一个右侧节点指针、117. 填充每个节点的下一个右侧节点指针 II、199. 二叉树的右视图等。

五、二叉树的重建问题

这里的重建问题主要指的是两种:105. 从前序与中序遍历序列构造二叉树、106. 从中序与后序遍历序列构造二叉树。
例如对于105. 从前序与中序遍历序列构造二叉树一题来说,一般有递归和迭代两种解法:

# 递归解法一
class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        def dfs(inorder):
            if not preorder or not inorder:
                return None
            
            val = next(preorder)
            index = inorder.index(val)
            
            root = TreeNode(val)
            root.left = dfs(inorder[:index])
            root.right = dfs(inorder[index+1:])
            return root
        
        preorder = iter(preorder)
        return dfs(inorder)

# 递归解法二
class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        def myBuildTree(preorder_left: int, preorder_right: int, inorder_left: int, inorder_right: int):
            if preorder_left > preorder_right:
                return None
            
            # 前序遍历中的第一个节点就是根节点
            preorder_root = preorder_left
            # 在中序遍历中定位根节点
            inorder_root = index[preorder[preorder_root]]
            
            # 先把根节点建立出来
            root = TreeNode(preorder[preorder_root])
            # 得到左子树中的节点数目
            size_left_subtree = inorder_root - inorder_left
            # 递归地构造左子树,并连接到根节点
            # 先序遍历中「从 左边界+1 开始的 size_left_subtree」个元素就对应了中序遍历中「从 左边界 开始到 根节点定位-1」的元素
            root.left = myBuildTree(preorder_left + 1, preorder_left + size_left_subtree, inorder_left,
                                    inorder_root - 1)
            # 递归地构造右子树,并连接到根节点
            # 先序遍历中「从 左边界+1+左子树节点数目 开始到 右边界」的元素就对应了中序遍历中「从 根节点定位+1 到 右边界」的元素
            root.right = myBuildTree(preorder_left + size_left_subtree + 1, preorder_right, inorder_root + 1,
                                     inorder_right)
            return root
        
        n = len(preorder)
        # 构造哈希映射,帮助我们快速定位根节点
        index = {
     element: i for i, element in enumerate(inorder)}
        return myBuildTree(0, n - 1, 0, n - 1)

以上两种递归解法核心思想都是差不多的,就是都是从前序遍历序列中取出根节点的值并完成根节点的构建,然后再基于中序遍历序列,去找出其左右子树的节点的范围,最后递归完成构建。
我个人偏爱于第一种递归方法,采用迭代器的方法以及切片的方法可以较为优雅的完成根节点的构造以及左右子树节点范围的确定;第二种递归方法主要是通过边界限定的方法去完成上述功能。
Tips:个人觉得在很多树的题目中都可以用上迭代器去优化代码。比如某种情况下,需要递归访问左右孩子,但右孩子的访问结果可能需要基于左孩子访问后的结果来继续进行,比如列表的索引值这种,这个时候如果使用迭代器,在对右孩子进行访问时,列表索引就通过迭代器进行了记录,即可较为简洁地完成上述功能。

class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        if not preorder:
            return None

        root = TreeNode(preorder[0])
        stack = [root]
        inorderIndex = 0
        for i in range(1, len(preorder)):
            preorderVal = preorder[i]
            node = stack[-1]
            if node.val != inorder[inorderIndex]:  # 倘若当前前序遍历的节点node不是最左边的孩子,则构建左孩子并继续入栈
                node.left = TreeNode(preorderVal)
                stack.append(node.left)
            else:  # 已找到当前最左边节点node
                while stack and stack[-1].val == inorder[inorderIndex]:  # 寻找尚未处理的第一个右孩子
                    node = stack.pop()
                    inorderIndex += 1
                node.right = TreeNode(preorderVal)
                stack.append(node.right)

        return root

作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/construct-binary-tree-from-preorder-and-inorder-traversal/solution/cong-qian-xu-yu-zhong-xu-bian-li-xu-lie-gou-zao-9/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

以上代码是一个迭代的代码。个人感觉迭代还是有点复杂的,需要对遍历有更加深入的理解。
这里我简单说一下我自己的理解:
对于以下这棵树来说:
LeetCode-Tree篇总结_第1张图片
其前序(中左右)以及中序(左中右)遍历序列为:
preorder = [3, 9, 8, 5, 4, 10, 20, 15, 7], inorder = [4, 5, 8, 10, 9, 3, 15, 20, 7]
首先我们先遍历前序序列,并比较当前节点值与中序遍历序列当前的第一个值,若不相等,说明当前节点不是最左边节点,继续遍历前序序列;
当当前节点与中序遍历的第一个值相等时,说明此时已经找到了最左边节点,也就是图中的4,这个时候我们需要回溯,去构建各个节点的右孩子,这个时候就需要用到中序遍历的顺序问题了。
举例来说:对于前序遍历的这一段9, 8, 5以及中序遍历的这一段5, 8, 10, 9来说,可以看到在中序遍历中多了一个10,而之所以多这个10是因为10是一个右孩子,在中序遍历中,回溯到上一个根节点前,会先访问当前节点的右孩子,据此我们可以确定,这个10是一个右孩子,而它是谁的右孩子呢?10夹在8,9之间,而中序遍历又是个深度优先遍历,因此,10应该是`8``的右孩子,这样我们就恢复出了第一个右孩子,其余情况类似。

六、总结

最后对树篇做一个简单总结。

  1. 树的大部分题目都是遍历问题的改版,而主要差异在于“遍历”操作具体是什么。
  2. 树的迭代做法是很多看上去较麻烦题目的关键,栈的使用很重要,并且栈不仅可以用来保存节点,还可以用来顺带保存节点的一些状态,例如高度、路径等。
  3. 迭代器可以用于一些列表访问的情况,可以进一步优化代码。
  4. 基本模板要较为牢固的掌握,无论是递归还是迭代,四种遍历都要能轻松写出。

希望大家刷题顺利!!!

参考文献

[1] https://leetcode-cn.com/problems/construct-binary-tree-from-preorder-and-inorder-traversal/solution/cong-qian-xu-yu-zhong-xu-bian-li-xu-lie-gou-zao-9/
[2] https://leetcode-cn.com/problems/binary-tree-inorder-traversal/solution/python3-er-cha-shu-suo-you-bian-li-mo-ban-ji-zhi-s/

你可能感兴趣的:(LeetCode,算法,二叉树,数据结构,python)