刷LeetCode刷到Easy题,很多人的心态就是“看一眼答案,过了就完事”。但单值二叉树这道题,我建议你认真手动写一遍——它是我见过的把递归三要素体现得最干净的小题之一。判断一棵二叉树是不是“单值”(所有节点的值都相同),用递归解,核心代码只需要五六行,却能把终止条件、子问题拆分、短路逻辑全讲明白。这篇文章就从这题出发,把递归解法从原理到代码、从复杂度到与迭代方案的对比,完整拆一遍。适合刚学树结构的人,也适合准备面试但想巩固递归基础的选手。
1. 单值二叉树到底是什么:题目语义与边界条件拆解
1.1 定义梳理:一棵树所有节点值相同
单值二叉树(Univalued Binary Tree)的定义非常直白:给定一棵二叉树,如果每个节点具有相同的值,那么它就是单值二叉树。举个例子,一棵树的所有节点值都是5,那它算单值;只要出现一个节点的值是6,整棵树立刻不算。
这个定义最容易被忽视的点是:判断的不是“相邻节点值相同”,而是“所有节点值相同”。虽然两者在树结构里是等价的,但理解角度不同,写出的代码风格完全不同。
我的建议是把它想象成整栋楼的检查:你要确认每个房间的灯泡型号一样,不需要把所有灯泡拆下来堆在一起对比,只需要沿着走廊挨个看,只要发现有一间房和邻居不一样,整栋楼就可以直接下结论“不合格”。单值二叉树的判断也是这个逻辑。
1.2 边界条件的“坑”:空树到底算不算单值
第一次写这道题的人,几乎都会在空树这里卡一下。空树没有任何节点,它的“值”是什么?不同的人会有不同的第一反应,有人觉得应该返回false,有人觉得应该返回true。
实际题目约定很明确:空树返回true。
为什么?你可以把它理解成数学上的“真空真”(vacuous truth):如果树为空,那么“所有节点值相同”这个命题没有被任何反例推翻,所以命题成立。更实际的理由是:递归解法需要一个干净的终止条件。如果空树返回false,那所有非空树的递归都会在叶子节点处断裂,比如一个单节点树,它的左右孩子都是空,递归到空节点返回false,整棵树反而变成了false,这明显不对。
所以在代码里,第一个if一定是:
if root is None: return True这个base case是整个递归的基石,也是很多Easy题考察的细节之一。
1.3 单节点和左右子树组合的直觉判断
单节点树必然是单值,因为只有一个节点,不存在“不同”的比较对象。两个节点的树,只要父子值相同就是单值。三个节点的树,除了根节点,还要保证左右两个子树各自都是单值的。
从这里可以提炼出一个递归式:
- 一棵树是单值的,当且仅当:根节点与左孩子值相同,且根节点与右孩子值相同,且左子树是单值,且右子树是单值。
这个“且”字非常关键。任何一个条件不满足,结果就是false。这也是后面写递归时的核心逻辑。
2. 递归解法的直觉来自树的递归定义
2.1 为什么“判断单值”天然适合递归
二叉树本身就是递归定义的:它由一个根节点和左子树、右子树组成,而左右子树仍然是二叉树。这意味着,处理一棵树的问题,通常可以拆成“处理根节点 + 处理左子树 + 处理右子树”三个子问题,左右子树的处理方式和整棵树完全一样。
单值判断正好匹配这种结构。判断整棵树是否单值,等价于先判断根节点和左右孩子是否相等,再递归判断左右子树是否单值。你可以直接把这个定义翻译成代码,不需要额外的状态记录。
这里也是递归和普通循环思维差异最大的地方:循环强调“维护一个变量,不断推进”;递归强调“用子问题的解组合出原问题的解”。单值二叉树判断是理解这种思维切换的绝佳入门题。
2.2 递归三要素在本题里的具体映射
写任何递归,我都建议先问自己三个问题:
- 终止条件是什么?
- 当前节点需要做什么?
- 子问题之间如何组合结果?
对应到单值二叉树:
- 终止条件:节点为空,返回
true。 - 当前节点要做的事:检查当前节点和左孩子(如果存在)是否等值,检查当前节点和右孩子(如果存在)是否等值。
- 子问题组合:左子树的结果和右子树的结果用
and连接,同时还要和当前层的比较结果做and。
这三步想清楚,代码就是顺着写下来的。很多人写递归容易乱,根源是没想清楚“当前层做什么”,总想把所有事都塞给递归调用,导致逻辑越写越绕。
2.3 判断顺序与短路逻辑:为什么要尽早返回
递归的判断顺序有讲究。最自然的写法是在当前层先做“父节点 vs 孩子节点”的比较,只要有一处不等,立即返回false,不再深入递归。
看一下这段代码:
def is_unival_tree(root): if root is None: return True if root.left and root.left.val != root.val: return False if root.right and root.right.val != root.val: return False return is_unival_tree(root.left) and is_unival_tree(root.right)当左子树已经返回false时,and右侧的is_unival_tree(root.right)根本不会执行,这就是短路。如果一棵树的第二层就出现了不同值,后面的节点根本不会被访问,省下大量无谓的递归调用。
有些人的写法是“先递归左右子树,再比较值”,这在正确性上也没问题,但会无谓地遍历完整棵树。在面试中,主动提一句短路优化,通常能让面试官觉得你对递归的执行机制有真实理解。
3. 手写代码:核心实现与参数设计
3.1 最直观的版本:当前节点与孩子值比较
上面那段就是最直观的写法,直接在函数内部比较root和左右孩子的值。它的优点是语义清楚,不需要额外参数,缺点是比较逻辑分散在“前奏判断”和“递归调用”两步中,代码一多容易看乱。
这个版本还有一个好处:入口不需要判空之外的任何准备,空树直接返回true,非空树从根开始一路向下比。
3.2 目标值传递版本:DFS加闭包
另一种常见思路是:先取出根节点的值作为目标值target,然后写一个辅助的DFS函数,遍历过程中检查每个节点的值是否等于target。
def is_unival_tree(root): if root is None: return True target = root.val def dfs(node): if node is None: return True if node.val != target: return False return dfs(node.left) and dfs(node.right) return dfs(root)这个版本的代码非常对称:入口判空,取出目标值,辅助函数负责遍历。它的缺点是闭包里绑定了一个外部变量,如果哪天你想复用一个检查函数来判断“任意目标值”,还得改造。
3.3 双参数递归:把目标值一路传下去
还有一种在题解区很常见的写法:递归函数接收一个val参数,每一层都把当前节点的值作为新的目标传给下一层。
def is_unival_tree(root): if root is None: return True def dfs(node, val): if node is None: return True if node.val != val: return False return dfs(node.left, node.val) and dfs(node.right, node.val) return dfs(root, root.val)这里的关键点是:虽然参数val不断变化,但判断逻辑依然正确。因为单值树的等价定义是:所有父子边两端的值都相等。只要每条父子边都满足相等,整棵树必然所有节点同值。所以每层把当前节点值传下去,等于逐条检查父子边,最终结果和固定target完全一致。
Java版本也顺手贴一下,入口判空那里最容易踩NPE:
public boolean isUnivalTree(TreeNode root) { if (root == null) return true; return dfs(root, root.val); } private boolean dfs(TreeNode node, int val) { if (node == null) return true; if (node.val != val) return false; return dfs(node.left, node.val) && dfs(node.right, node.val); }3.4 全局变量写法的隐藏风险
我也见过有人用全局变量记录结果,遍历时一旦发现不同就置位:
flag = True def dfs(node, target): global flag if node is None or not flag: return if node.val != target: flag = False return dfs(node.left, target) dfs(node.right, target)这种写法能通过测试,但我不推荐。全局变量在并发、多次调用、嵌套调用场景下很容易残留状态,导致“上一次调用的结果污染下一次判断”。而且它破坏了递归函数的纯度——函数不再通过返回值表达结果,而是靠外部副作用。面试中如果写成这样,至少会被追问一轮“你觉得这样有什么问题”。
三种“正规”写法各有偏好,我平时的选择是:如果只是刷题,用当前节点与孩子比较的版本,代码最短;如果在生产代码里需要一个可复用的判断工具,用双参数版本,语义边界清晰。
4. 复杂度分析与极端测试:为什么这个解法是O(n)
4.1 时间复杂度:每个节点访问一次
递归版本的时间复杂度是O(n),n是树的节点总数。理由很简单:每个非空节点只会作为“当前节点”被处理一次,每次处理只做常数次比较和值获取,然后向左右子树递归。不存在某个节点被重复访问的情况。
这里有个容易被忽略的细节:由于短路逻辑,如果一棵树在很靠上的位置就发现了不等,实际访问的节点数会远小于n。但复杂度分析看的是最坏情况——当整棵树确实单值时,没有任何提前返回发生,必须访问全部节点,所以最坏时间复杂度就是O(n)。
4.2 空间复杂度:递归调用栈占用O(h)
递归版本真正的成本在空间上。每次函数调用都会在系统调用栈上压入一层帧,深度等于当前递归链的长度,也就是树的高度h。
- 平衡二叉树:
h = O(log n),空间复杂度O(log n)。 - 退化成链表的树:
h = O(n),空间复杂度O(n)。
很多刚学递归的人只记得“递归有栈溢出风险”,但不知道来源。栈深度就是函数嵌套调用层数,写一个10000层的链表树递归,必然爆栈。面试时能主动说出“空间复杂度不是O(1)而是O(h)”,是很加分的细节。
4.3 实测边界输入清单
我每次写完树相关代码,都会跑一遍下面的输入,覆盖所有容易出问题的分支:
| 输入树结构 | 预期结果 | 说明 |
|---|---|---|
| 空树 | true | base case |
| 单节点 [5] | true | 没有比较对象 |
| [1, 1, 1] | true | 简单全等 |
| [1, 1, 2] | false | 右孩子不等 |
| [1, null, 2] | false | 斜树,非空右孩子不等 |
| [2, 2, 2, 2, null, 2, 2] | true | 多层全等 |
| [2, 2, 2, 3, null, 2, 2] | false | 左子树内部有不等 |
其中[1, null, 2]这个输入是专门用来测“当前节点只和存在的孩子比较”的分支逻辑的。如果代码里没写root.right的存在性判断,直接访问root.right.val,在这里就会抛空指针。
4.4 深度很大的树会触发递归上限
Python默认递归深度通常是1000层左右。如果测试用例是一棵10000层的链状树,递归版本会直接抛出RecursionError。
有两种应对方向:一是临时调大递归上限,比如sys.setrecursionlimit(20000),但这是治标不治本;二是把递归改写成显式栈的迭代版本,这也是下一节要展开的内容。
5. 递归与迭代的对比:不只是“栈溢出”那么简单
5.1 递归的开销到底在哪
递归代码简洁,但每次调用都要经历“压栈、传参、跳转、返回、弹栈”一整套流程,函数调用框架本身有时间和空间成本。在Python里递归调用的常数开销尤其明显,同样的算法递归版通常比迭代版慢一些。而且Python没有做尾递归优化,哪怕你的递归写成了尾调用形式,深度依然会累积。
单值判断这种“带有提前返回分支”的逻辑,递归代码其实并不比迭代短多少。这时候最理性的选择是:如果树的深度可能很大,直接上迭代,别逞强。
5.2 迭代写法:BFS队列和DFS显式栈
迭代版单值判断的核心思想是用显式容器管理“待访问节点”。BFS用队列:
from collections import deque def is_unival_tree_iterative(root): if root is None: return True target = root.val q = deque([root]) while q: node = q.popleft() if node.val != target: return False if node.left: q.append(node.left) if node.right: q.append(node.right) return TrueDFS用显式栈:
def is_unival_tree_iterative_stack(root): if root is None: return True target = root.val stack = [root] while stack: node = stack.pop() if node.val != target: return False if node.right: stack.append(node.right) if node.left: stack.append(node.left) return True两个版本的时间复杂度都是O(n),空间复杂度都是O(n)。队列版最差情况要容纳一整层节点,栈版最差情况接近树高。两个版本都不受递归深度限制,链状树也能放心跑。
5.3 用阶乘和快速排序理解递归与迭代的本质区别
递归和迭代最经典的对比例子是阶乘。递归版本fact(n) = n * fact(n-1)依赖“用子问题表达当前问题”,迭代版本用循环累乘,依赖“维护一个中间状态并不断推进”。两者结果一样,但思维路径完全不同。
放到本题里:递归版本是从树的定义出发,直接翻译成代码;迭代版本是手动维护一个容器,把“系统帮我做的函数调用栈”显式地写出来。
快速排序是另一个绝佳例子。快排的递归思路是:选pivot,分区,然后递归排序左右两个区间。非递归快排就是把递归时由系统管理的“待排序区间”换成你自己的栈:
def quick_sort_iterative(arr): if len(arr) <= 1: return arr stack = [(0, len(arr) - 1)] while stack: low, high = stack.pop() if low >= high: continue pivot = arr[(low + high) // 2] i, j = low, high while i <= j: while arr[i] < pivot: i += 1 while arr[j] > pivot: j -= 1 if i <= j: arr[i], arr[j] = arr[j], arr[i] i += 1 j -= 1 if low < j: stack.append((low, j)) if i < high: stack.append((i, high)) return arr这里的核心思路和二叉树递归改迭代一模一样:把隐式的函数调用栈,换成显式的数据结构栈。理解了这一点,不止单值二叉树,任何“递归遍历树”的题目都能用同一套模板改成迭代版。
5.4 什么场景该选递归,什么场景该选迭代
根据我个人的经验,可以按下面这张表快速决策:
| 场景 | 递归 | 迭代 |
|---|---|---|
| 代码简洁度 | 高,尤其树形结构 | 低,需要手动维护容器 |
| 可读性 | 接近问题定义 | 接近执行细节 |
| 栈溢出风险 | 有,深度受语言限制 | 无,容器在堆上 |
| 调试难度 | 函数调用轨迹直观 | 容器状态需要自己理 |
| 性能常数 | 偏大 | 偏小 |
| 适合场景 | 树、分治、回溯类问题 | 深度不可控、性能敏感 |
具体到单值判断,树深度不确定时我直接写迭代;深度可控、写Demo或面试讲解递归思想时,我写递归。两者没有谁绝对取代谁,能快速切换才是真本事。
6. 实际刷题和面试中的常见失误与排查链路
6.1 最容易犯的三个错
第一个错误是忘判“存在性”。比如写if root.left.val != root.val前,没检查root.left是否为None。在Python里会直接报AttributeError: 'NoneType' object has no attribute 'val'。正确写法是先判断root.left存在,再做值比较。
第二个错误是目标值漂移。固定target的版本和双参数版本混着写,一会儿用root.val做基准,一会儿把node.val传下去但比较时又拿target去比,导致逻辑混乱。
第三个错误是混淆and和or。单值判断要求“所有条件同时满足”,必须用and。如果把and写成or,只要左子树或右子树有一个单值,整棵树就被错误判定为单值,这是逻辑层面的严重错误。
6.2 一个真实排查过程:为什么漏判了一个反例
有一次我帮朋友看一段代码,测试用例[1, null, 2]预期返回false,实际却返回了true。他写的代码是这样的:
def is_unival_tree(root): if root is None: return True return is_unival_tree(root.left) and is_unival_tree(root.right)一眼就能看出问题:递归函数里根本没有做任何值比较,只判断了左右子树是否单值。对于[1, null, 2]来说,左子树为空返回true,右子树只有节点2也返回true,所以整棵返回true。
但更有意思的是:为什么他之前的测试用例通过了?因为他之前跑的树是[1, 1, 1]这种全等树,全等树左右子树都单值,即使漏掉值比较也返回true。他之所以没发现,是测试用例只覆盖了“正例”,没有覆盖“反例”。
这次排查给我的启发是:测试单值判断,必须同时准备一个“整棵树全等但子树不全等”的用例,比如[1, 1, 2]。它能立刻暴露你漏掉根节点与孩子值比较的问题。
6.3 调试技巧:打印递归轨迹和构造最小反例
递归出问题时,我习惯在函数入口加一行打印,用缩进表示递归深度:
def dfs(node, depth=0): if node is None: print(" " * depth + "None -> True") return True print(" " * depth + f"visit {node.val}") print(" " * depth + f"left={node.left.val if node.left else None}, right={node.right.val if node.right else None}") ...跑[1, null, 2]时,输出会清楚显示:根节点1访问后,直接进入右子树节点2,根节点和右孩子的比较结果一目了然。
调试另一个技巧是构造最小反例。单值二叉树的最小反例就是两个节点的树:[1, 2]。如果这个用例不过,说明递归逻辑有问题,没必要先用复杂树去试。
6.4 面试中怎么答才能加分
这题虽然简单,但面试官经常在follow-up里加东西。常见的追问包括:
- “递归的空间复杂度是多少?” 要答:O(h),h是树高。
- “能不能改成迭代?” 要会写显式栈版本。
- “如果树特别深,递归会发生什么?” 要能说出栈溢出和递归深度限制。
- “能不能用更小的空间?” 理论上Morris遍历可以把空间压到O(1),但它会临时修改树结构,对单值判断这种需要反复比较值的场景收益不大,面试里点一句即可。
我最喜欢的回答套路是:先给出递归版,然后主动说明调用栈开销,再补一个迭代版对比。这样既展示了代码能力,又展示了工程意识。
刷这道题踩过几次坑之后,我慢慢养成了一个小习惯:凡是写递归,先把终止条件、当前层职责、子问题组合方式这三件事写进注释里,再动手写代码。单值二叉树只是个开始,后续的“相同的树”“对称二叉树”“路径总和”全都是同一个套路。如果你也想把这套递归思维打牢,我建议你把这题和上面提到那几题放在一起刷,你会发现它们共享同一副骨架。最后留个小技巧:给递归函数加一个带深度的打印函数,调试完再删掉那一行,以后遇到任何树形递归,你都能快速定位问题。