二叉树基础:从定义到两种存储方式

说实话,二叉树这东西,我刚入行时觉得它就是个理论玩具。直到有一次做嵌入式协议栈,需要快速查找配置参数,用数组硬查太慢,链表又不好管理。后来一拍脑袋——用二叉搜索树啊!从那以后,我对二叉树的态度彻底变了。

今天咱们就把二叉树的基础打牢。说白了,它就是每个节点最多有两个孩子的树结构。左孩子、右孩子,就这么简单。

二叉树的定义与性质

先给个正式定义:二叉树是n个节点的有限集合。要么是空集(n=0),要么由一个根节点加上两棵互不相交的左子树和右子树组成。

嗯,这里要注意——左右子树是有顺序的,不能随便换。我在项目中见过有人把左右子树搞反,结果遍历出来的数据顺序全乱了,排查了半天。

二叉树有几个重要性质,我挑最常用的说:

  • 性质1:第i层最多有 2^(i-1) 个节点(i≥1)。比如第3层最多4个节点。
  • 性质2:深度为k的二叉树,最多有 2^k - 1 个节点。深度为3的树最多7个节点。
  • 性质3:叶子节点数 = 度为2的节点数 + 1。这个性质在验证树结构时特别有用。

满二叉树 vs 完全二叉树

满二叉树:每一层都塞满了节点。深度为k,节点数就是 2^k - 1。

完全二叉树:除了最后一层,上面都是满的。最后一层的节点都靠左排列。这个结构在顺序存储时特别方便。

你想想看,为什么完全二叉树适合顺序存储?因为它的节点编号和数组下标能完美对应,没有空洞。

二叉树的顺序存储

顺序存储,就是用数组来存二叉树。核心思路:把根节点放在下标1的位置(有人从0开始,但我习惯从1),然后:

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

举个例子,一个完全二叉树,节点值分别是 A、B、C、D、E,存储到数组里就是:

// 顺序存储二叉树
// 下标:  1   2   3   4   5
// 值:    A   B   C   D   E

// 访问节点
char getLeftChild(char tree[], int i) {
    int left = 2 * i;
    if (left <= MAX_SIZE && tree[left] != '\0')
        return tree[left];
    return '\0';
}

char getRightChild(char tree[], int i) {
    int right = 2 * i + 1;
    if (right <= MAX_SIZE && tree[right] != '\0')
        return tree[right];
    return '\0';
}

char getParent(char tree[], int i) {
    if (i == 1) return '\0';  // 根节点没有父节点
    return tree[i / 2];
}

我的经验:顺序存储最适合完全二叉树。如果是一棵普通二叉树,中间有很多空节点,数组里就会有很多空洞,浪费空间。我曾经在一个资源受限的MCU上试过,存一棵稀疏二叉树,数组开了256个元素,实际只用了30多个,太浪费了。

顺序存储的优缺点很明显:

优点缺点
内存连续,访问快插入删除要移动大量数据
通过下标直接定位父子节点非完全二叉树浪费空间
实现简单,代码量少树的大小固定,不易扩展

二叉树的链式存储

链式存储才是真正灵活的方式。每个节点就是一个结构体,包含数据域和两个指针域。

// 二叉树的链式存储节点定义
typedef struct TreeNode {
    char data;                 // 数据域
    struct TreeNode *left;     // 左孩子指针
    struct TreeNode *right;    // 右孩子指针
} TreeNode;

// 创建一个新节点
TreeNode* createNode(char data) {
    TreeNode *node = (TreeNode*)malloc(sizeof(TreeNode));
    if (node == NULL) {
        printf("内存分配失败\n");
        return NULL;
    }
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}

// 构建一棵简单的二叉树
//        A
//       / \
//      B   C
//     / \
//    D   E
TreeNode* buildTree() {
    TreeNode *root = createNode('A');
    root->left = createNode('B');
    root->right = createNode('C');
    root->left->left = createNode('D');
    root->left->right = createNode('E');
    return root;
}

避坑指南:我曾经在链式存储中忘记初始化左右指针为NULL,结果遍历时指针乱飞,程序直接跑飞。记住:每次创建节点,一定要把left和right置为NULL。malloc出来的内存不会自动清零。

链式存储的好处是灵活,想加节点就加,想删就删。但每个节点多了两个指针的开销。在32位系统上,每个节点额外占8字节(两个指针),64位系统上就是16字节。

我个人习惯在嵌入式项目里这样选:

  • 如果树的结构固定,不会频繁增删 → 用顺序存储
  • 如果树经常变化,或者不是完全二叉树 → 用链式存储
  • 如果内存极度紧张 → 优先考虑顺序存储

两种存储方式对比

对比项顺序存储链式存储
内存分配静态数组,一次性分配动态分配,按需申请
空间利用率完全二叉树高,普通树低较高,但有指针开销
访问速度O(1) 通过下标O(n) 需要遍历
插入/删除需要移动数据修改指针即可
适用场景完全二叉树、堆普通二叉树、搜索树

下面这张图展示了两种存储方式的核心区别,我画了很久才调好布局:

二叉树两种存储方式对比 顺序存储(数组) A B C D E 1 2 3 4 5 A B C D E 左孩子 = 2*i 右孩子 = 2*i+1 父节点 = i/2 链式存储(链表) A | left | right B | left | right C | left | right D | NULL|NULL E | NULL|NULL 每个节点包含数据 + 左右指针 NULL 表示没有孩子

这张图左边是顺序存储,数组下标和节点一一对应。右边是链式存储,每个节点通过指针串联起来。你看,同样的树结构,两种存储方式差别很大。

我的建议:刚开始学二叉树,先用链式存储。它更直观,调试时也容易看。等把遍历、查找这些操作搞熟了,再回头研究顺序存储。我在带新人时都是这个路子,效果不错。

最后说一句:二叉树的基础就这些,但它是后面所有高级树结构(二叉搜索树、平衡树、堆)的基石。把今天的内容吃透了,后面的路就好走了。


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