本文是算法与数据结构的学习笔记第五篇,将持续更新,欢迎小伙伴们阅读学习。有不懂的或错误的地方,欢迎交流
引言
前面章节介绍的都是线性存储的数据结构,包括数组、链表、栈、队列。本节带大家学习一种非线性存储的数据结构,即树(tree)。
不管是在面试时,还是日常开发过程中,树都是一种曝光率极高的数据结构。可以说树是数据结构最为承上启下的部分,其可以转化为线性表(通过二叉树的线索化),也是学习图的基础。本文将介绍树的基本概念、常见类型和应用、二叉树以及 C 语言实现,帮助大家深入理解树的本质和用途。
树的基本概念
树的定义
树是 n(n>=0) 个结点的有限集。当 n=0 时,称为空树。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当 n>1 时,其余节点可分为 m(m>0) 个互不相交的有限集 T1,T2,…,Tm,其中每个集合 Ti(1<=i<=m) 本身又是一棵树,并且称为根的子树。
显然,树的定义是递归的,即在树的定义中又用到了自身,树是一种递归的数据结构。树同时也是一种分层结构,具有以下两个特点:
- 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
- 树中所有结点可以有零个或多个后继。
因此 n 个结点的树中有 n-1 条边。
基本术语
下面结合图示来说明一下树的一些基本术语和概念。
结点:和链表类似,树存储结构中也将存储的各个元素称为结点。例如在上图中,元素 A 就是一个结点。对于树中某些特殊位置的结点,还可以进行更细致的划分,比如:
父结点(双亲结点)、孩子结点和兄弟结点:以上图中的结点 A、B、C、D 为例,A 是 B、C、D 结点的父结点(也称为双亲结点),而 B、C、D 都是 A 结点的孩子结点(也称“子结点”)。对于 B、C、D 来说,它们都有相同的父结点,所以它们互为兄弟结点;
树根结点(简称根结点):特指树中没有双亲(父亲)的结点,一棵树有且仅有一个根结点。上图中,结点 A 就是整棵树的根结点;
祖先、子孙:根 A 到结点 K 的唯一路径上的任意结点,称为结点 K 的祖先。如结点 B 是结点 K 的祖先,而结点 K 是结点 B 的子孙。
子树:仍以上图的树为例,A 是整棵树的根结点。但如果单看结点 B、E、F、K、L 组成的部分来说,它们也组成了一棵树,结点 B 是这棵树的根结点。通常,我们将一棵树中几个结点构成的“小树”称为这棵树的“子树”。
知道了子树的概念后,树也可以这样定义:树是由根结点和若干棵子树构成的。例如,上图这棵树就是由根结点 A 和分别以 B、C、D 为根节点的子树构成。
注意:单个结点也可以看作是一棵树,该结点即为根结点。例如上图中,结点 K、L、F 各自就可以看作是一棵树,只不过树中只有一个根节点而已。
结点的度:一个结点拥有子树的个数,就称为该结点的度(Degree)。例如上图中,根结点 A 有3个子树,它们的根节点分别是 B、C、D,因此结点 A 的度为3。
树的度:一棵树中,最大的节点的度称为树的度。如上图:所有结点中最大的度为3,所以整棵树的度就是3。
叶节点或终端节点:度为0的节点称为叶节点; 如上图:I、J、F、K、L、M 等节点为叶节点。
分支节点或非终端节点:度不为0的节点; 如上图:A、B、C、D 等节点为分支节点。
结点的层次:结点的层次从一棵树的树根开始定义,树根所在层为第一层,根的孩子结点所在的层为第二层,依次类推。对于上图这棵树来说,A 结点在第一层,B、C、D 为第二层,E、F、G、H、I、J 在第三层,K、L、M 在第四层。
树中结点层次的最大值,称为这棵树的深度或者高度。例如上图这棵树的深度为 4。
如果两个结点的父结点不同,但它们父结点的层次相同,那么这两个结点互为堂兄弟。例如上图中,结点 G 和 E、F、H、I、J 的父结点都在第二层,所以它们互为堂兄弟。
有序树和无序树:树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树。假设图为有序树,若将子结点位置互换,则变成一棵不同的树。
在有序树中,结点最左边的子树称为 “第一个孩子”,最右边的称为 “最后一个孩子”。拿上图这棵树来说,如果它是一棵有序树,那么以结点 B 为根结点的子树为整棵树的第一个孩子,以结点 D 为根结点的子树为整棵树的最后一个孩子。
路径和路径长度:树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。
注意:由于树中的分支是有向的,即从双亲指向孩子,所以树中的路径是从上向下的,同一层的两个结点之间不存在路径。
森林:森林是 m(m≥0) 棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给 m 棵独立的树加上一个结点,并把这 m 棵树作为该结点的子树,则森林就变成了树。
树的性质
树具有如下最基本的性质:
- 树中的结点数等于所有结点的度数加1;
- 度为 m 的树中第 i ii 层上至多有}mi−1 个结点(i≥1);
- 高度为 h 的 m 叉树至多有(mh−1)/(m−1) 个结点;
- 具有 n 个结点的 m 叉树的最小高度为)logm(n(m−1)+1)。
树的其它表示方法
上图中使用的是树状表示法,最基本的逻辑结构表示法,使用一棵树倒置表示,非常直观。除了这种方法之外,还有其它的方式可以表示一棵树:
上图左侧是文氏图标表示法:是使用集合以及集合包含关系描述树结构(集合之间绝不能相交,即任意两个圆圈不能有交集)。
上图右侧使用的是凹入表示法,最长条为根结点,相同长度的表示在同一层次。例如 B、C、D 长度相同,都为 A 的子结点,E 和 F 长度相同,为 B 的子结点,K 和 L 长度相同,为 E 的子结点,依此类推。
还可以使用括号表示法:将树的根结点写在括号的左边,除根结点之外的其余结点写在括号中,并用逗号分隔。例如上面的树用括号表示为 (A,(B(E(K,L),F),C(G),D(H(M),I,J)))
树的存储结构
说到存储结构,自然就会想到我们前面讲过的顺序存储和链式存储两种结构。
顺序存储结构:树中某个结点的孩子可以有多个,若将树中所有结点存储到数组中,结点的存储位置无法直接反应其逻辑关系,因此简单的顺序存储结构是不能满足树的实现要求的。
链式存储结构:链式存储结构的特点,完全可以实现对树的存储结构的表示。
表示方式:实际中树有很多种表示方式, 如::双亲表示法,孩子表示法、孩子兄弟表示法等等,我们这里就简单的了解其中最常用的孩子兄弟表示法。
对于树这样的层级结构,我们观察后发现,任意—棵树,它的结点的第一个孩子如果存在就是唯一的,它的右只弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
结点结构如下表所示:
data | firstchild | rightsib |
其中 data 是数据域;firstchild 为指针域,存储该结点的第—个孩子结点的存储地址;rightsib是指针域,存储该结点的右兄弟结点的存储地址。
结构定义代码如下:
/* 树的孩子兄弟表示法结构定义 */ typedef struct CSNode { TElemType data; // 结点中的数据域 struct CSNode *firstchild1,*rightsib; // 指向其第一个孩子结点和下一个兄弟结点 }CSNode,*CSTree;
对于下图左边的树来说,这种方法实现的示意图如右下图所示:
其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树,这样就可以充分利用二叉树的特性和算法来处理这棵树了。嗯?有人问,二叉树是什么?哈哈,别急,这正是接下来要重点讲的内容。
常见类型的树
二叉树
二叉树是一种最基本的树型数据结构。简单地理解,度不超过2的有序树就是二叉树。
例如,下图左侧就是一棵二叉树,而右侧则不是。
二叉树还可以继续分类,衍生出完全二叉树、满二叉树等:
满二叉树
如果二叉树中除了叶子结点,每个结点的度都为 2,则此二叉树称为满二叉树,如下图所示:
完全二叉树
如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。满二叉树是完全二叉树的一种特殊情况。
堆
堆也是完全二叉树的一种特殊情况,针对的是节点数据的大小对比。堆中每个节点的值都必须大于等于(或者小于等于)其子树中的每个节点的值。大于等于每个子树节点的值称为大顶堆,反正称为小顶堆,如下图所示。后面将有文章专门分析堆。
二叉搜索树
二叉搜索树是一种特殊的二叉树,又称为二叉查找树、二叉排序树等等,它实际上是数据域有序的二叉树,即对树上的每个结点,都满足其左子树上所有结点的数据域均小于根结点的数据域,右子树上所有结点的数据域均大于根结点的数据域。
二叉排序树意味着二叉树中的数据是排好序的,顺序为左结点<根节点<右结点,这表明二叉排序树的中序遍历结果是有序的(二叉树四种遍历方式[前序遍历、中序遍历、后序遍历、层序遍历]将在下面给出)。
二叉搜索树的特点:
每个节点包含一个值,每个节点至多有两个子树。
每个节点左子树节点的值都小于自身的值,每个节点右子树节点的值都大于自身的值。
二叉搜索树的查询时间复杂度是 O ( log N ) O(\log N)O(logN),但是随着不断的插入、删除节点,二叉树的树高可能会不断变大,当一个二叉搜索树所有节点都只有左子树或者都只有右子树时,其查找性能就退化成线性的了。
平衡二叉树
平衡二叉树又被称为 AVL 树,它在符合二叉搜索树的条件下,还满足“高度平衡”,即任何节点的两个子树的高度最大差为1。
平衡二叉树的产生是为了解决二叉搜索树在插入时发生线性排列的现象。平衡二叉树在插入或删除数据时,采用旋转的调整方式,使得二叉树在插入数据后保持平衡。平衡二叉树的查询时间复杂度最好情况和最坏情况都维持在 O ( log N ) O(\log N)O(logN)。但是频繁旋转会使插入和删除牺牲掉 O ( log N ) O(\log N)O(logN) 左右的时间,不过相对二叉搜索树来说,时间上稳定了很多。
红黑树
平衡二叉树(AVL)为了追求高度平衡,需要通过平衡处理使得左右子树的高度差必须小于等于1。高度平衡带来的好处是能够提供更高的搜索效率,其最坏的查找时间复杂度都是 O ( log N ) O(\log N)O(logN)。但是由于需要维持这份高度平衡,所付出的代价就是当对树种结点进行插入和删除时,需要经过多次旋转实现复衡。这导致 AVL 的插入和删除效率并不高。
为了解决这样的问题,红黑树被提出了。红黑树通过将结点进行红黑着色,使得原本高度平衡的树结构被稍微打乱,平衡程度降低。红黑树不追求完全平衡,只要求达到部分平衡。这是一种折中的方案,大大提高了结点删除和插入的效率,更加实用。
红黑树VS平衡二叉树
除了上面所提及的树结构,还有许多广泛应用在数据库、磁盘存储等场景下的树结构。比如B树、B+树等。这里就先不介绍了诶。
树的应用
1.文件系统:文件系统通常使用树的结构来组织文件和目录。树形结构的特性使得文件系统可以方便地进行文件的查找、插入和删除操作。
2.数据库索引:数据库索引是一种数据结构,用于加快数据库中数据的访问速度。常见的数据3.库索引结构,如B树和B+树,利用树的特性实现高效的数据检索。
表达式解析:在编译器和解释器中,树结构常用于表示和解析数学表达式。表达式树(Expression Tree)可以将复杂的数学表达式转化为树的形式,便于计算和优化。
4.图形算法:许多图形算法,如最短路径算法和最小生成树算法,都可以使用树的结构进行表示和求解。树的特性可以帮助解决图形问题中的路径搜索和优化。
二叉树
二叉树的性质
二叉树的存储结构
二叉树的顺序存储结构
前面我们已经谈到了树的存储结构,并且谈到顺序存储对树这种—对多的关系结构实现起来是比较困难的。但是二叉树是—种特殊的树,由于它的特殊性,使得用顺序存储结构也可以实现。
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树。因为不是完全二叉树会有空间的浪费。
二叉链表
既然顺序存储适用性不强,我们就要考虑链式存储结构。二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域是比较自然的想法,我们称这样的链表叫做二叉链表。结点结构图如下表所示:
lchild | data | rchild |
其中 data 是数据域;lchild 和 rchild 都是指针域,分别存放指向左孩子和右孩子的指针。
以下是我们的二叉链表的结点结构定义代码:
/* 二叉树的二叉链表结点结构定义*/ typedef struct BiTNode // 结点结构 { TElemType data; // 结点中的数据域 struct BiTNode *lchild,*rchild; // 左右孩子指针 }BiTNode,*BiTree;
结构示意图如下图所示:
二叉树的遍历
二叉树的遍历(traversing binary tree)是二叉树的一种重要操作。二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。
这里有两个关键词:访问和次序。访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是—个抽象操作。在这里我们可以简单地假定访问就是输出结点的数据信息。
二叉树的遍历次序不同干线性结构,最多也就是从头至尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。
先序遍历
算法讲解
- 若二叉树为空,则空操作返回,否则遍历顺序:根结点->左子树->右子树。
- 动态图解:和上面的动态图一样,先序遍历就像一个小人从根结点开始,围绕二叉树的外圈开始跑(遇到缝隙就钻进去),按照跑的顺序,依次输出序列。
代码演示
void PreOrderTraversal(BiTree BT) { if( BT != NULL ) { printf(“%d\n”, BT->Data); //对节点的数据进行打印 PreOrderTraversal(BT->Left); //访问左子树 PreOrderTraversal(BT->Right); //访问右子树 } }
中序遍历
算法讲解
- 若二叉树为空,则空操作返回,否则遍历顺序:左子树->根结点->右子树。
- 动态图解:中序遍历就像投影仪一样,将二叉树从最左侧到最右侧依次投影到同一水平线上面,得到的从左到右的相关序列就是二叉树的中序遍历。
代码演示
void InOrderTraversal(BiTree BT) { if(BT != NULL) { InOrderTraversal(BT->Left); printf("%d\n", BT->Data); InOrderTraversal(BT->Right); } }
后序遍历
算法讲解
- 若二叉树为空,则空操作返回,否则遍历顺序:左子树->右子树->根结点。
- 动态图解:后序遍历就像剪葡萄,只能一个个剪,不能让超过1个的葡萄一起掉下来,那就错了。例如上图中的 B,剪去 B 后面的 D、E、H、I、J 都会掉下来(达咩),而 H 剪去只会掉下 H,规律就是这个规律。
代码演示
void PostOrderTraversal(BiTree BT) { if (BT != NULL) { PostOrderTraversal(BT->Left); PostOrderTraversal(BT->Right); printf("%d\n", BT->Data); } }
层次遍历
层次遍历就是从根节点开始,一层一层,从上到下,每层从左到右,依次取值。
代码演示
void LevelOrder(BiTree T){ InitQueue(Q); //初始化辅助队列 BiTree p; EnQueue(Q,T); //将根结点入队 while(!IsEmpty(Q)) { //队列不空则循环 DeQueue(Q,p); //队头结点出队 visit(p); //访问出队结点 if(p->1child!=NULL) EnQueue(Q,p->lchild);//左子树不空,则左子树根结点入队 if(p->rchild!=NULL) EnQueue(Q,p->rchild);//右子树不空,则右子树根结点入队 } }
C语言
以下是使用C语言实现二叉树(包括创建树、插入结点、删除结点、遍历树、计算树的深度/高度和大小等基础操作)的示例代码:
#include <stdio.h> #include <stdlib.h> // 定义二叉树节点 typedef struct Node { int data; struct Node* left; struct Node* right; } Node; // 创建新节点 Node* createNode(int data) { Node* newNode = (Node*)malloc(sizeof(Node)); if (newNode == NULL) { printf("Memory allocation failed\n"); return NULL; } newNode->data = data; newNode->left = NULL; newNode->right = NULL; return newNode; } // 插入节点(递归实现) Node* insertNode(Node* root, int data) { if (root == NULL) { return createNode(data); } else { if (data <= root->data) { root->left = insertNode(root->left, data); } else { root->right = insertNode(root->right, data); } return root; } } // 删除节点 Node* deleteNode(Node* root, int data) { if (root == NULL) { return root; } else if (data < root->data) { root->left = deleteNode(root->left, data); } else if (data > root->data) { root->right = deleteNode(root->right, data); } else { if (root->left == NULL && root->right == NULL) { free(root); root = NULL; } else if (root->left == NULL) { Node* temp = root; root = root->right; free(temp); } else if (root->right == NULL) { Node* temp = root; root = root->left; free(temp); } else { Node* minRight = findMin(root->right); root->data = minRight->data; root->right = deleteNode(root->right, minRight->data); } } return root; } // 查找最小节点 Node* findMin(Node* root) { while (root->left != NULL) { root = root->left; } return root; } // 计算树的深度/高度 int calculateHeight(Node* root) { if (root == NULL) { return 0; } else { int leftHeight = calculateHeight(root->left); int rightHeight = calculateHeight(root->right); return (leftHeight > rightHeight) ? (leftHeight + 1) : (rightHeight + 1); } } // 计算树的大小(节点数) int calculateSize(Node* root) { if (root == NULL) { return 0; } else { int leftSize = calculateSize(root->left); int rightSize = calculateSize(root->right); return leftSize + rightSize + 1; } } // 先序遍历(根-左-右) void preorderTraversal(Node* root) { if (root != NULL) { printf("%d ", root->data); preorderTraversal(root->left); preorderTraversal(root->right); } } // 中序遍历(左-根-右) void inorderTraversal(Node* root) { if (root != NULL) { inorderTraversal(root->left); printf("%d ", root->data); inorderTraversal(root->right); } } // 后序遍历(左-右-根) void postorderTraversal(Node* root) { if (root != NULL) { postorderTraversal(root->left); postorderTraversal(root->right); printf("%d ", root->data); } } int main() { Node* root = NULL; // 插入节点 root = insertNode(root, 4); root = insertNode(root, 2); root = insertNode(root, 6); root = insertNode(root, 1); root = insertNode(root, 3); root = insertNode(root, 5); root = insertNode(root, 7); // 先序遍历 printf("Preorder traversal: "); preorderTraversal(root); printf("\n"); // 中序遍历 printf("Inorder traversal: "); inorderTraversal(root); printf("\n"); // 后序遍历 printf("Postorder traversal: "); postorderTraversal(root); printf("\n"); // 删除节点 root = deleteNode(root, 3); // 先序遍历(删除节点后) printf("Preorder traversal (after deletion): "); preorderTraversal(root); printf("\n"); // 计算树的深度/高度 int height = calculateHeight(root); printf("Tree height: %d\n", height); // 计算树的大小(节点数) int size = calculateSize(root); printf("Tree size: %d\n", size); return 0; }
结论
树作为一种重要的数据结构,在计算机科学中有广泛的应用。通过理解树的基本概念、常见类型和应用场景,我们可以更好地理解和运用树结构解决实际问题。通过进一步的学习和实践,我们可以不断深入和拓展树的应用领域,提升算法和数据结构的能力。