二叉树2、

目录

2.5、二叉树的存储结构:

3、二叉树的顺序结构及实现:

3.1、二叉树的顺序结构:

3.2、堆的概念及结构 :

3.3、堆的实现:

3.3.1、堆向下调整算法:

3.3.2、堆的创建:

3.3.3、建堆时间复杂度 :

3.3.4、堆的插入: 

3.3.5、堆的删除: 

扫描二维码关注公众号,回复: 14288690 查看本文章

3.3.6、堆的代码实现: 

3.4、堆的应用:

3.4.1、堆排序:

3.4.2、TOP-K问题:


2.5、二叉树的存储结构:

二叉树一般可以使用两种结构存储一种顺序结构,一种链式结构、

1、顺序存储:
完全二叉树和满二叉树 是可以通过 顺序表 来存储的,这是因为他们两种二叉树有一个特点,即,从根节点开始,一层一层的按照顺序依次放进顺
序表中,由于 这两种二叉树中的节点是连续的 ,所以存到顺序表中是正合适的、
顺序结构存储 就是使用 顺序表,数组 来存储 ,顺序表存储 优点很多,不需要存储指 针等等 ,一般使用 顺序表 只适合表示 完全二叉树, 当不是完全二叉树
时,即 非完全二叉树 时,不太适合使用 顺序表 来表示,是因为, 非完全二叉树中的节点不是连续的 ,就如:下图所示,已知节点D是节点B的右孩子,但
是节点B没有左孩子,那也不能把节点D存储到下标为3的位置上,否则当计算父子节点之间的下标时就会乱套,假设把节点D放在下标为3的位置上,若要
求节点B的左孩子,即,左孩子下标=1*2+1=3,而发现下标为3的位置上存储了节点D,但 是节点D不是节点B的左孩子,而是右孩子,所以不可以把节点
D存储到下标为3的位置上,只能存储到下标为4的位置上,而在顺序表中必须要求数据是连续存放的,这样就不满足要求了,所以 不可以使用顺序表来存
储非完全二叉树 ,若使用数组来存储已知规模的非完全二叉树是可以的,因为可以不用连续存放数据,但是, 当非完全二叉树中的节点很多时,若存储到
数组中的话,则有可能会有很多空间未使用 ,这样的话,就会造成空间的浪费, 所以, 当是非完全二叉树时,是可以通过数组来存储的,但是可能会造
成空间浪费 ,所以 一般情况下,若是非完全二叉树,则不使用顺序表,数组来存储,而是使用链式结构来存储、
而现实中使用中只有 才会使用 顺序表 来存储,关于堆我们后面的章节会专门讲解,二叉树 顺序存储 物理上是一个顺序表 ,在 逻辑上是一颗二
叉树、

2、链式存储:
二叉树的 链式存储结构 是指,用 链表 来表示一棵二叉树,即用链来指示元素的逻辑关系、
通常的方法是 链表中每个结点由三个域组成,数据域和左右指针域 左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 ,链
式结构又分为 二叉链和三叉链 ,当前我们学习中一般都是 二叉链 ,后面课程学到高阶数据结构如 红黑树等会用到三叉 链,三叉链即指, 节点结构
体中定义了指向该节点左孩子和右孩子的指针,除此之外,还定义了指向该节点父亲的指针变量,这就是三叉链、
在链式存储中,不考虑孩子兄弟表示法,该方法适合多叉树,不是二叉树,二叉树中某一个节点最多有两个孩子,不需要使用孩子兄弟表示法,
而在多叉树中,不知道某一个节点具体有几个孩子,所以使用兄弟孩子表示法比较合适、
在定义节点结构体时,一般会定义指向该节点左孩子和右孩子的两个指针变量,和当前节点值域,因为二叉树默认其度为2,即指二叉树中每个
节点的度最多为2,即每个节点最多有两个孩子,当某一个节点有左右孩子时,该节点中的两个指针分别指向该节点的左右孩子,当某一个节点
中只有一个孩子时,则该节点中的两个指针,一个指针指向该节点唯一的孩子,另外一个指针则指向空指针,当某一个节点没有孩子时,则该节
点中的两个指针都指向空指针NULL,由于在该二叉树中,可能有部分节点的度为0或1,则避免不了存在指针资源浪费,但是在此不考虑这些浪
费,因为已知二叉树的度为2,所以即使浪费也浪费不多,在此就忽略这些指针的资源浪费, 链式存储没有顺序存储好,是因为,顺序存储一个
值时只需要把值直接存储到顺序表中即可,而链式存储一个值时,还要为该值多存储两个指针,并且链式结构的CPU高速缓存命中率低于顺序结
,所以 当顺序结构和链式结构都可以使用时,优先选择顺序结构,比如存储完全二叉树时、

 队列和非完全二叉树一般都是不适合使用顺序存储结构,相比而言,非完全二叉树更不适合使用顺序存储结构、

typedef int BTDataType;

// 二叉链
struct BinaryTreeNode
{
     struct BinTreeNode* _pLeft;      // 指向当前节点左孩子
     struct BinTreeNode* _pRight;     // 指向当前节点右孩子
     BTDataType _data;                // 当前节点值域
}

// 三叉链
struct BinaryTreeNode
{
     struct BinTreeNode* _pParent;    // 指向当前节点的双亲
     struct BinTreeNode* _pLeft;      // 指向当前节点左孩子
     struct BinTreeNode* _pRight;     // 指向当前节点右孩子
     BTDataType _data;                // 当前节点值域
};

3、二叉树的顺序结构及实现:

3.1、二叉树的顺序结构:

普通的二叉树 是不适合用 顺序表 来存储的,因为可能会存在大量的 空间浪费 ,而 完全二叉树更适合使用顺序结构存储 ,现实中我们通常把 堆(一种
二叉树) 使用 顺序结构的顺序表来存储 ,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事, 一个是数据结构,一个是操作系统
中管理内存的一块区域分段、

