👉树的概念及结构👈
树的概念
树是一种非线性的数据结构,它是由n(n >= 0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
有一个特殊的结点,称为根结点,根节点没有前驱结点。
除根节点外,其余结点被分成M(M>0)个互不相交的集合 T1、T2、……、Tm,其中每一个集合 Ti (1<= i <= m) 又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有 0 个或多个后继。因此,树是递归定义的。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
如果子树有交集,那么这个结构就不再是树,而是更加高级的数据结构 - - 图
树的相关概念
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A 的为 6
叶节点或终端节点:度为 0 的节点称为叶节点; 如上图:B、C、H、I 等节点为叶节点
非终端节点或分支节点:度不为 0 的节点; 如上图:D、E、F、G 等节点为分支节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A 是 B 的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B 是 A 的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C 是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为 6
节点的层次:从根开始定义起,根为第 1 层,根的子节点为第 2 层,以此类推;
树的高度或深度:树中节点的最大层次; 如上图:树的高度为 4
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I 互为兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A 是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
森林:由 m(m>0)棵互不相交的树的集合称为森林(并查集)
以上有关于树的概念,是在树加上人类亲属关系的基础上建立起来的。知道了这些,我们现在来学一下树的表示。
树的表示
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间
的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法
等。
如果知道了树的度为N
,我们可以像下面这样来定义树。
#define N 5 struct TreeNode { int data; struct TreeNode* childArr[N]; int childSize; // 孩子个数 }
虽然树向上面那样定义是可以的,但是比较浪费空间。因为不是每个节点的都都为N。有人又想出了另一种定义树的方法。如下图代码所示:
struct TreeNode { int data; // 顺序表存储孩子节点指针 struct TreeNode** childArr; int childSize; // 孩子个数 int childCapacity; // 孩子容量 }
以上的定义方式可以做到,你想要多少空间就就动态申请多少空间。不过这种树也是相当地复杂,又有人想出更加厉害的定义树的方法,称之为左孩子右兄弟表示法。
左孩子右兄弟表示法
typedef int TDataType; struct Node { struct Node* firstChild1; // 左孩子 struct Node* pNextBrother; // 右兄弟 TDataType data; };
左孩子右兄弟表示法就是父亲指向左边第一个孩子,孩子之间用兄弟指针链接起来。
双亲表示法
树在实际中的运用
表示文件系统的目录树结构
👉二叉树概念及结构👈
概念
一棵二叉树是结点的一个有限集合,该集合为空或者由一个根节点加上两棵被称为左子树和右子树的二叉树组成
从上图可以看出:
- 二叉树不存在度大于 2 的结点
- 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意:对于任意的二叉树都是由以下几种情况复合而成的
特殊的二叉树
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为 K,且结点总数是 2 K 2^{K}2
K
- 1,则它就是满二叉树。
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为 K 的,有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。
现实中的二叉树
二叉树的性质
若规定根节点的层数为 1,则一棵非空二叉树的第 i 层上最多有 2 i − 1 2^{i - 1}2
i−1
个结点
若规定根节点的层数为 1,则深度为 h 的二叉树的最大结点数是 2 h 2^{h}2
h
- 1
对任何一棵二叉树, 如果度为 0 的叶结点个数为 n0, 度为 2 的分支结点个数为 n2,则有 n0= n2 + 1
若规定根节点的层数为 1,具有 n 个结点的满二叉树的深度 h = . l o g 2 n log_2{n}log
2
n
对于具有 n 个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从 0 开始编号,则对于序号为 i 的结点有:
若 i > 0,i 位置节点的双亲序号为 (i - 1) / 2;i = 0时,i 为根节点编号,无双亲节点
若2i + 1 < n,左孩子序号为2i + 1。当2i + 1 >= n时,则该节点无左孩子
若2i + 2 < n,右孩子序号为2i + 2,当2i + 2 >= n时,则该节点无右孩子
现在我们来做一道选择题,加深对上面的概念和性质的理解。
在具有2n个结点的完全二叉树中,叶子结点个数为( )
A. n
B. n+1
C. n-1
D. n/2
二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
- 顺序存储
- 顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
链式存储
二叉树的链式存储结构是指用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面学到高阶数据结构如红黑树等会用到三叉链。
👉二叉树的顺序结构及实现👈
二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种完全二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
堆的概念及结构
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树
一道选择题
1.下列关键字序列为堆的是(
A
)A 100,60,70,50,32,65
B 60,70,65,50,32,100
C 65,100,70,32,50,60
D 70,65,100,32,50,60
E 32,50,100,70,65,60
F 50,100,70,65,60,32
堆的实现
堆是一种完全二叉树,我们可以使用顺序表来存储堆的数据。堆要实现的函数接口有初始化堆、销毁堆、打印堆、插入数据 x 并保持堆的形态、删除堆顶的数据、返回堆顶数据、判断堆是否为空以及堆中数据的个数。
1.Heap.h
#pragma once #include <stdio.h> #include <assert.h> #include <stdlib.h> #include <stdbool.h> typedef int HPDataType; typedef struct Heap { HPDataType* a; int size; int capacity; }HP; void HeapInit(HP* php); void HeapDestroy(HP* php); void HeapPrint(HP* php); // 插入数据x并保持堆的形态 void HeapPush(HP* php, HPDataType x); // 删除堆顶的元素 -- 找次大或次小的数 // log(N) void HeapPop(HP* php); // 返回堆顶元素 HPDataType HeapTop(HP* php); bool HeapEmpty(HP* php); int HeapSize(HP* php);
2.Heap.c
#include "Heap.h" // 初始化堆 void HeapInit(HP* php) { assert(php); php->a = NULL; php->size = php->capacity = 0; } // 销毁堆 void HeapDestroy(HP* php) { assert(php); free(php->a); php->size = php->capacity = 0; } // 打印堆 void HeapPrint(HP* php) { assert(php); for (int i = 0; i < php->size; i++) { printf("%d ", php->a[i]); } printf("\n"); } // 交换堆的数据 void Swap(HPDataType* p1, HPDataType* p2) { HPDataType tmp = *p1; *p1 = *p2; *p2 = tmp; } // 向上调整算法 void AdjustUp(HPDataType* a, int child) { int parent = (child - 1) / 2; while (child > 0) { if (a[child] < a[parent]) { Swap(&a[child], &a[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } } // 插入数据x并保持堆的形态 void HeapPush(HP* php, HPDataType x) { assert(php); if (php->size == php->capacity) { int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2; HPDataType* tmp = (HPDataType*)realloc(php->a, newCapacity * sizeof(HPDataType)); if (tmp == NULL) { perror("realloc fail"); exit(-1); } php->a = tmp; php->capacity = newCapacity; } php->a[php->size] = x; php->size++; AdjustUp(php->a, php->size - 1); } // 向下调整算法 // n为堆中元素的个数 void AdjustDown(HPDataType* a, int n, int parent) { int minChild = 2 * parent + 1; while (minChild < n) { if (minChild + 1 < n && a[minChild + 1] < a[minChild])// 有左孩子且左孩子的值小于右孩子的值 { minChild++; } if (a[minChild] < a[parent]) { Swap(&a[minChild], &a[parent]); parent = minChild; minChild = 2 * parent + 1; } else { break; } } } // 删除堆顶的元素 -- 找次大或次小的数 // log(N) void HeapPop(HP* php) { assert(php); assert(!HeapEmpty(php)); Swap(&php->a[0], &php->a[php->size - 1]); php->size--; AdjustDown(php->a, php->size, 0); } // 返回堆顶元素 HPDataType HeapTop(HP* php) { assert(php); assert(!HeapEmpty(php)); return php->a[0]; } // 判断堆是否为空 bool HeapEmpty(HP* php) { assert(php); return php->size == 0; } // 堆中数据的个数 int HeapSize(HP* php) { assert(php); return php->size; }
插入数据x并保持堆的形态
1.判断是否需要扩容
2.插入数据
3.使用向上调整算法保持堆的形态
// 插入数据x并保持堆的形态 void HeapPush(HP* php, HPDataType x) { assert(php); if (php->size == php->capacity) { int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2; HPDataType* tmp = (HPDataType*)realloc(php->a, newCapacity * sizeof(HPDataType)); if (tmp == NULL) { perror("realloc fail"); exit(-1); } php->a = tmp; php->capacity = newCapacity; } php->a[php->size] = x; php->size++; AdjustUp(php->a, php->size - 1); }
向上调整算法
// 向上调整算法 void AdjustUp(HPDataType* a, int child) { int parent = (child - 1) / 2; while (child > 0) { if (a[child] < a[parent]) { Swap(&a[child], &a[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } }
以小根堆为例,大根堆同理。小根堆向上调整算法:如果插入的数据x小于父节点的数据,那么就交换x和父节点的数据,然后更新子节点的下标child和父节点的下标parent,以此类推直至数据x大于父节点的数据或者child <= 0。
删除堆顶的数据
1.判断堆是否为空
2.交换堆顶的数据和最后一个数据
3.删除堆顶的数据php->size--
4.使用向下调整算法保持堆的形态
// 删除堆顶的元素 -- 找次大或次小的数 // log(N) void HeapPop(HP* php) { assert(php); assert(!HeapEmpty(php)); Swap(&php->a[0], &php->a[php->size - 1]); php->size--; AdjustDown(php->a, php->size, 0); }
向下调整算法
使用向下调整算法的前提:左右子树必须是一个堆,才能调整。也就是说除了根节点不满足堆,因此需要从根节点开始向下调整为堆。
// n为堆中元素的个数 void AdjustDown(HPDataType* a, int n, int parent) { int minChild = 2 * parent + 1; while (minChild < n) { if (minChild + 1 < n && a[minChild + 1] < a[minChild])// 有左孩子且左孩子的值小于右孩子的值 { minChild++; } if (a[minChild] < a[parent]) { Swap(&a[minChild], &a[parent]); parent = minChild; minChild = 2 * parent + 1; } else { break; } } }
以小根堆为例,大根堆同理。小根堆向下调整算法:创建变量minChild,记录较小孩子的下标并赋初始值为2 * parent + 1。若有右孩子且右孩子的值小于左孩子的值,则minChild++。当孩子的值小于父节点的值时,交换孩子的值和父节点的值,并更新子节点的下标minChild和父节点的下标parent。;当孩子的值大于父节点的值或者子节点的下标minChild大于堆中数据的个数n时,退出while循环,向下调整结束。
3.Test.c
以下为函数接口的测试代码,大家可以参考一下。
#include "Heap.h" int main() { int a[] = { 15, 18, 19, 25, 28, 34, 65, 49, 27, 37 }; //int a[] = { 65, 100, 70, 32, 50, 60 }; HP hp; HeapInit(&hp); for (int i = 0; i < sizeof(a) / sizeof(int); ++i) { HeapPush(&hp, a[i]); } HeapPrint(&hp); HeapPush(&hp, 10); HeapPrint(&hp); //HeapPop(&hp); //HeapPrint(&hp); //HeapPop(&hp); //HeapPrint(&hp); while (!HeapEmpty(&hp)) { printf("%d ", HeapTop(&hp)); HeapPop(&hp); } return 0; }
👉堆的应用👈
堆的创建
通过Test.c源文件的代码,我们可以看到,我们是将数组a中的数据全部插入到堆中,才建成堆的。而不是直接将数组a建成一个堆,那这样的建堆方式似乎过于麻烦。因为我们还需要将堆的实现代码写出来,才能建成堆。为了解决这个问题,我们现在就来学习堆的创建。以建小根堆为例,建大根堆同理。
1.向上调整建堆
将数组第一个元素看成一个堆,然后利用
for
循环模拟数据插入的过程向上调整建堆。向上调整建堆的时间复杂度为O(N*logN)
。
#include <stdio.h> void Swap(int* x, int* y) { int tmp = *x; *x = *y; *y = tmp; } // 向上调整算法 void AdjustUp(int* a, int child) { int parent = (child - 1) / 2; while (child > 0) { if (a[child] < a[parent]) { Swap(&a[child], &a[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } } // 堆排序 void HeapSort(int* a, int size) { // 建堆 -- 向上调整建堆 - O(N*logN) // for循环模拟数据插入的过程向上调整 for (int i = 1; i < size; i++) { AdjustUp(a, i); } for (int i = 0; i < size; i++) { printf("%d ", a[i]); } } int main() { int a[] = { 15,1,19,25,8,34,65,4,27,7 }; int sz = sizeof(a) / sizeof(a[0]); HeapSort(a, sz); return 0; }
2.向下调整建堆
使用向下调整算法的前提是左右子树都是堆,那么我们就从倒数第一个非叶子节点(最后一个节点的父节点)开始向下调整,直到调整到根。向下调整建堆的时间复杂度为O(N)。
#include <stdio.h> void Swap(int* x, int* y) { int tmp = *x; *x = *y; *y = tmp; } // 向下调整算法 void AdjustDown(int* a, int size, int parent) { int minChild = 2 * parent + 1; while (minChild < size) { // 右孩子存在且右孩子小于左孩子 if (minChild + 1 < size && a[minChild + 1] < a[minChild]) { minChild++; } if (a[minChild] < a[parent]) { Swap(&a[minChild], &a[parent]); parent = minChild; minChild = 2 * parent + 1; } else { break; } } } // 堆排序 void HeapSort(int* a, int size) { // 建堆 -- 向下调整建堆 - O(N) // 从倒数第一个非叶节点(最后一个节点的父亲)开始向下调整直到调整到根节点 for (int i = (size - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, size, i); } for (int i = 0; i < size; i++) { printf("%d ", a[i]); } } int main() { int a[] = { 15,1,19,25,8,34,65,4,27,7 }; int sz = sizeof(a) / sizeof(a[0]); HeapSort(a, sz); return 0; }
3.建堆的实现复杂度证明
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的
就是近似值,多几个节点不影响最终结果)。向上调整建堆的时间复杂度为O(N*logN),向下调整建堆的时间复杂度为O(N)。
向上调整建堆
向下调整建堆
堆排序
堆排序即利用堆的思想来进行排序,总共分为两个骤:
建堆
升序:建大堆
降序:建小堆
利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
以升序为例,降序同理。升序建大根堆,堆顶的数据跟最后位置的数据交换,把最后位置的数据不看做堆里面的数据,向下调整,选出次大的数据,依次类推就能实现升序。
#include <stdio.h> void Swap(int* x, int* y) { int tmp = *x; *x = *y; *y = tmp; } // 向下调整算法 void AdjustDown(int* a, int size, int parent) { int minChild = 2 * parent + 1; while (minChild < size) { // 右孩子存在且右孩子小于左孩子 if (minChild + 1 < size && a[minChild + 1] > a[minChild]) { minChild++; } if (a[minChild] > a[parent]) { Swap(&a[minChild], &a[parent]); parent = minChild; minChild = 2 * parent + 1; } else { break; } } } // 堆排序 void HeapSort(int* a, int size) { // 大思路:选择排序,依次选数,从后往前排 // 升序 -- 建大根堆 // 降序 -- 建小根堆 // 建堆 -- 向下调整建堆 - O(N) // 从倒数第一个非叶节点(最后一个节点的父亲)开始向下调整直到调整到根节点 for (int i = (size - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, size, i); } // 选数 int i = 1; while (i < size) { Swap(&a[0], &a[size - i]); AdjustDown(a, size - i, 0); i++; } for (i = 0; i < size; i++) { printf("%d ", a[i]); } } int main() { int a[] = { 15,1,19,25,8,34,65,4,27,7 }; int sz = sizeof(a) / sizeof(a[0]); HeapSort(a, sz); return 0; }
堆排序的时间复杂度为O(N*logN),相较于O(N^2)的排序算法已经快了很多。比如:用O(N*logN)和O(N^2)的排序算法来排序 100w 个数据,看看它们的效率差别。
TOP-K问题
TOP-K 问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于 Top-K 问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
用数据集合中前 K 个元素来建堆
前K个最大的元素,则建小堆
前K个最小的元素,则建大堆
用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素,将剩余 N-K 个元素依次与堆顶元素比完之后,堆中剩余的 K 个元素就是所求的前K 个最小或者最大的元素。
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <assert.h> // 创建1w个数据的文件 void CreateDataFile(const char* filename, int N) { FILE* fin = fopen(filename, "w"); if (fin == NULL) { perror("fopen fail"); return; } srand((unsigned int)time(NULL)); for (int i = 0; i < N; ++i) { fprintf(fin, "%d\n", rand() % 30000); // 生成N个小于30000的数据 } fclose(fin); fin = NULL; } // 选出前K大的数 void PrintTopK(const char* filename, int K) { assert(filename); FILE* fout = fopen(filename, "r"); if (fout == NULL) { perror("fopen fail"); return; } int* minHeap = (int*)malloc(sizeof(int) * K); if (minHeap == NULL) { perror("malloc fail"); return; } // 如何读取前K个数据 for (int i = 0; i < K; ++i) { fscanf(fout, "%d", &minHeap[i]); } // 建K个数小堆 for (int j = (K - 2) / 2; j >= 0; --j) { AdjustDown(minHeap, K, j); } // 继续读取后N-K个数据 int val = 0; while (fscanf(fout, "%d", &val) != EOF) { if (val > minHeap[0]) { minHeap[0] = val; AdjustDown(minHeap, K, 0); } } for (int i = 0; i < K; ++i) { printf("%d ", minHeap[i]); } free(minHeap); fclose(fout); fout = NULL; } int main() { const char* filename = "Data.txt"; int N = 10000; int K = 10; CreateDataFile(filename, N); PrintTopK(filename, K); return 0; }
上面的程序,利用随机数生成器(srand函数只会生成0~32767的数字)在Data.txt文件中生成了 1w 个小于 30000 的数据,然后选出 10 大的数据。那我们该如何验证这个程序的正误呢?我们可以在Data.txt文件加上几个大于 30000 的数据,来验证程序的正误。 注意:修改了Data.txt文件后,记得将CreateDataFile函数屏蔽掉,否则Data.txt文件里的数据会被刷新。
👉总结👈
本篇博客主要讲解了树和二叉树的基本概念、二叉树的性质、堆的实现、堆排序已经TOP-K问题。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!💖💝❣️