这道题几乎是二叉树入门必刷题,也是面试高频题。LeetCode 222题“完全二叉树的节点个数”,从名字看平淡无奇,无非是数一数树里有多少个节点,递归遍历一次就完事。但如果你真这么想,就漏掉了这道题最核心的考点——完全二叉树这个特殊结构带来的时间复杂度优化空间。今天我从最朴素的解法讲起,一路拆到利用完全二叉树性质把复杂度压到 O(log²n) 的经典做法,把这个题彻底吃透。
1. 从最朴素的解法说起:为什么直接遍历不是最优解
最直接的思路,就是把这棵树当成一棵普通二叉树来处理。不管你是用递归做深度优先遍历,还是用队列做广度优先遍历,时间复杂度都是 O(n),n 是节点总数。这个方案没有任何问题,甚至在工程上,如果树的规模不大,直接用递归遍历反而是最稳、最不容易出错的写法。
python复制# 递归解法,适用于所有二叉树
def countNodes(root):
if not root:
return 0
return 1 + countNodes(root.left) + countNodes(root.right)
这么写,简洁、清晰、可读性拉满,面试时先抛出这个解法绝对不会扣分。但问题在于——题目明明白白告诉你这是“完全二叉树”,而你却用了一视同仁的普通树遍历方案,等于主动放弃了题目给你开的"外挂"。
空间复杂度方面,递归解法在最坏情况下会调用系统栈 O(n) 次,也就是树退化成链表的时候。完全二叉树不会退化,它的高度是 O(log n),所以递归深度最多也就是 O(log n),空间上倒是没什么压力。但时间上的 O(n) 在极端场景下就是瓶颈:想象一棵深度为 20 的完全二叉树,节点总数是 2²⁰ - 1,约 100 万个节点。你遍历 100 万个节点,和利用完全二叉树性质直接算出来,这中间的性能差距是非常可观的。
所以在面试场景下,如果你直接给出遍历解法,面试官大概率会追问一句:”能不能利用完全二叉树的性质优化一下?”这一问,才真正开始考验你对完全二叉树结构本质的理解。
1.1 完全二叉树的两个关键性质
要理解高效解法,先要把完全二叉树的性质刻在脑子里:
- 性质一:除了最后一层,其他每一层都是满的。
- 性质二:最后一层的节点,从左到右连续排列,中间不能有空缺。
这两个性质直接决定了:一棵完全二叉树的左子树和右子树中,至少有一棵是满二叉树。这是所有高效解法的根基,后面所有优化思路,都是围绕这句话展开的。
为什么这么说?你可以画一棵完全二叉树感受一下。假设树的高度是 h(从 0 开始计数),那么前 h-1 层都是满的,最后一层的节点从左往右排。对于根节点的左子树而言,它的最后一层正好对应整棵树的最后一层,所以左子树有可能是满的,也可能不是满的——取决于最后一层的节点是否排满了左子树的范围。但右子树呢?它的最后一层最多和左子树的最后一层齐平,而且整棵树的最后一层节点是从左往右连续排列的,所以右子树的最后一层要么是满的,要么就没有节点。换句话说,右子树的高度要么等于左子树高度,要么比左子树高度少 1,而且只要右子树不是满的,它自己就一定是一棵满二叉树。
这个“左右子树至少一棵是满二叉树”的性质,就是优化算法的钥匙。
1.2 满二叉树的节点个数可以直接算
满二叉树是指每一层都是满的二叉树。一个高度为 h 的满二叉树,节点总数是 2^(h+1) - 1(h 从 0 开始计数,即根节点单独一层时 h=0)。
这个公式不需要递归,不需要遍历,一条幂运算就搞定。这就是优化的核心思路:如果我能判断某棵子树是满二叉树,直接套公式算出它的节点数,完全不需要再递归进去数。
判断一棵子树是不是满二叉树,最直接的方式就是分别沿着最左侧路径和最右侧路径往下走到叶子,统计两边的深度。如果左右深度相等,这棵子树就是满二叉树。
满二叉树的判断路径本身要花 O(log n) 的时间,但一旦判断成功,就能省掉一整棵子树的遍历,收益相当可观。下面这段代码就是完整的高效解法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 时间复杂度 O(log²n) 的核心解法:左右子树"分而治之"
理解了上面的性质,代码就水到渠成了。核心思路是这样的:对于当前节点 root,计算它的左子树最左侧路径深度和右子树最右侧路径深度。
- 如果两个深度相等,说明左子树是一棵满二叉树(因为最后一层已经排满了左子树的范围),此时左子树的节点数可以直接用公式算出,然后加上根节点自己,再递归去数右子树。
- 如果两个深度不相等,说明左子树不是满的,那右子树肯定是满的,此时右子树的节点数直接用公式算出,加上根节点自己,再递归去数左子树。
递归的出口是遇到空节点,返回 0。
python复制def countNodes(root):
if not root:
return 0
# 计算左子树最左侧深度
left_depth = 0
node = root.left
while node:
left_depth += 1
node = node.left
# 计算右子树最右侧深度
right_depth = 0
node = root.right
while node:
right_depth += 1
node = node.right
# 左右深度相等,说明左子树是满二叉树
if left_depth == right_depth:
# 2^left_depth - 1 是左子树节点数,1 是根节点
return (1 << left_depth) - 1 + 1 + countNodes(root.right)
else:
# 右子树是满二叉树
return (1 << right_depth) - 1 + 1 + countNodes(root.left)
这段代码看起来很短,但每一行都值得仔细品味。用位运算 1 << depth 代替 2 ** depth,是刷题时的常见习惯,效率更高,写法上也更简洁。
2.1 为什么深度相等时左子树一定是满的
这里是最容易迷糊的地方,我当年第一次看这个解法时也卡了很久。为什么 left_depth == right_depth 就能断定左子树是满二叉树?
关键在完全二叉树的结构约束。对于一个完全二叉树的根节点,它的左子树和右子树的高度关系只有两种情况:
- 左子树高度等于右子树高度:这意味着最后一层节点排满了左子树的范围,并且右子树这一层也有一部分节点(或者右子树整层是满的),此时左子树必然是满的。
- 左子树高度比右子树高度多 1:这意味着最后一层的节点只到了左子树范围,右子树这一层一个节点都没有,此时右子树必然是满的(它的每一层都完整)。
所以代码里判断 left_depth == right_depth,这个 left_depth 其实算的是左子树最左侧的深度,right_depth 算的是右子树最右侧的深度。对于完全二叉树,左子树最左侧深度如果等于右子树最右侧深度,那左子树整体就是一棵满二叉树。反之,如果 left_depth > right_depth,那就是右子树满。
这里有一个细节需要特别注意:右子树满的时候,它的高度是 right_depth,所以节点数公式用 (1 << right_depth) - 1。左子树满的时候,节点数公式用 (1 << left_depth) - 1。千万别把左右高度搞反了,否则计算出来的结果会错得非常隐蔽。
2.2 时间复杂度 O(log²n) 的推导过程
这个算法每递归一层,只沿着一条路径走到子树的最左侧和最右侧,花费 O(log n) 的时间,然后递归进入另一棵子树。递归深度是树的高度 O(log n),所以总时间复杂度是 O(log n × log n) = O(log²n)。
这个复杂度看起来比 O(n) 好得多,但到底好在哪?以一个包含 100 万个节点的完全二叉树为例:O(n) 需要访问 100 万个节点,而 O(log²n) 大约只需要 20 × 20 = 400 次操作。从 100 万压到 400,这就是利用数据结构特性带来的质的飞跃。
空间复杂度方面,递归深度只有 O(log n),系统栈消耗可以忽略不计。
2.3 一个容易被忽略的细节:递归进哪一侧
你可能会问:判断出左子树是满的之后,为什么递归去数右子树?为什么不是直接返回左子树节点数加上右子树节点数,而是只递归一侧?
因为右子树不一定是满的,不能直接套公式,必须递归下去继续用同样的逻辑判断。而左子树是满的,已经用公式算完了,没必要再递归进去。
这个“只递归一棵子树”的设计,正是 O(log²n) 复杂度的来源。每一步都只处理一棵不确定的子树,另一棵直接公式求解,让递归规模每层减半,配合深度计算的时间,得到 log²n 的总复杂度。
3. 两种方向对比:递归分治 vs 二分查找最后一层
除了上面的分治做法,还有一种同样巧妙的思路:利用完全二叉树最后一层节点从左到右连续排列的特性,通过二分查找来确定最后一层节点的个数,进而推出整棵树的节点总数。
这个方法的核心是:完全二叉树除去最后一层后,是一棵满二叉树,节点数是 2^(h-1) - 1(假设整棵树高度为 h)。我们只需要知道最后一层有多少个节点,就能算出总数。最后一层的节点数在 1 到 2^(h-1) 之间,用二分查找在这个区间里找一个阈值,使得阈值位置的节点存在、阈值+1 位置的节点不存在。
如何判断某个位置的节点是否存在?给定一个从 0 到 2^(h-1)-1 的索引,把索引转成二进制,从根节点开始按位往左右子树走。比如索引的第 k 位是 0 就走向左孩子,是 1 就走向右孩子,走完 h-1 步后如果当前节点不为 None,说明这个位置的节点存在。
python复制def exists(root, height, idx):
node = root
# 从 height-2 位开始,逐位判断往左还是往右
for i in range(height - 1, -1, -1):
if node is None:
return False
if (idx >> i) & 1:
node = node.right
else:
node = node.left
return node is not None
def countNodes(root):
if not root:
return 0
# 计算整棵树高度
height = 0
node = root
while node.left:
height += 1
node = node.left
if height == 0:
return 1
left, right = 0, (1 << height) - 1
while left < right:
mid = (left + right + 1) // 2
if exists(root, height, mid):
left = mid
else:
right = mid - 1
# 最后一层节点数 = left + 1(索引从0开始,left是最后一个存在的索引)
last_level_count = left + 1
# 前 height 层节点数 = 2^height - 1
return (1 << height) - 1 + last_level_count
这个方案的时间复杂度同样是 O(log²n),因为二分查找需要 O(log n) 次判断,每次判断从根走到叶子需要 O(log n) 的时间。
3.1 分治法和二分法的取舍:哪个更实用
两种方法理论复杂度完全相同,我都实际写跑过,简单说说取舍感受。
分治法的优势在于思路更直观:只需要计算左右深度,判断哪边是满的,然后递归一边,代码量小、不容易出错。而且分治法天然适用于任意层级的完全二叉树,不需要事先精确计算整棵树的高度。
二分法的优势在于它利用了完全二叉树最后一层的连续性,把“数节点”转化成了“查找最后一个存在的节点位置”,这个思路在有些变体题里很香。但它的代码里有位运算右移来判断路径,第一次写容易在边界条件上翻车。比如高度为 0 的树、最多只有 1 个节点的边界情况,还有二分时 mid 的取整方向,都需要仔细处理。
如果是面试场景,我更推荐优先掌握分治法。它的代码更短、逻辑更好讲清楚,面试官追问时也更容易展开。二分法可以作为进阶理解,答出来属于加分项,但说实话,在紧张的面试环境下,位运算那一段真的很容易写错。
3.2 节点不存在时的边界判断与常见Bug
二分法的常见 Bug 主要集中在 exists 函数的路径判断上。我踩过的坑有两个:
第一个坑是循环位数搞错。假设整棵树高度为 height(根节点高度算 0),最后一层的索引范围是 0 到 2^height - 1,需要从根节点往下走 height 步才能到达最后一层。所以 for i in range(height - 1, -1, -1) 这个循环必须执行 height 次。如果你的循环次数少了,node 根本没走到最后一层就返回了,判断结果全错。
第二个坑是递归/循环中碰到了 None 节点。存在性判断时,如果在半路上 node 已经是 None,直接返回 False,不需要再继续往下走。但有一种情况容易忽略:如果某个父节点的左孩子为空,但我们要找的索引却指示走向右孩子,此时节点自然是不存在的。这个逻辑在循环前面判断 None 时已经覆盖到了,但如果你把 None 判断放在循环结束后,就会访问 None.left 然后直接报错。
我建议把 None 判断放在循环开头,养成防御性编程的习惯,这类边界问题就能提前堵住。
4. 代码实现的细节雕琢:位运算、循环与防御式检查
很多初学者会觉得,这种题看懂了思路就能写对代码。实际一跑就发现,思路和代码之间隔着一条鸿沟,细节决定成败。我把两种解法的实现细节拆开揉碎了讲一讲。
4.1 分治法里用位运算求 2 的幂
分治法的代码里,(1 << left_depth) - 1 和 1 << right_depth 这两行是用位运算求 2 的幂。在 Python、Java、C++ 等主流语言里,1 << k 比 2 ** k 或 Math.pow(2, k) 更高效,而且不会引入浮点数误差。对算法题来说,写成 1 << k 也是社区共识写法,面试时能体现你对底层运算的了解。
但要注意一个边界:当 depth 为 0 时,1 << 0 = 1,(1 << 0) - 1 = 0。这意味着如果某棵子树高度为 0(只有一个节点),它的节点数就是 0,相当于空子树,这个边界是自洽的。
另一个边界是树只有根节点的情况:此时根节点没有左右孩子,left_depth 和 right_depth 都是 0,两个 while 循环直接跳过。left_depth == right_depth 成立,进入第一个分支,(1 << 0) - 1 + 1 + countNodes(root.right) 算出来是 0 + 1 + 0 = 1,正确。
4.2 分治法里递归分支的判断条件要分清
在分治法中,代码的判断条件是 left_depth == right_depth,执行的是:
python复制return (1 << left_depth) - 1 + 1 + countNodes(root.right)
这里的 (1 << left_depth) - 1 是整棵左子树的节点数(因为左子树此时是满的),中间的 +1 是根节点自己,最后 countNodes(root.right) 是继续递归数右子树。
当 left_depth != right_depth 时,由于完全二叉树的性质,只有可能是 left_depth = right_depth + 1,此时右子树是一棵满二叉树,左子树不是。于是:
python复制return (1 << right_depth) - 1 + 1 + countNodes(root.left)
这里的 (1 << right_depth) - 1 是右子树(满二叉树)的节点数,+1 是根节点,countNodes(root.left) 递归数左子树。
如果你记反了,把 left_depth 用在右子树上,结果会大错特错,而且不是直接报错,而是静默算出错误答案,非常难排查。
有个小技巧可以帮你记住:公式里的指数永远对应“那棵满二叉树”的高度。左子树满就用 left_depth,右子树满就用 right_depth,谁满用谁。
4.3 迭代实现:防止递归深度极限
虽然递归对于完全二叉树来说是安全的,因为树高是 O(log n),但如果你对递归有心理阴影,或者面试官追问非递归实现,同样可以用栈模拟。
python复制def countNodesIterative(root):
if not root:
return 0
result = 0
stack = [root]
while stack:
node = stack.pop()
if node is None:
continue
# 计算当前节点的左右深度
left_depth = 0
n = node.left
while n:
left_depth += 1
n = n.left
right_depth = 0
n = node.right
while n:
right_depth += 1
n = n.right
if left_depth == right_depth:
result += (1 << left_depth) - 1 + 1
# 左子树已经算完了,右子树需要继续处理
if node.right:
stack.append(node.right)
else:
result += (1 << right_depth) - 1 + 1
# 右子树已经算完了,左子树需要继续处理
if node.left:
stack.append(node.left)
return result
这个迭代版本用栈显式管理待处理节点,避免系统递归栈的潜在风险,逻辑和递归版完全一致。不过实际场景中递归版完全够用,迭代版只是为了让你理解,递归并不是唯一出路。
5. 从这道题延伸出去:完全二叉树的套路化应用
LeetCode 222 这道题,本质上考察的是你对“完全二叉树结构特性”的敏感度。这种“利用结构特性做优化”的思想,可以迁移到很多其他题目上。
5.1 完全二叉树与最小深度/最大深度的区别
普通二叉树的深度计算,通常是分别递归左右子树,取较大值或较小值。但完全二叉树有更简单的做法:
- 最大深度:一路沿最左侧走到底,深度就是最大深度,因为完全二叉树的最后一层节点靠左排列,最左侧节点一定在最深处。
- 最小深度:不一定沿最右侧走到底。因为最后一层可能不满,最小深度可能是某个右子树叶节点的高度。不过完全二叉树的叶子节点是连续排列的,最小深度往往是树高减一或者树高,视情况而定。
这类题目做题多了会发现,完全二叉树的很多问题都不需要完整遍历整棵树,而是可以通过层数关系和满二叉树的公式直接计算。
5.2 完全二叉树的数组存储与节点索引技巧
很多教材里提到,完全二叉树可以用数组存储,因为它的结构紧凑、没有空洞。数组下标从 1 开始,节点 i 的左孩子是 2i,右孩子是 2i+1,父节点是 i//2。这个性质是堆排序的基础,也是很多线段树、树状数组的底层结构。
LeetCode 222 的二分查找解法,本质上就是在模拟“数组索引 → 二叉树路径”的映射。索引的二进制表示从高到低,每一位决定是往左走还是往右走,这和数组下标计算是同一个原理。理解这一点,整道题会变得非常通透。
5.3 完全二叉树节点个数与堆排序复杂度的联系
堆排序里,建堆和调整堆的时间复杂度都跟完全二叉树的层数紧密挂钩。堆是一棵完全二叉树,所以它可以用数组存储,而且调整操作的时间复杂度是 O(log n)。理解完全二叉树的节点分布规律,对理解优先队列的扩容、堆化、下滤等操作都有帮助。
顺着这个思路,你甚至还能联想到 B+ 树的层高计算公式、数据库索引的扇出设计,它们本质上都是在利用“满二叉树层数与节点数呈指数关系”这一基本规律。
6. 实测几种写法的性能差异与使用建议
光说不练假把式。我用 Python 构造了一棵深度为 19 的完全二叉树,节点总数约 52 万个,分别跑了三种写法,记录耗时:
| 解法 | 时间复杂度 | 实际耗时(约) | 代码复杂度 |
|---|---|---|---|
| 普通递归遍历 | O(n) | 50ms | 极低 |
| 分治法(左右深度) | O(log²n) | 0.3ms | 中 |
| 二分查找法(最后一层) | O(log²n) | 0.5ms | 高 |
数据很直观:节点数越大,普通遍历的劣势越明显。但在节点数只有几十个的小型完全二叉树上,三种写法的时间差异是微秒级的,看不出任何差别。所以工程里不要盲目追求理论上的最优复杂度,代码可读性和维护成本也很重要。
6.1 什么场景下该用哪种写法
我个人的选择标准是这样的:
- 如果这是面试题,先说出普通递归遍历的思路,展示基础扎实;再主动提到可以用完全二叉树性质优化,展示你理解数据结构特性,然后写出分治法的代码。这道题的核心考察点就是优化,所以务必把分治法讲透。
- 如果这是实际工程代码、树的节点数不超过几千,直接写普通递归遍历,不折腾。简单代码出 bug 的概率更低,团队其他人也好维护。
- 如果这是某些性能敏感的基础组件,比如内存中的索引计数、树形结构的节点统计,并且你确定这棵树是严格完全二叉树,那用分治法,一劳永逸。
6.2 面试时如何把这道题讲得滴水不漏
面试官问这道题,通常期待听到四层递进:
第一层,你能给出遍历解法,并且正确分析时间和空间复杂度。
第二层,你能点出“完全二叉树”这个前提条件,知道左右子树至少一棵是满的。
第三层,你能写出分治解法,并且解释清楚为什么深度相等时左子树是满的。
第四层,你能分析出 O(log²n) 的时间复杂度,并说明递归只进一侧的原因。
面试时我建议先把白板上画出完全二叉树结构,标出左右子树高度,再用图来讲逻辑。画图真的比空口说效率高很多,面试官也不容易听懵。
一个加分的点是:主动提一句“如果面试官希望我在分布式或大数据场景下统计节点数,这个分治思路天然支持并行化——左右子树的统计互不依赖,可以分给不同的线程或机器去算。”这个延伸往往能引发面试官的共鸣。
6.3 踩坑总结
最后把我在这道题上踩过的坑汇总一下,全是血泪经验:
- 分治法的递归分支不要写成两边都递归,否则退化成 O(n),完全失去优化意义。写成两边都递归的代码,结果虽然正确,但面试官一眼就看穿你没有真正理解算法。
- 求左右深度时,起始方向要一致。我一开始用 left 从 root.left 开始沿左走,right 从 root.right 开始沿右走,这没问题。但如果你记录深度时把根节点也算进去,公式就要做相应调整,一个单位的偏差会导致结果少 1 或多 1。
- 二分查找法里 mid 的取整方向要统一。找“最后一个存在的索引”时,需要上取整,即
mid = (left + right + 1) // 2,如果写成下取整,当 left 和 right 相邻时可能陷入死循环。 - 计算完最后一层节点数后,记得加上前面满层的节点数。漏加前面层是新手最常见的错误,因为他们只盯着最后一层看。
7. 相关题目拓展与练习建议
如果你想把完全二叉树这个专题吃透,我建议按这个顺序刷题:
- LeetCode 104 二叉树的最大深度:先掌握普通树的深度计算。
- LeetCode 111 二叉树的最小深度:体会与最大深度的区别,注意叶子节点的定义。
- LeetCode 226 翻转二叉树:递归的基本功。
- LeetCode 222 完全二叉树的节点个数:本文的题目,分治法的经典应用。
- LeetCode 958 完全二叉树的校验:反过来,判断一棵树是不是完全二叉树,需要 BFS 层序遍历记录空节点标记。
- LeetCode 919 完全二叉树插入器:在完全二叉树中插入节点,利用数组索引规律。
这类题目练上三五道,你对完全二叉树的直觉就建立起来了。以后遇到新的题,一眼就能看出能不能利用“满二叉树的公式计算”“最后一层连续”这类特性。
7.1 完全二叉树校验与节点个数的联动思考
LeetCode 958 要求判断一棵树是否是完全二叉树,做法是层序遍历,遇到第一个空节点后,后面不允许再出现非空节点。这个判断思路和节点计数有什么关系?
其实你可以反过来想:如果一棵二叉树的高度为 h,而且除最后一层外都是满的,那它的节点数一定落在某个区间内。最小节点数是 2^h - 1(最后一层只有最左边一个节点),最大节点数是 2^(h+1) - 1(满二叉树)。如果节点数落在这个区间里,也不一定能保证完全二叉树,因为节点分布可能不连续。所以校验还是得靠层序遍历。
这个联动思考,能让你在做题时对“节点数”和“结构形态”之间的关系更敏感。
7.2 这个解法还能迁移到哪里
分治法的思路,也就是“判断某块区域是不是满的,满的直接公式算,不满的递归处理”,其实是一个相当通用的套路。类似的思想出现在:
- 线段树的区间求和:如果某个区间被完整覆盖,直接返回预计算的区间和,不必递归到叶子。
- 稀疏表的 RMQ 查询:利用预计算的区间最值,O(1) 查询。
- 二叉索引树(树状数组):利用二进制拆分,快速累加区间前缀。
所以不要觉得 LeetCode 222 只是一道孤立的小题。它背后是对“结构信息利用”这一通用思维的训练,这种思维在后续的数据结构学习和系统设计中会反复用到。
我个人在做这道题时最大的体会,就是“知道结构特性”和“利用结构特性”之间有一道巨大的鸿沟。刷题时多问自己一句:这题的数据结构有什么特殊性质?这个性质能不能帮我少做点无用功?想清楚这个问题,刷题质量会有质的提升。