3.2、堆的概念及结构 :

如果有一个关键码的集合 K = { k0,k1 ,k2 ,…,k(n-1)} ,把它的 所有元素 完全二叉树 的顺序存储方式存储在一个 一维顺序表 中,并满足:
Ki <=K(2*i+1) 且 Ki <=K(2*i+2)   (Ki >= K(2*i)+1 且 Ki>=K(2*i+2)),i=0,1,2...  则称为 小堆(或大堆) ,将 根节点最大的堆叫做最大堆或大根堆,
根节点最小的堆叫做最小堆或小根堆、
由于 堆在逻辑上是一个完全二叉树 ,在 物理上是一个顺序表 ,所以对于 堆而言,它是一个非线性结构 ,这是因为判断是否为线性结构要根据其
辑结构 来看,而对于堆的逻辑结构是一个完全二叉树,所以, 堆不是一个线性结构、
堆的性质:
堆中 某个节点的值总是不大于或不小于其父节点的值 ,堆总是一棵 完全二叉树 堆顶即根节点 是最大值或者是最小值、

3.3、堆的实现:

3.3.1、堆向下调整算法:

现在我们给出一个 顺序表 ,逻辑上看做一颗 完全二叉树 ,我们通过从 根节点 开始的 向下调整算法 可以把它调整成一个 小堆 ,向下调整算法有一个
前提: 左右子树必须是一个堆,并且还是性质一样的堆,现在要建成小堆,所以这两个子树必须是小堆才可以,才能调整、

3.3.2、堆的创建:

即指直接 在数组上进行建堆 操作,在此要进行 建大堆 操作、
下面我们给出一个 数组 ,这个 数组 逻辑上可以看做一颗 完全二叉树 ,但是还不是一个 大堆 ,现在我们通过算法,把它构建成一个 大堆 ,根节点左
右子树 不是大堆 ,我们怎么调整呢?这里我们从 倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆、
int a[] = {1,5,3,8,7,6};

3.3.3、建堆时间复杂度 :

一、向上调整算法建堆的时间复杂度求解:

二、向下调整算法建堆的时间复杂度求解: 

3.3.4、堆的插入: 

先插入一个数据10顺序表的尾上,即尾插,再进行向上调整算法,直到满足

3.3.5、堆的删除: 

删除堆 是删除 堆顶 的数据,将堆顶的数据和最后一个数据一换,然后删除 顺序表 中最后一个数据,再进行 向下调整算法

3.3.6、堆的代码实现: 

一、Test.c源文件:

#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void test1()
{
	HP hp;
	HeapInit(&hp);
	HeapPush(&hp, 1);
	HeapPush(&hp, 5);
	HeapPush(&hp, 0);
	HeapPush(&hp, 8);
	HeapPush(&hp, 3);
	HeapPush(&hp, 9);
	HeapPrint(&hp);
	HeapPop(&hp);
	HeapPrint(&hp);
	bool ret = HeapEmpty(&hp);
	if (ret == 1)
	{
		printf("空堆\n");
	}
	else
	{
		printf("非空堆\n");
	}
	size_t size = HeapSzie(&hp);
	printf("%d\n", size);
	printf("%d\n", HeapTop(&hp));
	HeapDestroy(&hp);
}

升序、
总时间复杂度就是2 * (N*logN),即为O(N*logN)、
算法套算法、
//void HeapSort(HPDataType* a, int size)
//{
//	//小堆、
//	HP hp;
//	HeapInit(&hp);
//	//外层循环次数固定,是N次,内层HeapPush函数中的循环次数是不固定的,则需要把具体的次数列出来进行计算,具体见时间复杂度求冒泡排序的例子,则时间复杂度是N*logN、
//	for (int i = 0; i < size; i++)
//	{
//		HeapPush(&hp, a[i]);
//	}
//	int j = 0;
//	//外层循环次数固定,是N次,内层HeapPop函数中的循环次数是不固定的,则需要把具体的次数列出来进行计算,具体见时间复杂度求冒泡排序的例子,则时间复杂度是N*logN、
//	while (!HeapEmpty(&hp))
//	{
//		//a[j++] = HeapTop(&hp);
//
//		a[j]=HeapTop(&hp);
//		j++;
//		HeapPop(&hp);
//	}
//}
//只有根节点时,可以看成小堆,也可看成大堆、
//若顺序表中的数据有序的话,则必然是堆,若顺序表中的数据无序时,则是不是堆都有可能、

//堆排序空间复杂度是:O(N),这里看的是HeapPush函数进行动态内存开辟时所额外开辟的内存空间,由于每一次测试用例中要入堆的数据的个数是不确定的,
//所以每一次测试用例中所额外开辟的内存空间的个数也是不确定的,则空间复杂度就是:O(N),还需要再优化,要注意此处的空间复杂度和main函数中的数组a的元素
//个数是没有关系的,所谓的空间和时间复杂度是指的算法的复杂度、
//通过调用函数HeapSort来进行 堆 排序,上述这种方法是不可行的,第一,写出来调用函数HeapSort比较简单,但是在此之前要先实现一个堆所需的接口,比如HeapPush,HeapPop等,而实现堆接口函数的过程比较麻烦,第二就是
//上述方法中的空间复杂度是:O(N),所以真正使用堆来进行堆排序并不是上述这样、


