1. 从一道题说起完全二叉树判断到底在考什么第一次接触“判断完全二叉树”这个题目是在帮一个刚入行的朋友梳理数据结构面试题的时候。他当时刷到这道题第一反应是“这不就是层序遍历一下看看有没有空节点夹在中间吗”结果代码写出来跑测试用例十个里面挂了六个。这个场景我太熟悉了因为我自己当年也是这么踩坑过来的。完全二叉树这个概念定义本身并不复杂一棵二叉树除了最后一层之外每一层都被节点填满并且最后一层的节点全部集中在左侧连续排列。但就是这么一个看起来直白的定义落到代码实现上却有好几种截然不同的思路每种思路的边界处理都不一样稍不留神就会在某个犄角旮旯的测试用例上翻车。这篇文章我想做的事情很明确把“判断完全二叉树”这个题目彻底拆开揉碎从概念本质、多种实现方案、边界条件、常见错误、性能对比这几个维度讲透。不管你是正在准备面试的应届生还是工作中需要用到树结构校验的开发者或者是想重新夯实数据结构基础的进阶者都能从里面找到可以直接拿去用的东西。我会给出完整的代码实现也会解释每一种写法背后的思考逻辑更重要的是我会把那些只有真正写过、错过、改过之后才能总结出来的经验分享出来。2. 完全二叉树的本质不只是“看起来满”2.1 定义背后的数学约束很多人对完全二叉树的理解停留在“长得比较整齐”这个层面但真正写代码判断的时候必须把这种直觉转化成精确的数学条件。完全二叉树的严格定义包含两个层面第一所有层除了最后一层外都是满的第二最后一层的节点从左到右连续排列中间不能有空洞。这个定义等价于另一个更便于编程判断的表述对一棵完全二叉树按照层序编号根节点编号为1左孩子为2i右孩子为2i1如果树中有n个节点那么所有节点的编号恰好是1到n的连续整数不多不少。这个性质是后面很多高效解法的理论基础。我第一次真正理解这个性质是在画图分析一个反例的时候。假设有一棵树根节点有左孩子和右孩子左孩子又有左孩子右孩子没有孩子。这棵树看起来“挺整齐”但它不是完全二叉树因为按层序编号的话节点编号是1、2、3、4中间缺了5和6的位置却出现了4最后一层的节点没有连续靠左排列。画出来一看就明白了右孩子那一支“悬空”了导致最后一层出现了空洞。2.2 为什么这道题值得反复琢磨你可能会问判断完全二叉树在实际工作中有多大用处说实话直接用到的地方不算特别多但它是一道极好的“思维训练题”。它考察的是你对二叉树遍历方式的理解深度、对边界条件的敏感度、以及把数学性质转化为代码的能力。在实际工程中完全二叉树的判断会出现在这些场景里堆结构的合法性校验堆必须是一棵完全二叉树、某些特定数据结构的序列化与反序列化验证、以及一些算法题的前置条件判断。更重要的是面试中这道题出现的频率相当高因为它能很好地区分“背题”和“真懂”的候选人。我见过不少候选人一上来就说“层序遍历遇到空节点后面还有非空节点就不是完全二叉树”。这个思路方向是对的但实现细节上有一堆坑空节点怎么表示遇到空节点后怎么继续遍历如果队列里存的是节点指针空指针怎么处理这些问题不解决代码就是跑不通。3. 三种主流解法全拆解3.1 层序遍历法最直观但最容易写错层序遍历BFS判断完全二叉树的思路是这样的按照从上到下、从左到右的顺序遍历每个节点一旦遇到一个空节点那么它后面所有的节点都必须是空节点。如果后面又出现了非空节点说明最后一层有空洞不是完全二叉树。这个思路用文字描述很清晰但代码实现时有几个关键决策点。第一个决策点是队列里到底存什么如果只存非空节点那么遇到空节点时你无法判断它后面还有没有节点。所以必须把空节点也入队或者用一个标记来记录“是否已经遇到过空节点”。我比较推荐的写法是队列里存节点指针空指针也入队。每次从队列取出一个节点如果它是空节点就把标记置为true如果它不是空节点但标记已经是true了直接返回false。如果它不是空节点且标记为false就把它的左右孩子包括空指针都入队。from collections import deque def is_complete_tree(root): if root is None: return True queue deque([root]) seen_null False while queue: node queue.popleft() if node is None: seen_null True else: if seen_null: return False queue.append(node.left) queue.append(node.right) return True这段代码看起来很短但里面有几个容易出问题的地方。第一空树返回True这是约定俗成的但有些题目可能要求返回False或者抛异常需要根据具体场景确认。第二seen_null标记一旦置为true后面再遇到非空节点就立刻返回false这个逻辑不能反。第三左右孩子入队时即使为None也要入队否则无法检测到“空节点后面还有非空节点”的情况。注意这种写法的时间复杂度是O(n)空间复杂度在最坏情况下是O(n)因为队列中可能同时存在大量节点。对于完全二叉树来说最后一层的节点数大约是n/2所以空间开销不小。3.2 节点计数与编号法数学性质直接转化第二种思路利用前面提到的编号性质对树进行层序遍历同时给每个节点分配一个编号根为1左孩子为2i右孩子为2i1并统计节点总数n。遍历完成后如果所有节点的编号都小于等于n且没有重复那么就是完全二叉树。这个方法的巧妙之处在于它把“结构是否连续”的问题转化成了“编号是否越界”的问题。实现上可以用一个队列同时存储节点和它的编号。def is_complete_tree_by_index(root): if root is None: return True queue deque([(root, 1)]) count 0 max_index 0 while queue: node, idx queue.popleft() count 1 max_index max(max_index, idx) if node.left: queue.append((node.left, 2 * idx)) if node.right: queue.append((node.right, 2 * idx 1)) return max_index count这个写法的核心判断是max_index count。为什么这个条件成立就说明是完全二叉树因为如果树是完全二叉树那么节点编号恰好是1到n的连续整数最大值就是n。如果中间有空洞某个节点的编号会超过n导致max_index大于count。这个方法的优点是逻辑简洁不需要处理空指针入队的问题。但缺点也很明显当树退化成链状时编号会指数级增长可能导致整数溢出。比如一棵只有左孩子的链状树深度为1000时最后一个节点的编号是2^1000这个数字大到无法存储。所以这个方法在实际使用时需要加一个判断如果编号超过节点总数直接返回false避免溢出。3.3 递归法利用完全二叉树的结构性质第三种思路是递归判断。完全二叉树有一个递归性质如果一棵树是完全二叉树那么它的左子树和右子树也必须是完全二叉树并且左右子树的高度差不能超过1且如果高度差为1那么最后一层的节点必须全部在左子树中。这个思路实现起来比较复杂因为需要同时返回“是否为完全二叉树”和“树的高度”两个信息。通常的做法是定义一个辅助函数返回一个包含这两个信息的结构体或元组。def is_complete_tree_recursive(root): def check(node): if node is None: return (True, 0) left_complete, left_height check(node.left) right_complete, right_height check(node.right) if not left_complete or not right_complete: return (False, 0) if left_height right_height: return (True, left_height 1) elif left_height right_height 1: return (True, left_height 1) else: return (False, 0) return check(root)[0]这个写法看起来简洁但实际上有一个隐藏的坑它只能判断“满二叉树”或者“最后一层节点全部在左侧”的情况对于“最后一层节点部分在左子树、部分在右子树”的情况判断不准确。比如一棵树左子树高度为2右子树高度为1左子树是一棵满二叉树右子树也是一棵满二叉树这棵树确实是完全二叉树。但如果左子树不是满二叉树而是最后一层有节点但不满情况就复杂了。所以递归法在实际面试中并不推荐因为边界情况太多容易写错。我个人的经验是层序遍历法最稳妥编号法最优雅但有溢出风险递归法最不推荐。4. 边界条件与常见错误实录4.1 那些年我踩过的坑第一个坑空树返回什么这个问题看似简单但不同题目要求不一样。有些题目约定空树是完全二叉树有些题目认为空树不是树。我建议在面试时主动问清楚或者默认返回True因为从数学定义上讲空树满足完全二叉树的所有条件。第二个坑单节点树。只有一个根节点的树显然是完全二叉树。但有些实现会在判断左右孩子时出错比如把根节点的左右孩子都入队后发现队列里有两个空节点然后错误地返回false。实际上根节点的左右孩子为空是正常的因为最后一层只有一个节点。第三个坑队列中空节点的处理。如果队列里存的是节点指针空指针入队后下一次循环取出时需要进行判空。有些实现忘记判空直接访问node.left导致空指针异常。这个错误在LeetCode上提交时经常出现因为测试用例里包含各种奇怪的树形。第四个坑编号溢出。前面提到过编号法在链状树的情况下会溢出。我实测过Python的整数是任意精度的不会溢出但在C或Java中int类型在深度超过30时就会溢出。所以如果要用编号法必须加溢出保护。第五个坑层序遍历时队列的初始化。有些实现用列表模拟队列每次pop(0)的时间复杂度是O(n)导致整体复杂度变成O(n^2)。正确的做法是用collections.deque或者用两个列表交替。4.2 常见问题速查表问题现象可能原因解决方案空树返回false没有处理root为None的情况开头加if root is None: return True单节点树返回false左右孩子入队后误判空节点入队但不影响判断逻辑链状树超时队列用列表pop(0)改用deque或双列表编号法结果错误整数溢出加判断if idx count: return False递归法判断不准未考虑左右子树高度差为1时的细节改用层序遍历法空指针异常取出空节点后访问其孩子取出后先判空再访问提示在实际面试中我建议优先写层序遍历法因为它的逻辑最直观边界条件最少而且不容易写错。编号法可以作为备选方案但一定要提溢出问题。递归法除非面试官明确要求否则不要主动写。5. 性能对比与选型建议5.1 三种方法的时间空间复杂度层序遍历法的时间复杂度是O(n)因为每个节点恰好入队一次、出队一次。空间复杂度是O(n)因为队列中最多同时存在约n/2个节点最后一层的节点数。这个空间开销在n很大时比较明显但通常可以接受。编号法的时间复杂度也是O(n)空间复杂度同样是O(n)。但编号法有一个额外的好处它不需要存储空节点所以队列中的元素数量等于实际节点数比层序遍历法少一半左右。不过编号法的整数溢出问题需要额外处理。递归法的时间复杂度是O(n)空间复杂度是O(h)其中h是树的高度。在最好的情况下满二叉树hlogn空间开销很小。但在最坏情况下链状树hn空间开销和层序遍历法一样。而且递归法有栈溢出的风险深度太大时程序会崩溃。5.2 实际场景下的选型建议如果你是在面试中写这道题我建议用层序遍历法。原因很简单面试官看的是你的思路是否清晰、代码是否健壮而不是追求极致的空间优化。层序遍历法的逻辑最容易讲清楚也最容易处理边界条件。如果你是在实际工程中需要判断完全二叉树比如校验堆结构的合法性那么我建议用编号法。因为堆的节点数通常不会太大几万到几十万整数溢出不是问题而编号法的代码更简洁不容易出错。如果你是在资源受限的环境下比如嵌入式系统那么递归法可能更合适因为它的空间开销在平均情况下更小。但要注意栈深度限制必要时可以改成迭代版本。6. 从判断到构造完全二叉树的实际应用6.1 堆结构中的完全二叉树校验堆是一种特殊的完全二叉树分为大顶堆和小顶堆。在实现优先队列时我们经常需要校验一个数组是否满足堆的性质。这个校验过程的第一步就是判断它是否构成一棵完全二叉树。对于数组表示的完全二叉树判断起来更简单数组长度为n对于下标i从0开始左孩子下标为2i1右孩子下标为2i2。如果所有非空节点的孩子下标都小于n那么就是完全二叉树。这个判断可以在O(n)时间内完成而且不需要额外的空间。我在一个模拟项目中实现过一个优先队列当时为了调试堆的构建过程写了一个校验函数。这个函数的核心就是上面说的下标判断。实测下来这个校验函数帮我在早期发现了好几个堆调整的bug因为堆调整出错时数组往往就不再满足完全二叉树的性质了。6.2 序列化与反序列化中的结构验证在分布式系统中树结构经常需要序列化后传输。接收方在反序列化时需要验证收到的数据是否构成一棵合法的完全二叉树。这时候层序遍历法就派上用场了接收方可以按照层序顺序重建树然后判断是否满足完全二叉树的条件。这个场景下有一个额外的挑战序列化格式可能包含空节点的占位符。比如用“#”表示空节点那么反序列化时需要正确处理这些占位符。我遇到过一种情况发送方在序列化时省略了最后一层的空节点导致接收方重建的树看起来是完全二叉树但实际上缺少了某些节点。这个问题的根源在于序列化协议没有明确定义空节点的表示方式。解决方法是在序列化时对于完全二叉树可以只传输节点值数组因为完全二叉树的结构是确定的给定节点数n树形唯一确定。接收方根据数组长度n直接按照完全二叉树的规则重建即可。这样既节省了传输空间又避免了结构验证的麻烦。6.3 与其他树形结构的对比完全二叉树和满二叉树、完美二叉树经常被混淆。满二叉树是指每个节点要么是叶子节点要么有两个孩子节点。完美二叉树是指所有叶子节点在同一层且所有非叶子节点都有两个孩子。完全二叉树允许最后一层的节点不满但必须靠左排列。这三者的关系是完美二叉树一定是满二叉树也一定是完全二叉树满二叉树不一定是完全二叉树比如一个节点只有左孩子但左孩子又有两个孩子这棵树是满二叉树但不是完全二叉树完全二叉树不一定是满二叉树最后一层可以只有一个节点。理解这些区别对于正确实现判断逻辑很重要。我见过有人在判断完全二叉树时错误地使用了满二叉树的条件导致结果出错。所以在写代码之前一定要把定义搞清楚。7. 进阶话题完全二叉树的节点编号与数组存储7.1 为什么数组存储适合完全二叉树完全二叉树有一个非常好的性质它可以紧凑地存储在数组中不需要额外的指针来维护父子关系。对于下标i从0开始左孩子下标为2i1右孩子下标为2i2父节点下标为(i-1)/2。这个性质使得完全二叉树在内存中的存储效率极高没有空洞。这个性质也是堆能用数组实现的基础。在堆中我们不需要存储指针只需要一个数组和几个下标计算就能完成所有操作。这也是为什么堆的操作插入、删除能在O(logn)时间内完成因为树的高度是logn每次操作只需要沿着一条路径上下调整。7.2 节点编号法的数学证明前面提到的编号法其正确性可以用数学归纳法证明。假设树有n个节点按层序编号后如果所有节点的编号都小于等于n那么编号必然是1到n的某个排列。又因为层序遍历的顺序保证了父节点编号小于子节点编号且左孩子编号为2i右孩子编号为2i1所以如果最大编号等于n那么编号就是连续的1到n。反过来如果树不是完全二叉树那么必然存在某个位置的空洞导致某个节点的编号大于n。比如一棵树有4个节点但结构是根节点有左孩子和右孩子左孩子有左孩子右孩子没有孩子。按层序编号根为1左孩子为2右孩子为3左孩子的左孩子为4。这看起来没问题最大编号是4等于节点数。但这棵树不是完全二叉树因为右孩子没有左孩子而左孩子的左孩子却存在最后一层的节点没有靠左排列。等等这里我故意留了一个陷阱。实际上按照层序遍历的顺序如果右孩子没有左孩子那么左孩子的左孩子应该在右孩子之前被访问吗不是的。层序遍历的顺序是根、左孩子、右孩子、左孩子的左孩子、左孩子的右孩子、右孩子的左孩子、右孩子的右孩子……所以如果右孩子没有左孩子那么左孩子的左孩子编号是4右孩子的左孩子编号是6如果存在的话。但在这个例子中右孩子的左孩子不存在所以编号4的节点是左孩子的左孩子编号5的位置是空的。最大编号是4等于节点数4但树不是完全二叉树。这说明编号法的判断条件max_index count并不充分我当年就是在这里栽了跟头。正确的判断条件应该是在层序遍历过程中如果遇到一个节点的编号大于count直接返回false。但即使这样上面的例子中最大编号等于count仍然会误判。所以编号法需要更严格的条件不仅最大编号要等于count而且所有节点的编号必须恰好是1到count的连续整数不能有重复或缺失。但在层序遍历中我们无法直接检测缺失只能检测越界。上面的例子中编号5缺失了但最大编号是4没有越界。因此编号法实际上是有缺陷的。正确的做法是在层序遍历时如果遇到一个节点的编号大于count返回false同时如果队列中还有节点但编号已经超过了count也返回false。但上面的例子中编号4的节点在队列中编号5的位置是空的我们无法知道编号5应该存在。所以编号法只适用于“满二叉树”或者“节点编号连续”的情况对于一般的完全二叉树判断它并不可靠。这也是为什么我最终推荐层序遍历法的原因。注意这个坑非常隐蔽很多网上的题解都没有提到。如果你在面试中用了编号法面试官可能会追问这个边界情况到时候答不上来就很尴尬。8. 我的实操心得与建议8.1 写代码前的三个确认第一确认空树的返回值。不同题目要求不同提前问清楚或者默认True。第二确认节点值的类型。如果节点值可能为None或特殊标记需要区分“空节点”和“值为空的节点”。第三确认是否允许修改树结构。有些题目允许在遍历过程中修改节点有些不允许。层序遍历法不需要修改树所以最安全。8.2 调试技巧画图 小规模测试我调试完全二叉树判断代码时最有效的方法是画图。把树画出来标上编号然后手动模拟算法的执行过程。对于小规模的树节点数小于10手动模拟能发现大部分逻辑错误。另外我建议写几个固定的测试用例空树、单节点、只有左孩子的链、只有右孩子的链、满二叉树、完全二叉树但最后一层不满、非完全二叉树但看起来很像。这些用例覆盖了大部分边界情况跑一遍就能发现大部分问题。8.3 面试中的表达技巧如果在面试中被问到这道题我建议先说出思路再写代码。思路部分要讲清楚为什么用层序遍历、遇到空节点后怎么处理、时间复杂度是多少。写代码时变量命名要清晰比如用seen_null而不是flag用is_complete而不是check。写完后主动跑一个测试用例比如“根节点有左孩子和右孩子左孩子有左孩子右孩子没有孩子”然后解释为什么这棵树不是完全二叉树。这个主动验证的过程能给面试官留下好印象。最后再分享一个小技巧如果面试官让你优化空间复杂度你可以提“Morris遍历”的思路虽然完全二叉树的Morris遍历实现起来很复杂但至少说明你知道这个方向。不过一般情况下面试官不会要求这么高层序遍历法已经足够了。
阅读完成 · 觉得有帮助?