023 · Balanced Binary Tree
My First Thoughts
这道题第一眼看起来,是在复用 019 · Maximum Depth of Binary Tree。
因为题目问一棵树是不是高度平衡,而高度平衡的判断,本质上还是要看左右子树的最大深度。
所以可以先把 019 里的最大深度函数拿出来:
def maxDepth(root):
if root is None:
return 0
left_depth = maxDepth(root.left)
right_depth = maxDepth(root.right)
return 1 + max(left_depth, right_depth)接下来就要想:怎么用这个 maxDepth 来判断平衡?
最开始可能会想,比较 root 的左右节点最大深度是不是就行:
left_depth = maxDepth(root.left)
right_depth = maxDepth(root.right)
if abs(left_depth - right_depth) > 1:
return False但这马上会遇到第一个问题:只看 root 的左右高度差是不够的。
因为可能出现这种情况:从根节点看,左右两边差不多高;但是左子树内部某个节点的左分支一直往下走,右分支直接没了。这样根节点本身可能没问题,但下面的节点已经违背了平衡要求。
所以这道题不是只判断根节点,而是:
每个节点的左右分支最大深度差都需要在
1以内。
因此对每个节点,都可以先做同样的事情:
left_depth = maxDepth(node.left)
right_depth = maxDepth(node.right)
if abs(left_depth - right_depth) > 1:
return False如果当前节点已经不平衡,那可以直接返回 False。
但如果当前节点平衡,也不一定能直接返回 True。因为这只说明当前节点没问题,不代表它的左子树和右子树内部也都没问题。
所以还需要继续往下判断:
isBalanced(node.left)
isBalanced(node.right)这里真正容易卡住的是:这个“下一个节点”到底怎么表述?
它不是只去左节点,也不是只去右节点,而是左右两个方向都要判断。也就是说,当前节点通过高度差检查之后,还要继续确认:
左子树是平衡的
右子树也是平衡的
两个都成立,当前这棵树才算平衡。
所以返回值应该表达成:
return isBalanced(node.left) and isBalanced(node.right)这里不是 !isBalanced(...)。Python 里逻辑取反写作 not,但这道题不需要取反。因为我们要的是“左边平衡并且右边平衡”,所以直接用:
isBalanced(node.left) and isBalanced(node.right)如果有一边返回 False,整体就会返回 False。如果两边都返回 True,整体才返回 True。
再看边界。
如果 root is None,它本身没有节点,也就没有任何节点违反平衡条件,所以空树可以返回 True。
叶子节点也能自然处理:它的 left 和 right 都是 None,左右最大深度都是 0,高度差是 0,然后继续判断两个空子树,都会返回 True。
所以一个直观版本可以写成:
def isBalanced(root):
def maxDepth(node):
if node is None:
return 0
left_depth = maxDepth(node.left)
right_depth = maxDepth(node.right)
return 1 + max(left_depth, right_depth)
if root is None:
return True
left_depth = maxDepth(root.left)
right_depth = maxDepth(root.right)
if abs(left_depth - right_depth) > 1:
return False
return isBalanced(root.left) and isBalanced(root.right)这个版本的逻辑是对的。
它确实检查了每一个节点:当前节点先比较左右最大深度,如果当前节点没问题,再递归检查左子树和右子树。
Why That Is Not Enough
上面的解法已经可以通过逻辑判断,但它不是最优解。
问题在于:maxDepth 会被重复计算很多次。
例如在 isBalanced(root) 里,我们会计算:
maxDepth(root.left)
maxDepth(root.right)然后递归到 isBalanced(root.left) 时,又会继续计算:
maxDepth(root.left.left)
maxDepth(root.left.right)这些子树的高度,其实在前面算 maxDepth(root.left) 的时候已经算过一次了。
也就是说,直观解法是:
- 先用
maxDepth扫一遍子树,得到高度 - 再进入子树,又重新扫里面的小子树
- 越靠上的节点,越容易造成重复计算
在一棵完全平衡的树里,这个重复也会很明显:
1
/ \
2 3
/ \ / \
4 5 6 7
检查节点 1 时,maxDepth(root.left) 会扫过以 2 为根的整棵子树,maxDepth(root.right) 会扫过以 3 为根的整棵子树。
接着递归到节点 2 时,又会重新计算节点 4 和节点 5 下面的高度。
递归到节点 3 时,也会重新计算节点 6 和节点 7 下面的高度。
也就是说,同一批节点的高度信息会在不同层里被反复求。对一棵平衡树来说,这种写法通常会到 \(O(n \log n)\);如果写成不提前停止、每个节点都完整检查的 top-down 版本,还可能更糟。
它相较于正解的不足,不是思路方向错了,而是:
判断平衡和计算高度分成了两套递归,导致高度被反复计算。
更好的做法是把这两件事合并在一次递归里完成。
Final Idea
直观解法的问题是重复计算高度。
那怎么减少重复呢?
先回到原来的 maxDepth:
def maxDepth(node):
if node is None:
return 0
left_depth = maxDepth(node.left)
right_depth = maxDepth(node.right)
return 1 + max(left_depth, right_depth)这个函数本来就在做一件事:
先拿到左子树高度,再拿到右子树高度,最后返回当前节点高度。
而判断平衡时,也正好需要这两个值:
abs(left_depth - right_depth) <= 1所以可以把问题改成:
能不能在计算高度的时候,顺便判断这棵子树是不是平衡?
也就是说,不要在外面另外写一个 isBalanced,然后反复调用 maxDepth。
而是让“求高度”这个递归函数多做一步:
- 先求左子树高度
- 再求右子树高度
- 用这两个高度判断当前节点是否平衡
- 如果平衡,就返回当前高度
- 如果不平衡,就返回一个特殊信号
这里的问题是:函数本来应该返回高度,那如果发现不平衡,返回什么?
可以返回 -1。
因为正常高度不会是负数:
- 空树高度是
0 - 叶子节点高度是
1 - 更高的树高度只会更大
所以 -1 可以专门表示:
这棵子树已经不平衡了,不用再把它当作正常高度处理。
这样,原来的 maxDepth 就变成一个更强的函数。它不只是求高度,而是:
如果子树平衡,返回它的高度;如果子树不平衡,返回
-1。
可以把它叫作 height(node)。
先处理空节点:
if node is None:
return 0然后像 maxDepth 一样,先去算左子树:
left_height = height(node.left)但这里要多检查一步。
如果左子树返回的是 -1,说明左子树内部已经不平衡。既然左子树已经不平衡,那么当前这棵树也一定不平衡,所以可以直接继续返回 -1:
if left_height == -1:
return -1右子树也是一样:
right_height = height(node.right)
if right_height == -1:
return -1走到这里,说明左右子树本身都没有发现不平衡。现在才检查当前节点:
if abs(left_height - right_height) > 1:
return -1如果当前节点左右高度差超过 1,当前子树不平衡,返回 -1。
如果当前节点也平衡,那就和原来的 maxDepth 一样,返回真实高度:
return 1 + max(left_height, right_height)这样一来,整棵树只需要从根节点调用一次:
return height(root) != -1如果最后不是 -1,说明整棵树平衡;如果是 -1,说明某个位置已经发现不平衡。
这个写法的核心变化其实只有一句话:
把“先求高度,再判断平衡”合并成“求高度的过程中顺便判断平衡”。
Why It Works
高度平衡的定义要求每个节点都满足:
左子树平衡
右子树平衡
左右子树高度差不超过 1
height(node) 正好按这个顺序检查。
它先递归拿到左子树高度。如果左子树已经返回 -1,说明左子树内部有节点不平衡,当前树也不可能平衡。
右子树同理。
当左右子树都没有问题时,当前节点只需要比较:
abs(left_height - right_height)如果高度差超过 1,返回 -1;否则返回当前子树的真实高度。
所以这个函数每次返回都有明确含义:
- 返回
-1:这棵子树不平衡 - 返回非负数:这棵子树平衡,并且这个数就是它的高度
主函数检查 height(root) != -1,就能得到整棵树是否平衡。
Code
def isBalanced(root):
def height(node):
if node is None:
return 0
left_height = height(node.left)
if left_height == -1:
return -1
right_height = height(node.right)
if right_height == -1:
return -1
if abs(left_height - right_height) > 1:
return -1
return 1 + max(left_height, right_height)
return height(root) != -1Complexity
| Time | \(O(n)\) - 每个节点最多被访问一次,同时完成高度计算和平衡判断 |
| Space | \(O(h)\) - 递归调用栈的深度等于树的高度 h,最坏情况下可以到 \(O(n)\) |
Takeaway
这题的关键不是单独写一个函数去“判断平衡”,再单独写一个函数去“计算高度”,而是意识到计算高度的递归过程中已经拿到了判断平衡需要的信息。递归返回值不一定只能表示正常答案,也可以用特殊值传递失败状态:非负数表示子树高度,
-1表示这棵子树已经不平衡。
← Quiz