树与二叉树基础:从现实到代码的桥梁
说实话,每次讲到树结构,我都会想起刚入行时的一个项目。那时候我负责一个文件系统的索引模块,用数组硬生生地存层级关系,结果查找一个文件要遍历整个数组,性能惨不忍睹。后来一位老前辈看了一眼代码,说:「小伙子,你该用树。」嗯,从那以后,我才真正开始理解树的价值。
树这种结构,说白了就是「一对多」的关系。你想想看,公司的组织架构、电脑里的文件夹、甚至你家的族谱,都是活生生的树。今天我们就从最基础的东西聊起,把树的定义、术语、二叉树的形态,以及怎么在内存里存它,一次性讲清楚。
树的定义与基本术语
树是 n(n≥0)个结点的有限集合。n=0 时叫空树,这没什么好说的。n>0 时,有且只有一个根结点,其余结点可以分成 m 个互不相交的子树。
这里有几个术语,我建议你死记硬背下来,因为后面所有操作都离不开它们:
- 根(Root):唯一没有前驱的结点。就像公司的CEO,上面没人了。
- 叶子(Leaf):度为0的结点,也就是没有孩子的结点。像基层员工,下面没人了。
- 度(Degree):一个结点拥有的子树个数。比如一个经理管3个人,他的度就是3。
- 深度(Depth):从根到该结点的唯一路径上的边数。根深度为0,它的孩子深度为1,以此类推。
- 森林(Forest):m 棵互不相交的树的集合。把根砍掉,剩下的子树就是森林。
我个人习惯:在定义结构体时,我会把「度」这个字段也带上。虽然大多数教材不这么做,但我在调试时能快速判断结点是否异常,省了不少事。
二叉树的五种基本形态
二叉树是树里最特殊也最常用的一种。每个结点最多有两个孩子,分别叫左孩子和右孩子。注意,是「最多两个」,不是必须两个。
二叉树的五种基本形态,我画个图你就明白了:
这五种形态,你写代码时都会遇到。尤其是第三种和第四种,很多初学者容易搞混——左子树为空不等于没有左孩子,只是左孩子指针指向 NULL 而已。
满二叉树与完全二叉树
这两个概念,面试时经常被问到。我简单说说区别:
- 满二叉树:每一层的结点数都达到最大值。深度为 k 的满二叉树,结点总数是 2^(k+1) - 1。说白了就是「一个萝卜一个坑,每个坑都填满了」。
- 完全二叉树:除了最后一层,其他层都是满的。最后一层的结点必须从左到右连续排列,中间不能有空位。
我曾经踩过一个坑:在实现堆排序时,我把完全二叉树和满二叉树搞混了。满二叉树一定是完全二叉树,但反过来不一定成立。堆排序用的是完全二叉树,不是满二叉树。那次调试了整整一个下午,才发现是初始化时多分配了结点。
这里有个实用技巧:完全二叉树可以用数组来存,而且父子关系可以通过下标直接计算。这个特性太重要了,堆、优先队列都靠它。
二叉树的顺序存储
顺序存储,就是用数组来存二叉树。对于完全二叉树,这是最省空间的方式。
假设数组下标从 0 开始,那么:
- 下标为 i 的结点,左孩子下标为 2*i + 1
- 右孩子下标为 2*i + 2
- 父结点下标为 (i-1)/2(整数除法)
// 顺序存储示例:用数组表示完全二叉树
#define MAX_TREE_SIZE 100
typedef struct {
int data[MAX_TREE_SIZE]; // 存储结点值
int last; // 最后一个结点的下标
} SeqBinaryTree;
// 初始化
void initTree(SeqBinaryTree *tree) {
tree->last = -1;
}
// 插入根结点
void insertRoot(SeqBinaryTree *tree, int value) {
if (tree->last == -1) {
tree->data[0] = value;
tree->last = 0;
}
}
// 获取左孩子
int leftChild(int index) {
int child = 2 * index + 1;
return child; // 调用者需判断是否越界
}
我个人习惯:在顺序存储中,我会预留一个哨兵位(下标0不用,从1开始存)。这样父子关系变成:左孩子 2*i,右孩子 2*i+1,父结点 i/2。虽然浪费了一个元素,但代码更直观,尤其在堆排序中。
顺序存储的缺点也很明显:如果树不是完全二叉树,会有大量空间浪费。比如一个深度为 4 的右斜树,需要 2^4 - 1 = 15 个数组元素,但实际只存了 4 个结点。这种场景下,链式存储更合适。
二叉树的链式存储
链式存储是更通用的方式。每个结点包含三部分:数据域、左孩子指针、右孩子指针。
// 链式存储结构定义
typedef struct BTNode {
int data; // 数据域
struct BTNode *left; // 左孩子指针
struct BTNode *right; // 右孩子指针
} BTNode, *BTree;
// 创建一个新结点
BTNode* createNode(int value) {
BTNode *node = (BTNode*)malloc(sizeof(BTNode));
if (node == NULL) {
printf("内存分配失败\n");
exit(1);
}
node->data = value;
node->left = NULL;
node->right = NULL;
return node;
}
// 构建一个简单的二叉树
// A
// / \
// B C
// /
// D
BTree buildSampleTree() {
BTNode *root = createNode('A');
root->left = createNode('B');
root->right = createNode('C');
root->left->left = createNode('D');
return root;
}
链式存储的好处是灵活,任意形态的二叉树都能存,而且插入删除操作只需要改指针,时间复杂度 O(1)。但缺点也很明显:每个结点多存了两个指针,空间开销大。对于 n 个结点的二叉树,有 n+1 个空指针域(这个结论你可以自己推导一下)。
这里有个实用经验:我在嵌入式项目中,如果内存紧张,会用「孩子兄弟表示法」来压缩存储。把多叉树转成二叉树,每个结点只存第一个孩子和下一个兄弟,这样指针域从 m 个降到了 2 个。虽然操作复杂了点,但内存省了一半以上。
两种存储方式的选择
| 对比项 | 顺序存储 | 链式存储 |
|---|---|---|
| 空间利用率 | 完全二叉树高,非完全二叉树低 | 始终较高,但有指针开销 |
| 随机访问 | O(1),通过下标直接访问 | O(n),需要遍历 |
| 插入/删除 | O(n),需要移动大量元素 | O(1),改指针即可 |
| 适用场景 | 堆、优先队列、完全二叉树 | 一般二叉树、动态变化的结构 |
你想想看,如果你要实现一个堆排序,用顺序存储就对了。但如果你要做一个表达式树解析器,结点随时增删,那链式存储才是正道。
好了,树的基础就聊到这里。记住一句话:树是递归定义的,所以大部分树的操作都可以用递归来实现。后面我们会深入遍历、线索化、哈夫曼树等内容,到时候你会发现,今天打下的基础会反复用到。
公众号:蓝海资料掘金营,微信deep3321