一、二叉树的基本概念
二叉树是数据结构中的重要概念,每个节点最多有两个子树,分别为左子树和右子树。这种结构具有明确的层次性和特定的性质。
二叉树有五种基本形态:
- 空二叉树:没有任何节点。
- 只有一个根结点的二叉树:仅有一个节点作为整个树的根。
- 只有左子树:根节点仅存在左子树,右子树为空。
- 只有右子树:根节点仅存在右子树,左子树为空。
- 完全二叉树:除最后一层外,每一层的节点数都是最大的,最后一层的节点尽可能靠左排列。
完全二叉树具有一些特点,例如叶子结点只可能出现在层序最大的两层上,并且某个结点的左分支下子孙的最大层序与右分支下子孙的最大层序相等或大 1。此外,满二叉树也是一种特殊的二叉树,除了叶结点外每一个结点都有左右子叶且叶子结点都处在最底层。平衡二叉树则是左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。
二叉树作为一种数据结构,在实际问题中具有广泛的应用。许多实际问题抽象出来的数据结构往往是二叉树形式,因为二叉树的存储结构及其算法都较为简单。例如,在计算机科学中的搜索算法、排序算法等领域,二叉树都发挥着重要的作用。
尽管二叉树与树有许多相似之处,但二叉树不是树的特殊情形。树中结点的最大度数没有限制,而二叉树结点的最大度数为 2;树的结点无左、右之分,而二叉树的结点有左、右之分。
二、二叉树的特殊类型
(一)满二叉树
满二叉树是一种特殊形态的二叉树。定义为除最后一层无任何子节点外,每一层上的所有结点都有两个子结点,叶子结点都在最下层,没有度为 1 的结点。其特点包括:
- 每层的结点数都达到最大,所有叶子结点必须在同一层上。
- 如果一颗树深度为 h,最大层数为 k,它的叶子数是 2^(h - 1),第 k 层的结点数是 2^(k - 1),总结点数是 2^k - 1,总节点数一定是奇数。
(二)完全二叉树
完全二叉树是由满二叉树引出来的。对于深度为 K 的,有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应时称之为完全二叉树。其特点如下:
- 叶子结点只可能在最大的两层上出现。
- 若 i≤⌊n/2⌋,则结点 i 为分支结点,否则为叶子结点。
- 对于最大层次中的叶子结点,都依次排列在该层最左边的位置上。
- 若有度为 1 的结点,则只可能有一个,且该结点只有左孩子而无右孩子。
- 按层序编号后,一旦出现某结点为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点。
- 若 n 为奇数,则每个分支结点都有左孩子和右孩子;若 n 为偶数,则编号最大的分支结点只有左孩子,没有右孩子,其余分支结点左、右孩子都有。
(三)平衡二叉树
平衡二叉树又被称为 AVL 树,具有以下定义及性质:
- 它是一棵空树或它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。
- 平衡二叉树的常用实现方法有红黑树、AVL、替罪羊树、Treap、伸展树等。
- 在平衡二叉搜索树中,高度一般都良好地维持在 O(log(n)),大大降低了操作的时间复杂度。常见的算法有红黑树、AVL、Treap、伸展树等。红黑树可以在 O (log n) 时间内做查找,插入和删除;AVL 树中任何节点的两个儿子子树的高度最大差别为一,n 个结点的 AVL 树最大深度约 1.44log2n;Treap 是一棵二叉排序树,它的左子树和右子树分别是一个 Treap,同时满足堆的性质;伸展树能在 O (log n) 内完成插入、查找和删除操作。
三、二叉树的性质
二叉树具有多种重要性质,这些性质对于理解和操作二叉树至关重要。
(一)节点总数与深度关系
对于一棵深度为 k 的二叉树,最多具有 个结点,最少有 k 个结点。这是因为每一层的节点数是上一层的两倍,深度为 k 的满二叉树的节点总数为 。而对于非满二叉树,其节点总数会小于这个值,但至少有 k 个结点,因为每一层至少有一个结点。
(二)叶子结点与度为 2 的结点关系
对于任何一棵二叉树,如果其叶结点数为 ,度为 2 的结点数为 ,则有 。证明如下:如果 表示度为 0(即叶子结点)的结点数,用 表示度为 1 的结点数, 表示度为 2 的结点数,n 表示整个完全二叉树的结点总数,则有 。根据二叉树和树的性质,可知 (所有结点的度数之和加 1 等于结点总数)。根据两个等式可知 ,即 ,也即 。
(三)完全二叉树的深度性质
具有 n个结点的完全二叉树的深度为 ⌊log2n⌋ + 1。证明:根据性质 2,深度为 k的二叉树,最多有 2^k - 1个结点,且完全二叉树的定义是与同深度的满二叉树前边的编号相同,即它们的结点总数n 位于 k层和 k-1层的满二叉树容量之间,即 2^{k - 1} - 1 < n <= 2^k - 1之间,或 2^{k - 1} <= n < 2^k,两边同时取对数得,k - 1 <= log2(n) < k ,又因层数为整数,故log2(n)=k - 1 ,即 k = log2(n)+1。
这些性质在实际应用中非常有用,例如在计算二叉树的节点数量、判断二叉树的结构特点等方面。了解这些性质可以帮助我们更好地理解和使用二叉树这种数据结构。
四、二叉树的存储结构
(一)顺序存储结构
顺序存储结构是指用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。对于完全二叉树来说,顺序存储是十分合适的,因为完全二叉树的节点可以按照层次编号依次存储在数组中,通过节点的编号可以快速确定其在数组中的位置,进而方便地找到其双亲、左右孩子节点。
在完全二叉树的顺序存储中,数组的下标与节点的编号有直接对应关系。例如,对于一个深度为 的完全二叉树,其节点总数最多为 。如果节点编号从 开始,那么根节点存储在数组下标为 的位置,节点 的左孩子存储在下标为 的位置,右孩子存储在下标为 的位置,节点 的双亲节点存储在下标为 的位置。
然而,顺序存储结构也有明显的优缺点。优点包括:索引计算简单,通过节点在数组中的索引,可以快速计算出其父节点、左子节点和右子节点的索引,不需要进行指针操作,提高了访问效率;存储紧凑,相比于链式存储,顺序存储不需要额外的指针空间,节省了存储空间;遍历方便,由于节点在数组中的顺序与遍历顺序一致,可以方便地进行层次遍历、前序遍历、中序遍历和后序遍历等操作。
缺点主要有:空间浪费,如果二叉树是一棵稀疏树(即节点较少),那么顺序存储会造成大量的空间浪费,因为数组中会有很多位置被空节点占据;插入和删除操作复杂,由于顺序存储需要保持二叉树的完全二叉树结构,插入和删除节点时需要进行大量的数据搬移操作,效率较低;高度限制,顺序存储的数组长度是固定的,因此对于高度较大的二叉树,可能会导致数组长度不够,无法存储完整的二叉树。
(二)链式存储结构
1.二叉链表
二叉链表是二叉树的一种常见链式存储结构。在二叉链表中,每个节点包含三个域:数据域、左指针域和右指针域。当左孩子或右孩子不存在时,相应指针域值为空。
二叉链表的特点是:除了指针外,二叉链比较节省存储空间。占用的存储空间与树形没有关系,只与树中节点个数有关。在二叉链中,找一个节点的孩子很容易,但找其双亲不方便。
二叉链表适用于各种类型的二叉树,尤其是那些非完全二叉树。在实际应用中,二叉链表常用于二叉树的遍历、查找、插入和删除等操作。例如,在二叉树的遍历中,可以通过递归或非递归的方式遍历二叉链表,实现对二叉树中每个节点的访问。
2.三叉链表
三叉链表是二叉树链式存储结构的另一种形式。三叉链表与二叉链表的主要区别在于,它的节点比二叉链表的节点多一个指针域,该域用于存储指向本节点双亲的指针。
在三叉链表中,每个结点由四个域组成,其中,data、lchild 以及 rchild 三个域的意义与二叉链表结构相同。这种存储结构既便于查找孩子结点,又便于查找双亲结点。但是,相对于二叉链表存储结构而言,它增加了空间开销。
三叉链表适用于需要频繁查找双亲节点的场景。例如,在某些算法中,需要快速找到某个节点的双亲节点进行操作,此时三叉链表就可以发挥其优势。不过,由于其增加了空间开销,在空间有限的情况下,需要权衡使用。
五、二叉树的遍历方式
(一)先序遍历
先序遍历的顺序是根左右,即先访问根节点,再访问左子树,最后访问右子树。
在 C 语言中,先序遍历可以通过递归和非递归两种方式实现。
1.递归实现:
// 前序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void preOrder(BiTree T) {
// 如果当前节点不为空
if (T!= NULL) {
// 调用 visit 函数访问当前节点,visit 函数应该是一个自定义的用于处理节点的函数
visit(T);
// 递归遍历左子树
preOrder(T->lchild);
// 递归遍历右子树
preOrder(T->rchild);
}
// 函数返回,结束遍历
return;
}
2.非递归实现:借助栈实现,先将根节点入栈,当栈不为空时,取出栈顶元素并访问,然后将其右孩子和左孩子依次入栈(如果存在)。
// 非递归实现前序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void preOrderNonRecursive(BiTree T) {
// 创建一个栈
Stack stack;
// 初始化栈
initStack(&stack);
// 将根节点入栈
push(&stack, T);
// 当栈不为空时循环
while (!isEmpty(&stack)) {
// 弹出栈顶元素
BiTree node = pop(&stack);
// 如果弹出的节点不为空
if (node!= NULL) {
// 访问该节点
visit(node);
// 先将右子树入栈(因为先访问左子树,后访问右子树)
push(&stack, node->rchild);
// 再将左子树入栈
push(&stack, node->lchild);
}
}
}
(二)中序遍历
中序遍历的顺序是左根右,即先访问左子树,再访问根节点,最后访问右子树。
C 语言实现方式如下:
1.递归实现:
// 中序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void midOrder(BiTree T) {
// 如果当前节点不为空
if (T!= NULL) {
// 先递归遍历左子树
midOrder(T->lchild);
// 访问当前节点
visit(T);
// 再递归遍历右子树
midOrder(T->rchild);
}
// 函数返回,结束遍历
return;
}
2.非递归实现:借助栈实现,从根节点开始,将其所有左孩子依次入栈,直到左孩子为空。然后弹出栈顶元素并访问,接着将其右孩子作为新的根节点重复上述过程。
// 非递归实现中序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void midOrderNonRecursive(BiTree T) {
// 创建一个栈
Stack stack;
initStack(&stack);
// 设置当前节点为传入的根节点
BiTree node = T;
// 当当前节点不为空或者栈不为空时循环
while (node!= NULL ||!isEmpty(&stack)) {
if (node!= NULL) {
// 当前节点不为空时,将其入栈,并将当前节点移动到左子节点
push(&stack, node);
node = node->lchild;
} else {
// 当前节点为空,说明左子树已遍历完,从栈中弹出一个节点
node = pop(&stack);
// 访问该节点
visit(node);
// 将当前节点移动到右子节点
node = node->rchild;
}
}
}
(三)后序遍历
后序遍历的顺序是左右根,即先访问左子树,再访问右子树,最后访问根节点。
C 语言代码实现:
1.递归实现:
// 后序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void afterOrder(BiTree T) {
// 如果当前节点不为空
if (T!= NULL) {
// 先递归遍历左子树
afterOrder(T->lchild);
// 再递归遍历右子树
afterOrder(T->rchild);
// 最后访问当前节点
visit(T);
}
// 函数返回,结束遍历
return;
}
2.非递归实现:借助栈实现,需要额外使用一个辅助栈来记录遍历顺序。先将根节点入栈,然后依次将其右孩子和左孩子入栈(如果存在)。当栈为空时,遍历结束。最后将辅助栈中的元素依次弹出并访问,得到后序遍历结果。
// 非递归实现后序遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void afterOrderNonRecursive(BiTree T) {
// 创建两个栈 stack1 和 stack2
Stack stack1, stack2;
// 初始化 stack1
initStack(&stack1);
// 初始化 stack2
initStack(&stack2);
// 将根节点入栈 stack1
push(&stack1, T);
// 当 stack1 不为空时循环
while (!isEmpty(&stack1)) {
// 从 stack1 弹出一个节点
BiTree node = pop(&stack1);
// 将弹出的节点入栈 stack2
push(&stack2, node);
// 如果当前节点的左子树不为空,将左子树入栈 stack1
if (node->lchild!= NULL) {
push(&stack1, node->lchild);
}
// 如果当前节点的右子树不为空,将右子树入栈 stack1
if (node->rchild!= NULL) {
push(&stack1, node->rchild);
}
}
// 当 stack2 不为空时循环
while (!isEmpty(&stack2)) {
// 从 stack2 弹出一个节点并访问
visit(pop(&stack2));
}
}
(四)层次遍历
层次遍历是从上层至下层,同层自左至右遍历。借助队列实现,先将根节点入队,然后当队列不为空时,取出队首元素并访问,接着将其左孩子和右孩子依次入队(如果存在),直到队列为空。
C 语言代码如下:
// 层次遍历二叉树的函数,假设 BiTree 是一个二叉树类型
void levelOrder(BiTree T) {
// 创建一个队列
Queue queue;
// 初始化队列
initQueue(&queue);
// 将根节点入队
enqueue(&queue, T);
// 当队列不为空时循环
while (!isEmptyQueue(&queue)) {
// 从队列中取出一个节点
BiTree node = dequeue(&queue);
// 如果取出的节点不为空
if (node!= NULL) {
// 访问该节点
visit(node);
// 将该节点的左子节点入队
enqueue(&queue, node->lchild);
// 将该节点的右子节点入队
enqueue(&queue, node->rchild);
}
}
}
六、二叉树的应用场景
二叉树在计算机科学领域有广泛的应用场景,以下是对其在不同领域的具体介绍:
(一)哈夫曼编码
哈夫曼编码是一种使用二叉树(特别是赫夫曼树)实现的数据压缩方法。它通过构建一个带权路径长度最短的二叉树,即最优二叉树,来提高数据传输的有效性。具体步骤如下:
- 首先将待编码的文章当成一个字符串,遍历这个字符串中所有的字符,并且统计每一个字符出现的次数。
- 根据字符出现的次数对字符进行排序。
- 选取排序后出现次数最少的两个字符加入哈夫曼树,并将两个字符的出现次数取值相加,合并成为一个中间节点。中间节点仅记录两个节点取值的加和,但是不记录任何字符。
- 将中间节点加入字符排序序列中,删除已经使用过的节点,对序列重新排序,重复步骤 2 - 4,构建过程中产生的中间节点也算作在内。
- 当所有的字符和中间节点全部合成完毕时,序列中只剩余一个节点,就是哈夫曼树的根节点,哈夫曼树创建完毕。
接着为每一个存储字符的节点分配哈夫曼编码:从根节点开始,向下寻找每一个叶子节点;如果向左孩子方向走一步,则记 0;如果向右孩子方向走一步,则记 1;重复上述步骤,直到遍历完成整个哈夫曼树为止,最终得到根节点通往每一个叶子节点的路径字符串,就是这个叶子节点对应字符的哈夫曼编码。
例如,一篇文章经过哈夫曼编码后,完全由 0 和 1 构成,且每一篇文章因为内容的不同,即使是相同的字符所对应的哈夫曼编码也是不同的。所以,即使单纯得到一篇文章的密文结构,没有得到对应的哈夫曼编码表,也是无法进行解密的。哈夫曼树和哈夫曼编码在密码学当中具有非常高的学术研究价值。
(二)海量数据并发查询
在处理大量动态数据时,二叉排序树(二叉查找树)因其既有链表的好处也有数组的好处,能够在处理大批量的动态数据时提供高效的查询性能。这种数据结构在复杂度为 O (K + LgN) 的情况下,对于海量数据的并发查询非常有用。
例如,在一些需要处理大量实时数据的系统中,如金融交易系统、网络流量监测系统等,二叉排序树可以快速地对数据进行查找和插入操作,满足系统对数据处理的实时性要求。
(三)数据结构实现
C++ STL 中的 set/multiset、map 以及 Linux 虚拟内存的管理,都是通过红黑树实现的。红黑树能够在查找、插入和删除操作中保持相对平衡,提供高效的查找效率,最大查找 / 删除 / 插入操作的时间复杂度为 O (logk)。
红黑树是一种自平衡的二叉查找树,它在插入和删除操作后通过旋转和翻色,自动调整结构以保持平衡,从而保证了查找、插入和删除操作的效率。在内存中的有序数据存储中,红黑树可以快速地进行增删操作,且由于内存存储不涉及 I/O 操作,红黑树的性能优势更加明显。此外,红黑树还适用于实现 Key - Value 对的数据结构,通过键值对进行查找,适用于需要快速查找特定键值对应的值的应用场景。
(四)文件系统
B - Tree 和 B+ - Tree 在文件系统中有着重要的应用,特别是在目录管理上。它们能够高效地处理大量的数据,并提供快速的查找、插入和删除操作,这对于文件系统的性能至关重要。
B + 树只在叶节点存储数据,而非叶子节点只存储索引信息。这种结构使得 B + 树能够更好地适应磁盘读取方式,因为在磁盘上读取一条记录的成本非常高。B + 树的叶节点形成了一个链表,可以很容易地实现范围查询,非常适合需要高效处理大量数据的系统。此外,B + 树相比 B 树有更好的空间利用率和查询性能,更适合用作大型数据库的索引结构。B + 树的所有数据记录都存储在叶子节点上,且叶子节点同时还维护了一条双向链表,这提高了范围查询的效率。因此,B + 树在需要处理大量范围查询和排序操作的场景中表现出色,如文件系统等。
(五)路由搜索引擎
路由器使用二叉树结构进行路由表的查找,这种结构能够快速地根据目的地址查找最佳的路由路径,从而提高网络通信的效率。
例如,在互联网中,路由器需要根据数据包的目的地址快速地选择最佳的路由路径,将数据包转发到正确的目的地。二叉树结构可以有效地组织路由表,使得路由器能够快速地进行查找和决策,提高网络通信的效率和可靠性。