CC++ & Algorithm

完全二叉树与数组存储

极难2
语言版本:通用
概述:通过“填格子”的比喻,讲解完全二叉树如何用连续的数组完美存放,并实现父子下标的快速计算,附带堆的初步认识。

完全二叉树与数组存储:让树形结构像书架一样整齐

想象一下,你有一个长长的书架,每本书代表树中的一个节点。你想把树形结构的书整齐地放进书架,而且希望占用的位置尽量紧凑,没有空位。更神奇的是,当你需要找某本书的“左邻居”或“右邻居”时,只要知道书的序号,就能立刻算出它在哪。完全二叉树就是为这种“紧凑存储”而生的树形数据组织方式,而数组则是它的最佳搭档。

这种组合在计算机科学中非常重要,比如**堆(Heap)**数据结构就是基于数组存储的完全二叉树,堆排序、优先队列都靠它高效工作。今天我们就来揭开它的秘密。


生活中的例子:从排队到书架

书架上的书要排得密密的

如果你去图书馆借书,书架上每本书都紧挨着,中间没有空位。完全二叉树就像这种排法:除了最后一排(最底层),其他每一排都放得满满的;最后一排的书需要靠左对齐,中间不能有缺口。这样,整个书架就是连续的、没有浪费的空间。

班级里的座位号

想象一下,班级里同学按学号坐座位,学号1在最前面,学号2和3坐在第二排左侧和中间(如果第二排有4个座位,那么学号2、3、4、5坐满,学号6、7继续坐第三排)。这个学号顺序就是“层序编号”,完全二叉树用数组存储时,就相当于给每个节点分配一个唯一的“学号”,通过学号就能找到它的“同桌”(左、右孩子)和“班主任”(父节点)。

积木搭塔

你用积木搭一座塔,底层必须从左到右连续放置,不能有空缺。如果底层只有4块积木,第二层就只能放2块(左右各一块),第三层放1块(左边那一块的正上方)。这就是完全二叉树的形状——每一层从左到右铺满,只有最后一层可以不满,但必须集中在左边。


什么是完全二叉树?

完全二叉树(Complete Binary Tree) 的定义需要记住两个要点:

  1. 除最后一层外,其他所有层都是满的(即节点数达到最大值)。
  2. 最后一层的节点必须集中在左侧,中间不能有空洞

举个例子,下图是一棵完全二叉树(节点旁边是它的层序编号,从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)
1A无(根)23
2B145
3C167(不存在)
4D28(不存在)9(不存在)
5E210(不存在)11(不存在)
6F312(不存在)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
  • 完全二叉树的其他变种:比如斐波那契堆(但那是更高级的话题了)。

试着在纸上画一棵完全二叉树,给每个节点标上数组下标,然后手动运行代码输出,你就能彻底掌握这些公式了。记住:树和数组,只是同一个数据结构的两种不同表现方式而已。

例题精讲

1单选题

在完全二叉树的数组存储中,若根节点存储在数组下标0的位置,那么对于下标为i的节点(i≥0),其右子节点的下标计算公式是?

A2*i+1
B2*i+2
C2*i
Di/2(向下取整)
2单选题

对于一个用数组存储的完全二叉树(根下标为0),数组长度为n,请问最后一个非叶子节点的下标是多少?

An/2 - 1(向下取整)
Bn/2(向下取整)
Cn/2 + 1
Dn-1
3判断题

在完全二叉树的数组存储中,若根节点存放在下标1的位置,则下标为i的节点的左子节点下标为2i。

4填空题
以下函数实现将数组下标为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);
    }
}
5单选题

已知一个完全二叉树利用数组存储(根下标为1),现在要对堆中下标为i的节点进行插入操作(上浮调整),需要不断与其父节点比较,父节点的下标是?

Ai/2(整除)
B(i-1)/2(向下取整)
Ci-1
Di*2