//堆排序优化、
//所谓堆排序的优化即指不需要再借助之前所实现的堆的函数接口HeapPush,HeapPop等来进行堆排序,直接在main函数中的数组上进行建堆、
//升序、
//若要求时间复杂度是:O(N*logN),空间复杂度是:O(1),来实现一个 堆排序 ,应该怎么做?
//在上述方法中,首先在main函数中创建一个数组a,该数组中存放着要进行排序的数据,再把这些数据依次进行Push操作,即尾插到顺序表中,即插在完全二叉树最后一层中最后一个数据的后面
//当顺序表的容量不够时,再对顺序表进行扩容操作,这是上述方法的思路,而此时,也是先在main函数中创建一个数组a,该数组中存放着要进行排序的数据,但是,在数组中我们已经知道所有的要排序的数据了,即
//不会再新增额外的数据参与排序,所以不存在扩容的问题,又因,堆的底层是一个顺序表,顺序表相对于数组而言,数据是连续存放的,并且顺序表可以扩容,而此时,数组中的数据已经是连续存放的,并且不需要进行扩容
//所以,也可使用数组来实现堆,此时就不需要在堆区上动态开辟内存空间了,而在main函数中已经有了数组,直接使用该数组建堆即可,所以在上述调用函数HeapSort中就不需要再额外动态开辟N个空间了,这样的话,空间复杂度就是O(1)了
//其次直接使用main函数中的数组a的话,当进行堆排序时,就不需要再先实现一个堆所需的接口了,比如HeapPush,HeapPop等、
void HeapSort(HPDataType* a, int n)
{	
	//对数组进行建堆、

	1、向上调整算法建堆、、
	即指数组中的数据已经在数组内了,只需要去判断每一个数据是否需要进行调整即可,数组中第一个数据一定不需要进行调整,因为当完全二叉树只有根节点时,既可以看成是小堆,也可以看成是大堆,则可以不进AdjustUp函数内部,
	只有从第二个数据开始才可能需要进行调整,所以需要进入该调用函数内部进行判断,所以直接从下标1开始即可,当然从0开始也是可以的,只不过直接在该调用函数内部break出来了、
	对于上述方法中,需要把数组a中的所有的元素都依次尾插到顺序表中,每次尾插后都要对该次尾插的数据进行调整,所以对于第一个元素也要插到顺序表中,即使第一个元素不需要进行调整,但是也需要把它尾插到顺序表
	内,插第一个数据时就要调用Push函数,而调用该函数就要进入向上调整算法函数中,此时直接在数组上建堆,所以第一个数据就不需要再进入向上调整算法中了、

	此时外层循环是固定值,内层循环是不固定的,则需要列出来具体的执行次数再去计算时间复杂度、
	经过计算可得,该方法的时间复杂度为:O(N*logN)、
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);	
	//}
	在此要建成小堆,对于向上调整算法只要保证在判断下标为i的数据之前是小堆即可,则可以直接调用AdjustUp的原因是因为,当树结构中只有根节点时,可以看做小堆,也可看做大堆,所以当第二个数据进入AdjustUp中时,前面就已经保证了是小堆,
	然后当第三个数据进入AdjustUp时,前两个数据已经保证了是小堆,所以可以直接调用AdjustUp函数、 若从下标0开始的话,当第一个数据进入AdjustUp中时,前面还不存在数据,break出去再判断第二个数据即可、

	//2、向下调整算法建堆、
	//即指数组中的数据已经在数组内了,只需要去判断每一个数据是否需要进行调整即可,对于所有的叶子结点都是不需要进行调整的,所以从倒数第一个非叶子节点开始即可、
	//不可以直接调用AdjustDown来正向进行建堆,使用AdjustDowm函数正向建堆是有前提的,必须要保证该树中的两个子树是堆,并且还是性质一样的堆,现在是以小堆为例,要建成小堆,所以这两个子树必须是小堆才可以、
	//如果对数组直接进行向下调整算法正向建堆的话,在该树中,其两个子树有可能都不是堆,即使都是堆也不一定是相同性质的堆,所以不可以直接调用AdjustDown进行正向建堆、
	//而在前面实现的堆数据结构中直接使用了正向向下调整算法是因为,再使用该算法之前已经控制了树的左右子树都是堆了,并且是相同性质的堆,具体是什么性质取决于push进去的时候建成的堆的性质、
	//但是可以通过倒着来进行向下调整算法进行建堆,就避免了这种缺陷,从倒数第一个非叶子节点开始,即最后一个节点的父亲就是倒数第一个非叶子节点,这样就保证了当前所在的树中的两个子树都是堆,并且性质也一样,即都是小堆,只有一个子树的话只需要保证这一个子树是小堆即可、

	此时外层循环是固定值,内层循环是不固定的,则需要列出来具体的执行次数再去计算时间复杂度、
	经过计算可得,该方法的时间复杂度为:O(N),所以在建堆的时候,还是选择该种方法更加合适、

	//2、向下调整算法建堆、
	int j = 0;
	for (j = (n - 1 - 1) / 2; j >= 0; j--)
	{ 
		AdjustDown(a, n, j);
	}

	//通过上面两种方法建成的堆,此处以小堆为例,即建成的小堆,结果是不一样的,但是都保证了是小堆,即指,同一个数组通过不同的两种建堆方法得到的结果可能是 不唯一 的,但确定的是,尽管这两种结果不唯一但是这些这两种结果都是小堆、
	
	//堆排序、
	//要按照降序进行排列,则要选择建小堆、
	//要按照升序进行排列,则要选择建大堆、

	//1、建堆,尽量选择向下调整算法进行建堆,时间复杂度是O(N)、
	//2、继续选数,则有两种方式,其一是通过堆进行选择,其二就是直接选择,直接选择即指遍历数组第一遍找最小,执行次数是N,再遍历剩下的N-1个数据,执行次数是N-1,再遍历剩下的N-2个数据,执行次数是N-2,咋等差数列求和,最终的时间复杂度是O(N^2)、
	//若通过堆来进行选数的话,尽量不要建小堆,而自己实现的堆数据结构中建的是小堆,但是在删除之前进行了操作,即交换顺序表中第一个和最后一个元素,再把最后一个元素删除,此时满足删除堆顶元素,但并未影响堆的结构,再调整为和原来一样性质的堆即可,这里由于是进行了push操作
	//所以,原main中的数组就可以进行使用了,直接把最小值放在对一个位置上,然后再通过调整找出次小放在原数组第二个位置上,这是可以的,但是如果直接在原数组上建堆的话,若建小堆来升序的话,找到的最小值不能放在原数组的第一个位置上,会破坏堆或完全二叉树的结构,否则会影响下一次调整找次小,所以不能建小堆要建成大堆
	//先让最大值与原数组最后一个数据交换,再让该最大值不放在堆中,这样不影响下一次调整找次大,所以这样是可以的、
	//若建成小堆的话,此时栈顶元素是最小的,但是若再取次小的话,需要把堆顶元素删除,此时是删除了数组的第一个元素,就相当于把第二个及以后的元素向前挪动了一位,此时整个完全二叉树或堆的结构都发生了改变,需要重新建才能选出次小,而建堆的时间复杂度是O(N),即找最小时建堆的时间复杂度是O(N),
	//删除堆顶元素后,再对剩下的N-1个元素进行建堆,时间复杂度是O(N-1),则等差数列,最后的时间复杂度也是O(N^2),这样的话,时间复杂度就和直接选择是一样的,那么使用堆排序就体现不出来它的优势,即,可以通过使用堆排序建小堆的方法来进行排序,只不过效率不高,所以要使用堆排序中的建大堆的方法来进行排序操作、
	
	//升序建大堆,则堆顶元素就是最大值,与数组中最后一个元素进行交换,然后把该最大值不视为堆内的数据,此时左右子树都是大堆,只需要让记录数组中元素个数的变量减1即可,在向下调整算法中可以控制堆的大小,再对堆顶元素进行向下调整,从而选出次大的数,然后再把该次大的数与原数组倒数第二个数进行交换,直到指向了第一个数就停止、
	//降序建小堆,则堆顶元素就是最小值,与数组中最后一个元素进行交换,然后把该最小值不视为堆内的数据,此时左右子树都是小堆,只需要让记录数组中元素个数的变量减1即可,在向下调整算法中可以控制堆的大小,再对堆顶元素进行向下调整,从而选出次小的数,然后再把该次小的数与原数组倒数第二个数进行交换,直到指向了第一个数就停止、
	
	//建堆后数组中的元素为:8 6 7 4 5 1 0 2、
	//此时end指向的是数据2,end的值为7、
	size_t end = n - 1;
	//当end=0,即只剩一个数的时候,就停止、
	while (end>0)
	{
		//交换数组第一个和最后一个数据,交换后数组中的内容是:2 6 7 4 5 1 0 8、
		Swap(&a[0], &a[end]);
		//此时end指向的是数据8,end的值为7,但是将end传给AdjustDown后,要调整的数据只有2 6 7 4 5 1 0,因为end的值为7,即要调整的数组的大小为7个元素,即end传给AdjustDown时代表着指向了最后一个元素的后一个位置、
		AdjustDown(a, end, 0);
		end--;
	}
	//堆排序属于选择排序,通过堆来选、
	//外层循环次数固定为N次,内层循环次数不固定,则要列出来进行计算总次数,时间复杂度为O(N*logN)、

	//则总的时间复杂度为:O(N*logN+N)=O(N*logN)、
	//总的空间复杂度为:O(1)、
}

