一、树的概念和结构
1.树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
- 有一个特殊的节点,称为根节点,根结点没有前驱结点。
- 除根节点外,其余节点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i<= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
- 因此,树是递归定义的。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
- 结点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的度为6。
- 叶节点或终端节点:度为0的节点称为叶节点; 如上图。
- 非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点。
- 双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点。
- 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点。
- 兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点。
- 树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6。
- 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。
- 树的高度或深度:树中节点的最大层次; 如上图:树的高度为4。
- 堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点。
- 节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先。
- 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙。
- 森林:由m(m>0)棵互不相交的树的集合称为森林。
2.数的表示结构:左孩子右兄弟法
它的好处是:不管你有多少个孩子,都可以使用两个指针表示。
每一个节点都有两个指针,其中一个指针指向它最左边的孩子另一个指针指向它的兄弟。
二、二叉树的概念和结构
1.概念
一棵二叉树是结点的一个有限集合,该集合:
- 或者为空
- 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出:
- 二叉树不存在度大于2的结点
- 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意:对于任意的二叉树都是由以下几种情况复合而成的:
2.特殊的二叉树
- 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 ,则它就是满二叉树。
- 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
完全二叉树的最后一层不一定满,但是从左到右必须是连续的。满二叉树是完全二叉树,但是完全二叉树不一定是满二叉树。
3.二叉树的性质
- 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2的i-1次方个结点。
- 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2的h次方-1。
- 对任何一棵二叉树, 如果度为0其叶结点个数为n0 , 度为2的分支结点个数为n2 ,则有n0 =n2 +1。
- 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= .log2(n+1)。(ps: log2(n+1)是log以2为底,n+1为对数)。
- 对于具有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否则无右孩子
4.二叉树的存储
第一种存储方式使用数组存储。它一层一层依次往数组里面存。它的物理结构是数组,逻辑结构是二叉树。
父子存储的下标位置规律:leftchild = parent*2+1,rightchild = parent*2+2。parent = (child-1)/ 2 .
当这个树是满二叉树或者完全二叉树时,适合用数组存储,但当它不是满二叉树也不是完全二叉树时,就不适合用数组存储。
它不适合使用数组存储,它适合使用链式存储。
三、堆的概念和结构
1.概念
堆是一个完全二叉树它分为小堆和大堆。小堆中,任何一个父亲<=孩子,大堆则相反。
小堆
大堆
它没有规定兄弟之间的大小关系,也没有规定叔侄之间的大小关系。所以不能认为他是有序的。
2.堆的实现
2.1 堆的底层结构
我们采用数组作为堆的底层结构。它的底层和顺序表的一样。
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
2.2 堆的初始化和销毁
初始化:
//初始化
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->capacity = 0;
php->size = 0;
}
销毁:
//销毁
void HPDestory(HP* php)
{
assert(php);
free(php->a);
php->capacity = 0;
php->size = 0;
}
2.3 交换数据
在之后我们会经常交换数组中两个数据的位置,我们先实现一个交换函数。
//交换函数
void Swap(HPDataType* px, HPDataType* py)
{
HPDataType tmp = *px;
*px = *py;
*py = tmp;
}
它接收两个数据的地址,并在函数内部完成交换元素。
2.3 堆插入数据
插入数据时,假如此时堆的类型为小堆,那么插入数据时,可能会影响的是祖先。如果孩子的值比父亲小,那么就将它俩交换。然后再将它与它的父亲相比,以此类推。所以我们需要实现一个向上调整算法。
那么如何通过孩子找到它的父亲呢?上文我们提到过,父子存储得下标位置关系为
leftchild = parent*2+1
rightchild = parent*2+2
parent = (child-1)/ 2 .
我们以这个小堆为例:
假如我们要插入一个值为60,那么插入的地方就应该是70的右边。然后发现它比它的父亲56大,那么它俩就不交换。
假如要插入一个50,50比56小,那就交换。
假如插入一个5,它比50,10都要小,那么就让5成为根。
我们需要实现一个向上调整算法来实现上述操作。
//向上调整
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;//先找出父亲的下标
//while(parent>=0)
while (child>0)//交换的终止条件是child>0,但是也可以使用parent>=0。因为算到最后parent始终为0,child也为0,程序走到break结束。
//但是这种方法不太好,是一种巧合。于是我们就使用判断child是否大于零来结束程序。
{
if (a[child] < a[parent])//如果孩子小于父亲,就交换,同时将父亲和孩子的下标改变,使它们的身份互换。
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
当程序进入while循环时,会一直判断这个数与它的父亲的大小关系,并完成交换,知道最小的数交换到根为止。
接下来就是数据的插入。
//插入数据
void HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)//如果空间不够就扩容
{
size_t newcapacity = php->capacity==0 ? 4 : 2 * php->capacity;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}//扩容方法和顺序表一样。
php->a[php->size] = x;
php->size++;
AdjustUp(php->a,php->size-1);//此时size指向数组的下一个元素,传参时需要将它减1.
}
当我们想要从小堆换成大堆时,只需改变向上调整函数里面的while循环里面的if语句中的判断条件中的负号即可。当负号为‘>’时,此时为大堆,当负号为‘<’时,此时为小堆。
2.4 取堆顶
//取堆顶
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
2.5 删除堆顶元素
现在有两种删除堆元素的方法,第一种是挪动删除法,但是挪动之后堆的结构被破坏了,需要重新调整,而重新调整又要浪费好长的时间,而且它的时间复杂度为O(n)。那就需要一种全新的算法来实现了。下面就介绍一种非常厉害的一种算法。
这个算法的过程是先将堆顶与堆尾的值互换,然后再删去堆尾的值,然后再调整使这个结构保持为堆。当两个值互换完之后,这个堆的结构并没有被破坏,只有它们的值中有一些问题,它可能不满足大堆或者小堆。接下来要做的就是向下调整,使这个结构变成大堆或者小堆。它会将自己与自己的孩子进行比较,并且是与较小的孩子比较。然后交换两个值,如此反复直到它没有子孙或者满足堆的要求为止。
交换的函数我们已经实现过了,我们下面要实现的是向下调整算法。
//向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;//先假定左孩子比较小
while (child < n)//每次交换结束,这个值的下标都会增长,当增长到size时,也就是来到了数组尾,那么就代表交换结束。
{
if (child + 1 < n && a[child + 1] < a[child])//如果是右孩子比较小,那么就和右孩子交换。
//并且需要保证右孩子存在,如果右孩子不存在,那么会越界。
{
child++;
}
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
我们在删除堆顶元素函数中调用它们。
//出堆顶
void HeapPop(HP* php)
{
assert(php);
assert(php->size);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}//先交换,在调整。
2.6给定一串数组建堆
假如给定我们一个数组,让我们给它建成一个堆, 那该要怎么建呢?它与之前的方式不同,之前的是插入一个调整一次,这个是直接给定了一串数组。
1.向上调整建堆
代码如下:
//建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{
assert(php);
//申请一块大小为n的空间
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
//将数组中的值拷贝到a所指的空间里面
memcpy(php->a, a, sizeof(HPDataType) * n);
php->size = n;
php->capacity = n;
//然后调用size次向上调整,将数组调整为堆
for (int i = 0; i < php->size; i++)
{
AdjustUp(php->a, i);
}
}
时间复杂度分析:
由代码可知,主要占用时间的是代码里面的for循环,我们分析for循环的时间复杂度,就是这个建堆算法的时间复杂度。
当拷贝完成后,数组里面存储的元素集合还不能构成堆,我们假设树的高度为 h ,它先从第一层开始调整,然后再从第二层,依次往下调整,最坏的情况下,每层调整都要交换 h-1 次。并且每一个节点都要进行调整。所以每一层的调整次数为节点数*(层数-1)次。第一层不需要调整,而第二层需要调整 2的1次方 * 1 次,第三层需要调整(2的2次方 * 2)次,以此类推。当调整到h层时,总的调整次数为每一层调整次数的累加值,我们求出这个累加值即可。利用高中时学的数列的错位相减法,我们就可以求得调整次数前 h 项和。我们就得到了一个调整次数F(h)关于层数h的函数关系式F(h) = 2^h*(h-2)+2。但是时间复杂度一般与n相关,我们需要把h换成n。n就是节点数,他们之间的关系为h = logN + 1.(第h层有2^(h-1)个节点)我们就得到了F(N)关于N的函数关系式F(N) = ( N + 1 ) * ( logN - 1 ) + 2.即建堆向上调整的时间复杂度为N * logN.
2.向下调整建堆
我们从最后一个非叶子节点开始调。因为调整是针对节点的,假如我们从第一个节点开始调,调整到靠下面的层数时,就会多花费很长的时间。代码如下:
//建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{
assert(php);
//申请一块大小为n的空间
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
//将数组中的值拷贝到a所指的空间里面
memcpy(php->a, a, sizeof(HPDataType) * n);
php->size = n;
php->capacity = n;
//然后调用size次向上调整,将数组调整为堆
/*for (int i = 0; i < php->size; i++)
{
AdjustUp(php->a, i);
}*/
for (int i = (php->size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(php->a, php->size, i);
}
}
它的时间复杂度分析法跟上一个类似,它的时间复杂度为N-log(N)。
那么为什么两种方法会有差异呢?先思考一个问题,满二叉树最后一个节点占所有的多少呢?几乎是一半。向上调整,节点多,那么调整次数多。向下调整,最后一层节点不需要调整,是一个节点数量多,调整数量少。所以向下调整的效率快一些。
因此,一个一个的push是不如向下调整建堆的。
3.堆排序
给我们一个数组,我们可以对堆进行排序。我们可以将堆中的元素进行连续的pop和打印堆顶元素,就可以得到一串有序的数字,这一串数字就是排序过的堆中的元素。因为每一次pop,都是取出堆中最小的值,然后经过调整,堆中次要小的值被调为堆顶元素,然后再pop,一直这样进行,堆中的元素就可以有序地输出。但这还不足以称为堆排序。下面就要详细地介绍堆排序。排序的目的不只是为了将数字打印到屏幕上,而是建立一个有序的数组。这才可以称为排序。
//堆排序
int* HeapSort(HP* php, int* a,int n)
{
HPInitArray(php, a, n);
int i = 0;
while (!HPEmpty(php))
{
a[i++] = HeapTop(php);
HeapPop(php);
}
return a;
}
这就是堆排序的代码,但是他有两个缺陷:1.每次使用堆排序时,我们都要先写一个堆出来,因为没有现成的取堆顶等函数让我们调用。2.它的空间复杂度为O(n)。那我们就直接对传过来的数组建堆。然后调用向下调整建堆。那我们应该建大堆还是建小堆呢?答案是建大堆。因为如果要建小堆,那么面临的问题是如何选出次小的数,要是使用剩下的数再建堆,那么又面临着一个问题,我们不能通过向下调整建堆,因为它的关系全都乱了,就会出现兄弟变父子,父子变兄弟。只能重新建堆。但是如果一直这样建堆,那么堆排序的优势就没有了,它的时间复杂度就会很大。因此我们就要建大堆。我们要使数组升序,具体是怎么做呢?我们可以结合堆的删除操作,我们将堆中最大的数(也就是堆顶的数)放在数组尾,将数组尾的数放在堆顶,然后将数组尾的数忽略掉(但不是删除,只是原理相似)然后向下调整一下,就可以将次大的数放在堆顶。然后再将堆顶的数放在堆尾,如此循环往复,这样数组中的元素就会变得有序。
//堆排序
void HeapSort02(int* a, int n)
{
for (int i = (n - 1 - 1) / 2 ; i>= 0 ; --i)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
这个向下调整函数它需要的参数不需要接受一个结构体指针,说明它不是为了堆而实现的。我们在实现堆排序的时候,只需再实现一个向下调整,一个交换函数即可,不需要再手搓出一个堆的数据结构。当我们想要变成降序时,只需调整向下调整函数, 让他变成建小堆即可。
4.TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。它的特点是块、节省空间。时间复杂度为K + (N - K) * logK。
假如说现在有100亿个数据,我们现在要找出前十个最大的值,那我们就建一个十个数的小堆,让后续数据跟堆顶数据比较,如果比堆顶的数据大,那么就让这个数据替代堆顶进堆。因为堆是小堆,当有比最小的数据大的数据,就让它替换堆顶,并对堆进行向下调整,使堆中最小的值重新放到堆顶,然后再让外部数据跟对顶比较,如此操作之后,当遍历完后续数据,堆中就保留着最大的十个值,其中堆顶是十个值中最小的那个。下面是代码实现:
//造数据
void CreateNData()
{
int n = 10000;
srand(time(0));
FILE* fin = fopen("Test.txt", "w");//打开文件
if (fin == NULL)
{
perror("fopen error");
return;
}
//向文件里面写n个随机数
for (int i = 0; i < n; i++)
{
int x = (rand() + i) % n;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
这个代码的意思是造n个数据。在主函数中调用,就可以看到它造了10000个数据。
void topk()
{
int k = 0;
printf("请输入一个值\n");
scanf("%d", &k);
FILE* fout = fopen("Test.txt", "r");
if (fout == NULL)
{
perror("fopen error");
return ;
}
int val = 0;
//建k个数的小堆
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc fail");
return ;
}
//从文件里面读取数据放到小堆中
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
}
//向下调整为小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDownSmall(minheap, k, i);
}
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
//读取剩余数据,比堆顶的值大,就替换它进堆
if (x > minheap[0])
{
minheap[0] = x;
AdjustDownSmall(minheap, k, 0);
}
}
//我们也可以调用堆排序来对筛选出来的十个最大值进行排序
HeapSort(minheap, k);
//打印堆中数据
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
fclose(fout);
return;
}
这就是TOP-K的算法。当我们想要改变生成的随机数的个数时,只需在CreateNData()函数中修改n的值即可。其中我将向下调整分为了两个函数,分为向下调整建小堆AdjustDownSmall()和向下调整建大堆AdjustDownBig()。