3. 二叉树的存储结构:顺序存储与链式存储
聊到二叉树的存储,我脑子里第一个蹦出来的问题就是:这棵树,到底该怎么放进内存里?
你想想看,树是一种非线性结构,每个节点可能有左孩子、右孩子,甚至还有父节点。而我们的内存,说白了就是一维的线性空间。把一棵立体的树塞进一条直线上,这事本身就挺有意思的。
实际开发中,我见过两种主流做法:顺序存储和链式存储。各有各的脾气,也各有各的坑。今天咱们就把它掰开揉碎了讲清楚。
3.1 顺序存储结构(数组实现)
顺序存储,说白了就是用数组来存二叉树。它的核心思想是:按照完全二叉树的编号规则,把节点依次放进数组里。
具体怎么放?规则很简单:
- 根节点放在下标为
1的位置(有些教材从0开始,但我个人习惯从1开始,这样公式更直观) - 对于下标为
i的节点: - 左孩子下标:
2 * i - 右孩子下标:
2 * i + 1 - 父节点下标:
i / 2(整数除法)
举个例子,一棵这样的完全二叉树:
1
/ \
2 3
/ \ \
4 5 7
用数组存储就是:
| 下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 值 | 1 | 2 | 3 | 4 | 5 | 空 | 7 |
注意下标6是空的,因为节点3没有左孩子,但右孩子7占了下标7的位置。这就是顺序存储的一个特点——必须按完全二叉树来占位。
核心公式(记住这组公式,面试常考):
- 左孩子:
2 * i - 右孩子:
2 * i + 1 - 父节点:
i / 2 - 判断叶子节点:
2 * i > n(n为节点总数)
3.1.1 代码实现
下面是一个简单的顺序存储实现,我当年在嵌入式项目里就用过类似的写法:
#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 100
// 顺序存储的二叉树
typedef struct {
int data[MAX_SIZE]; // 存储节点值
int size; // 当前节点数
} SeqBinaryTree;
// 初始化
void initTree(SeqBinaryTree *tree) {
tree->size = 0;
for (int i = 0; i < MAX_SIZE; i++) {
tree->data[i] = -1; // -1表示空节点
}
}
// 插入根节点
void insertRoot(SeqBinaryTree *tree, int value) {
if (tree->size == 0) {
tree->data[1] = value;
tree->size = 1;
}
}
// 插入左孩子
void insertLeft(SeqBinaryTree *tree, int parent, int value) {
int index = 2 * parent;
if (index < MAX_SIZE && tree->data[index] == -1) {
tree->data[index] = value;
tree->size++;
}
}
// 插入右孩子
void insertRight(SeqBinaryTree *tree, int parent, int value) {
int index = 2 * parent + 1;
if (index < MAX_SIZE && tree->data[index] == -1) {
tree->data[index] = value;
tree->size++;
}
}
// 获取左孩子
int getLeft(SeqBinaryTree *tree, int index) {
int left = 2 * index;
if (left < MAX_SIZE && tree->data[left] != -1) {
return tree->data[left];
}
return -1;
}
// 获取右孩子
int getRight(SeqBinaryTree *tree, int index) {
int right = 2 * index + 1;
if (right < MAX_SIZE && tree->data[right] != -1) {
return tree->data[right];
}
return -1;
}
// 获取父节点
int getParent(SeqBinaryTree *tree, int index) {
if (index > 1) {
return tree->data[index / 2];
}
return -1;
}
我的经验:顺序存储最适合完全二叉树和满二叉树。如果树形很歪斜,比如只有右孩子,那数组里会空出大量位置,浪费空间。我曾经在一个堆排序项目里用过这种结构,因为堆本身就是完全二叉树,用数组存简直天作之合。
注意:顺序存储的数组大小是固定的。如果树在运行时会动态增长,你得提前预估最大节点数,否则会越界。我曾经在早期的一个项目中没注意这个问题,结果树长到一半数组满了……嗯,调试了一下午。
3.2 链式存储结构
链式存储,就是用指针把节点串起来。每个节点除了存数据,还存指向左右孩子的指针。这种方式更灵活,也更符合树的「天然形态」。
链式存储有两种常见形式:二叉链表和三叉链表。
3.2.1 二叉链表
二叉链表是最基础的链式存储。每个节点包含三个部分:
data:数据域left:指向左孩子的指针right:指向右孩子的指针
结构体定义如下:
typedef struct BiTNode {
int data; // 数据域
struct BiTNode *left; // 左孩子指针
struct BiTNode *right; // 右孩子指针
} BiTNode, *BiTree;
这种结构,说白了就是每个节点只知道自己「往下」的路,不知道「往上」的路。想找父节点?你得从根节点重新遍历一遍。
我画了一张图,帮你直观理解二叉链表的连接方式:
3.2.2 三叉链表
三叉链表在二叉链表的基础上,多了一个指向父节点的指针。结构体长这样:
typedef struct TriTNode {
int data; // 数据域
struct TriTNode *left; // 左孩子指针
struct TriTNode *right; // 右孩子指针
struct TriTNode *parent; // 父节点指针
} TriTNode, *TriTree;
多了这个 parent 指针,好处很明显:
- 你可以从任意节点出发,向上回溯到根节点
- 某些算法(比如求两个节点的最近公共祖先)会变得简单很多
- 删除节点时,可以方便地更新父节点的孩子指针
但代价也很直接:每个节点多了一个指针,内存开销增加了。对于一棵有 n 个节点的树,三叉链表比二叉链表多占 n 个指针的空间。
两种链式存储的对比:
| 特性 | 二叉链表 | 三叉链表 |
|---|---|---|
| 每个节点指针数 | 2 个 | 3 个 |
| 能否找父节点 | 不能(需遍历) | 能(直接访问) |
| 内存开销 | 较小 | 较大 |
| 适用场景 | 一般遍历、搜索 | 需要频繁回溯父节点 |
3.2.3 二叉链表的常用操作
下面我写几个最常用的操作,都是我在实际项目中反复用到的:
// 创建新节点
BiTree createNode(int data) {
BiTree node = (BiTree)malloc(sizeof(BiTNode));
if (node == NULL) {
printf("内存分配失败\n");
exit(1);
}
node->data = data;
node->left = NULL;
node->right = NULL;
return node;
}
// 前序遍历(根左右)
void preOrder(BiTree root) {
if (root == NULL) return;
printf("%d ", root->data); // 访问根
preOrder(root->left); // 遍历左子树
preOrder(root->right); // 遍历右子树
}
// 中序遍历(左根右)
void inOrder(BiTree root) {
if (root == NULL) return;
inOrder(root->left); // 遍历左子树
printf("%d ", root->data); // 访问根
inOrder(root->right); // 遍历右子树
}
// 后序遍历(左右根)
void postOrder(BiTree root) {
if (root == NULL) return;
postOrder(root->left); // 遍历左子树
postOrder(root->right); // 遍历右子树
printf("%d ", root->data); // 访问根
}
// 求树的高度
int treeHeight(BiTree root) {
if (root == NULL) return 0;
int leftHeight = treeHeight(root->left);
int rightHeight = treeHeight(root->right);
return (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
}
// 求节点个数
int treeSize(BiTree root) {
if (root == NULL) return 0;
return treeSize(root->left) + treeSize(root->right) + 1;
}
我的建议:如果你刚开始学二叉树,先用二叉链表。它结构简单,代码好写,也足够应付大部分场景。等遇到需要频繁找父节点的需求时,再考虑升级到三叉链表。别一开始就上三叉链表,容易把自己绕晕。
3.3 三种存储结构的对比总结
好了,三种存储方式都讲完了。我帮你总结一下,方便你以后选型时参考:
| 存储方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 顺序存储(数组) | 访问快,O(1) 定位节点;内存连续,缓存友好 | 空间浪费(非完全二叉树);大小固定,不易扩展 | 完全二叉树、堆、线段树 |
| 二叉链表 | 结构灵活,空间利用率高;动态扩展方便 | 无法直接找父节点;每个节点有指针开销 | 一般二叉树、表达式树、搜索树 |
| 三叉链表 | 可快速回溯父节点;删除节点方便 | 内存开销更大;代码复杂度略高 | 需要频繁回溯父节点的场景 |
我个人在实际项目中,用得最多的是二叉链表。它就像一把瑞士军刀,够用、灵活、不花哨。顺序存储我只在堆排序和线段树里用过,因为那两种场景下树本身就是完全二叉树,用数组存简直是天作之合。三叉链表嘛,说实话用得不多,但有一次在做表达式求值的时候,需要从叶子节点回溯到根,三叉链表帮了大忙。
嗯,存储结构就聊到这儿。记住一句话:没有最好的结构,只有最合适的结构。选哪种,取决于你的树长什么样,以及你要对它做什么操作。