int main()
{

	//test1();

	//升序、
	HPDataType a[] = { 4,2,7,8,5,1,0,6 };
	HeapSort(a,sizeof(a)/sizeof(a[0]));
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
	return 0;
}

二、Heap.c源文件:

#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
//堆只分为大堆和小堆,大堆即指,树中所有的父亲都大于等于孩子,小堆即指,树中所有的父亲都小于等于孩子,若某一个完全二叉树不满足
//大堆,也不满足小堆,则该完全二叉树存放在顺序结构中就不能称之为堆、

//初始化堆、
void HeapInit(HP* php)
{
	assert(php);
	//结构体变量的地址不可能为空指针NULL、
	php->a = NULL;
	php->capacity = 0;
	php->size = 0;
}

//销毁堆、
void HeapDestroy(HP* php)
{
	assert(php);
	//结构体变量的地址不可能为空指针NULL、
	free(php->a);
	php->a = NULL;
	php->capacity = php->size = 0;
}

//交换数据、
//传址调用、
void Swap(HPDataType* pa, HPDataType* pb)
{
	assert(pa && pb);
	HPDataType tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

//向上调整算法、
//算法逻辑思想是完全二叉树,物理上操作的是顺序表中的数据、
//一级指针传参,一级指针接收、
//在小堆中,每个根节点都是它当前所在树中的最小值、
//在大堆中,每个根节点都是它当前所在树中的最大值、
void AdjustUp(HPDataType* a, size_t child)
{
	assert(a);
	size_t parent = (child - 1) / 2;
	//此处的while的判断条件不可以写成parent >= 0,这是因为,当child等于0时,parent还是0,即,if里面求parent时是不能够求出来parent是负数的,这和parent的类型是int
	//还是sizei_t无关,这和除法运算符 / 的规则有关,除此之外,parent的类型是size_t类型,所以parent始终都是大于等于0的,不会小于0,根据上面两种结论,若while的判断条件写成parent>=0的话
	//就会造成死循环、
	//由于在向上调整算法调用函数内部,形参部分中的child类型是size_t ,所以child不可能为负值,除此之外,在调用函数AdjustUp之前,就已经进行了插入数据的操作,所以
	//即使child的类型是int,那么child也不可能为负值,所以此处的while的判断条件直接是child就行了,不需要写成 child > 0 、
	while (child)
	{
		//大堆
		//if (a[child] > a[parent])

		//小堆
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//入堆数据、
//总的时间复杂度就是O(logN),在此不考虑扩容的时间复杂度,是因为,并不是每一次入堆数据都需要进行扩容,像这种并不是每次都要执行的代码就不考虑其时间复杂度,即使考虑了也不影响,这是因为扩容可以看做是O(1)、
//入堆数据时,即在完全二叉树中进行插入数据时,一定要在完全二叉树的最后一层中的最后一个数据后面进行插入,数据结构对入堆数据的 位置 没有规定,只是规定了,若原来是小堆的话,入堆数据后要保持仍是
//和原来一样性质的堆即可,即入堆数据后要保持仍是小堆才行,还要保证效率比较高,即要在顺序表中进行尾插,若在顺序表中进行头插或者中间插的话,不仅效率比较低,因为要挪动数据,而且若是头插
//则把该顺序表还原成完全二叉树的话,就改变了根节点,就改变了完全二叉树的结构,此时完全二叉树可能就不再是堆了,但是对于入堆操作,要保证插入数据后仍是堆,并且堆的性质要和之前相同,所以可能还要重新把这个完全二叉树整理成和之前性质一样的堆,
//这就比较比较麻烦,中间插的话也会改变完全二叉树的结构,所以直接在顺序表中进行尾插,不仅效率高,而且对其他节点的影响比较小,只影响该要入堆的数据所在的这一条路径,其他路径均不影响
//并且该完全二叉树中其他父子节点之间的关系都没变,所以选择在顺序表中进行尾插,即在完全二叉树中最后一层中的最后一个数据后面进行插入数据、

//顺序表尾插、
//入堆数据后,则可能是堆,也有可能不是堆,所以要进行判断,若入堆数据后仍是堆,则不需要调整,若入堆数据后不是堆,则要对其进行调整,使之成为堆,假设
//某一个堆在入堆数据之前是一个小堆,现在要入堆一个数据,但是要入堆的这个数据发现小于它的父节点,此时入堆后就不再是堆了,要对其进行调整,要记住凡是这种原来是堆
//但是入堆数据后发现不是堆的情况,只会影响要入堆的该数据到根节点所在的这一条路径,而不会影响其他路径,还是上述的假设,若要入堆的该数据比它的父节点小,则要让该数据与
//其父节点进行交换,交换完之后,还要看该节点的父节点和该节点的父节点的父节点之间是否遵循小堆规则,若还不符合的话,就继续进行交换,最坏的情况就是交换到根节点,也有可能在该条路径中间就满足了小堆规则,就不需要再进行交换了、
//所以此时的入堆数据,不仅要把数据放进去,还要对不符合规则的节点进行调整,这个方法就叫做:向上调整算法、
void HeapPush(HP* php, HPDataType x)
{
	assert(php);
	//结构体变量的地址不可能为空指针NULL、

	//1、先把要入堆的数据插进顺序表中、
	//判断是否需要进行扩容、
	if (php->size == php->capacity)
	{
		//需要进行扩容、
		size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType)*newCapacity);
		if (tmp == NULL)
		{
			//增容失败、
			printf("realloc fail\n");
			return;
		}
		else
		{
			//增容成功、
			php->a = tmp;
			php->capacity = newCapacity;
		}
	}
	php->a[php->size] = x;
	php->size++;
	//到此只是把数据插入到了顺序表中,但是还要保持插入数据后仍是堆,并且和未插入之前的堆的性质保持一致、

	//2、入堆数据后,若仍是和原来是一样性质的堆,则不需要调整,若不是,则要进行调整,使入堆数据后仍保持和原来一样性质的堆、
	//在AdjustUp中进行判断是否要进行调整、
	//向上调整算法、
	//当堆中只有一个数据时,不需要进行调整,可以看成小堆,也可以看成是大堆、
	AdjustUp(php->a,php->size-1);
}
//在小堆中,根节点是最小的,即堆顶是最小的,即,每个根节点就是它当前所在那个树中的最小值、


//出堆数据、
//删除堆顶,即根节点的数据,即删除(最小/最大值),使删除完该数据之后仍保持和原来一样的堆的性质、
//删除堆顶后,那么新的堆顶,即新的根节点中放的就是次小/大的数据、
//如果是小堆的话,则根节点是最小值,记录下来,再把根节点删除,则新的根节点中就是次小的数据,然后再记录,再删除,直到顺序表中所有数据都删除完毕,则刚才记录的数据就从小到大排列了起来,这就是堆排序,大堆的话,也是如此、
//如果直接把顺序表中第二个及后面的所有数据往前挪动一位,即挪动数据覆盖根位置的数据删除,则时间复杂度就是O(N),并其把完全二叉树/堆的结构破坏了,此时的完全二叉树可能就不再是堆了,但是对于出堆操作而言,
//要保证出堆数据后,保持和原来一样的堆的性质,所以可能还要重新把这个完全二叉树整理成和之前性质一样的堆,这就比较麻烦了,若在Pop之前的小堆在顺序表中数据为:031859的话,现在删除堆顶,即删除0,则顺序表中的数据则为:31859,就不再是小堆了,而且父子关系也变了,血缘关系都乱了、

//解决方案:
//1、
//交换顺序表中第一个和最后一个数据,时间复杂度是O(1)、
//2、
//然后再把交换后的顺序表中的最后一个数据删除,即顺序表的尾删,只需要让size--即可,在这一次交换中,则整个完全二叉树就不再是小堆了,甚至就可能不是堆了,但是,该完全二叉树的两个子树都还是小堆,时间复杂度也是O(1)、
//3、
//再使用向下调整算法,使之变成小堆,默认根节点所在的层为第一层,假设该完全二叉树的高度为h,则最多需要调整h-1次,现在要求完全二叉树的高度h,则要分类讨论,若该完全二叉树的最后一层是满的,即该完全二叉树中节点的个数最多为
//2^h-1个,则完全二叉树的高度h最多为log以2为底N+1的对数,若该完全二叉树中的最后一层不满,即只有一个节点时,即完全二叉树中节点的个数最少为:2^(h-1)-1+1个,则完全二叉树的高度h最少为:log以2为底N的对数,整体再加1, 所以对于h-1而言,
//h-1最多为log以2为底N+1的对数整体减1,,,h-1最少为log以2为底N的对数,整体再加1再减去1,,选最坏的情况,则  h  应该选择最大值,则需要调整的次数就是最多的,即最坏的,则时间复杂度就是O(log以2为底N的对数)、
//向下调整算法的使用是有 前提 的,由于在出堆数据后要保持与原来的堆具有一样的性质,已知原来的堆是小堆,则出堆数据后应该也是小堆,则要保证该树的左右子树都是小堆才可以进行向下调整算法、
 
//则总的时间复杂度就是O(logN)、
//对于向上调整算法而言也是同样的,时间复杂度也是O(logN)、
//所以堆排序的优势就是效率较高,比时间复杂度O(N)的效率还要高的多、

//一级指针传参,一级指针接收、
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
	//1、找出左右孩子中值较小的一个,若两个孩子相等,则随便取一个即可,若两个孩子相等,则最后的结果可能有两种,这两种都是正确的,随便取一种即可、
	//2、拿该较小的孩子与父亲比较,若比父亲小,则交换,则另外一个子树不受影响,即使第一步中的两个孩子相等,也不受影响、
	//3、再从交换的孩子的位置继续往下调整、
	assert(a);

	方法一:
	//size_t child = root;
	//while (child < size)
	//{
	//	size_t parent = child;
	//	//两个孩子都存在、
	//	if ((2 * parent + 1)<size && (2 * parent + 2)<size)
	//	{
	//		//小堆、
	//		//若左右孩子相等的话,则随便取一个即可,在此取的是左孩子、
	//		child = a[2 * parent + 1] > a[2 * parent + 2] ? 2 * parent + 2 : 2 * parent + 1;
	//		//若左右孩子相等的话,则随便取一个即可,如果想要取到的是右孩子,则应为:
	//		//child = a[2 * parent + 1] >= a[2 * parent + 2] ? 2 * parent + 2 : 2 * parent + 1;
	//		//当左右孩子相等话,任意从左右孩子中取一个即可,最后的结果可能有两种,这两种都是正确的、

	//		//大堆、
	//		//若左右孩子相等的话,则随便取一个即可,在此取的是右孩子、
	//		//child = a[2 * parent + 1] > a[2 * parent + 2] ? 2 * parent + 1 : 2 * parent + 2;
	//		//若左右孩子相等的话,则随便取一个即可,如果想要取到的是左孩子,则应为:
	//		//child = a[2 * parent + 1] >= a[2 * parent + 2] ? 2 * parent + 1 : 2 * parent + 2;
	//		//当左右孩子相等话,任意从左右孩子中取一个即可,最后的结果可能有两种,这两种都是正确的、
	//	}
	//	//只有一个孩子存在,且是左孩子存在、
	//	if ((2 * parent + 1) < size && (2 * parent + 2) >= size)
	//	{
	//		child = 2 * parent + 1;
	//	}
	//	//不存在孩子、
	//	if ((2 * parent + 1) >= size)
	//	{
	//		break;
	//	}
	//	//若孩子大于父亲,则交换、
	//	//大堆、
	//	//if (a[child] > a[parent])

	//	//若孩子小于父亲,则交换、
	//	//小堆、
	//	if (a[child] < a[parent])
	//	{
	//		Swap(&a[child], &a[parent]);
	//	}
	//	else
	//	{
	//		break;
	//	}
	//}

	//方法二:
	size_t parent = root;
	//假设child指的就是左孩子,则有:
	size_t child = parent * 2 + 1;
	//只要进入while循环,则代表着左孩子一定存在,右孩子存不存在,还要进行判断,但是只要不进入while循环,则代表左孩子不存在,那么右孩子就更
	//不存在,即左右孩子都不存在、
	while (child < size)	
	{
		//找出左右孩子中值较小的一个,若两个孩子相等,则随便取一个即可,若两个孩子相等,则最后的结果可能有两种,这两种都是正确的,随便取一种即可、
		//如果两个孩子都存在,并且相等的话,也不进去下面的第一个if语句中,那么此时child指向的还是左孩子,因为随便取其中一个即可,在此取的就是左孩子、
		//if里面的&&前后不可以互换位置,如果互换了位置,若child+1 >= size,再通过下标进行访问就是越界,但是在while内部,已经保证了child<size,所以
		//child+1最多等于size,不会大于size,但是当chile+1等于size时,再通过下标访问也是越界访问,所以在进行下标访问之前必须保证child+1<size才行、
		//当只有左孩子时,就不再进入下面的if语句,则child指向的就是左孩子、

		//大堆、
		//对于大堆而言,要选左右孩子中较大的一个,则改为:
		//if (child + 1 < size && a[child + 1] > a[child])

		//小堆、
		if (child+1 < size && a[child + 1] < a[child])
		{
			//左右孩子都存在并且右孩子小于左孩子、
			child++;
		}
		//当执行到此处时,不清道child到底指的是左孩子还是右孩子,但是确定的是,child指向的就是左右两个孩子中较小的一个、

		//若孩子大于父亲,则交换、
		///大堆、
		//if (a[child] > a[parent])

		//若孩子小于父亲,则交换、
		//小堆、
		if (a[child] < a[parent])
		{
			//拿该较小的孩子与父亲比较, 若比父亲小, 则交换, 则另外一个子树不受影响, 即使第一步中的两个孩子相等, 也不受影响、
			Swap(&a[child], &a[parent]);
			parent = child;
			//再次计算child时,仍默认指向左孩子、
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	//1、交换顺序表中第一个和最后一个数据、
	Swap(&php->a[0], &php->a[php->size - 1]);

	//2、删除顺序表中最后一个数据、
	php->size--;

	//3、向下调整、
	AdjustDown(php->a,php->size, 0);
}


//打印、
void HeapPrint(HP* php)
{
	assert(php);
	for (size_t i = 0; i < php->size; i++)
	{
		printf("%d ", php->a[i]);
	}
	printf("\n");
}

//判断堆是否为空、
bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

//记录堆中的数据个数、
size_t HeapSzie(HP* php)
{
	assert(php);
	return php->size;
}

//取堆顶的数据,即取最大/小值、
HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}

三、Heap.h头文件:

#pragma once 
//防止头文件被重复包含、
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>

//堆这个数据结构的底层就是一个顺序表、

//大堆小堆均可、

//以小堆为例、

//一般情况下都考虑使用动态的,不考虑静态、
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	size_t size;     //顺序表中有效元素的个数、
	size_t capacity; //顺序表的容量、
}HP;

