二叉树的递归问题主要有两类:遍历和分解问题。
遍历
遍历是一种系统地访问树中所有节点的过程。二叉树的遍历主要有以下几种方式:
-
前序遍历(Pre-order Traversal):
- 访问根节点。
- 递归地前序遍历左子树。
- 递归地前序遍历右子树。
代码示例(Python):
python复制代码def pre_order_traversal(root): if root: print(root.value) # 访问根节点 pre_order_traversal(root.left) # 递归遍历左子树 pre_order_traversal(root.right) # 递归遍历右子树
-
中序遍历(In-order Traversal):
- 递归地中序遍历左子树。
- 访问根节点。
- 递归地中序遍历右子树。
代码示例(Python):
python复制代码def in_order_traversal(root): if root: in_order_traversal(root.left) # 递归遍历左子树 print(root.value) # 访问根节点 in_order_traversal(root.right) # 递归遍历右子树
-
后序遍历(Post-order Traversal):
- 递归地后序遍历左子树。
- 递归地后序遍历右子树。
- 访问根节点。
代码示例(Python):
python复制代码def post_order_traversal(root): if root: post_order_traversal(root.left) # 递归遍历左子树 post_order_traversal(root.right) # 递归遍历右子树 print(root.value) # 访问根节点
分解问题
分解问题的思路是将复杂的问题分解成更小的、相似的子问题,利用递归的方法逐层解决这些子问题。这种方法通常用于解决涉及树结构的复杂问题,例如计算树的高度、判断是否为平衡树等。
举个例子,计算二叉树的最大深度:
-
分解思路:
- 如果树为空,则深度为0。
- 否则,树的深度等于左子树和右子树深度的最大值加1。
-
递归实现:
python复制代码def max_depth(root): if not root: return 0 left_depth = max_depth(root.left) right_depth = max_depth(root.right) return max(left_depth, right_depth) + 1
另一个例子,判断一个二叉树是否是对称的:
-
分解思路:
- 一棵树是对称的,当且仅当它的左子树和右子树是镜像对称的。
- 对左子树和右子树进行递归对比,判断是否对称。
-
递归实现:
python复制代码def is_symmetric(root): if not root: return True return is_mirror(root.left, root.right) def is_mirror(left, right): if not left and not right: return True if not left or not right: return False return (left.value == right.value and is_mirror(left.left, right.right) and is_mirror(left.right, right.left))
总结起来,遍历主要是系统地访问所有节点,而分解问题则是将复杂问题分解成更小的子问题来解决。在二叉树的递归问题中,这两种思路是最常见的解决方法。
是的,通常在遍历二叉树时没有返回值,而是通过对每个节点进行处理(例如打印节点值、累加计数器等)来实现特定的操作。不过,遍历过程可以修改为带返回值,以便在遍历过程中收集信息或构建新的数据结构。
下面是几种常见遍历方式的示例,说明在遍历时没有返回值的情况和带返回值的情况。
前序遍历(Pre-order Traversal)
无返回值的前序遍历:
python复制代码def pre_order_traversal(root):
if root:
print(root.value) # 处理节点
pre_order_traversal(root.left)
pre_order_traversal(root.right)
带返回值的前序遍历:
python复制代码def pre_order_traversal(root):
result = []
def helper(node):
if node:
result.append(node.value) # 收集节点值
helper(node.left)
helper(node.right)
helper(root)
return result
中序遍历(In-order Traversal)
无返回值的中序遍历:
python复制代码def in_order_traversal(root):
if root:
in_order_traversal(root.left)
print(root.value) # 处理节点
in_order_traversal(root.right)
带返回值的中序遍历:
python复制代码def in_order_traversal(root):
result = []
def helper(node):
if node:
helper(node.left)
result.append(node.value) # 收集节点值
helper(node.right)
helper(root)
return result
后序遍历(Post-order Traversal)
无返回值的后序遍历:
python复制代码def post_order_traversal(root):
if root:
post_order_traversal(root.left)
post_order_traversal(root.right)
print(root.value) # 处理节点
带返回值的后序遍历:
python复制代码def post_order_traversal(root):
result = []
def helper(node):
if node:
helper(node.left)
helper(node.right)
result.append(node.value) # 收集节点值
helper(root)
return result
带返回值的应用场景
带返回值的遍历方法可以用于一些特定的应用场景,例如:
- 构建列表:将遍历过程中访问的节点值收集到一个列表中。
- 查找特定值:在遍历过程中查找特定值并返回查找到的节点。
- 计算统计值:在遍历过程中计算某些统计值(如节点个数、节点值的总和等)并返回。
示例:计算二叉树的所有节点值的总和
def sum_of_values(root):
if not root:
return 0
return root.value + sum_of_values(root.left) + sum_of_values(root.right)
遍历也可以有返回值,也可以没有返回值。遍历针对每个结点进行处理。明白自己的职业是股性的,所以要多花时间多花精力来工作,增加波动。
self.res.append(“->”.join(self.path)): 这是需要处理的部分。这行代码的作用是将路径列表self.path中的所有元素使用"->"连接成一个字符串,然后添加到结果列表self.res中。例如,如果self.path是[‘1’, ‘2’, ‘3’],那么这行代码将生成字符串’1->2->3’并将其添加到self.res
回溯是深度优先搜索中的一个重要概念,它允许算法在达到某个节点的末端后,能够返回到上一个节点,并尝试其他可能的路径。在这段代码中,回溯确保了算法能够探索二叉树的所有路径,直到找到所有从根节点到叶子节点的路径。当找到一个叶子节点时,会将当前路径添加到结果列表self.res中,然后通过回溯,移除路径中的最后一个元素,继续搜索其他可能的路径。这个过程会一直重复,直到所有可能的路径都被探索完毕。
回溯这一步非常关键,每走到一步的self.traverse(right) 之后都要pop一下,回到上一步。同时把值添加到res之后也要进行一步pop
class Solution:
def binaryTreePaths(self, root: Optional[TreeNode]) -> List[str]:
self.traverse(root)
return self.res
def __init__(self):
self.path = []
self.res = []
def traverse(self, root:TreeNode):
if root is None:
return
if root.left is None and root.right is None:
self.path.append(str(root.val))
self.res.append("->".join(self.path))#把path中元素用->连接放到res中去,
self.path.pop() #进行一步回溯
return
self.path.append(str(root.val))
self.traverse(root.left)
self.traverse(root.right)
self.path.pop()
关键点:两次 pop
操作
-
处理叶子节点时的
pop
:python复制代码if root.left is None and root.right is None: self.path.append(str(root.val)) self.res.append("->".join(self.path)) self.path.pop() # 移除叶节点 return
- 当遍历到叶节点时,将其值加入路径中,并将路径字符串加入结果列表。随后需要将叶节点从路径中移除,以便正确回溯。
-
递归回溯时的
pop
:python复制代码self.path.append(str(root.val)) self.traverse(root.left) self.traverse(root.right) self.path.pop() # 回溯,移除当前节点
- 在递归遍历左右子树后,需要将当前节点从路径中移除,以便回到父节点时路径正确。
这两次 pop
操作分别处理了不同的情境:一个是专门针对叶子节点的情况,另一个是处理递归回溯的情况。这样保证了在每次回溯时,路径都能正确表示从根节点到当前节点的路径。第一题需要两次 pop
(一次在叶节点处理时,一次在递归回溯时),第二题只需一次切片操作(在递归回溯时),这是由于路径表示方式(列表 vs. 字符串)和具体的需求(保存路径 vs. 累加数字)不同导致的。
让我获取所有路径数字之和,那我递归遍历一遍二叉树,沿路记录下来路径上的数字,到叶子节点的时候求和,不就完事了?
数字如何表达是这个题的一个难点 采用的思路是path用str来保存,做加法的时候再转化成int
关键点:回溯时移除路径中的最后一个字符
在遍历完左子树和右子树后,需要将当前节点从路径中移除。这是为了确保在回溯到上一个节点时,路径是正确的。例如,当从 2
回溯到 1
时,路径应该从 '12'
变为 '1'
,而不是 '12'
。
这段代码中有一步确实是关键的,它确保了路径能够正确表示从根节点到当前节点的路径:
python
复制代码
self.path = self.path[:-1]
这行代码在每次递归调用结束后,移除路径中的最后一个字符,保证路径能够正确回溯到父节点。
总结来说,回溯时移除路径中的最后一个字符是必要的,以确保路径在遍历和回溯过程中始终正确。
搞清楚回溯几次还是很有必要的,草稿本上理一理。反正最多也就是两个地方回溯。
class Solution:
def __init__(self):
self.path = ""
self.res = 0
def sumNumbers(self, root: TreeNode) -> int:
# 遍历一遍二叉树就能出结果
self.traverse(root)
return self.res
# 二叉树遍历函数
def traverse(self, root):
if root is None:
return
# 前序遍历位置,记录节点值
self.path += str(root.val)
if root.left is None and root.right is None:
# 到达叶子节点,累加路径和
self.res += int(self.path)
# 二叉树递归框架,遍历左右子树
self.traverse(root.left)
self.traverse(root.right)
# 后续遍历位置,撤销节点值
self.path = self.path[:-1]
199.二叉树的右视图
class Solution:
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
self.depth = 0
self.res = []
self.traverse(root)
return self.res
def traverse(self, root):
if root is None:
return
self.depth += 1
if len(self.res) < self.depth:
self.res.append(root.val)
self.traverse(root.right)
self.traverse(root.left)
self.depth -= 1#也是回溯操作。 前序遍历,看成是自上而下的
deque
全称是 “double-ended queue”。它是一种支持在两端高效插入和删除操作的数据结构
class Solution:
def sumOfLeftLeaves(self, root: Optional[TreeNode]) -> int:
self.traverse(root)
return self.sum
def __init__(self):
self.sum = 0
def traverse(self, root):
if root is None:
return
if root.left is not None and root.left.left is None and root.left.right is None:
self.sum += root.left.val
self.traverse(root.left)
self.traverse(root.right) #这一题找到左子叶找到就和就行了,所以没有回溯这一步操作。
class Solution:
def smallestFromLeaf(self, root: TreeNode) -> str:
self.traverse(root)
return self.res
# 遍历过程中的路径
path = ""
res = None
# 二叉树遍历函数
def traverse(self, root):
if root is None:
return
if root.left is None and root.right is None:
# 找到叶子结点,比较字典序最小的路径
# 结果字符串是从叶子向根,所以需要反转
self.path = chr(ord('a') + root.val) + self.path
s = self.path
if self.res is None or self.res > s:
# 如果字典序更小,则更新 res
self.res = s
# 恢复,正确维护 path 中的元素
self.path = self.path[1:] #对叶子节点操作完了立马回溯
return
# 前序位置
self.path = chr(ord('a') + root.val) + self.path
self.traverse(root.left)
self.traverse(root.right)
# 后序位置
self.path = self.path[1:] #处理完一个左右回溯一次。
ord('a')
是获取字符 ‘a’ 的 ASCII 码值,结果是 97。
ord('a') + root.val
是将当前节点的值加到 ‘a’ 的 ASCII 码值上。假设 root.val
是 2,那么 ord('a') + root.val
的结果是 99。
chr(ord('a') + root.val)
将上一步的结果转换回字符。接着,chr(99)
会返回字符 ‘c’。
self.path = chr(ord('a') + root.val) + self.path
将当前字符(比如 ‘c’)添加到路径字符串 self.path
的前面。
- 类变量 在所有实例之间共享,而 实例变量 是每个实例独有的。
- 尽管类变量可以通过
self
访问,但这种做法不推荐,因为容易引发混淆和错误。 - 类变量和实例变量在 Python 中有显著的区别:
- 类变量:属于类本身,并且在所有实例之间共享。这意味着如果修改一个类变量的值,所有实例都会看到这个变化。
- 实例变量:属于类的具体实例,每个实例都有自己独立的副本。修改一个实例变量的值只会影响该实例。
ord
是 Python 内置函数的名称,源自“ordinal”,意为序数或顺序。在 Python 中,ord
函数用于获取单个字符对应的 Unicode 码点。
class Solution:
def sumRootToLeaf(self, root: Optional[TreeNode]) -> int:
self.traverse(root)
return self.res
def __init__(self):
self.path = 0
self.res = 0
def traverse(self, root):
if root is None :
return
if root.left is None and root.right is None:
self.res += self.path << 1 | root.val #将path左移,腾出位置与root.val取或运算,相当于把它加上去
return #有这个return 更好理解一些
self.path = self.path << 1 | root.val
self.traverse(root.left)
self.traverse(root.right)
self.path = self.path >> 1
思考
- 如何把二进制数转为十进制,
- 叶子节点,是否需要回溯两次? 好像需要?
总结
- 不需要多次回溯的情况:当路径的记录或修改是局部的、线性的,不影响其他分支时,例如路径和问题。
- 需要两次回溯的情况:当路径的记录需要完整的状态恢复,以确保在遍历新的分支时路径信息正确,例如路径字符串记录问题。
在二叉树的遍历中,我们需要确保在处理完一个节点的所有子节点后,才能回溯到父节点。这是因为:
- 在进入子节点之前,当前路径的状态需要包括父节点的值。
- 如果我们在到达叶子节点后立即回溯(即在叶子节点的条件判断中进行回溯),则回溯操作会在处理完一个叶子节点后立即撤销对父节点的操作,从而影响另一子节点的遍历。
记录路径时,需要进行两次回溯,是因为在某些问题中,路径信息的结构和记录方式要求我们不仅要回溯到父节点的状态,还要回溯到进入当前节点前的状态。这是为了确保路径信息在不同分支之间不会混淆,从而准确记录所有路径。
两次回溯的必要性
让我们通过一个具体例子来详细解释为什么在某些情况下需要进行两次回溯。
总结
- 一次回溯:通常适用于路径或状态记录是线性、局部的情况,只需恢复到父节点的状态。
- 两次回溯:适用于路径或状态记录需要全局维护的情况,需要确保路径或状态在不同分支间的正确性。 跟路径有关的问题就需要回溯两次
选择一次回溯还是两次回溯取决于具体问题对路径或状态记录的需求。希望这个解释能够帮助你理解不同回溯策略的必要性及其应用场景。
class Solution:
def pseudoPalindromicPaths (self, root: Optional[TreeNode]) -> int:
self.traverse(root)
return self.res
def __init__(self):
self.count = [0] * 10
self.res = 0
def traverse(self, root):
if root is None:
return
if root.left is None and root.right is None:
self.count[root.val] += 1
odd = 0
for n in self.count:
if n % 2 == 1:
odd += 1
if odd <= 1:
self.res += 1
self.count[root.val] -= 1
self.count[root.val] += 1
self.traverse(root.left)
self.traverse(root.right)
self.count[root.val] -= 1
难点:如果一组数字中,只有最多一个数字出现的次数为奇数,剩余数字的出现次数均为偶数,那么这组数字可以组成一个回文串。
题目说了 1 <= root.val <= 9
,所以我们可以用一个大小为 10 的 count
数组做计数器来记录每条路径上的元素出现次数,到达叶子节点之后根据元素出现的次数判断是否可以构成回文串。
首次回溯:在检查叶子节点后,我们需要将该叶子节点的计数恢复到进入该节点之前的状态。这是为了确保在离开叶子节点后,计数器状态能正确反映当前路径的状态。
再次回溯:当遍历完左右子树后,我们需要将当前节点的计数恢复到进入该节点之前的状态。这是为了确保在回溯到父节点时,计数器状态能正确反映当前路径的状态。
求和为什么只需要一次回溯,因为求和只用到这个值,直接加到res中去 ,加完回到父节点就好,
而找路径问题,因为路径需要记录下来,所以到了叶子节点之后要把这个叶子结点的值从res中删除,严格来讲这不叫回溯,这只是把值从记录中删去而已