☰
完全二叉树与数组下标:GESP六级核心考点与C++实现
2026/10/20 18:05:06 网站建设 项目流程

最近在洛谷刷题的时候,看到一道编号 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 历年的出题风格看,完全二叉树很少单独考“什么叫完全二叉树”这种概念题,更多是给你一些条件,让你反推树的形态或者计算节点数量。常见问法有:

  1. 已知完全二叉树的节点总数为 n,求树的高度。
  2. 已知某个节点的编号,输出它到根节点的路径。
  3. 已知前序遍历或层序遍历的序列,还原这棵完全二叉树。
  4. 判断一棵树(以数组形式给出)是否为完全二叉树。
  5. 计算最后一层叶子节点的数量。

P15801 这道题,我在提交记录里看到不少人卡在“父节点路径”这个点上,其实就是没有意识到每次 i /= 2 就能一路走到根。你只要在草稿纸上画一棵 7 个节点的完全二叉树,把编号 1 到 7 标上去,就能直观看到:8 号节点的父节点是 4,4 的父节点是 2,2 的父节点是 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 = 7,log2(7) 向下取整是 2,加 1 等于 3,恰好是 7 个节点时满二叉树的高度;n = 8,log2(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++ 里还有一个更快的等价写法:位运算。对正整数 n,floor(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,右孩子是 2i+1。如果孩子编号不超过 n,说明存在。

如果是输出根到某个叶子节点的路径,做法就是先找到叶子编号 k,然后不断 k /= 2 收集节点,最后反转输出。代码如下:

#include <bits/stdc++.h> using namespace std; int main() { long long n, k; cin >> n >> k; vector<long 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 开始,结果左孩子 20+1 = 1,右孩子 20+2 = 2,然后父节点又变成 (i-1)/2,每次都要小心地处理边界,代码写出来又长又容易错。

我的建议很明确:除非题目已经明确用 0 基下标给了数组(比如根节点在 tree[0]),否则一律改成 1 基下标。如果你读入的数据本身就是 0 基,那就先把数据整体往右挪一位,或者干脆用一个虚拟节点占位:

vector<int> 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 开 n+1
递归无限循环没有判断 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,它既不是严格意义的叶子(有孩子),也不能算有完整的两个孩子。题目如果问“输出所有左右孩子都存在的节点”,你就不能只判断 idx2+1 <= 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^7,cin/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 号节点放到新数组的 i+1 位,处理和输出时再用额外数组记录映射关系。

这样做的好处是逻辑部分完全复用你最熟悉的 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 的节点,然后手动模拟题目要的操作。你会发现规律就在那里,只是刚才脑子短路了。这个方法我百试百灵,强烈推荐。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询