//初始化堆、
void HeapInit(HP* php);

//销毁堆、
void HeapDestroy(HP* php);

//入堆数据、
void HeapPush(HP* php, HPDataType x);

//出堆数据、
void HeapPop(HP* php);

//打印、
void HeapPrint(HP* php);

//判断堆是否为空、
bool HeapEmpty(HP* php);

//记录堆中的数据个数、
size_t HeapSzie(HP* php);

//取堆顶的数据,即取最大/小值、
HPDataType HeapTop(HP* php);

// 堆的构建、
void HeapInitArray(HP* php, HPDataType* a, size_t n);
//即把main函数中的数组直接拷贝在初始化函数中所开的空间中去,再使用建堆算法、


//堆排序优化->向上调整算法、
void AdjustUp(HPDataType* a, size_t child);

//堆排序优化->向下调整算法、
void AdjustDown(HPDataType* a, size_t size, size_t root);

//堆优化排序->交换数据、
void Swap(HPDataType* pa, HPDataType* pb);

3.4、堆的应用:

3.4.1、堆排序:

堆排序 即利用 堆的思想直接在 数组 上进行 建堆 进行排序,总共分为 两个步骤
1、建堆 :
升序:建大堆                      降序:建小堆
2、利用堆删除思想来进行排序:
建堆和堆删除 中都用到了 向下调整算法 ,因此掌握了向下调整,就可以完成 堆排序、

