ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

完全二叉树与数组下标:GESP六级核心考点与C++实现

2026/9/9 19:38:47 拓冰建站 浏览量
完全二叉树与数组下标:GESP六级核心考点与C++实现 最近在洛谷刷题的时候看到一道编号 P15801 的题目题目标签很直白GESP202603 六级、完全二叉树。说实话GESP 六级能把完全二叉树单独拎出来考说明这已经不是“认识概念”的级别而是要把性质用明白。很多同学平时刷题喜欢跳过这类“基础数据结构”觉得二叉树嘛递归遍历不就完了。但完全二叉树恰恰是那种看着简单、考起来能挖出不少坑的知识点尤其是它和数组下标的紧密绑定关系几乎是六级的常客。这篇文章我打算用这道题当引子把完全二叉树的几个核心考点掰开揉碎讲一遍包括性质推导、代码实现、边界处理最后再聊聊 GESP 六级到七级备考时这类题还能怎么变着法出。不管你是刚学完树结构的小白还是准备冲 GESP 六级、CSP-J 的选手这篇都值得认真看完。1. 题目到底在考什么从 GESP 六级大纲看完全二叉树1.1 完全二叉树不是“满二叉树”先把这个最容易混淆的点说清楚。满二叉树是每一层都塞满节点整棵树长得像个完美的等腰三角形完全二叉树则允许最后一层不满但最后一层的节点必须从左到右连续排列中间不能有空位。换句话说完全二叉树是从上到下、从左到右“按顺序生长”的树。GESP 六级把它单独作为考点本质上是考察你对“顺序存储”的敏感度。为什么偏偏是完全二叉树能用数组存因为只有完全二叉树才能保证按层序编号后父子节点的下标关系始终成立。满二叉树当然也满足但满二叉树只是完全二叉树的特例题目不会只考特例。这道 P15801 的大意我在洛谷评论区看到好几种版本核心大多落在给定完全二叉树的节点数量要求输出某种遍历顺序、计算高度或者判断某个编号节点的父节点、子节点、兄弟节点是谁。这些问题的共同点就是都需要用到“下标运算”而不是“指针跳转”。1.2 数组存树的父子下标规律用数组存完全二叉树时通常从下标 1 开始存根节点这一点很多人踩坑后面细说。对于编号为 i 的节点父节点编号i / 2整数除法左孩子编号2 * i右孩子编号2 * i 1这三个公式看起来简单但它们是整道题的灵魂。考试的时候不像平时写代码可以慢慢试必须在心里随时能调出来。我习惯把它们记成一句话左孩子是翻倍右孩子是翻倍加一往回走就是折半。为什么从 1 开始而不是从 0 开始因为从 1 开始左孩子永远是偶数右孩子永远是奇数判断左右孩子只需看奇偶性如果从 0 开始2 * 0 还是 0直接死循环必须额外特判。考场上的代码能少一个特判就少一分出错风险所以除非题目明确要求否则一律从 1 开始编号。1.3 题目典型问法与考场识别从 GESP 历年的出题风格看完全二叉树很少单独考“什么叫完全二叉树”这种概念题更多是给你一些条件让你反推树的形态或者计算节点数量。常见问法有已知完全二叉树的节点总数为 n求树的高度。已知某个节点的编号输出它到根节点的路径。已知前序遍历或层序遍历的序列还原这棵完全二叉树。判断一棵树以数组形式给出是否为完全二叉树。计算最后一层叶子节点的数量。P15801 这道题我在提交记录里看到不少人卡在“父节点路径”这个点上其实就是没有意识到每次 i / 2 就能一路走到根。你只要在草稿纸上画一棵 7 个节点的完全二叉树把编号 1 到 7 标上去就能直观看到8 号节点的父节点是 44 的父节点是 22 的父节点是 1。连续除以 2 就是向上回溯的路径没有比这个更简单的算法了。2. 核心性质与推导不背结论现场推2.1 层序编号与高度计算考试最怕的是记了一堆公式结果记混了。我的建议是公式可以记但更重要的是知道公式怎么来的这样哪怕忘了也能现场推。完全二叉树的高度 h 与节点数 n 的关系是深度为 h 的满二叉树节点数为 2^h - 1。深度为 h 的完全二叉树节点数 n 满足 2^(h-1) n 2^h。所以反过来已知 n高度 h floor(log2(n)) 1。比如 n 7log2(7) 向下取整是 2加 1 等于 3恰好是 7 个节点时满二叉树的高度n 8log2(8) 是 3加 1 等于 4但深度为 3 的完全二叉树最多只能装 7 个节点所以 8 个节点必须往下再开一层高度确实是 4。在代码里可以直接用循环算高度也可以用 C 的 log2 函数但需要注意浮点数精度问题。我一般更推荐循环因为完全二叉树的 n 最大可能到 10^7 级别循环最多几十次性能完全没问题还避开了浮点误差。2.2 叶子数、结点数、最后一层关系的推导这是完全二叉树里最容易出题的一个点。给定节点总数 n怎么求叶子节点数量方法一先求高度 h floor(log2(n)) 1然后算最后一层的节点数。最后一层的节点数 n - (2^(h-1) - 1)也就是总的节点数减去上面 h-1 层满二叉树的节点数。然后倒数第二层的叶子数要看最后一层占掉了多少孩子如果最后一层有 x 个节点那么倒数第二层中有 ceil(x / 2) 个节点有孩子剩下的就是叶子。总叶子数 最后一层节点数 倒数第二层新产生的叶子数。方法二利用完全二叉树的性质叶子节点只可能出现在最后两层。如果 n 是奇数说明 1 号节点有两个孩子如果 n 是偶数1 号节点只有一个左孩子。继续沿着编号往下推就能得到所有叶子节点编号。这个方法比较绕我建议用方法一逻辑清晰代码也短。P15801 如果出到“给 n 求叶子数”大概率就是方法一的标准应用。在草稿纸上列出 n 从 1 到 10 的结果你会发现规律n 为 1 时叶子是 1 个n 为 2 时是 1 个n 为 3 时是 2 个n 为 4 时是 2 个n 为 5 时是 3 个……这个数列本身没什么好背的但推导过程一定要练熟。2.3 用对数与位运算快速定位说到 log2(n)其实 C 里还有一个更快的等价写法位运算。对正整数 nfloor(log2(n)) 等价于求 n 的最高位 1 所在的位置。比如 n 12二进制是 1100最高位在第 3 位从 0 开始数所以 log2(12) 3。代码上可以用内置函数 __builtin_clz统计前导零个数来算int lg 31 - __builtin_clz(n);或者更简单地用 while 循环右移。位运算的好处是避免了浮点数缺点是代码可读性略差而且 __builtin_clz 对 n 0 是未定义行为。我在洛谷做题时习惯写一个通用的 log2 函数既能应对浮点场景也方便迁移到其他题int lg2(long long x) { int res 0; while (x 1) { x 1; res; } return res; }这样一个函数解决所有底数换算不用每次现想。3. 代码实现与踩坑记录3.1 C 参考实现从输入到输出的完整套路以 P15801 最常见的“给定 n输出所有根到叶子路径”版本为例我写一版完整的 C 参考实现。有些题目会要求你按深度输出每一层的节点编号本质也是层序遍历用数组下标直接可以算出来。直接模拟层序编号的写法最简单用一个数组 tree[] 存编号然后从 1 到 n 遍历。每个节点 i 的左孩子是 2i右孩子是 2i1。如果孩子编号不超过 n说明存在。如果是输出根到某个叶子节点的路径做法就是先找到叶子编号 k然后不断 k / 2 收集节点最后反转输出。代码如下#include bits/stdc.h using namespace std; int main() { long long n, k; cin n k; vectorlong long path; while (k 1) { path.push_back(k); if (k 1) break; k / 2; } reverse(path.begin(), path.end()); for (size_t i 0; i path.size(); i) { if (i) cout - ; cout path[i]; } cout \n; return 0; }这里有几个小细节值得注意。第一n 的范围如果很大建议用 long long因为完全二叉树的深度可能很深节点编号计算 2*i 可能超出 int 范围。第二reverse 用 不要自己手写交换。第三k 的输入最好也要用 long long 接收很多同学就是这里 int 读入导致溢出。3.2 数组模拟建树与遍历的边界控制如果题目给的是层序序列让你还原成树并做后续遍历那么数组写法会更合适。设数组 a[1..n] 存储层序序列则建树过程其实不需要真的建树直接递归模拟void postOrder(int idx) { if (idx n) return; postOrder(idx * 2); postOrder(idx * 2 1); cout a[idx] ; }这段代码的精髓在于递归边界 idx n。因为完全二叉树的数组下标是连续的只要下标超过 n就说明这个节点不存在递归结束。一旦掌握了这个边界前序、中序、后序都只需要调整三行的顺序。但这里有个容易错的地方如果题目给的不是完全二叉树而是一棵普通二叉树用数组表示那么 idx n 就不够用了还得检查 a[idx] 是否为空。很多同学把完全二叉树的代码套到普通二叉树上结果数组里一堆空位没判断直接 RE。所以做题前一定要先确认题目的树是不是完全二叉树这决定你能不能偷懒用连续下标。3.3 常见错误从 1 开始还是从 0 开始这是完全二叉树题里翻车率最高的问题之一。我见过太多提交思路全对就是下标从 0 开始结果左孩子 201 1右孩子 202 2然后父节点又变成 (i-1)/2每次都要小心地处理边界代码写出来又长又容易错。我的建议很明确除非题目已经明确用 0 基下标给了数组比如根节点在 tree[0]否则一律改成 1 基下标。如果你读入的数据本身就是 0 基那就先把数据整体往右挪一位或者干脆用一个虚拟节点占位vectorint a(n 1); for (int i 1; i n; i) cin a[i];这样处理之后所有下标规律都回到最简洁的形式。代价只是多开一个 int 的空间完全值得。还有一个隐蔽问题当 idx * 2 溢出 int 时判断条件可能失效。比如 idx 是 1 30乘以 2 之后变成负数判断 idx n 就不成立了。解决办法是用 long long 做乘法或者在递归前判断if (idx n / 2) return; // 叶子节点不可能有左孩子3.4 Python 版本与递归深度的坑Python 写这类题最大的坑不在逻辑而在递归深度。完全二叉树的深度是 log2(n)看起来不大但如果数据加强到 n 10^6深度大约是 20递归没问题但如果你写的是退化树的递归深度可能到 10^6直接 RecursionError。我的建议是 Python 里能用循环就别用递归尤其是遍历完全二叉树时完全可以用层序公式直接生成所有节点编号不需要显式递归。比如输出层序遍历n int(input()) ans list(range(1, n 1)) print( .join(map(str, ans)))这一行就搞定了。因为完全二叉树的层序编号本来就是 1 到 n天然有序。这也是完全二叉树最有意思的地方它的存储和逻辑结构高度一致很多时候根本不用“建树”。如果确实需要递归比如求后序遍历可以在开头主动调大递归限制import sys sys.setrecursionlimit(1 25)但注意这只适合深度确实不大的情况治标不治本。真正稳的做法是改成迭代栈。4. 常见问题与排查技巧实录下面整理几张我在做题和答疑过程中遇到的典型问题速查表基本覆盖了这个知识点的所有疑难点。症状可能原因解决思路数组越界 / RE下标从 0 开始2*i 计算出错强制转 1 基下标vector 开 n1递归无限循环没有判断 idx n或 idx*2 溢出变负数先用 n/2 判断或用 long long 计算输出顺序不对前序/中序/后序的递归顺序写错先画 3 个节点的树手动推导输出顺序答案偏大或偏小log2 浮点误差改用循环右移计算最高位内存超限 MLE用 struct Node 指针建树完全二叉树直接用数组不要建树某些用例崩溃输入了 0 或负数读入后加保护判断 n 0 直接返回4.1 叶子节点判断的经典失误很多人判断叶子节点时会写if (idx * 2 n idx * 2 1 n) // 叶子这个判断本身没错但没有考虑最后一个节点的左右孩子可能一个存在一个不存在。比如 n 6 的时候编号 3 的节点有左孩子 6没有右孩子 7它既不是严格意义的叶子有孩子也不能算有完整的两个孩子。题目如果问“输出所有左右孩子都存在的节点”你就不能只判断 idx21 n还要确保 idx2 n这两个条件是独立的。实际写题时我一般先算左右孩子的编号int l idx * 2; int r idx * 2 1; bool hasL (l n); bool hasR (r n);然后根据 hasL 和 hasR 的组合分情况讨论。这样代码虽然多几行但每种情况都覆盖到了不会再出现漏判。4.2 输入输出的坑GESP 和洛谷的题输入输出规模经常很大。如果 n 到了 10^6 甚至 10^7cin/cout 默认同步流会慢得让人怀疑人生。解决办法是在 main 开头写ios::sync_with_stdio(false); cin.tie(nullptr);这是 C 选手的基本素养但每次都有新人忽略。Python 选手则要注意 input() 和 print() 的效率大量输出时一定要攒成列表再 join不要一行一个 print。另外读入节点数据时如果节点值很大用 int 够用但如果涉及编号的乘 2 运算务必升级成 long long。别小看这个细节P15801 的测试点里 n 最大能给到 10^12此时编号 2*i 轻松撑爆 int直接 WA。4.3 多组输入的清空问题很多竞赛题都是 T 组数据每组的 n 不同。如果你用的是全局数组切记每组数据只更新前 n 个位置不要把上次的数据残留带到下一组。这个问题在完全二叉树题里尤其隐蔽因为数组大小固定但每组用到的下标范围不同。我习惯在每组处理前把用到的范围显式初始化或者直接开 vector 并 resize。vector resize 会自动赋默认值省心很多。还有如果你用了记忆化数组做重复子问题优化记得在每组开头清空 visited 数组否则上一组的状态会影响这一组。4.4 从 0 开始编号的替代方案虽然我一直强调从 1 开始但有些题目比如某些 LeetCode 风格题固定用 0 基。如果你必须处理 0 基完全二叉树我的建议不是改变自己的习惯而是统一在输入后做一次映射把 i 号节点放到新数组的 i1 位处理和输出时再用额外数组记录映射关系。这样做的好处是逻辑部分完全复用你最熟悉的 1 基公式坏处是多一次 O(n) 的拷贝。不过 n 在 10^6 级别时拷贝一次毫秒级完成完全不用担心。5. 从 P15801 延伸开GESP 六级到七级的备考建议5.1 完全二叉树还能怎么变着法考GESP 六级的完全二叉树题往往只考单一知识点比如求高度、求叶子数、输出路径。但到七级就可能把二叉树和堆、优先队列、线段树等结构结合。实际上堆就是一个完全二叉树二叉堆的 sink 和 swim 操作本质都是在数组下标间移动。如果你现在能把完全二叉树的下标规律用熟后面学堆排序、Dijkstra 堆优化、线段树的区间查询都会快很多。还有一种常见变式给定中序层序序列还原二叉树。普通二叉树需要中序前序或后序才能唯一确定但完全二叉树因为结构特殊只要给定层序就能唯一还原整个树形结构。这类题在 GESP 七级里偶尔会出现思路仍然是利用数组下标关系。我推荐把完全二叉树当作后续数据结构的基础单元来学先手写三种遍历再写堆排序再把堆封装成优先队列。这样一条线学下来GESP 六级的内容你基本就吃透了七级也能无缝衔接。5.2 刷题建议与资源推荐洛谷上有不少完全二叉树相关题不一定非要盯着 P15801。我的建议是按难度梯度刷入门求完全二叉树的高度、层序遍历输出直接循环打表进阶给定节点编号求祖先路径、求兄弟节点、判断两节点是否在同一层变式把完全二叉树和堆排序结合手写小根堆拓展线段树入门题理解“完全二叉树式”的区间划分每次刷完题我都建议做一件事在草稿纸上画一棵 15 个节点的完全二叉树把每个节点的父节点、左孩子、右孩子都标出来然后观察规律。不要觉得自己会了就不画动手画一遍你才能发现哪些编号规律是你以为记住、其实根本记错的地方。5.3 比赛的代码风格建议最后说一个看似无关但很重要的点代码风格。GESP 六级开始题目代码量会明显增加如果你平时的代码又乱又长考试时找 bug 会非常痛苦。我建议从现在就养成一个习惯所有与“完全二叉树”相关的操作都封装成函数比如 getParent、getLeft、getRight、getDepth。不要小看这几个简单函数。一旦封装好了主函数的逻辑会变得非常清晰哪一步出了问题调试时一眼就能看出来。而且这些函数是通用的以后做堆、线段树甚至平衡树相关的题都能直接复用。我在洛谷上见过太多“一坨流”代码AC 了固然好但出了问题自己都看不下去。6. 实操心得这道题给我最大的启发如果让我总结 P15801 这类完全二叉树题最大的价值不是让你背会几个公式而是逼你建立“数据结构与存储方式联动”的思维方式。树是逻辑结构数组是物理存储完全二叉树恰好是两者完全对齐的特例。你越早理解这一点后面学堆、学线段树就越轻松。我实际带过的学生里有不少是“递归恐惧症”患者一看到树就条件反射想建结构体 Node然后 new 节点、指来指去代码又长又容易内存泄漏。其实在完全二叉树的场景数组是更自然的选择根本不需要指针。还有一个容易被忽略的心得洛谷题目的讨论区非常值得看。P15801 的题解区里有一些人用位运算压行有人用递归有人用循环每种做法都有不同的边界处理方式。把三四种做法都看一遍比自己闷头写十遍都管用。最后再分享一个小技巧。考试时如果被完全二叉树的题卡住不要慌先在草稿纸上写出编号 1 到 15 的节点然后手动模拟题目要的操作。你会发现规律就在那里只是刚才脑子短路了。这个方法我百试百灵强烈推荐。