完全二叉树与数组存储
极难2完全二叉树与数组存储:让树形结构像书架一样整齐
想象一下,你有一个长长的书架,每本书代表树中的一个节点。你想把树形结构的书整齐地放进书架,而且希望占用的位置尽量紧凑,没有空位。更神奇的是,当你需要找某本书的“左邻居”或“右邻居”时,只要知道书的序号,就能立刻算出它在哪。完全二叉树就是为这种“紧凑存储”而生的树形数据组织方式,而数组则是它的最佳搭档。
这种组合在计算机科学中非常重要,比如**堆(Heap)**数据结构就是基于数组存储的完全二叉树,堆排序、优先队列都靠它高效工作。今天我们就来揭开它的秘密。
生活中的例子:从排队到书架
书架上的书要排得密密的
如果你去图书馆借书,书架上每本书都紧挨着,中间没有空位。完全二叉树就像这种排法:除了最后一排(最底层),其他每一排都放得满满的;最后一排的书需要靠左对齐,中间不能有缺口。这样,整个书架就是连续的、没有浪费的空间。
班级里的座位号
想象一下,班级里同学按学号坐座位,学号1在最前面,学号2和3坐在第二排左侧和中间(如果第二排有4个座位,那么学号2、3、4、5坐满,学号6、7继续坐第三排)。这个学号顺序就是“层序编号”,完全二叉树用数组存储时,就相当于给每个节点分配一个唯一的“学号”,通过学号就能找到它的“同桌”(左、右孩子)和“班主任”(父节点)。
积木搭塔
你用积木搭一座塔,底层必须从左到右连续放置,不能有空缺。如果底层只有4块积木,第二层就只能放2块(左右各一块),第三层放1块(左边那一块的正上方)。这就是完全二叉树的形状——每一层从左到右铺满,只有最后一层可以不满,但必须集中在左边。
什么是完全二叉树?
完全二叉树(Complete Binary Tree) 的定义需要记住两个要点:
- 除最后一层外,其他所有层都是满的(即节点数达到最大值)。
- 最后一层的节点必须集中在左侧,中间不能有空洞。
举个例子,下图是一棵完全二叉树(节点旁边是它的层序编号,从1开始):
A (1)
/ \
B(2) C(3)
/ \ /
D(4) E(5) F(6)
这里:第1层有1个节点(满),第2层有2个节点(满),第3层有3个节点(不满,但节点都在左侧)。如果你想象在节点F右边再放一个节点G,它就会成为完全二叉树;如果F的右边是空的,但左边也有空位(比如D和E之间缺一个),那就不是完全二叉树了。因为最后一层必须连续靠左。
对比一棵非完全二叉树:
A
/ \
B C
\ / \
D E F
B节点只有一个右孩子D,左孩子缺失,这意味着D不是从左开始的最左边位置,所以这棵树不是完全二叉树。
为什么完全二叉树这么重要?
因为它的“紧凑性”可以让数组完美存储。如果树中任意一个节点缺失,数组里就必须留一个空位,导致空间浪费。而完全二叉树保证了没有“空洞”,所以我们可以用连续数组下标一一对应所有节点。
数组存储的规则:下标里藏着的数学秘密
假设我们有一个长度为 n 的数组,用来存放完全二叉树的 n 个节点。为了方便计算,我们让数组下标从1开始(即 tree[1] 存根节点,tree[0] 闲置或放辅助值)。那么对于任意下标 i(1 ≤ i ≤ n),有下面三个黄金公式:
- 父节点下标 =
i / 2(整数除法,即i // 2) - 左孩子下标 =
2 * i(如果2*i ≤ n,否则无左孩子) - 右孩子下标 =
2 * i + 1(如果2*i+1 ≤ n,否则无右孩子)
为什么公式长这样?因为层序遍历的顺序恰好给每个节点分配了连续的序号(就像班级的学号),而完全二叉树的结构保证了:对于第 i 个节点,它的左孩子一定是第 2*i 个,右孩子是第 2*i+1 个。你可以用上面的树验证一下:根节点1的左孩子是2(2=12),右孩子是3(3=12+1);节点2的左孩子是4(4=22),右孩子是5(5=22+1);节点3的左孩子是6(6=3*2),右孩子不存在(7 > n=6)。完美吻合!
如果从下标0开始(即 tree[0] 存根),公式会变成:
- 父节点 =
(i-1) / 2 - 左孩子 =
2*i + 1 - 右孩子 =
2*i + 2
两种都可以,但1-indexed(下标从1开始)的计算更整洁,因为整除没有减1。很多教材和算法(如堆)都采用1-indexed。本教程也使用1-indexed。
复习:用公式画个表格
| 节点下标 i | 节点值 | 父节点下标 (i/2) | 左孩子下标 (2*i) | 右孩子下标 (2*i+1) |
|---|---|---|---|---|
| 1 | A | 无(根) | 2 | 3 |
| 2 | B | 1 | 4 | 5 |
| 3 | C | 1 | 6 | 7(不存在) |
| 4 | D | 2 | 8(不存在) | 9(不存在) |
| 5 | E | 2 | 10(不存在) | 11(不存在) |
| 6 | F | 3 | 12(不存在) | 13(不存在) |
注意:当左孩子下标或右孩子下标超过总节点数n时,说明该孩子不存在。你写代码时一定要检查边界,否则会访问到数组外的位置,造成错误。
新手最容易犯的错误
错误1:下标越界(访问不存在的孩子)
比如我们上面例子中,节点3的右孩子下标是7,但n=6,所以不存在。如果代码中不检查 2*i+1 ≤ n,直接访问 tree[7] 就会发生下标越界(在C++中可能导致段错误,在Python中抛出IndexError)。一定要先判断再访问。
错误2:混淆1-indexed和0-indexed
很多新手在写代码时,一开始用下标1存根,但算孩子时却用了0-indexed的公式(比如左孩子 = 2i+1),或者反过来。选择一个统一标准并全程坚持。本教程采用的是1-indexed(根在tree[1]),所以左孩子=2i,右孩子=2*i+1。
错误3:父节点计算不对根进行特判
根节点(i=1)没有父节点,计算 i/2 = 0,这在1-indexed下会得到下标0,而数组下标0是闲置的。所以当 i==1 时,函数应该返回 -1 或抛出异常,不能直接返回 0。
错误4:用数组存储非完全二叉树时试图使用下标公式
如果树不是完全二叉树(比如节点B只有右孩子,左孩子空缺),那么用数组存储时,空缺处必须占位(比如存 null 或特殊值),但这时下标公式会出错(因为树不满足“连续”条件)。完全二叉树数组存储只适用于完全二叉树。
错误5:忘记调整树的深度
插入或删除节点后,数组大小要相应变化。在堆的实现中,我们通常使用动态数组(如C++的vector或Python的list)来方便扩展。
完整代码示例:C++和Python实现
C++代码(基于vector,下标从1)
#include <iostream>
#include <vector>
using namespace std;
class CompleteBinaryTree {
private:
vector<char> tree; // 存储节点的数组,下标0不用
int n; // 节点总数
public:
// 构造函数:传入按层序排列的节点值列表
CompleteBinaryTree(const vector<char>& values) {
n = values.size();
tree.resize(n + 1); // tree[0] 空着不用
for (int i = 0; i < n; ++i) {
tree[i + 1] = values[i]; // 把values[i]放到下标i+1
}
}
// 获取下标i的节点的左孩子下标,不存在返回-1
int leftChild(int i) {
int lc = 2 * i;
return (lc <= n) ? lc : -1;
}
// 获取右孩子下标
int rightChild(int i) {
int rc = 2 * i + 1;
return (rc <= n) ? rc : -1;
}
// 获取父节点下标,根节点返回-1
int parent(int i) {
if (i == 1) return -1; // 根节点没有父节点
return i / 2; // 整数除法
}
// 获取节点值,如果下标越界返回'?'
char get(int i) {
if (i < 1 || i > n) return '?';
return tree[i];
}
// 打印数组内容
void print() {
cout << "数组存储(下标从1): ";
for (int i = 1; i <= n; ++i) {
cout << tree[i] << " ";
}
cout << endl;
}
};
int main() {
// 构造一个完全二叉树,节点为 A,B,C,D,E,F
vector<char> values = {'A', 'B', 'C', 'D', 'E', 'F'};
CompleteBinaryTree cbt(values);
cbt.print();
cout << "根节点: " << cbt.get(1) << endl;
cout << "左孩子(下标2): " << cbt.get(cbt.leftChild(1)) << endl;
cout << "右孩子(下标3): " << cbt.get(cbt.rightChild(1)) << endl;
cout << "节点B(下标2)的父节点: " << cbt.get(cbt.parent(2)) << endl;
cout << "节点C(下标3)的左孩子(下标6): " << cbt.get(cbt.leftChild(3)) << endl;
cout << "节点C的右孩子下标: " << cbt.rightChild(3) << " (不存在,返回-1)" << endl;
return 0;
}
输出:
数组存储(下标从1): A B C D E F
根节点: A
左孩子(下标2): B
右孩子(下标3): C
节点B(下标2)的父节点: A
节点C(下标3)的左孩子(下标6): F
节点C的右孩子下标: -1 (不存在,返回-1)
Python代码(使用列表,下标从1)
class CompleteBinaryTree:
def __init__(self, values):
# values: 按层序排列的节点值列表
self.n = len(values)
self.tree = [None] * (self.n + 1) # tree[0] 闲置
for i in range(self.n):
self.tree[i + 1] = values[i]
def left_child(self, i):
lc = 2 * i
return lc if lc <= self.n else -1
def right_child(self, i):
rc = 2 * i + 1
return rc if rc <= self.n else -1
def parent(self, i):
if i == 1:
return -1
return i // 2 # 整数除法
def get(self, i):
if 1 <= i <= self.n:
return self.tree[i]
return '?'
def print_tree(self):
print("数组存储(下标从1):", self.tree[1:])
# 测试
values = ['A', 'B', 'C', 'D', 'E', 'F']
cbt = CompleteBinaryTree(values)
cbt.print_tree()
print("根节点:", cbt.get(1))
print("左孩子(下标2):", cbt.get(cbt.left_child(1)))
print("右孩子(下标3):", cbt.get(cbt.right_child(1)))
print("节点B(下标2)的父节点:", cbt.get(cbt.parent(2)))
print("节点C(下标3)的左孩子(下标6):", cbt.get(cbt.left_child(3)))
print("节点C的右孩子下标:", cbt.right_child(3), "(不存在)")
输出与C++一致。
堆的简单认识:完全二叉树数组存储的黄金应用
堆(Heap) 是一种特殊的完全二叉树,它满足一个性质:每个父节点都不小于(或不大于)它的左右孩子。根据这个性质,堆分为两种:
- 最大堆:每个父节点 ≥ 它的子节点。最大值在根。
- 最小堆:每个父节点 ≤ 它的子节点。最小值在根。
堆使用数组存储完全二叉树,因此插入、删除堆顶等操作都依赖下标公式。例如,向最小堆插入一个新元素时,先放到数组末尾(保持完全二叉树结构),然后不断“上浮”(与父节点比较,如果比父节点小就交换),直到满足堆性质。这个过程的时间复杂度是 O(log n),非常快。
堆排序和优先队列是堆的两大经典应用。优先队列就像生活中排队时VIP可以插队:每次从队列中取出优先级最高的元素(最大堆的堆顶),而插入时自动调整顺序。这些底层都基于完全二叉树的数组存储。
在后续课程中,你会详细学习堆的插入、删除、建堆等操作。你现在只需要记住:堆就是“住在数组里的完全二叉树”。
总结与相关指引
要点回顾
- 完全二叉树:除最后一层外全部满,最后一层节点靠左连续。
- 数组存储:下标从1开始,根在tree[1]。
- 下标公式:左孩子=2i,右孩子=2i+1,父节点=i/2。
- 优点:紧凑、O(1)访问父子,无需指针。
- 限制:只能表示完全二叉树,否则浪费空间。
- 应用:堆、优先队列、线段树(部分实现)、某些B树变种。
下一步学习
- 二叉堆:了解最大堆、最小堆的插入和删除操作。
- 堆排序:利用堆在O(n log n)时间内排序数组。
- 优先队列:C++的
priority_queue,Python的heapq。 - 完全二叉树的其他变种:比如斐波那契堆(但那是更高级的话题了)。
试着在纸上画一棵完全二叉树,给每个节点标上数组下标,然后手动运行代码输出,你就能彻底掌握这些公式了。记住:树和数组,只是同一个数据结构的两种不同表现方式而已。
例题精讲
在完全二叉树的数组存储中,若根节点存储在数组下标0的位置,那么对于下标为i的节点(i≥0),其右子节点的下标计算公式是?
对于一个用数组存储的完全二叉树(根下标为0),数组长度为n,请问最后一个非叶子节点的下标是多少?
在完全二叉树的数组存储中,若根节点存放在下标1的位置,则下标为i的节点的左子节点下标为2i。
以下函数实现将数组下标为i的元素向下调整(最大堆),假设堆的大小为heapSize,根节点下标为0。请在空白处填写正确代码。
void siftDown(int arr[], int heapSize, int i) {
int largest = i;
int left = ___;
int right = 2*i + 2;
if (___ && arr[left] > arr[largest]) {
largest = left;
}
if (right < heapSize && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
swap(arr[i], arr[largest]);
siftDown(arr, heapSize, largest);
}
}已知一个完全二叉树利用数组存储(根下标为1),现在要对堆中下标为i的节点进行插入操作(上浮调整),需要不断与其父节点比较,父节点的下标是?