3.4.2、TOP-K问题:

若在N个数中找出前K个最大或最小的数不需要对这K个数进行排序,只需要找到即可,则首先想到的就是进行排序,,比如:

1、使用效率较高的排序,比如刚学的堆排序或者是快排,则时间复杂度是O(N*logN),空间复杂度是O(1),直接对数组进行排序,不需要额外开

      辟N个空间降序排列,则前K个数即为最大的K个数,升序排列,则前K个数即为最小的K个数、

2、建立N个数的大堆,Pop操作K次,就可以选出最大的前K个数,建立N个数的小堆,Pop操作K次,就可以选出最小的前K个数,建堆所需时间

     复杂度为O(N),Pop操作K次,则时间复杂度为O(K*logN),外层循环固定为K次,内层循环是不固定的,要列出来具体的执行次数再去计算 

     时间复杂度,可以参考test.c源文件中优化之前的Pop函数的时间复杂度计算,此时时间复杂度就是O(N+K*logN),不清楚 N 和 K*logN 的大

     小,不能忽略任何一个,直接在数组上进行建堆,则空间复杂度就是O(1)、

但是如果N非常大,远大于K,就不再适合使用上述方法来解决了,是因为上述方法,必须要使用数组来把这N个数都存储起来,但是若N非常

大,就不方便这些数的存储,可能会导致内存不够不是栈溢出, 所以这种情况下不适合使用上述方法来实现、

