目录:
- 前言
- 一、树
- 1.什么是树结构?
- 2.为什么使用树结构?
- 3.树的概念
- 4.树的表示
- 5.树在现实生活中的应用
- 二、二叉树
- (一)二叉树概念既结构
- 1.概念
- 2.现实中的二叉树
- 3.特殊的二叉树
- 4.二叉树的性质
- 5.二叉树的存储结构
- (二)二叉树的顺序存储既实现
- 1.二叉树的顺序存储
- 2.堆的概念既结构
- 3.堆的实现
- 向下调整算法
- 堆的创建
- 建堆时间复杂度
- 堆的插入
- 堆的删除
- 4.堆的代码实现
- 5.堆的应用
- 堆排序
- Top-k问题
- (三)二叉树的链式存储既实现
- 1.二叉树的遍历
- 2.层序遍历
- 3.基本操作补充
- 总结
前言
打怪升级:第8天 |
---|
朋友们大家好啊,前段时间我们结束了线性存储结构的学习,经过之前的刷级打怪,今天我们就要开启新的地图、挑战更强大的怪兽 - - 树。
一、树
1.什么是树结构?
树,顾名思义就是像现实生活中的树一样的,不过这棵树和我们平常见到的树不太一样:它是一颗倒立的树。
如下图:
树:树是由n(n>=0)个有限节点组成带有层次结构的集合。由图我们可以看出树是一种非线性数据结构,把它叫做树是因为他看起来像是一个倒挂的树。
2.为什么使用树结构?
我们在前面学到的线性存储结构包含:顺序表和链表两种,而线性表中数据的对应关系为:1对1,
也就是说一个数据最多只能和一个数据存在关联,换句话说就是一个人最多只能认识一个其他人。
那么显而易见,这种存储结构是不能满足我们现实生活使用的,而今天要介绍的树则可以将多个数据关联在一起,
就比如:我们的族谱、公司或学校的领导管理层次划分等。
3.树的概念
-
有一个特殊节点:根节点,根节点没有前驱节点
-
除根节点外,其他节点被分成m个互不相交的集合T1,T2,…Tn,其中每一个集合Ti(0<=i<=m)又是一颗结构与树类似的子树,每一个根节点有唯一一个前驱,可以有0个、或多个后继,因此树是递归定义的。
-
需要注意的是:树形结构中子树之间不能存在交集,否则就不是树了(属于图)。
如下图:
-
节点的度:一个节点含有的子树的个数叫做节点的度,比如上图中:A的度为3,B的度为2;
-
叶子节点或终端节点:度为0的节点称为叶子结点,比如上图中:J, F, K, L, H, I;
-
非终端节点或分支节点:度不为0的节点,如上图的:B, C, D, E等;
-
双亲节点或父节点:若一个节点含有子节点,那这个节点就称为它子节点的父节点,如上图中:A是B的父节点;
-
孩子节点或子节点:若一个节点含有子节点,那这个节点的子节点就是它的孩子节点,如上图中:B是A的子节点;
-
兄弟节点:具有相同父节点的节点互称为兄弟节点,如上图中:B, C, D互称为兄弟节点;
-
树的度:一个树中最大的节点的度称为树的度,如上图中:树的度为3;
-
节点的层次:从根节点开始计为1(或0)的话,第二层就是2(或1),以此类推;
如图: -
树的高度或深度: 树中节点的最大层次,上图中为:4;
-
堂兄弟节点:双亲在同一层次的节点互为堂兄弟节点,如上图中:F和G,F和H;
-
节点的祖先:从根节点出发到达该节点所经过的所以节点都是它的祖先节点(父节点也是它的祖先节点),如上图中A是所有节点的祖先;
-
节点的子孙:以某节点为根的子树的所有节点都是它的子孙节点,如上图中:所以节点都是A的子孙节点;
-
森林:由m(m>0)棵互不相交的树的集合称为森林。
4.树的表示
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存结点和结点之间
的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法
等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};
5.树在现实生活中的应用
二、二叉树
(一)二叉树概念既结构
1.概念
一棵二叉树是结点的一个有限集合,该集合:
- 或者为空;
- 由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
如下图:
从上图可以看出: - 二叉树不存在度大于2的节点;
- 二叉树有左右子树之分,次序不能颠倒,因此二叉树为有序树。
注意:任意二叉树都是由以下几种情况复合而成的:
2.现实中的二叉树
3.特殊的二叉树
- 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是
说,如果一个二叉树的层数为K,且结点总数是 2^k -1 ,则它就是满二叉树。 - 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K
的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对
应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
示例:
4.二叉树的性质
- 若规定根节点为1,则一棵非空二叉树第i层上最多有 2^(i-1) 个节点;
- 若规定根节点为1,则一棵非空二叉树最多有 2^n-1 个节点;
- 对任意一棵二叉树,如果度为0的叶子节点有n0个,度为2的节点有n2个,则有:n0 = n2 + 1;
- 若规定根节点为1,则一棵有n 个节点的完全二叉树深度为:h = log(n + 1),(n = 2^h -1);
- 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对
于序号为i的结点有:
①若 i > 0 ,则 i 位置的双亲节点序号为: (i - 1) / 2
②若 i * 2 + 1 < n ,左孩子下标为: i * 2 + 1,如果 i * 2 + 1 >= n ,则无左孩子
③若 i * 2 + 2 < n, 右孩子下标为: i * 2 + 2,如果 i * 2 + 2 >= n ,则无右孩子
5.二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
- 顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空
间的浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们放在下面讲解。
二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
- 链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是
链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所
在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面课程
学到高阶数据结构如红黑树等会用到三叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
BTDataType data; // 当前节点值域
BinaryTreeNode* left; // 指向当前节点左孩子
BinaryTreeNode* right; // 指向当前节点右孩子
};
// 三叉链
struct BinaryTreeNode
{
BTDataType data; // 当前节点值域
BinaryTreeNode* parent; // 指向当前节点双亲
BinaryTreeNode* left; // 指向当前节点左孩子
BinaryTreeNode* right; // 指向当前节点右孩子
};
(二)二叉树的顺序存储既实现
1.二叉树的顺序存储
普通二叉树是不适合顺序存储的,那可能会造成大量的空间浪费,而完全二叉树更适合顺序存储。
在现实生活中我们把**堆(一种二叉树)**使用顺序结构来存储。
需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2.堆的概念既结构
如果有一个关键码的集合K = { k0,k1 ,k2 ,…,k n-1 },把它的所有元素按完全二叉树的顺序存储方式存储
在一个一维数组中,并满足: k i<= k 2i+1且 k i<= k 2i+2( k i>= k 2i+1且 k i>= k 2i+2) i = 0,1,
2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆的根节点一定不大于或不小于它的子节点;
- 堆一定是完全二叉树。
3.堆的实现
堆的实现可以使用向下调整和向上调整两种方法,不过由于向上调整比向下调整时间复杂度大很多(向上调整:O(nlogn),向下O(n) ),因此在实际使用是我们都使用向下调整,下面我们使用的方法也是向下调整。
向下调整算法
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整
成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
int array[] = {27,15,19,18,28,34,65,49,25,37};
堆的创建
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算
法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的
子树开始调整,一直调整到根节点的树,就可以调整成堆。
int a[] = {1,5,3,8,7,6};
建堆时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的
就是近似值,多几个节点不影响最终结果):
因此建堆的时间复杂度位O(n)。
堆的插入
插入数据到数组的尾上,然后通过向上调整算法,直到满足堆。
堆的删除
删除堆是删除堆顶数据,将最后堆顶数据与数组最后一个数据交换,删除最后一个数据,再对堆顶数据进行向下调整。
4.堆的代码实现
// 堆 -- 数组
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
// 堆的构建
void HeapCreate(Heap* php, HPDataType* a, int n)
{
assert(php);
php->_a = a;
php->_size = php->_capacity = n;
for (int i = (php->_size - 2) / 2; i >= 0; i--) // 从第一个非叶子节点开始向下调整
{
AdjustDown(php->_a, n, i);
}
}
//堆的销毁
void HeapDestroy(Heap* php)
{
assert(php);
php->_a = NULL;
php->_size = php->_capacity = 0;
}
static void Swap(HPDataType*x, HPDataType*y)
{
HPDataType z = *x;
*x = *y;
*y = z;
}
// 向上调整 -- 大堆存储
static void AdjustUp(Heap* php)
{
assert(php);
int child = php->_size - 1;
while (child > 0)
{
int parent = (child - 1) / 2;
if (php->_a[child] > php->_a[parent])
{
Swap(&php->_a[child], &php->_a[parent]);
child = parent;
}
else
{
break;
}
}
}
//堆的插入,同时保持堆的形态
void HeapPush(Heap* php, HPDataType data)
{
assert(php);
if (php->_size == php->_capacity)
{
int newcapacity = php->_capacity == 0 ? 4 : php->_capacity * 2;
HPDataType* newnode = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * newcapacity);
if (newnode == NULL)
{
perror("HeapPush::malloc");
exit(-1);
}
php->_a = newnode;
php->_capacity = newcapacity;
}
php->_a[php->_size++] = data;
AdjustUp(php);
}
// 堆的打印
void HeapPrint(Heap* php)
{
assert(php);
for (int i = 0; i < php->_size; i++)
{
printf("%d ", php->_a[i]);
}
printf("\n");
}
// 向下调整
static void AdjustDown(HPDataType* arr, int n, int parent)
{
assert(arr);
int child = parent * 2 + 1; // 左孩子下标
while (child < n)
{
if (child + 1 < n && arr[child + 1] < arr[child]) // 右孩子存在,并且值更大
child++;
if (arr[parent] > arr[child])
{
Swap(&arr[parent], &arr[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
// 堆的判空 为空返回 1
bool HeapEmpty(Heap* php)
{
assert(php);
return php->_size == 0;
}
//堆的删除,保持堆的形态
void HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->_a[php->_size - 1], &php->_a[0]); // 将最后一个数据放到第一个
php->_size--;
// 将交换到最上面的数据进行向下调整
AdjustDown(php->_a, php->_size, 0);
}
//获取堆中数据个数
int HeapSize(Heap* php)
{
assert(php);
return php->_size;
}
//获取堆顶数据
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->_a[0];
}
5.堆的应用
堆排序
堆排序是利用堆的思想来排序
- 建堆:
升序:建大堆
降序:建小堆- 利用堆删除的特性来进行排序
建堆和堆删除过程中都用到了向下调整,因此掌握了向下调整就可以实现堆排序。
升序排序思想介绍:
首先建大堆,此时堆顶数据就是最大值,我们在每次删除的过程中会将当前堆顶元素换到最后,后重新调整堆,那么我们每次删除数据时都会将当前堆中的最大值调整到最后,直到堆中只剩下一个数据就结束,此时获得的数据就是按升序排列好的。
如图:
代码如下:
void HeapSort(int* a, int n)
{
Heap hp;
HeapCreate(&hp, a, n);
while(HeapSize(&hp) > 1)
{
HeapPop(&hp);
}
HeapDestory(&hp);
}
Top-k问题
Top-k问题:求数据集合中前k个最大元素或最小元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能
数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆- 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
示例:
void PrintTopK(int* a, int n, int k)
{
// 1. 建堆--用a中前k个元素建堆
Heap hp;
HeapCreate(&hp, a, k);
int i=k;
while(i<n)
{
if(hp._a[0]<a[i])
{
Swap(&hp._a[0], a[i]);
AdjustDown(hp._a, k, 0);
}
i++;
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满足则替换
for(i=0;i<k;i++)
{
printf("%d ", hp._a[i]);
}
printf("\n");
HeapDestroy(&hp);
}
void TestTopk()
{
int n = 100000;
int* a = (int*)malloc(sizeof(int)*n);
srand((unsigned int)time(NULL));
for (size_t i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;
}
a[5] = 1000000 + 1; // 额外创建k个数据,便于判断结果的有效性
a[1231] = 1000000 + 2;
a[531] = 1000000 + 3;
a[5121] = 1000000 + 4;
a[115] = 1000000 + 5;
a[2335] = 1000000 + 6;
a[9999] = 1000000 + 7;
a[76] = 1000000 + 8;
a[423] = 1000000 + 9;
a[3144] = 1000000 + 10;
PrintTopK(a, n, 10);
}
(三)二叉树的链式存储既实现
在学习二叉树的实现之前先来分析一下二叉树的结构:
- 空树
- 非空树:根节点,根节点的左子树,根节点的右子树
从概念中可以看出二叉树是递归定义的,因此在后序基本操作中基本都是按照概念实现的。
1.二叉树的遍历
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉
树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历
是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
- 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
- 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
- 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为
根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
根的前序遍历图解:
代码实现:
void BinaryTreePrevOrder(BTNode* root)
{
if (root)
{
printf("%c->", root->_data);
BinaryTreePrevOrder(root->_left);
BinaryTreePrevOrder(root->_right);
}
else
{
printf("NULL->");
}
}
// 二叉树中序遍历
void BinaryTreeInOrder(BTNode* root)
{
if (root)
{
printf("%c->", root->_data);
BinaryTreeInOrder(root->_left);
BinaryTreeInOrder(root->_right);
}
else
{
printf("NULL->");
}
}
// 二叉树后序遍历
void BinaryTreePostOrder(BTNode* root)
{
if (root)
{
printf("%c->", root->_data);
BinaryTreePostOrder(root->_left);
BinaryTreePostOrder(root->_right);
}
else
{
printf("NULL->");
}
}
2.层序遍历
层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在
层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层
上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历
typedef BTNode* QDataType;
// 链式结构:表示队列
typedef struct QListNode
{
QDataType _data;
struct QListNode* _next;
}QNode;
// 队列的结构
typedef struct Queue
{
int len;
QNode* _front;
QNode* _rear;
}Queue;
// 初始化队列
void QueueInit(Queue* q)
{
assert(q);
q->len = 0;
q->_front = q->_rear = NULL;
}
// 队尾入队列
void QueuePush(Queue* q, QDataType data)
{
QNode* tmp = (QNode*)malloc(sizeof(QNode));
if (tmp == NULL)
exit(-1);
tmp->_data = data;
tmp->_next = NULL;
if (q->_front == NULL) // 空队列
{
q->_front = q->_rear = tmp;
}
else
{
q->_rear->_next = tmp;
q->_rear = q->_rear->_next;
}
q->len++;
}
// 检测队列是否为空,为空返回1
bool QueueEmpty(Queue* q)
{
assert(q);
return q->len == 0;
}
// 队头出队列
void QueuePop(Queue* q)
{
assert(q);
if (QueueEmpty(q))
exit(-1);
QNode* next = q->_front->_next;
free(q->_front);
q->_front = next;
if (q->_front == NULL)
q->_rear = NULL;
q->len--;
}
// 获取队列头部元素
QDataType QueueFront(Queue* q)
{
assert(q);
if (QueueEmpty(q))
exit(-1);
return q->_front->_data;
}
// 销毁队列
void QueueDestroy(Queue* q)
{
assert(q);
QNode* cur = q->_front;
while (cur)
{
QNode* next = cur->_next;
free(cur);
cur = next;
}
}
// 层序遍历 -- 借助队列,出一个根节点,进两个孩子
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root);
BTNode* cur = NULL;
while (cur || !QueueEmpty(&q))
{
cur = QueueFront(&q); // 出一个 ,进两个
QueuePop(&q);
printf("%c->", cur->_data);
if (cur->_left)
QueuePush(&q, cur->_left);
if (cur->_right)
QueuePush(&q, cur->_right);
cur = NULL;
}
QueueDestroy(&q);
}
3.基本操作补充
// 通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi);
// 二叉树销毁
void BinaryTreeDestory(BTNode* root);
// 二叉树节点个数
int BinaryTreeSize(BTNode* root);
// 二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root);
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k);
// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x);
// 判断二叉树是否是完全二叉树
bool BinaryTreeComplete(BTNode* root);
// 通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi)
{
assert(a);
if (a[*pi] == '#')
{
return NULL;
}
else
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
newnode->_data = a[*pi];
(*pi)++;
newnode->_left = BinaryTreeCreate(a, n, pi);
(*pi)++;
newnode->_right = BinaryTreeCreate(a, n, pi);
return newnode;
}
}
// 二叉树销毁
void BinaryTreeDestory(BTNode* root)
{
if (root)
{
BinaryTreeDestory(root->_left);
BinaryTreeDestory(root->_right);
free(root);
}
}
// 二叉树节点个数
int BinaryTreeSize(BTNode* root)
{
if (root)
return 1 + BinaryTreeSize(root->_left) + BinaryTreeSize(root->_right);
else
return 0;
}
// 二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root)
{
if (root)
{
if (root->_left == NULL && root->_right == NULL)
return 1;
else
return BinaryTreeLeafSize(root->_left) + BinaryTreeLeafSize(root->_right);
}
else
return 0;
}
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root)
{
if (k == 1)
return 1;
else
return BinaryTreeLevelKSize(root->_left, k - 1) + BinaryTreeLevelKSize(root->_right, k - 1);
}
else
return 0;
}
// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root)
{
if (root->_data == x)
return root;
else
{
BTNode* target = BinaryTreeFind(root->_left, x);
if (target)
return target;
else
return BinaryTreeFind(root->_right, x);
}
}
else
return NULL;
}
// 判断二叉树是否是完全二叉树
bool BinaryTreeComplete(BTNode* root)
{
if (root)
{
if (root->_left == NULL && root->_right != NULL) // 此情况则不是完全二叉树
return false;
return BinaryTreeComplete(root->_left)
&& BinaryTreeComplete(root->_right);
}
else
return true;
}
总结
文章内容汇总:
树的介绍、
二叉树的顺序存储–堆,
以及链式存储,
堆的应用–堆排序、top-k问题,
以及二叉树链式存储相关的基本操作,
如果有什么疑问或者建议都可以在评论区留言,感谢大家对的支持。