目录
2.5、二叉树的存储结构:
3、二叉树的顺序结构及实现:
3.1、二叉树的顺序结构:
3.2、堆的概念及结构 :
3.3、堆的实现:
3.3.1、堆向下调整算法:
3.3.2、堆的创建:
3.3.3、建堆时间复杂度 :
3.3.4、堆的插入:
3.3.5、堆的删除:
3.3.6、堆的代码实现:
3.4、堆的应用:
3.4.1、堆排序:
3.4.2、TOP-K问题:
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构、
队列和非完全二叉树一般都是不适合使用顺序存储结构,相比而言,非完全二叉树更不适合使用顺序存储结构、
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; // 当前节点值域
};
int a[] = {1,5,3,8,7,6};
一、向上调整算法建堆的时间复杂度求解:
二、向下调整算法建堆的时间复杂度求解:
先插入一个数据10到顺序表的尾上,即尾插,再进行向上调整算法,直到满足堆、
一、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) 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 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
#include
#include
#include
//堆这个数据结构的底层就是一个顺序表、
//大堆小堆均可、
//以小堆为例、
//一般情况下都考虑使用动态的,不考虑静态、
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);
若在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非常
大,就不方便这些数的存储,可能会导致内存不够,不是栈溢出, 所以这种情况下不适合使用上述方法来实现、
#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 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;
}