树是一种非线性存储结构,一棵树由多个逻辑关系为“一对多”的结点构成,这些结点之间的关系可以用父子、兄弟、表兄弟等称谓描述。
理论上,一棵树上的各个结点可以有任意个孩子。但是,如果一棵树具备以下两个特征:
- 本身是一棵有序树,即各个结点的孩子位置不能改变;
- 树中各个结点的度(子树个数)不能超过 2,即只能是 0、1 或者 2。
同时具备这 2 个特征的树就称为「二叉树」。
例如下图画了两棵有序树,图 1a) 是二叉树,而图 1b) 不是,因为它的根结点的度为 3,不具备第 2 个特征。
图 1 二叉树示意图
存储二叉树的方式有两种,可以用顺序表存储,也可以用链表存储。
二叉树的顺序存储结构
二叉树的顺序存储,指的是使用顺序表(数组)存储二叉树。对的,你没有看错,虽然树是非线性存储结构,但也可以用顺序表存储。
需要注意的是,顺序存储只适用于完全二叉树。对于普通的二叉树,必须先将其转化为完全二叉树,才能存储到顺序表中。
满二叉树也是完全二叉树,可以直接用顺序表存储。
一棵普通二叉树转化为完全二叉树的方法很简单,只需要给二叉树额外添加一些结点,就可以把它"拼凑"成完全二叉树。如图 1 所示:
图 1 普通二叉树的转化
图 1 左侧是普通二叉树,右侧是转化后的完全(满)二叉树。解决了二叉树的转化问题,接下来学习如何顺序存储完全(满)二叉树。
所谓顺序存储完全二叉树,就是从树的根结点开始,按照层次将树中的结点逐个存储到数组中。
图 2 完全二叉树示意图
例如存储图 2 中的完全二叉树,各个结点在顺序表中的存储状态如图 3 所示:
图 3 完全二叉树存储状态示意图
存储由普通二叉树转化来的完全二叉树也是如此,比如将图 1 中的普通二叉树存储到顺序表中,树中结点的存储状态如图 4 所示:
图 4 普通二叉树的存储状态
由此就实现了完全二叉树的顺序存储。
二叉树的顺序存储结构用 C 语言表示为:
#define NODENUM 7 //二叉树中的结点数量
#define ElemType int //结点值的类型
//自定义 BiTree 类型,表示二叉树
typedef ElemType BiTree[MaxSize];
下面是用 BiTree 存储图 1 中二叉树的 C 语言代码:
/**
*快速入门数据结构(https://xiecoding.cn/ds/)
**/
#include <stdio.h>
#define NODENUM 7
#define ElemType int
//自定义 BiTree 类型,表示二叉树
typedef ElemType BiTree[NODENUM];
//存储二叉树
void InitBiTree(BiTree T) {
ElemType node;
int i = 0;
printf("按照层次从左往右输入树中结点的值,0 表示空结点,# 表示输入结束:");
while (scanf("%d", &node))
{
T[i] = node;
i++;
}
}
//查找某个结点的双亲结点的值
ElemType Parent(BiTree T, ElemType e) {
int i;
if (T[0] == 0) {
printf("存储的是一棵空树\n");
}
else
{
if (T[0] == e) {
printf("当前结点是根节点,没有双亲结点\n");
return 0;
}
for (i = 1; i < NODENUM; i++) {
if (T[i] == e) {
//借助各个结点的标号(数组下标+1),找到双亲结点的存储位置
return T[(i + 1) / 2 - 1];
}
}
printf("二叉树中没有指定结点\n");
}
return -1;
}
//查找某个结点的左孩子结点的值
ElemType LeftChild(BiTree T, ElemType e) {
int i;
if (T[0] == 0) {
printf("存储的是一棵空树\n");
}
else
{
for (i = 1; i < NODENUM; i++) {
if (T[i] == e) {
//借助各个结点的标号(数组下标+1),找到左孩子结点的存储位置
if (((i + 1) * 2 < NODENUM) && (T[(i + 1) * 2 - 1] != 0)) {
return T[(i + 1) * 2 - 1];
}
else
{
printf("当前结点没有左孩子\n");
return 0;
}
}
}
printf("二叉树中没有指定结点\n");
}
return -1;
}
//查找某个结点的右孩子结点的值
ElemType RightChild(BiTree T, ElemType e) {
int i;
if (T[0] == 0) {
printf("存储的是一棵空树\n");
}
else
{
for (i = 1; i < NODENUM; i++) {
if (T[i] == e) {
//借助各个结点的标号(数组下标+1),找到左孩子结点的存储位置
if (((i + 1) * 2 + 1 < NODENUM) && (T[(i + 1) * 2] != 0)) {
return T[(i + 1) * 2];
}
else
{
printf("当前结点没有右孩子\n");
return 0;
}
}
}
printf("二叉树中没有指定结点\n");
}
return -1;
}
int main() {
int res;
BiTree T = { 0 };
InitBiTree(T);
res = Parent(T, 3);
if (res != 0 && res != -1) {
printf("结点3的双亲结点的值为 %d\n", res);
}
res = LeftChild(T, 2);
if (res != 0 && res != -1) {
printf("结点2的左孩子的值为 %d\n", res);
}
res = RightChild(T, 2);
if (res != 0 && res != -1) {
printf("结点2的右孩子的值为 %d\n", res);
}
return 0;
}
执行结果是:
按照层次从左往右输入树中结点的值,0 表示空结点,# 表示输入结束:1 2 0 3 #
结点3的双亲结点的值为 2
结点2的左孩子的值为 3
当前结点没有右孩子
程序中实现查找某个结点的双亲结点和孩子结点,用到了完全二叉树具有的性质:将树中节点按照层次并从左到右依次标号 1、2、3、...(程序中用数组下标+1 表示),若节点 i 有左右孩子,则其左孩子节点的标号为 2*i,右孩子节点的标号为 2*i+1。
总结
虽然二叉树是非线性存储结构,但也可以存储到顺序表中。
顺序表只能存储完全二叉树,普通的二叉树必须先转化为完全二叉树之后才能用顺序表存储。
实际场景中,并非每个二叉树都是完全二叉树,用顺序表存储普通二叉树或多或少存在空间浪费的情况。
二叉树的链式存储结构
上一节介绍了二叉树的顺序存储结构,通过学习你会发现,其实二叉树并不适合用顺序表存储,因为并不是每个二叉树都是完全二叉树,普通二叉树使用顺序表存储或多或多会存在内存浪费的情况。
本节我们学习二叉树的链式存储结构。
图 1 普通二叉树示意图
所谓二叉树的链式存储,其实就是用链表存储二叉树,具体的存储方案是:从树的根节点开始,将各个节点及其左右孩子使用链表存储。例如图 1 是一棵普通的二叉树,如果选择用链表存储,各个结点的存储状态如下图所示:
图 2 二叉树链式存储结构示意图
由图 2 可知,采用链式存储二叉树时,树中的结点由 3 部分构成(如图 3 所示):
- 指向左孩子节点的指针(Lchild);
- 节点存储的数据(data);
- 指向右孩子节点的指针(Rchild);
图 3 二叉树节点结构
表示节点结构的 C 语言代码为:
typedef struct BiTNode{
TElemType data;//数据域
struct BiTNode *lchild,*rchild;//左右孩子指针
}BiTNode,*BiTree;
用链表存储图 2 所示的二叉树,对应的 C 语言程序为:
/**
*快速入门数据结构(https://xiecoding.cn/ds/)
**/
#include <stdio.h>
#include <stdlib.h>
#define TElemType int
typedef struct BiTNode {
TElemType data;//数据域
struct BiTNode* lchild, * rchild;//左右孩子指针
}BiTNode, * BiTree;
void CreateBiTree(BiTree* T) {
*T = (BiTNode*)malloc(sizeof(BiTNode));
(*T)->data = 1;
(*T)->lchild = (BiTNode*)malloc(sizeof(BiTNode));
(*T)->lchild->data = 2;
(*T)->rchild = (BiTNode*)malloc(sizeof(BiTNode));
(*T)->rchild->data = 3;
(*T)->rchild->lchild = NULL;
(*T)->rchild->rchild = NULL;
(*T)->lchild->lchild = (BiTNode*)malloc(sizeof(BiTNode));
(*T)->lchild->lchild->data = 4;
(*T)->lchild->rchild = NULL;
(*T)->lchild->lchild->lchild = NULL;
(*T)->lchild->lchild->rchild = NULL;
}
//后序遍历二叉树,释放树占用的内存
void DestroyBiTree(BiTree T) {
if (T) {
DestroyBiTree(T->lchild);//销毁左孩子
DestroyBiTree(T->rchild);//销毁右孩子
free(T);//释放结点占用的内存
}
}
int main() {
BiTree Tree;
CreateBiTree(&Tree);
printf("根节点的左孩子结点为:%d\n", Tree->lchild->data);
printf("根节点的右孩子结点为:%d\n", Tree->rchild->data);
DestroyBiTree(Tree);
return 0;
}
程序输出结果:
根节点的左孩子结点为:2
根节点的右孩子结点为:3
实际上,二叉树的链式存储结构远不止图 2 所示的这一种。某些实际场景中,可能会在树中做类似 "查找某节点的父节点" 的操作,可以在节点结构中再添加一个指针域,用于各个节点指向它的父亲节点,如图 4 所示:
图 4 自定义二叉树的链式存储结构
这样的链表结构,通常称为三叉链表。
利用图 4 所示的三叉链表,可以很轻松地找到各节点的父节点。因此,在解决实际问题时,构建合适的链表结构存储二叉树,可以起到事半功倍的效果。