TOP-K问题: 即求数据结构中 前K个最大的元素或者最小的元素 ,一般情况下 数据量都比较大、
比如:专业前 10 名、世界 500 强、富豪榜、游戏中前 100 的活跃玩家等、
对于 Top-K 问题,能想到的最简单直接的方式就是 排序 ,但是:如果数据量非常大,排序就不太可取了 ( 可能数据都不能一下子全部加载到内存
中), 最佳的方式就是用 来解决,基本思路如下:
1、用 数据集合 前K个元素 建堆
要求前 k 最大 的元素,则建 小堆、
要求前 k 最小 的元素,则建 大堆、
2、用剩余的 N-K个元素 依次与 堆顶 元素来比较,若比堆顶的数据大,则替换 堆顶 进堆,即 向下调整 ,此时进不去堆的一定不是前K个最大值、
将剩余 N-K 个元素依次与堆顶元素比完之后,堆中剩余的 K 个元素就是所求的前 K 最大 的元素,当建 大堆 时,用剩余 N-K个元素 依次与 堆顶元素
进行比较, 若比堆顶的数据小,则替换堆顶进堆,即向下调整 ,此时进不去堆的数据一定不是前K个最小值,则堆中剩余的K个元素就是所求的
前K个最小的元素、
这种方法:
前K个元素进行建堆,则时间复杂度就是O(K) 第2步外层循环是固定值N-K次,内层循环不固定,需要列出准确的执行次数去计算,则时间复杂
付为O((N-K)*logK) ,则 总的时间复杂度就是: O(k+(N-K)*logK)由于 N远大于K ,所以, 时间复杂度可写为:O(N*logK) ,若再进一步的话 ,N非
常大,K非常小的话, 则 logK对整体的影响就不大,直接忽略即可,则 时间复杂度就是:O(N) 空间复杂度就是:O(K) ,是因为 不是直接在 数组
上进行建堆的,实际情况中 数组是不存在的
#include"Heap.h"

