CC++ & Algorithm

完全二叉树与数组表示

困难4
语言版本:C++
概述:完全二叉树可以用数组高效存储,父节点下标i,左孩子下标2i,右孩子下标2i+1。

完全二叉树与数组表示:像排队一样高效存储

你有没有遇到过这样的场景:老师让全班同学按顺序坐成一个“三角形”座位,第一排1个座位,第二排2个,第三排4个……但是今天只有很少的同学来,座位从左到右连续坐满,空位都集中在右边。这种“左边满、右边可能空”的树,就是完全二叉树

计算机里经常要处理这种树,比如堆排序、优先队列(就是操作系统里任务调度的那个“优先队列”)。如果用指针建树,每个节点都要存左右孩子地址,很占内存。但是完全二叉树有一个超棒的规律:用数组就能存,通过下标快速找到父子关系。这篇笔记就来仔细讲讲这个技巧。


1. 什么是完全二叉树?——从“满二叉树”说起

先复习一下满二叉树:每一层的节点数都达到最大值。比如高度为3的满二叉树,第1层1个,第2层2个,第3层4个,总共7个节点。

完全二叉树就是:从满二叉树里,从右边开始去掉一些节点,但去掉的顺序必须是从右往左连续地去掉。换句话说,最后一层的节点都集中在左边,右边可以空着。

举个例子:有5个节点的完全二叉树,长这样:

       1
      / \
     2   3
    / \
   4   5

第1层1个,第2层2个,第3层本来是4个,但这里只有左边2个(4和5),右边两个位置空着。如果第3层节点是4,6(左边是4,右边是6),中间空了5,那就不是完全二叉树——因为没有连续从左到右。

生活中的类比:班级座位编号。老师要求所有同学按学号从左到右、从前到后坐。第1排1号,第2排2、3号,第3排4、5、6、7号……如果某排最后几个座位没人,但只要前面的座位都坐满了(没有跳号),这就是完全二叉树。你按学号找座位,知道排数和位置,非常方便。


2. 数组表示法:利用下标关系“指路”

如果我们把完全二叉树的节点按层序遍历(先第一层,再第二层,逐层从左到右)编号,从1开始,那么会得到一个神奇的规律:

  • 根节点下标是 1
  • 下标为 i 的节点,它的左孩子下标是 2*i
  • 下标为 i 的节点,它的右孩子下标是 2*i + 1
  • 下标为 i 的节点,它的父节点下标是 i / 2(整数除法,向下取整)

为什么这么巧?因为层序遍历的编号顺序,正好让左孩子排在 2i 的位置(把当前层所有节点放完两倍,恰好跳到下一层左边第一个)。我们可以用数学归纳法简单理解:根为1,左孩子2,右孩子3;然后节点2的左孩子4,右孩子5……确实每个节点都能用这个公式找到。

用数组存储的好处

  • 不需要存指针(left、right),省内存
  • 通过下标计算直接访问亲戚节点,速度超快
  • 堆排序、优先队列底层都用这种数组实现

注意:下标从0还是从1?

  • 如果数组下标从 0 开始,公式会变成:左孩子 2*i+1,右孩子 2*i+2,父节点 (i-1)/2
  • 但为了直观,许多教材和竞赛代码(特别是CSP-J)习惯从下标1开始(浪费tree[0]不用)。我们下面都采用从1开始。

3. 生活中的例子:电影院找座位

假设电影院有3排,第1排1个座位(1号),第2排2个(2、3号),第3排4个(4、5、6、7号)。如果只坐了前5个观众(1~5号),那么第3排的6、7号空着。这时候,座位编号就构成了一个完全二叉树。

  • 你是3号观众,你左边是2号(兄弟),右边?没有,因为树里3号没有右兄弟(完全二叉树最后一层右边可能缺)。
  • 你想找4号观众(你的左孩子的左孩子):根据公式,3号的左孩子是2*3=6?不对,6号是空座位!所以这里要小心:如果你要访问的孩子下标超过了节点总数n,那么它不存在

这个小例子提醒我们:用数组存二叉树时,一定要检查下标是否越界


4. 新手容易踩的坑

错误1:数组开得不够大

完全二叉树最多有 2^h - 1 个节点(满二叉树),但实际可能只有 n 个。如果按公式 2*i2*i+1 可能达到 2n 甚至更大,所以数组大小要开到 至少 n+1,最好开到 2n 以上 才能安全访问孩子节点(尤其在建堆时)。一般开 4*n 或者 MAXN*2 比较保险。

错误2:忘记判断孩子是否存在

写代码找左孩子时,如果 2*i > n,就说明没有左孩子。直接访问 tree[2*i] 会越界或读到垃圾值。一定要先判断下标是否在 1~n 范围内。

错误3:混淆下标0和下标1

如果从0开始存,公式不同,别混用。建议统一风格,要么都用1,要么都用0,不要在同一段代码里换来换去。


