15. 二叉树的存储结构:顺序存储结构、链式存储结构、二叉树的建立

二叉树这东西,说白了就是每个节点最多有两个孩子。但怎么把它存到计算机里,却是个值得琢磨的问题。我早年做嵌入式开发时,内存就那么几KB,选错存储结构,程序直接跑不起来。今天咱们就把两种存储方式掰开揉碎了讲清楚。

15.1 顺序存储结构

顺序存储,就是用数组来存二叉树。听起来简单,但有个前提——你得知道节点之间的父子关系怎么映射到数组下标上。

规则其实就一句话:对于完全二叉树,根节点放在下标1(或0),那么左孩子就是 2i,右孩子就是 2i+1。嗯,这里要注意,如果数组从0开始,公式要微调一下。

核心公式(数组下标从1开始):

  • 父节点下标 i → 左孩子下标 2i
  • 父节点下标 i → 右孩子下标 2i+1
  • 孩子下标 j → 父节点下标 j/2(整数除法)

我在项目中遇到过一种情况:用顺序存储存一棵满二叉树,效率极高。因为节点连续排列,CPU缓存命中率特别好。但如果是普通二叉树,尤其是那种斜树,那就惨了——数组里会空出大量位置,浪费空间。

我曾经踩过的坑:用顺序存储存一棵深度为10的普通二叉树,结果数组长度要开到2^10=1024,实际节点才几十个。内存浪费了90%以上。从那以后,非完全二叉树我基本不用顺序存储。

顺序存储的代码实现其实很简单:

// 顺序存储结构定义
#define MAX_TREE_SIZE 100
typedef int TElemType;

typedef struct {
    TElemType data[MAX_TREE_SIZE];  // 从下标1开始存储
    int nodeCount;                  // 实际节点数
} SeqBinaryTree;

// 初始化
void InitSeqTree(SeqBinaryTree *T) {
    T->nodeCount = 0;
    for (int i = 0; i < MAX_TREE_SIZE; i++) {
        T->data[i] = 0;  // 0表示空节点
    }
}

// 插入根节点
void InsertRoot(SeqBinaryTree *T, TElemType val) {
    if (T->nodeCount == 0) {
        T->data[1] = val;
        T->nodeCount = 1;
    }
}

// 插入左孩子
void InsertLeft(SeqBinaryTree *T, int parentIdx, TElemType val) {
    int leftIdx = 2 * parentIdx;
    if (leftIdx < MAX_TREE_SIZE) {
        T->data[leftIdx] = val;
        T->nodeCount++;
    }
}

15.2 链式存储结构

链式存储就灵活多了。每个节点除了存数据,还要存两个指针——左孩子和右孩子。你想想看,这不就是双向链表的思想吗?只不过链表是前后指针,二叉树是左右指针。

我个人习惯用这种结构,因为99%的实际场景都是普通二叉树,链式存储既不浪费空间,插入删除也方便。

// 链式存储结构定义
typedef struct BiTNode {
    TElemType data;              // 数据域
    struct BiTNode *lchild;      // 左孩子指针
    struct BiTNode *rchild;      // 右孩子指针
} BiTNode, *BiTree;

// 创建节点
BiTree CreateNode(TElemType val) {
    BiTree node = (BiTree)malloc(sizeof(BiTNode));
    if (node) {
        node->data = val;
        node->lchild = NULL;
        node->rchild = NULL;
    }
    return node;
}

小技巧:链式存储还可以加一个parent指针,变成三叉链表。这样找父节点就是O(1)了。我在做表达式树求值时就用过,回溯起来特别方便。

15.3 两种存储结构的对比

对比维度 顺序存储 链式存储
空间利用率 完全二叉树高,普通二叉树低 始终较高,每个节点多两个指针开销
访问速度 随机访问O(1),缓存友好 需要指针跳转,缓存不友好
插入/删除 需要移动大量元素 修改指针即可,O(1)
适用场景 堆、完全二叉树 普通二叉树、动态变化场景

说白了,没有绝对的好坏。我建议你根据实际需求来选:如果树的结构固定且接近完全二叉树,用顺序存储;如果树经常变化,或者形状不规则,用链式存储。

15.4 二叉树的建立

建立二叉树,最经典的方式就是递归建树。你想想看,二叉树本身就是递归定义的——左子树是一棵树,右子树也是一棵树。那建树自然也可以用递归。

我常用的方法是:先序遍历序列 + 空节点标记。比如用'#'表示空节点,那么序列 "AB#D##C##" 就能唯一确定一棵二叉树。

// 根据先序遍历序列建立二叉树('#'表示空节点)
BiTree CreateByPreOrder() {
    char ch;
    scanf("%c", &ch);  // 读取一个字符
    
    if (ch == '#') {
        return NULL;  // 空节点
    }
    
    // 创建当前节点
    BiTree node = CreateNode(ch);
    
    // 递归建立左子树和右子树
    node->lchild = CreateByPreOrder();
    node->rchild = CreateByPreOrder();
    
    return node;
}

// 使用示例:输入 AB#D##C##
// 得到的树结构:
//     A
//    / \
//   B   C
//    \
//     D

建树的核心思想:先创建根节点,再递归创建左子树,最后递归创建右子树。这个顺序和先序遍历一模一样。

除了先序建树,还有层序建树的方式。说白了就是用队列,一层一层地建。这种方式更符合人的直觉,但代码稍微复杂一点。

// 根据层序序列建立二叉树(-1表示空节点)
BiTree CreateByLevel(int arr[], int n) {
    if (n == 0 || arr[0] == -1) return NULL;
    
    // 创建根节点
    BiTree root = CreateNode(arr[0]);
    
    // 用队列辅助层序建树
    Queue Q;
    InitQueue(&Q);
    EnQueue(&Q, root);
    
    int i = 1;
    while (!IsEmpty(Q) && i < n) {
        BiTree cur = DeQueue(&Q);
        
        // 处理左孩子
        if (i < n && arr[i] != -1) {
            cur->lchild = CreateNode(arr[i]);
            EnQueue(&Q, cur->lchild);
        }
        i++;
        
        // 处理右孩子
        if (i < n && arr[i] != -1) {
            cur->rchild = CreateNode(arr[i]);
            EnQueue(&Q, cur->rchild);
        }
        i++;
    }
    
    return root;
}

我曾经在做一个文件目录树解析时,就用层序建树。因为输入数据是按层级给的,用层序建树天然匹配,代码写起来一气呵成。

15.5 知识体系总览

下面这张图把本章的核心逻辑串起来了,你看一眼就能明白:

二叉树的存储结构与建立 二叉树存储 顺序存储结构 链式存储结构 数组实现,公式映射父子关系 适合完全二叉树,缓存友好 指针实现,lchild/rchild 灵活,适合动态变化场景 建树方法:先序 / 层序

嗯,到这里二叉树的存储和建立就讲完了。两种方式各有千秋,关键看你的应用场景。我个人更偏爱链式存储,因为它灵活,而且递归建树的代码写起来特别优雅。但如果你在做堆排序或者优先队列,顺序存储绝对是首选。

最后一个小建议:刚开始学的时候,两种方式都手写一遍。写多了你就会发现,二叉树其实没那么神秘——它就是个每个节点最多有两个孩子的特殊链表而已。


公众号:蓝海资料掘金营,微信deep3321