//一级指针传参,一级指针接收、
void AdjustDown(int* a, size_t size, size_t root)
{
	//1、找出左右孩子中值较小的一个,若两个孩子相等,则随便取一个即可,若两个孩子相等,则最后的结果可能有两种,这两种都是正确的,随便取一种即可、
	//2、拿该较小的孩子与父亲比较,若比父亲小,则交换,则另外一个子树不受影响,即使第一步中的两个孩子相等,也不受影响、
	//3、再从交换的孩子的位置继续往下调整、
	assert(a);
	//方法二:
	size_t parent = root;
	//假设child指的就是左孩子,则有:
	size_t child = parent * 2 + 1;
	//只要进入while循环,则代表着左孩子一定存在,右孩子存不存在,还要进行判断,但是只要不进入while循环,则代表左孩子不存在,那么右孩子就更
	//不存在,即左右孩子都不存在、
	while (child < size)
	{
		//找出左右孩子中值较小的一个,若两个孩子相等,则随便取一个即可,若两个孩子相等,则最后的结果可能有两种,这两种都是正确的,随便取一种即可、
		//如果两个孩子都存在,并且相等的话,也不进去下面的第一个if语句中,那么此时child指向的还是左孩子,因为随便取其中一个即可,在此取的就是左孩子、
		//if里面的&&前后不可以互换位置,如果互换了位置,若child+1 >= size,再通过下标进行访问就是越界,但是在while内部,已经保证了child<size,所以
		//child+1最多等于size,不会大于size,但是当chile+1等于size时,再通过下标访问也是越界访问,所以在进行下标访问之前必须保证child+1<size才行、
		//当只有左孩子时,就不再进入下面的if语句,则child指向的就是左孩子、

		//大堆、
		//对于大堆而言,要选左右孩子中较大的一个,则改为:
		//if (child + 1 < size && a[child + 1] > a[child])

		//小堆、
		if (child + 1 < size && a[child + 1] < a[child])
		{
			//左右孩子都存在并且右孩子小于左孩子、
			child++;
		}
		//当执行到此处时,不清道child到底指的是左孩子还是右孩子,但是确定的是,child指向的就是左右两个孩子中较小的一个、

		//若孩子大于父亲,则交换、
		///大堆、
		//if (a[child] > a[parent])

		//若孩子小于父亲,则交换、
		//小堆、
		if (a[child] < a[parent])
		{
			//拿该较小的孩子与父亲比较, 若比父亲小, 则交换, 则另外一个子树不受影响, 即使第一步中的两个孩子相等, 也不受影响、
			Swap(&a[child], &a[parent]);
			parent = child;
			//再次计算child时,仍默认指向左孩子、
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void PrintTopK(int* a,int n,int k) 
{
	//1、建堆--用a中前k个元素建堆、
	//此处只能动态开辟K个空间来建堆,不能直接从举例中的数组a上直接建堆,是因为这只是举例子,会存在数组a,但是真实情况下,是不存在数组a的,具体怎么拿到这n个数的前K个数据不需要掌握、
	//所以空间复杂度就是O(K),而不是O(1)、
	int* KminHeap = (int*)malloc(sizeof(int)*k);
	assert(KminHeap);
	//具体拿到n个数的前K个数的过程,不会是从数组a中取前K个数,是因为真实情况下,不会存在数组a,那么具体怎么拿到前K个数,不需要掌握、
	for (int i = 0; i < k; i++)
	{
		KminHeap[i] = a[i];
	}
	for (int j = (k - 1 - 1 / 2); j >= 0; j--)
	{
		AdjustDown(KminHeap, k, j);
	}
	// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换、
	//具体怎么遍历N-K个数据也不需要掌握,但一定不会是从数组a中遍历,因为实际情况下是不会存在数组a的、
	for (int i = k; i < n; i++)
	{
		//只有大于时才需要替换,小于时不能进行替换,等于时不需要进行替换,因为替换不替换,堆顶数据都一样、
		if (a[i] > KminHeap[0]) 
		{
			KminHeap[0] = a[i];
			AdjustDown(KminHeap, k, 0);
		}
	}
	for (int j = 0; j < k; j++)
	{
		printf("%d ", KminHeap[j]);
	}
	printf("\n");
}
void TestTopk()
{
	//假设n是10000,既在10000个数中找前10个最大的数、
	int n = 10000;
	//真正的n可能会非常非常大,不管在是堆区还是栈区都是内存中的,而对于TopK问题的话,不能直接把n个数放在内存中,可能会放不下,即不能直接放在堆区上或者栈区上,那这样的话怎么
	//来遍历N-K个数据呢,这n个非常多的数不能直接存储在内存中,比如,可能是把这n个数存放在硬盘中,然后一次拿一部分到高速缓存中,然后再放到内存中去,而不是直接把所有的数据存储在
	//内存中,即使拿一部分进内存也不是把这一部分存在一个数组中,具体怎么遍历的N-K个数不需要掌握,只知道,在实际情况下不会存在 数组 即可、
	//此时把10000个数据直接放在了堆区上,也属于放在了内存中,是因为这只是举了个例子,不放在栈区上的原因可能是害怕栈溢出,堆区比栈区要大,所以举例子就直接放在了堆区上,但是
	//实际上不是放在堆区上的,不是放在内存中的,堆区也不是无限大的,具体的大小和内存的大小有关,即,堆区和栈区是按照一定的比例瓜分内存条空间,堆区相对于栈区会较大一些、
	int* a = (int*)malloc(sizeof(int)*n);
	//产生一些0-9999999之间的随机数、
	for (int i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;
	}
	//故意在一些位置上产生比1000000数大的10个数,这些位置可以是其他位置,只要在下标为0-9999的范围内即可、
	//在这10000个数中,下面的10个数是最大的前10个,其他的数都比1000000要小,所以若结果是下面的10个数,则证明算法是正确的、
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[531] = 1000000 + 3;
	a[5121] = 1000000 + 4;
	a[115] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9299] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 1000;
	PrintTopK(a, n, 10);
}

int main()
{
	TestTopk();
	return 0;
}

剩余的知识点会在后期进行陆续更新,谢谢大家!

猜你喜欢

转载自blog.csdn.net/lcc11223/article/details/123913758