5. 完整代码示例:模拟班级座位查找

我们用数组存储一个完全二叉树(班级座位号),实现层序遍历、找父亲、找孩子,并加入越界检查。

#include <iostream>
using namespace std;

// 打印层序遍历(其实就是数组按顺序输出)
void printLevelOrder(int tree[], int n) {
    cout << "层序遍历(学号顺序): ";
    for (int i = 1; i <= n; i++) {
        cout << tree[i] << " ";
    }
    cout << endl;
}

// 查找节点下标 i 的左孩子(如果存在),返回下标,否则返回 -1
int leftChild(int i, int n) {
    int lc = 2 * i;
    if (lc <= n) return lc;
    else return -1;  // 不存在
}

// 查找节点下标 i 的右孩子(如果存在)
int rightChild(int i, int n) {
    int rc = 2 * i + 1;
    if (rc <= n) return rc;
    else return -1;
}

// 查找节点下标 i 的父节点(根节点没有父亲)
int parent(int i) {
    if (i == 1) return -1;   // 根节点没有父节点
    return i / 2;            // 整数除法,自动向下取整
}

int main() {
    int n = 6;  // 节点数量(班级有6个学生)
    // tree[1]~tree[6] 存储学号(也可以存储值)
    // 为了演示,直接用学号作为节点值
    int seats[7] = {0, 101, 102, 103, 104, 105, 106}; // seats[0] 不用

    // 树结构(完全二叉树):
    //       101
    //      /   \
    //   102    103
    //   / \    /
    // 104 105 106
    // 注意:103只有左孩子106,没有右孩子

    printLevelOrder(seats, n);

    // 演示:访问节点 102(下标2)的亲戚
    int idx = 2;
    cout << "节点 " << seats[idx] << " 的左孩子: ";
    int lc = leftChild(idx, n);
    if (lc != -1) cout << seats[lc] << " (下标" << lc << ")" << endl;
    else cout << "不存在" << endl;

    cout << "节点 " << seats[idx] << " 的右孩子: ";
    int rc = rightChild(idx, n);
    if (rc != -1) cout << seats[rc] << " (下标" << rc << ")" << endl;
    else cout << "不存在" << endl;

    int p = parent(idx);
    cout << "节点 " << seats[idx] << " 的父节点: ";
    if (p != -1) cout << seats[p] << " (下标" << p << ")" << endl;
    else cout << "根节点,无父节点" << endl;

    // 演示:访问节点 103(下标3),它没有右孩子
    idx = 3;
    cout << "\n节点 " << seats[idx] << " 的右孩子: ";
    rc = rightChild(idx, n);
    if (rc != -1) cout << seats[rc] << " (下标" << rc << ")" << endl;
    else cout << "不存在(因为下标 " << 2*idx+1 << " > n=" << n << ")" << endl;

    // 演示:访问根节点的父节点
    cout << "根节点 " << seats[1] << " 的父节点: " << (parent(1) == -1 ? "没有" : "有") << endl;

    return 0;
}

运行结果:

层序遍历(学号顺序): 101 102 103 104 105 106 
节点 102 的左孩子: 104 (下标4)
节点 102 的右孩子: 105 (下标5)
节点 102 的父节点: 101 (下标1)

节点 103 的右孩子: 不存在(因为下标 7 > n=6)
根节点 101 的父节点: 没有

6. 小总结

  • 完全二叉树:最后一层从左到右连续,右边可缺。数组存储时下标规律超简单:左孩子=2i右孩子=2i+1父节点=i/2
  • 数组大小要开够,访问孩子时记得判断下标是否越界。
  • 从下标1开始写代码更直观,但注意 tree[0] 不用。
  • 这个技巧在堆(最大堆、最小堆)、优先队列、线段树等数据结构中经常用到,是CSP-J必会的“基本功”。

学会了这个,你就可以自己去实现一个堆排序或者优先队列了!下一篇可以学习“堆的插入与删除”,或者“用数组存储完全二叉树的其他应用(比如线段树)”。加油!

例题精讲

1单选题

用数组表示一棵完全二叉树时,若根节点存储在下标1处,树中共有n个节点,则最后一个非叶子节点的下标是?

An/2
Bn-1
C(n-1)/2
Dn
2单选题

在C++中,用数组存储完全二叉树(下标从1开始),已知某一节点的下标为i,则该节点的右孩子(若存在)的下标是?

A2i
B2i+1
C2i-1
Di+1
3判断题

完全二叉树可以用一维数组来表示,数组的存储顺序就是该二叉树的层序遍历顺序。

4判断题

在一个用数组(下标从1开始)存储的完全二叉树中,若某节点的下标为i,则其左孩子一定存在。

5填空题
已知用数组 tree[1..n] 存储一棵完全二叉树,请补充下面的函数,返回下标 i 的父节点下标(若 i 为根节点则返回0):
int parent(int i) {
    return ___;
}