📖 引入故事:图书馆的“智能书架”
学校图书馆有 1000 排书架。管理员想给每排书架编个号,使得只要知道编号 i,就能立刻算出它的上一排(双亲)和左右两排(孩子)。
普通编号(1,2,3…随便排)做不到这一点。但如果按“一层一层、从左到右”的方式编号——就像完全二叉树那样——神奇的事发生了:
- 双亲编号 = ⌊i / 2⌋(向下取整)
- 左孩子 = 2 × i,右孩子 = 2 × i + 1
也就是说:树的结构不用“指针”,只用数组下标就全记下来了。这就是本讲的主角——完全二叉树的数组表示法。
掌握 2i / 2i+1 / ⌊i/2⌋ 的编号魔法,理解完全二叉树为什么能“数组化”,并初识堆。
从“给书架编号”出发,理解为什么完全二叉树可以只用一个数组表示,以及它带来的编号魔法。
学校图书馆有 1000 排书架。管理员想给每排书架编个号,使得只要知道编号 i,就能立刻算出它的上一排(双亲)和左右两排(孩子)。
普通编号(1,2,3…随便排)做不到这一点。但如果按“一层一层、从左到右”的方式编号——就像完全二叉树那样——神奇的事发生了:
也就是说:树的结构不用“指针”,只用数组下标就全记下来了。这就是本讲的主角——完全二叉树的数组表示法。
全家拍照,规定“长辈站第一排,子女站父母正下方、先左后右”。如果每一排都站满、最后一排从左往右连续站,就是“完全”的站位;中间一旦有人缺席又有人在更右边,就“不完全”了。完全二叉树就是这个规则。
这种“填满再开下一层”的规则,让我们可以用一个数组 a[1..n] 把整张“合影”存下来,而且任意人的位置都能由编号算出。
完全二叉树(Complete Binary Tree):一棵深度为 k 的二叉树,其前 k-1 层都是满的(每个结点都有两个孩子),第 k 层(最后一层)的结点从左往右连续排布、中间没有空位。
数组表示法:按层序(从上到下、同层从左到右)给结点编号 1,2,…,n,存入数组 a[1..n]。则结点 i 的双亲、左孩子、右孩子的下标分别是 ⌊i/2⌋、2i、2i+1(1-based)。
“完全才能数组存,2i、2i+1 管孩子,⌊i/2⌋ 管双亲;满二叉树是特例,堆也靠它立。”
| 知识点 | 你要掌握 | 难度 |
|---|---|---|
| 完全二叉树定义 | 前 k-1 层满、最后一层左连续 | ★ |
| 数组编号公式(1-based / 0-based) | 双亲 ⌊i/2⌋、孩子 2i/2i+1 | ★ |
| 满二叉树 vs 完全二叉树 | 满 ⊂ 完全;反之不成立 | ★ |
| 深度与最后非叶子结点 | k=⌊log₂n⌋+1;⌊n/2⌋ | ★★ |
| 判断完全二叉树 | 层序遇空后再有非空 → 否 | ★★ |
| 堆(大/小顶堆)与数组 | 满足堆性质的完全二叉树(拓展) | ★★★ |
完全二叉树为什么能“只用一个数组”表示?看编号公式、1-based/0-based 差异、满与完全的关系,以及数组里的堆。
下面两棵树同样是 10 / 约 10 个结点,但只有左边是完全二叉树。
判断口诀:从根开始层序编号,一旦遇到“空位”(某个该有的孩子不存在),后面所有位置都必须也是空;否则就不是完全二叉树。
把上图完全二叉树按层序存入 a[1..10],观察每个结点的双亲、左右孩子在数组里的下标:
#include <iostream>
#include <cmath>
using namespace std;
int main() {
int a[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 1-based,a[0] 弃用,共 10 个结点
int n = 10;
cout << "i 层号 双亲 左孩子 右孩子 是否叶子" << endl;
for (int i = 1; i <= n; i++) {
int depth = (int)log2(i) + 1; // 第几层
int p = i / 2; // 双亲 = ⌊i/2⌋
int lc = 2 * i; // 左孩子 = 2i
int rc = 2 * i + 1; // 右孩子 = 2i+1
bool leaf = (lc > n);
cout << i << " " << depth << " " << p << " "
<< (lc <= n ? lc : 0) << " "
<< (rc <= n ? rc : 0) << " "
<< (leaf ? "是" : "否") << endl;
}
cout << "最后一个非叶子结点下标 = " << n / 2
<< "(即结点 " << a[n / 2] << ")" << endl;
cout << "层序遍历 = 数组顺序:";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
return 0;
}
i 层号 双亲 左孩子 右孩子 是否叶子
1 1 0 2 3 否
2 2 1 4 5 否
3 2 1 6 7 否
4 3 2 8 9 否
5 3 2 10 0 否
6 3 3 0 0 是
7 3 3 0 0 是
8 4 4 0 0 是
9 4 4 0 0 是
10 4 5 0 0 是
最后一个非叶子结点下标 = 5(即结点 5)
层序遍历 = 数组顺序:1 2 3 4 5 6 7 8 9 10 规律一目了然:第 i 个结点的左孩子在 2i、右孩子在 2i+1、双亲在 ⌊i/2⌋。并且层序遍历 = 数组顺序,所以遍历完全二叉树只要一个 for 循环!
很多比赛题用 0-based(下标从 0 开始)。公式要改,但思想一样:
#include <iostream>
#include <cmath>
using namespace std;
int main() {
int n = 10; // 0-based 编号,共 10 个结点
cout << "i 层号 双亲 左孩子 右孩子" << endl;
for (int i = 0; i < n; i++) {
int depth = (int)log2(i + 1) + 1;
int p = (i == 0) ? -1 : (i - 1) / 2; // 双亲 = ⌊(i-1)/2⌋
int lc = 2 * i + 1; // 左孩子 = 2i+1
int rc = 2 * i + 2; // 右孩子 = 2i+2
cout << i << " " << depth << " " << p << " "
<< (lc < n ? lc : -1) << " "
<< (rc < n ? rc : -1) << endl;
}
return 0;
}
i 层号 双亲 左孩子 右孩子
0 1 -1 1 2
1 2 0 3 4
2 2 0 5 6
3 3 1 7 8
4 3 1 9 -1
5 3 2 -1 -1
6 3 2 -1 -1
7 4 3 -1 -1
8 4 3 -1 -1
9 4 4 -1 -1| 约定 | 双亲 | 左孩子 | 右孩子 |
|---|---|---|---|
| 1-based(a[0]弃用) | ⌊i/2⌋ | 2i | 2i+1 |
| 0-based | i=0 时无双亲;i>0 时 ⌊(i-1)/2⌋ | 2i+1 | 2i+2 |
⚠️ 用哪种约定,前后必须一致。写题时先定好,再代入公式,别混用!
满二叉树:每一层的结点数都达到最大(第 k 层有 2k-1 个),即每个非叶子结点都有两个孩子。
#include <iostream>
#include <cmath>
using namespace std;
int main() {
int n = 15; // 满二叉树,15 个结点
int k = (int)log2(n) + 1; // 深度 = ⌊log2 n⌋ + 1
cout << "n = " << n
<< " 深度 = " << k
<< " 最后一个非叶子结点下标 = " << n / 2 << endl;
return 0;
}
n = 15 深度 = 4 最后一个非叶子结点下标 = 7满二叉树 n 个结点,深度 k = ⌊log₂n⌋+1;最后一个非叶子结点下标 = ⌊n/2⌋。这两个公式对所有完全二叉树都成立。
“堆”就是一棵满足堆性质的完全二叉树:每个结点的值都 ≥(大顶堆)或 ≤(小顶堆)它的两个孩子。因为是完全二叉树,堆天生用数组存——这正是优先队列(priority_queue)的底层。
#include <iostream>
using namespace std;
void down(int a[], int n, int i) { // 大顶堆向下调整(1-based)
int largest = i;
int l = 2 * i, r = 2 * i + 1;
if (l <= n && a[l] > a[largest]) largest = l;
if (r <= n && a[r] > a[largest]) largest = r;
if (largest != i) {
int t = a[i]; a[i] = a[largest]; a[largest] = t; // 手动交换
down(a, n, largest);
}
}
int main() {
int a[] = {0, 4, 10, 3, 5, 1, 9, 7, 6, 8, 2}; // 1-based,n = 10
int n = 10;
cout << "建堆前:";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
for (int i = n / 2; i >= 1; i--) down(a, n, i); // 从最后一个非叶子 ⌊n/2⌋ 起
cout << "建堆后(大顶堆):";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
return 0;
}
建堆前:4 10 3 5 1 9 7 6 8 2
建堆后(大顶堆):10 8 9 6 2 3 7 4 5 1 关键事实:最后一个非叶子结点下标 = ⌊n/2⌋。建堆时从 ⌊n/2⌋ 往前,对每个结点做“向下调整”,就能把任意数组变成大顶堆。向下调整的核心:比较自己与左右孩子,若不如最大的孩子大,就交换,再继续往下。
📌 堆排序、优先队列都以“完全二叉树 + 数组”为基础;完整堆排序属拓展内容(见模块四)。
三道分析编程题:判断完全二叉树、求结点所在层与祖先、建大顶堆。均给出可编译运行的参考实现。
给定层序序列(# 表示空),判断它能否构成完全二叉树。核心:一旦遇到空结点,后面就不能再出现非空结点。
#include <iostream>
#include <string>
#include <queue>
using namespace std;
struct Node { int val; Node *l, *r; Node(int v) : val(v), l(nullptr), r(nullptr) {} };
// 按层序序列(# 表示空)建树,判断是否为完全二叉树
bool isComplete(const string s[], int n) {
queue<Node*> q;
q.push(new Node(stoi(s[0])));
int idx = 1;
bool seenNull = false;
while (!q.empty()) {
Node* cur = q.front(); q.pop();
if (idx < n) { // 左孩子
if (s[idx] == "#") { cur->l = nullptr; seenNull = true; }
else { cur->l = new Node(stoi(s[idx])); q.push(cur->l); if (seenNull) return false; }
idx++;
}
if (idx < n) { // 右孩子
if (s[idx] == "#") { cur->r = nullptr; seenNull = true; }
else { cur->r = new Node(stoi(s[idx])); q.push(cur->r); if (seenNull) return false; }
idx++;
}
}
return true;
}
int main() {
string s1[] = {"1","2","3","4","5","6","7","8","9","10"};
int n1 = sizeof(s1) / sizeof(s1[0]);
cout << "示例1 层序(1..10):是否完全二叉树 → "
<< (isComplete(s1, n1) ? "是" : "否") << endl;
string s2[] = {"1","2","3","#","#","6","7"};
int n2 = sizeof(s2) / sizeof(s2[0]);
cout << "示例2 层序(1 2 3 # # 6 7):是否完全二叉树 → "
<< (isComplete(s2, n2) ? "是" : "否") << endl;
return 0;
}
示例1 层序(1..10):是否完全二叉树 → 是
示例2 层序(1 2 3 # # 6 7):是否完全二叉树 → 否思路:用队列按层序建树,设标志 seenNull。遇到空孩子就置位;之后再遇到非空孩子直接判否。
完全二叉树用数组存,求某结点 i 在第几层、以及从根到它的祖先链。层号 = ⌊log₂i⌋+1;祖先链反复 i = ⌊i/2⌋ 直到 0。
#include <iostream>
#include <cmath>
using namespace std;
int main() {
int a[] = {0, 1, 2, 3, 4, 5, 6, 7}; // 1-based,共 7 个结点(满二叉树)
int n = 7;
int k = (int)log2(n) + 1;
cout << "层序遍历(= 数组顺序):";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
cout << "深度 = " << k
<< " 最后一个非叶子结点下标 = " << n / 2
<< "(结点 " << a[n / 2] << ")" << endl;
cout << "i 双亲 左孩子 右孩子" << endl;
for (int i = 1; i <= n; i++) {
int p = i / 2, lc = 2 * i, rc = 2 * i + 1;
cout << i << " " << p << " "
<< (lc <= n ? lc : 0) << " "
<< (rc <= n ? rc : 0) << endl;
}
return 0;
}
层序遍历(= 数组顺序):1 2 3 4 5 6 7
深度 = 3 最后一个非叶子结点下标 = 3(结点 3)
i 双亲 左孩子 右孩子
1 0 2 3
2 1 4 5
3 1 6 7
4 2 0 0
5 2 0 0
6 3 0 0
7 3 0 0注意层序遍历直接等于数组顺序,所以“输出整棵树”就是一个普通 for 循环——这是数组表示法最大的便利。
给定数组,从最后一个非叶子结点 ⌊n/2⌋ 起向前,对每个结点调用 down 向下调整,把数组整理成大顶堆。
#include <iostream>
using namespace std;
void down(int a[], int n, int i) { // 大顶堆向下调整(1-based)
int largest = i;
int l = 2 * i, r = 2 * i + 1;
if (l <= n && a[l] > a[largest]) largest = l;
if (r <= n && a[r] > a[largest]) largest = r;
if (largest != i) {
int t = a[i]; a[i] = a[largest]; a[largest] = t; // 手动交换
down(a, n, largest);
}
}
int main() {
int a[] = {0, 4, 10, 3, 5, 1, 9, 7, 6, 8, 2}; // 1-based,n = 10
int n = 10;
cout << "建堆前:";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
for (int i = n / 2; i >= 1; i--) down(a, n, i); // 从最后一个非叶子 ⌊n/2⌋ 起
cout << "建堆后(大顶堆):";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
cout << endl;
return 0;
}
建堆前:4 10 3 5 1 9 7 6 8 2
建堆后(大顶堆):10 8 9 6 2 3 7 4 5 1 要点:down(a,n,i) 只负责“以 i 为根的子树”满足大顶堆;从 ⌊n/2⌋ 到 1 依次调用,整棵树就满足堆性质。时间复杂度 O(n)。
把核心公式、易混概念、拓展方向一次性理清。
| 结论 | 公式 / 说明 |
|---|---|
| 完全二叉树定义 | 前 k-1 层满,第 k 层左连续无空位 |
| 1-based 双亲 / 左 / 右 | ⌊i/2⌋ / 2i / 2i+1 |
| 0-based 双亲 / 左 / 右 | i>0 时 ⌊(i-1)/2⌋ / 2i+1 / 2i+2(i=0 无双亲) |
| 深度 k | k = ⌊log₂n⌋ + 1 |
| 最后非叶子结点下标 | ⌊n/2⌋ |
| 满 ⊂ 完全 | 满二叉树是完全二叉树的特例 |
| 层序遍历 | = 数组顺序,for 循环即可 |
| 堆 | 满足堆性质的完全二叉树;大顶堆父 ≥ 子 |
priority_queue 底层就是堆(默认大顶堆);小顶堆用 greater<int>。⚠️ 以上属提高/拓展,CSP-J 初赛与复赛均不要求会写堆排序,但理解“完全二叉树可用数组存”对后续学习非常关键。
5 大易错点 + 速查表 + 笔记卡,考前一眼回顾。
把“完全二叉树”记成“满二叉树”。满 ⊂ 完全,完全二叉树最后一层可以没填满,只要左连续。
孩子公式用错:写成 2i+1、2i+2(那是 0-based)。1-based 是 2i、2i+1,别混。
⌊i/2⌋ 忘记向下取整,或 0-based 时 i=0 还去求双亲导致越界。先判断 i 是否为根。
判断完全二叉树只看“结点数”,不看“位置”。关键是层序遇空后再有非空即否。
建堆从 1 或 n 开始向下调整。正确起点是最后一个非叶子结点 ⌊n/2⌋,向前到 1。
| 需求 | 1-based | 0-based |
|---|---|---|
| 双亲 | ⌊i/2⌋ | i>0:⌊(i-1)/2⌋;i=0 无 |
| 左孩子 | 2i | 2i+1 |
| 右孩子 | 2i+1 | 2i+2 |
| 最后非叶子 | ⌊n/2⌋ | ⌊(n-2)/2⌋(即 ⌊n/2⌋-1) |
| 深度 | ⌊log₂n⌋+1 | |
完全二叉树 = 数组存储的最爱
① 定义:前 k-1 层满,第 k 层左连续无空位。
② 公式(1-based):双亲 ⌊i/2⌋,左 2i,右 2i+1。
③ 层序遍历 = 数组顺序。
④ 满二叉树 ⊂ 完全二叉树。
⑤ 堆 = 满足堆性质的完全二叉树,最后非叶子 = ⌊n/2⌋。
A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。
画图说明两种情形:满二叉树(每层都满)必然也是完全二叉树;完全二叉树(最后一层没填满)通常不是满二叉树。
满二叉树一定是完全二叉树(✔);完全二叉树不一定是满二叉树(✘)。例如 n=3 的完全二叉树(根+左右孩子,最后一层未满)是完全但不是满。
考点:满二叉树每层都填满,必然满足完全二叉树“从上到下、从左到右连续排列”的要求;反过来结点数没凑满 2^k-1 时,完全但不满。
易错:把二者当成等价概念,忽略 n=3 这种“是完全但不满”的情形。
直接套公式即可。
双亲 = ⌊i/2⌋,左孩子 = 2i,右孩子 = 2i+1(若超出 n 则不存在)。
思路:1-based 编号下父子关系直接由下标给出:双亲 = ⌊i/2⌋,左孩子 = 2i,右孩子 = 2i+1,孩子下标超过 n 就说明不存在。
易错:把左右孩子记成 2i-1、2i,或忘记检查 2i、2i+1 是否越界(> n)。
层序编号 1..7 本身就是值。
a=[_,1,2,3,4,5,6,7];双亲:2,3 的双亲是 1;4,5 的双亲是 2;6,7 的双亲是 3。
思路:满二叉树按层序编号后正好填满 a[1..7],对每个非根结点 i,它的双亲就是 ⌊i/2⌋。
易错:误用 0-based 下标去套公式,双亲结果全部偏一位。
套 k=⌊log₂n⌋+1 与 ⌊n/2⌋。
k=⌊log₂15⌋+1=3+1=4;最后非叶子=⌊15/2⌋=7。
思路:深度 k = ⌊log2 15⌋ + 1 = 4;最后一个非叶子结点满足 2i ≤ n,即 i = ⌊n/2⌋ = 7。
易错:把“最后一个非叶子结点”当成最后一个结点 15,或求深度时忘记加 1。
对比 1-based 推导:根编号为 0,没双亲。
i>0 时双亲 = ⌊(i-1)/2⌋,左 = 2i+1,右 = 2i+2;i=0 是根,没有双亲(代码中要先判断避免越界)。
思路:0-based 下标相当于把 1-based 整体平移 1:左孩子 = 2i+1,右孩子 = 2i+2,双亲 = ⌊(i-1)/2⌋;i = 0 是根,没有双亲。
易错:照搬 1-based 的 2i、2i+1,或对 i = 0 直接计算 ⌊(0-1)/2⌋ 造成负数越界。
按层序编号后看“空位之后是否还有非空”。
不是。编号 4、5 是空的,但编号 6、7(结点 6、7)非空,出现“空位后有非空”,违反完全二叉树定义。
考点:完全二叉树的层序编号中间不能出现“有结点—空位—又有结点”。这里 4、5 为空而 6、7 非空,编号不连续,故不是完全二叉树。
易错:只看“每个结点至多两个孩子”就判成完全二叉树,忘了编号连续这一要求。
用 2i 与 ⌊log₂i⌋+1。
左孩子 = 2×5 = 10(存在);层号 = ⌊log₂5⌋+1 = 2+1 = 3 层。
思路:结点 5 的左孩子 = 2×5 = 10,10 ≤ n 故存在;它的层号 = ⌊log2 5⌋ + 1 = 3。
易错:把层号算成 ⌊log2 5⌋ = 2,漏掉公式里的 +1。
比较自己与左右孩子,不够大就下沉。
取 i 与左右孩子中值最大者;若最大者不是 i,交换并使 i=最大者,递归对原最大者位置继续 down,直到 i 最大或越界。
思路:down 从结点 i 出发,比较它与左右孩子哪个更大;若最大者不是 i 就交换,并让 i 移到原最大者的位置继续向下调整,直到 i 本身最大或孩子不存在。
易错:只和其中一个孩子比较(漏掉另一个),或交换一次就收手,没有把影响继续往下传递。
从“最后一个结点 n 的双亲是 ⌊n/2⌋”切入。
结点 n 的双亲是 ⌊n/2⌋。由于完全二叉树结点编号连续,n 的双亲是“最后一个有孩子的结点”;再往后编号 >⌊n/2⌋ 的结点都没有孩子(是叶子)。故最后非叶子 = ⌊n/2⌋。
思路:结点 n 的双亲是 ⌊n/2⌋;所有编号大于 ⌊n/2⌋ 的结点都满足 2i > n、没有孩子,是叶子。因此编号最大的有孩子结点就是 ⌊n/2⌋。
易错:混淆“有孩子的结点”与“叶子”的编号范围,把最后一个非叶子结点说成 n。
用各层最大结点数求和。
前 k-1 层最多 2^(k-1)-1 个,加上第 k 层至少 1 个、最多 2^(k-1) 个,合计 2^(k-1) ≤ n ≤ 2^k-1。满二叉树第 k 层也满,n=2^k-1,取上界。
思路:前 k-1 层最多 2^(k-1) - 1 个结点;第 k 层最少 1 个、最多 2^(k-1) 个,两段相加即得 2^(k-1) ≤ n ≤ 2^k - 1。满二叉树第 k 层也是满的,故取到上界。
易错:把第 k 层的最大结点数写成 2^k(应是 2^(k-1)),导致上界偏大。
复用判断是否完全的算法,同时收集非空值到数组。
按层序读入,遇空标记 seenNull;非空结点依次存入 a[++cnt],若 seenNull 已为真则输出“否”。最后输出 a[1..cnt]。
思路:按层序读入,用 seenNull 记录“此前是否已出现过空位”:读到 # 就置真,之后若再读到非 # 就说明编号不连续,输出“否”;非空结点顺序存入 a[++cnt]。
易错:只在“空位的孩子位置出现非空”时判否,漏掉了“空位之后又冒出别的非空结点”这一更一般的情形。
实现 down 函数,循环 i=⌊n/2⌋..1。
实现 down(a,n,i):比较 a[i] 与左右孩子,必要时交换并递归;主程序 for(i=n/2;i>=1;i--) down(a,n,i);输出 a[1..n]。
思路:从最后一个非叶子结点 ⌊n/2⌋ 开始倒序到 1,对每个 i 做一次 down;down 内部比较 a[i] 与左右孩子,若孩子更大就交换并继续向下调整。
易错:从 1 开始正向调整(应从 ⌊n/2⌋ 倒序),或忘记只有 2i ≤ n 时才存在孩子可比较。