Leetcode-100 二叉树引发的递归思考
首先总结一下二叉树中非常常见的递归方法
二叉树常见递归方式总结
1. 前序遍历(Preorder Traversal)
访问顺序:根 -> 左 -> 右
def preorder(root):
if not root:
return
print(root.val) # 访问根节点
preorder(root.left) # 递归左子树
preorder(root.right) # 递归右子树
2. 中序遍历(Inorder Traversal)
访问顺序:左 -> 根 -> 右
def inorder(root):
if not root:
return
inorder(root.left) # 递归左子树
print(root.val) # 访问根节点
inorder(root.right) # 递归右子树
后序遍历(Postorder Traversal)
访问顺序:左 -> 右 -> 根
def postorder(root):
if not root:
return
postorder(root.left) # 递归左子树
postorder(root.right) # 递归右子树
print(root.val) # 访问根节点
4. 层序遍历(Level Order Traversal)
使用队列(BFS 方式)
from collections import deque
def level_order(root):
if not root:
return []
queue = deque([root])
result = []
while queue:
node = queue.popleft()
result.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return result
为什么书写二叉树递归时容易无从下手?
我们可以发现,二叉树的递归方式其实非常简单易懂,但在真正书写时,往往会陷入迷茫。这里总结出几个常见的原因:
1. 递归边界不清晰
递归的本质是拆分问题,最终必须有一个清晰的终止条件。
- 许多时候,我们在书写递归时,并没有考虑清楚递归的最小子问题是什么,导致递归终止条件模糊。
- 例如,在遍历二叉树时,通常需要设定递归的边界条件,以防止访问空节点时报错。
2. 过分纠结于递归细节
递归的核心思想是"假设子问题已经解决",然后只关注当前层的计算。
- 递归是自顶向下拆解问题,但很多时候,我们在编写代码时,容易去关注每一步的具体执行,而不是从整体思考。
- 解决方法是站在更高的层次思考,假设递归函数已经能正确处理子树,然后只关心当前层需要做什么。
3. 递归三部曲不熟练
解决二叉树问题,通常可以按照以下三步进行思考:
- 确定递归函数的作用:明确这个函数的输入和输出,思考它的返回值如何帮助解决问题。
- 确定递归终止条件:考虑最小规模的情况,比如空节点、叶子节点等。
- 确定单层递归的逻辑:关注当前层要执行的操作,并递归调用左右子树。
4. 递归方式选择不清晰
二叉树的常见递归方式包括:
- 先序遍历(Preorder):先访问根节点,再访问左子树,最后访问右子树。
- 中序遍历(Inorder):先访问左子树,再访问根节点,最后访问右子树。
- 后序遍历(Postorder):先访问左子树,再访问右子树,最后访问根节点。
- 层序遍历(Level Order):按层次从上到下、从左到右遍历树中的每个节点。
- 分治法(Divide & Conquer):将问题拆分成子问题,递归求解后合并结果。
5. 递归与回溯的结合不熟练
某些二叉树问题需要在递归过程中进行回溯,例如路径搜索问题。
- 递归过程中,可能需要在回溯时撤销当前操作,以确保不影响其他递归路径。
- 这类问题通常涉及路径存储、状态恢复等操作。
如何克服这些问题?
- 明确递归边界:思考最小子问题,明确递归终止条件。
- 假设递归已经解决子问题:避免过分关注细节,关注当前层逻辑。
- 牢记递归三部曲:思考递归的作用、终止条件和单层逻辑。
- 熟练掌握不同的递归方式:明确何时使用先序、中序、后序、层序遍历。
- 学会结合回溯:当递归涉及路径或状态恢复时,记得回溯以维护正确性。
牢记:递归的本质是分解问题 -> 解决子问题 -> 合并答案,只需关注当前层,剩下的交给递归即可!
结合例题深入理解递归
在掌握了二叉树递归的基本思想后,我们通过几道经典例题来深入理解递归的应用方式。
例题 1:二叉树的最大深度–简单
题目描述:
给定一个二叉树,找出其最大深度。
二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。
递归边界
- 如果当前节点为空,直接返回
0
,表示空节点的深度为0
。
当前层(节点)需要做的操作
- 递归求解左子树的最大深度
left_depth
。 - 递归求解右子树的最大深度
right_depth
。 - 取
max(left_depth, right_depth) + 1
作为当前节点的最大深度。
class Solution:
def maxDepth(self, root: TreeNode) -> int:
if not root:
return 0
return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1
例题 2:对称二叉树–简单
题目描述:
给定一个二叉树,找出其最大深度。
二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。
递归边界
- 如果两个子树
root1
和root2
都为空,说明对称,返回True
。 - 如果只有一个子树为空或者它们的值不同,则不对称,返回
False
。
当前层(节点)需要做的操作
- 递归检查
root1
的左子树和root2
的右子树是否对称。 - 递归检查
root1
的右子树和root2
的左子树是否对称。 - 只有在这两个条件都满足的情况下,整棵树才是对称的。
class Solution:
def isSymmetric(self, root: Optional[TreeNode]) -> bool:
def dfs(root1, root2):
if not root1 and not root2:
return True
if not root1 or not root2 or root1.val != root2.val:
return False
return dfs(root1.left, root2.right) and dfs(root1.right, root2.left)
return dfs(root, root)
例题 3:二叉树的直径–简单
题目描述
给定一棵二叉树,找到它的直径。二叉树的直径指的是任意两个节点之间最长路径的长度,这个路径可能经过根节点,也可能不经过根节点。
递归边界
- 当
root == None
时,说明到达了空节点,此时返回0
,表示该子树的深度为0
。
当前层(节点)需要做的操作
- 递归计算左子树的深度
left_depth
和右子树的深度right_depth
。 - 更新二叉树的直径:二叉树的直径等于某个节点的左子树深度
+
右子树深度,因此diameter = max(diameter, left_depth + right_depth)
。 - 返回当前节点的深度,即
max(left_depth, right_depth) + 1
。
代码实现
class Solution:
def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
diameter = 0 # 用于记录直径
def tree_depth(root):
nonlocal diameter
if not root:
return 0
left_depth = tree_depth(root.left)
right_depth = tree_depth(root.right)
# 更新直径
diameter = max(diameter, left_depth + right_depth)
return max(left_depth, right_depth) + 1
tree_depth(root)
return diameter
例题 4:验证二叉搜索树(BST)
题目描述
给定一个二叉树,判断其是否为二叉搜索树(Binary Search Tree,BST)。
BST 的定义:
- 对于任意一个节点,其左子树所有节点的值必须小于当前节点的值。
- 对于任意一个节点,其右子树所有节点的值必须大于当前节点的值。
- 左右子树也必须分别是二叉搜索树。
递归边界
- 当
root is None
时,说明已经遍历到空节点,返回True
,表示此分支没有违反 BST 规则。
当前层(节点)需要做的操作
- 取出当前节点值
x = root.val
。 - 检查 BST 规则:
x
必须严格大于左边界left
,小于右边界right
(即left < x < right
)。
- 递归检查左子树:
- 左子树的所有节点必须在范围
(left, x)
内。
- 左子树的所有节点必须在范围
- 递归检查右子树:
- 右子树的所有节点必须在范围
(x, right)
内。
- 右子树的所有节点必须在范围
- 只有当前节点满足 BST 规则,并且左右子树都是 BST 时,返回
True
。
代码实现
class Solution:
def isValidBST(self, root: Optional[TreeNode], left=float('-inf'), right=float('inf')) -> bool:
if root is None:
return True
x = root.val
return left < x < right and \
self.isValidBST(root.left, left, x) and \
self.isValidBST(root.right, x, right)
例题 5:二叉搜索树中的第 K 小元素
题目描述
给定一棵二叉搜索树(BST),找到其中第 k
小的元素。
BST 性质:
- 左子树所有节点的值小于根节点值。
- 右子树所有节点的值大于根节点值。
- 中序遍历二叉搜索树可得到递增序列。
递归边界
- 当
node is None
时,直接返回,表示当前路径已无可访问节点。
当前层(节点)需要做的操作
- 递归访问左子树(优先访问左子树,因为中序遍历先访问左子树)。
- 访问当前节点:
count += 1
记录访问的节点数量。- 当
count == k
时,找到第k
小元素,存入result
并返回。
- 递归访问右子树。
代码实现
class Solution:
def kthSmallest(self, root: Optional[TreeNode], k: int) -> int:
def dfs(node):
nonlocal count, result
if not node:
return
dfs(node.left) # 递归左子树
count += 1 # 访问当前节点
if count == k:
result = node.val
return
dfs(node.right) # 递归右子树
count = 0 # 计数器
result = None # 结果变量
dfs(root) # 开始中序遍历
return result
例题 6:二叉树展开为单链表
题目描述
给定一个二叉树,原地将其展开为单链表。
要求:
- 展开后的单链表按照前序遍历顺序排列。
- 左子树为空,右子树指向下一个节点。
递归边界
- 当
root is None
,返回,不进行任何操作。
当前层(节点)需要做的操作
- 递归展开右子树
flatten(root.right)
(保证后处理的节点按前序遍历顺序)。 - 递归展开左子树
flatten(root.left)
(确保左子树的展开完成)。 - 修改指针:
root.right = self.head
(将当前节点的右指针指向已展开部分)。root.left = None
(清空左指针,确保符合单链表要求)。self.head = root
(更新head
,使其指向当前节点)。
代码实现
class Solution:
head = None # 记录单链表的头节点
def flatten(self, root):
if root is None:
return
self.flatten(root.right) # 先递归右子树
self.flatten(root.left) # 再递归左子树
root.left = None # 清空左子树
root.right = self.head # 右子树指向已展开部分
self.head = root # 更新 head,使当前节点成为链表头
例题 7:路径总和 III
题目描述
给定一个二叉树的 root
和一个整数 targetSum
,计算二叉树中和等于 targetSum
的路径总数。
要求:
- 路径不一定从根节点开始,也不一定到叶子节点。
- 路径方向必须是向下的(从父节点到子节点)。
递归边界
- 当
node is None
时,直接返回,不做任何计算。
当前层(节点)需要做的操作
- 计算当前前缀和:
cur_sum += node.val
- 查找符合条件的路径:
统计pre_sum_count[cur_sum - targetSum]
作为当前符合要求的路径条数,并累加到self.ans
。 - 更新前缀和计数:
pre_sum_count[cur_sum] += 1
- 递归遍历左右子树:
dfs(node.left, cur_sum)
dfs(node.right, cur_sum)
- 回溯,恢复状态:
pre_sum_count[cur_sum] -= 1
这一操作确保回溯时不影响其他路径的计算。
代码实现
class Solution:
def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int:
from collections import defaultdict
pre_sum_count = defaultdict(int)
pre_sum_count[0] = 1 # 处理从根节点开始的路径情况
self.ans = 0
def dfs(node, cur_sum):
if not node:
return
cur_sum += node.val # 计算当前前缀和
# 查找当前前缀和是否存在匹配的路径
self.ans += pre_sum_count[cur_sum - targetSum]
# 更新前缀和计数
pre_sum_count[cur_sum] += 1
# 递归遍历左右子树
dfs(node.left, cur_sum)
dfs(node.right, cur_sum)
# 回溯,恢复状态(去掉当前节点贡献的前缀和)
pre_sum_count[cur_sum] -= 1
dfs(root, 0)
return self.ans
例题 8:二叉树的最近公共祖先
题目描述
给定一个二叉树的根节点 root
和两个节点 p
和 q
,返回这两个节点的最近公共祖先。
最近公共祖先的定义是:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
递归边界
- 当
root
为None
时,返回None
,因为没有节点可以是祖先。 - 当
root
为p
或q
时,返回root
,表示找到了其中一个节点。
当前层(节点)需要做的操作
-
递归查找左右子树:
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
-
判断左右子树是否都找到了:
- 如果左右子树都找到了节点(即
left
和right
都非None
),那么当前节点root
就是最近公共祖先。
- 如果左右子树都找到了节点(即
-
返回找到的节点:
- 如果只找到了左子树的节点,返回左子树的节点;如果只找到了右子树的节点,返回右子树的节点。
代码实现
class Solution:
def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
if root in (None, p, q):
return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right: # 左右都找到
return root # 当前节点是最近公共祖先
return left or right