1. 从一道经典面试题说起:为什么面试官总爱问“完全二叉树”?
如果你正在准备技术面试,尤其是那些对算法和数据结构有要求的岗位,那么“判断一棵二叉树是否是完全二叉树”这道题,你大概率会遇到。它不像“反转链表”那样基础,也不像“动态规划”那样复杂,但恰恰是这种“中等偏下”的题目,最能考察一个候选人的基本功是否扎实、思维是否严谨,以及代码的边界处理能力。
我第一次被问到这个问题时,心里想的是:“这还不简单?不就是按层遍历,遇到空节点之后,后面不能再有非空节点嘛。” 但当我真正动手写代码,并在面试官追问“为什么用队列?”、“如何处理只有一个节点的树?”、“你的算法时间复杂度是多少?”时,我才意识到,这个看似简单的定义背后,藏着不少值得深究的细节。它考察的远不止是你会不会写一个层序遍历(BFS),更是你对二叉树结构特性、遍历算法的理解深度,以及将自然语言定义转化为无懈可击的算法逻辑的能力。
完全二叉树在计算机科学中扮演着非常重要的角色。最典型的应用就是堆(Heap),无论是实现优先队列还是堆排序,其底层数据结构都是一棵完全二叉树。正因为它是“完全”的,我们才能用简单的数组来高效地存储和访问它,父子节点下标通过i, 2*i+1, 2*i+2这样的公式就能轻松算出。所以,判断一棵树是否具备成为堆的“潜质”,本质上就是在判断它是不是完全二叉树。理解了这一点,你就能明白,这个问题不是凭空捏造的,它背后有强烈的工程实践意义。
2. 完全二叉树的精确定义与核心特征
在动手写代码之前,我们必须把“完全二叉树”这个概念吃透。很多人的错误都源于对定义理解得模棱两可。
2.1 教科书式的定义
一本经典的数据结构教材可能会这样定义:对于一棵深度为h的二叉树,如果其第1层到第h-1层的节点都达到最大个数(即满的),且第h层的所有节点都连续集中在最左边,那么这棵树就是完全二叉树。
这个定义很严谨,但不够直观,尤其是“连续集中在最左边”这句话,在编程时不太好直接转化为条件判断。
2.2 更易于算法实现的“层序遍历视角”定义
在实践中,我们通常采用一个更操作化的定义,这也是面试中最常被接受和考察的思路:
对二叉树进行层序遍历(广度优先搜索),在遍历过程中:
- 如果遇到某个节点为
null(空),则将其视为一个“空位”。 - 从这个第一个遇到的“空位”开始,之后遍历到的所有节点都必须是
null。
换句话说,在层序遍历的序列中,空节点只能出现在所有非空节点之后,并且一旦出现空节点,后面就不能再出现非空节点。
让我们用几个例子来直观感受一下:
示例A(是完全二叉树):
1 / \ 2 3 / \ / 4 5 6层序遍历序列(用#表示空):[1, 2, 3, 4, 5, 6, #, #, #, #, #]。注意,节点6之后才出现空节点,并且之后全是空节点。符合定义。
示例B(不是完全二叉树):
1 / \ 2 3 / \ \ 4 5 7层序遍历序列:[1, 2, 3, 4, 5, #, 7, #, #, #, #]。这里,在节点5之后、节点7之前,我们遇到了一个空节点(节点3的右孩子)。但在这个空节点之后,我们又遇到了非空节点7。这违反了“空节点之后不能有非空节点”的规则。
示例C(边界案例:单节点树):
1层序遍历序列:[1, #, #]。第一个空节点出现在根节点之后,之后没有非空节点。这是一棵完全二叉树。
示例D(边界案例:左斜树):
1 / 2 / 3层序遍历序列:[1, 2, #, 3, #, #, #]。我们按层看:第一层1;第二层2, #;第三层3, #, #, #。在第二层,我们遇到了空节点(节点1的右孩子),但在这个空节点所在的层,后面还有节点3(节点2的左孩子)吗?不,节点3在下一层。关键在于,当我们从队列中取出节点2时,它的左右孩子(3和#)会被加入队列。此时,队列中已有的顺序是[#, 3, ...]。当我们处理到队列中的#时,就标志着遇到了第一个空节点,此时我们需要检查队列中剩余的元素是否全是#。显然,后面还有一个3,所以这不是完全二叉树。这个例子非常重要,它说明了为什么我们不能简单地“遇到空就结束”,而必须检查队列剩余元素。
2.3 与满二叉树、完美二叉树的区别
为了避免混淆,这里快速区分几个概念:
- 完美二叉树 (Perfect Binary Tree):所有层的节点都是满的。像一棵严丝合缝的三角形。
- 满二叉树 (Full Binary Tree):每个节点要么有0个,要么有2个子节点。
- 完全二叉树 (Complete Binary Tree):就是我们正在讨论的,按层填充,最后一层可以不满,但必须从左到右填充。
完全二叉树不一定是完美二叉树(最后一层可能不满),也不一定是满二叉树(倒数第二层的节点可能只有一个孩子)。但完美二叉树一定是完全二叉树,也一定是满二叉树。
3. 算法核心:基于队列的层序遍历(BFS)实现
基于2.2节的操作化定义,最直接、最清晰的算法就是使用队列进行层序遍历。这个算法的时间复杂度是 O(N),空间复杂度在最坏情况下也是 O(N)(当树为完全二叉树时,队列中会存储最后一层的所有节点)。
3.1 算法步骤拆解
假设我们有一个二叉树节点的定义(以Java为例):
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } }算法的核心步骤如下:
- 初始化:如果根节点
root为null,通常定义空树是完全二叉树(这一点可以根据面试官要求微调,但普遍如此)。创建一个队列queue,将根节点入队。 - 层序遍历与标记:进入循环,只要队列不为空,就出队一个节点
node。- 如果
node不为null,则将其左右孩子(无论是否为空)按顺序加入队列。这是关键!我们必须把空孩子也加入队列,作为“占位符”,这样才能在遍历序列中检测到空位。 - 如果
node为null,说明我们遇到了第一个“空位”。此时,我们应该跳出遍历循环。
- 如果
- 检查剩余队列:从步骤2跳出后,队列中可能还有元素。我们需要检查队列中剩余的所有元素。
- 如果剩余的所有元素都是
null,那么这棵树是完全二叉树。 - 如果剩余的元素中存在任何一个非
null的节点,那么这棵树就不是完全二叉树。因为非空节点出现在第一个空位之后,违反了定义。
- 如果剩余的所有元素都是
3.2 代码实现与逐行解析
下面是用Java实现的完整代码,并附上详细注释:
import java.util.LinkedList; import java.util.Queue; public class CompleteBinaryTreeChecker { public boolean isCompleteTree(TreeNode root) { // 边界条件:空树通常被认为是完全二叉树 if (root == null) { return true; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); // 根节点入队 boolean reachedNull = false; // 标志位:是否已经遇到了第一个空节点 while (!queue.isEmpty()) { TreeNode node = queue.poll(); // 出队当前节点 // 情况1:当前节点是空节点 if (node == null) { reachedNull = true; // 标记已遇到空位 // 注意:这里不break,继续检查队列中是否还有非空节点 } else { // 情况2:当前节点是非空节点 // 关键判断:如果已经遇到过空节点,又遇到了非空节点,则不是完全二叉树 if (reachedNull) { return false; } // 无论左右孩子是否为空,都入队。空孩子作为“占位符”至关重要。 queue.offer(node.left); queue.offer(node.right); } } // 如果遍历完整个队列都没有提前返回false,说明是完全二叉树 return true; } }代码逻辑深度解析:
reachedNull标志位:这是算法的灵魂。它记录了遍历过程中是否已经越过了“第一个空节点”这个分水岭。一旦设为true,就意味着我们进入了“只允许空节点”的区域。if (reachedNull)判断:当node不为空时,我们检查reachedNull。如果为true,说明当前这个非空节点出现在了一个空节点之后,立即判定不是完全二叉树。这个检查非常高效,一旦发现违规即可提前退出。- 空孩子入队:
queue.offer(node.left)和queue.offer(node.right)这行代码是很多人初学时容易忽略的。为什么空孩子也要入队?考虑示例D(左斜树)。如果不将空孩子入队,队列中永远不会出现null元素,reachedNull永远为false,算法会错误地判断它是完全二叉树。将空孩子入队,相当于在层序遍历的序列中明确地标记了“此处应有节点,但实际为空”的位置。 - 循环终止条件:算法没有在遇到第一个
null时立即break,而是依靠reachedNull标志和后续判断来工作。这样代码更简洁,逻辑统一在while循环内。
3.3 另一种等价的实现方式
有些教程或面试官喜欢另一种写法,即在遇到第一个空节点后,继续遍历队列并检查。这与上述逻辑完全等价,但更直观地对应了“检查剩余队列”的步骤:
public boolean isCompleteTree2(TreeNode root) { if (root == null) return true; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); boolean end = false; // 是否应该结束(即是否遇到了空节点) while (!queue.isEmpty()) { TreeNode node = queue.poll(); if (node == null) { end = true; // 遇到了第一个空节点,之后应该全是空 } else { // 在标记end为true后,又遇到了非空节点,违规 if (end) return false; // 正常入队左右孩子 queue.offer(node.left); queue.offer(node.right); } } return true; }两种写法本质一样,选择你更容易理解的一种即可。我个人更推荐第一种,因为reachedNull这个变量名更能体现其“分水岭”的语义。
4. 算法的时间与空间复杂度分析
对于一个合格的面试者,不仅要写出代码,还要能清晰地分析复杂度。
- 时间复杂度 O(N):其中 N 是二叉树中的节点总数。算法需要访问树中的每一个节点一次(无论是非空节点还是作为占位符的空节点)。每个节点都会执行一次入队和出队操作,这些都是 O(1) 的操作。因此总时间是线性的。
- 空间复杂度 O(N):在最坏情况下,当二叉树是一棵完全二叉树时,队列中需要存储最后一层的所有节点。对于一棵完全二叉树,最后一层的节点数最多约为 N/2(当树是完美二叉树时),因此空间复杂度是 O(N)。在最好情况下(如左斜树),空间复杂度会小一些,但我们通常用最坏情况来衡量。
面试技巧:当被问到复杂度时,可以补充一句:“这个复杂度对于判断完全二叉树的问题是 asymptotically optimal(渐进最优的),因为任何算法在最坏情况下都需要检查所有节点。”
5. 常见陷阱、边界条件与测试用例
这是最能体现你工程实践能力的地方。一个健壮的算法必须能处理各种奇葩的输入。
5.1 你必须考虑的边界条件
- 空树 (
root == null):如前所述,通常返回true。但务必与面试官确认,这是一个展示你注重边界条件的好机会。 - 单节点树:只有根节点,左右子树为空。这应该返回
true。 - 只有左孩子的树(左斜树):如示例D。这是一个经典的否定案例,务必用你的算法验证一下。
- 只有右孩子的树:这显然不是完全二叉树(因为第一层之后,左孩子位置就是空的)。你的算法应该能正确处理。
层序遍历序列(带空位):1 \ 2[1, #, 2, #, #]。处理根节点1后,队列为[#, 2]。下一个出队的是#,reachedNull设为true。再下一个出队的是2,此时reachedNull为true,直接返回false。正确。 - 最后一层节点不连续:如示例B,这是最核心的测试案例。
- 满二叉树/完美二叉树:这当然也是完全二叉树,算法应该返回
true。
5.2 一个容易忽略的“坑”:算法初始化
注意我们的算法在while循环中,对于非空节点,会无条件地将其左右孩子入队。这意味着,即使这个非空节点出现在第一个空节点之后(理论上不应该发生,因为我们在发现这种情况时会立即返回false),我们仍然会尝试访问它的left和right属性。这没有问题,因为能执行到这里的node肯定非空。
但是,考虑一种极端情况(虽然题目通常不会给出):如果树节点本身的值val无意义,但我们依赖left和right是否为null来判断。我们的算法是安全的,因为它只检查引用是否为null,不关心节点内部的值。
5.3 如何设计测试
自己写代码验证时,可以构造一个简单的树节点工具类来建树:
public class TreeBuilder { // 一种简单的建树方式:使用层序遍历的数组表示法 // 例如 [1,2,3,4,5,6] 表示一棵完全二叉树 // 数组中的 null 表示空节点 public static TreeNode build(Integer[] vals) { if (vals == null || vals.length == 0 || vals[0] == null) return null; TreeNode root = new TreeNode(vals[0]); Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int i = 1; while (!queue.isEmpty() && i < vals.length) { TreeNode node = queue.poll(); if (vals[i] != null) { node.left = new TreeNode(vals[i]); queue.offer(node.left); } i++; if (i < vals.length && vals[i] != null) { node.right = new TreeNode(vals[i]); queue.offer(node.right); } i++; } return root; } }然后可以轻松地测试各种案例:
public static void main(String[] args) { CompleteBinaryTreeChecker checker = new CompleteBinaryTreeChecker(); // 测试1:完全二叉树 [1,2,3,4,5,6] TreeNode tree1 = TreeBuilder.build(new Integer[]{1,2,3,4,5,6}); System.out.println("Test1 (Complete): " + checker.isCompleteTree(tree1)); // 应为 true // 测试2:非完全二叉树 [1,2,3,4,5,null,7] TreeNode tree2 = TreeBuilder.build(new Integer[]{1,2,3,4,5,null,7}); System.out.println("Test2 (Not Complete): " + checker.isCompleteTree(tree2)); // 应为 false // 测试3:左斜树 [1,2,null,3] TreeNode tree3 = TreeBuilder.build(new Integer[]{1,2,null,3}); System.out.println("Test3 (Left-skewed): " + checker.isCompleteTree(tree3)); // 应为 false // 测试4:单节点 [1] TreeNode tree4 = TreeBuilder.build(new Integer[]{1}); System.out.println("Test4 (Single node): " + checker.isCompleteTree(tree4)); // 应为 true // 测试5:空树 [] TreeNode tree5 = TreeBuilder.build(new Integer[]{}); System.out.println("Test5 (Empty): " + checker.isCompleteTree(tree5)); // 应为 true }6. 思路延伸:还有其他的判断方法吗?
基于队列的BFS方法是最主流、最清晰的。但在面试中,面试官可能会追问:“还有其他思路吗?” 这里可以提供两个思考方向,展示你的知识广度。
6.1 利用完全二叉树的节点索引性质
完全二叉树如果按层序遍历的顺序从1开始给每个节点编号(根节点为1),那么对于任意一个编号为i的节点:
- 它的左孩子编号为
2*i - 它的右孩子编号为
2*i + 1
算法思路:我们可以进行一次前序或层序遍历,在遍历的同时为每个节点计算其“理论编号”。如果这是一棵完全二叉树,那么实际遍历到的节点个数应该等于最后一个节点的编号。更具体地说,如果树有N个节点,且最后一个节点的编号恰好是N,那么它就是完全二叉树。如果在遍历过程中,发现某个节点的编号已经超过了当前节点总数N,说明中间出现了“空位”,就不是完全二叉树。
实现要点:需要同时记录节点和它的编号。可以用一个队列存储Pair<TreeNode, Integer>。这种方法同样需要遍历所有节点,时间复杂度也是 O(N),但避免了在队列中存储空节点。不过,代码相对BFS法稍复杂一些。
6.2 递归思路(DFS)的挑战
你可能会想,能不能用深度优先搜索(DFS)?理论上可以,但会非常麻烦。因为完全二叉树的定义是“层”相关的,而DFS是“深度”相关的。你需要记录每层的节点数,并判断最后一层是否从左到右连续,这需要在整个递归过程中维护复杂的状态信息(比如期望的节点数、当前层是否已出现空缺等),代码会变得晦涩难懂,且容易出错。在面试中,不推荐使用DFS来解决这个问题。BFS是更自然、更高效的选择。
7. 在真实面试中如何表现
最后,分享一些我作为面试者和面试官的经验。
- 先沟通,再动笔:不要一上来就写代码。先向面试官复述你对“完全二叉树”的理解,并确认边界条件(比如空树如何处理)。这能展示你的沟通能力和严谨性。
- 边说边写:在写代码时,同步解释你的思路。“我这里用一个队列来做层序遍历…”、“注意,空孩子也要入队,因为…”、“这里用一个
reachedNull标志来记录是否遇到了分界点…”。这能让面试官跟上你的思考过程。 - 主动分析复杂度:写完代码后,不要等面试官问,主动说出时间复杂度和空间复杂度,并简要解释原因。
- 设计测试用例:主动提出你要测试的几个边界案例,并口头运行一下你的代码。这比干巴巴的代码更有说服力。
- 思考备选方案:如果时间充裕,可以提一下节点索引性质的方法,作为思路的延伸,体现你的知识储备。
判断二叉树是否是完全二叉树,是一个融合了基础数据结构(树、队列)、基础算法(BFS)和严谨逻辑思维的经典问题。它像一块试金石,能有效区分出“背题者”和“理解者”。希望这篇详细的拆解,能帮你不仅搞定这道题,更能理解其背后的设计思想,在面试中游刃有余。