数据结构——二叉树(2)——堆

一、堆的概念和结构

堆是一棵完全二叉树,对于这棵二叉树,要求某个节点的值总是不大于或不小于其父节点的值。若某个节点的值总是不大于其父节点的值,我们称这样的堆为大堆或大根堆;若某个节点的值总是不小于其父节点的值,我们称这样的堆为小堆或小根堆。

堆是使用数组存储的。

堆在逻辑上是一棵完全二叉树,在物理上是使用数组存储的,那么堆这棵完全二叉树的数据是怎么存储到数组中的呢?

很简单,堆中的数据是从第一层开始,一层一层放到数组中的。

 二、堆的实现

2.1定义堆的结构

指针a指向动态开辟的数组空间,数组用来存放堆的数据;size记录堆的有效数据个数;capacity记录堆的容量大小。

typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;

 2.2堆的初始化

void HeapInit(HP* php)
{
    //php是指向结构体的指针,php不能为空
	assert(php);
    //先为堆开辟能存放4个数据的空间
	HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 4);
	if (tmp == NULL)
	{
		perror("malloc fail!");
		return;
	}
	php->a = tmp;
    //刚开始堆没有数据,所以size初始化为0
	php->size = 0;
    //因为开辟了4个空间,所以堆的容量大小是4,把capacity初始化为4
	php->capacity = 4;
}

2.3 堆的插入

大堆插入数据后必须仍是大堆,小堆插入数据后必须仍是小堆。

给堆插入一个数据之后,堆的结构可能被破坏,如下图:

当3插入之后,3的父亲是15,而15大于3,不满足小堆的定义,因此需要对3进行向上调整,使树的结构满足小堆的定义。

向上调整有一个前提:左右子树都满足是一个堆。在上面的堆中,除了新插入的数据不满足是一个堆,其他都满足是一个堆,因此要从3这个位置开始向上调整。

向上调整的基本思路如下:

通过下标找到要向上调整的结点(child为下标)及其父节点(parent为下标),将child下标的数据和parent下标的数据进行比较,若不满足堆,则将child下标的数据和parent下标的数据进行交换,然后让child走到parent的位置,parent通过计算走到当前parent的parent位置,然后再进行新一轮的调整,直到满足堆。

3的下标是size-1,那么如何通过3的下标找到3的父亲呢?根据上一篇文章提到的父子结点的下标关系,parent = (child - 1)/2,就可以找到3的父亲的下标。当孩子和父亲都找到之后,就对孩子和父亲进行比较,若孩子大于父亲,则证明满足小堆结构;若孩子小于父亲,则不满足小堆结构。若不满足小堆结构,则要把孩子和父亲进行交换,交换后,孩子和父亲下标要进行相应的变换,再进行比较,再判断是否满足小堆结构,一直调整到满足小堆结构为止。

向上调整过程如下:

向上调整的代码实现(以小堆为例):


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;
		}
	}
}

 Swap函数:

void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

 堆的插入:

void HeapPush(HP* php, HPDataType x)
{
    //php是指向结构体的指针,php不能为空
	assert(php);
    //判断堆还有没有剩余的空间插入数据,有就直接插入,没有就先扩容,再插入数据
	if (php->size == php->capacity)
	{
        //2倍扩容
		HPDataType* tmp = (HPDataType*)realloc(php->a, 2 * sizeof(HPDataType) * php->capacity);
		if (tmp == NULL)
		{
			perror("realloc fail!");
			return;
		}
		php->a = tmp;
        //扩容后,capacity的值要进行相应的修改
		php->capacity *= 2;
	}
	//插入数据
	php->a[php->size] = x;
    //插入数据后,堆的有效数据变多了。要进行相应的修改
	php->size++;
    //向上调整
	AdjustUp(php->a, php->size - 1);
}

 总的来说,在给堆插入数据时,有以下2个步骤:

1.把数据插入在堆的末尾;

2.判断堆的结构有没有被破坏,若堆的结构被破坏了,则要通过向上调整算法,把这个数据调整的合适的位置。

2.4堆的删除

堆的删除指的是删除堆顶元素。若是挪动删除堆顶元素,会有两个弊端:1.效率低下;2.父子兄弟关系全乱了,再要去调整成堆,会很麻烦。删除堆顶元素,可以先把堆顶元素和堆最后一个元素交换,然后通过size--来控制堆的有效数据个数,这样堆顶元素就被删除了。但是交换数据过后,还要保持是一个堆,这时可以通过向下调整算法来调整成堆。

需要注意的是,向下调整算法有一个前提:左右子树必须是一个堆,才能调整。

在把堆顶元素和堆最后一个元素交换后,左右子树仍是一个堆,只有堆顶元素不满足堆,因此把堆顶元素向下调整到合适的位置形成堆即可。

如要对下图的小堆进行删除操作:

交换元素过后:

交换元素之后,要从20开始向下调整。通过20的下标算20的左右孩子下标,让20和它左右孩子中小的那个进行交换,然后再调整chid和parent的值,不断循环,直到调成小堆。

向下调整过程如下:

向下调整代码实现(以小堆为例):

void AdjustDown(HPDataType* a, int n, int parent)
{
    //假设左孩子为左右孩子中小的那个
	int child = parent * 2 + 1;
    //最坏情况下调整到叶子
	while (child <= n-1)
	{
        //child + 1 < n是为了防止越界
        //a[child]>a[child+1]说明左孩子比右孩子大,不满足假设
		if (child + 1 < n && a[child] > a[child + 1])
		{
			child = parent * 2 + 2;//右孩子为左右孩子中小的那个
		}
		if (a[parent] > a[child])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;//形成堆,循环结束
		}
	}
}

堆的删除:

void HeapPop(HP* php)
{
	assert(php);
	assert(php->size != 0);//堆为空的时候不能删除
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, php->size, 0);
}

 总的来说,堆的删除有以下3个步骤:

1.将堆顶元素与堆中最后一个元素进行交换;

2.删除堆中最后一个元素(size--即可);

3.将堆顶元素向下调整,直到满足堆的特性为止。

2.5取堆顶的数据

取堆顶元素,直接把堆顶元素返回即可。

HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));//堆为空不能取堆顶元素
	return php->a[0];
}

2.6判断堆是否为空

若堆为空,则返回非0结果;若堆不为空,则返回0。

bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

 2.7获取堆的数据个数

直接返回size即可获得堆的数据个数。

int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}

2.8堆的销毁

由于堆是一块动态开辟出来的空间,因此为防止内存泄漏,在使用堆之后要把堆销毁。

void HeapDestroy(HP* php)
{
	assert(php);

    //释放指针a指向的空间
	free(php->a);
    //把a置空
	php->a = NULL;
    //堆被销毁之后,要把有效数据个数size和容量大小capacity置为0
	php->size = php->capacity = 0;
}

三、堆的应用

3.1建堆

在讲解堆的应用之前,我们先来看看两个建堆的方法。

下面我们给出一个数组,这个数组逻辑上可以看做一棵完全二叉树,但是还不是一个堆,现在我们要把它构建成一个堆,如何建堆?

也许有人会想到用堆这个数据结构建堆,但是这存在两个问题:1.当没有堆这个数据结构的时候,就需要自己手搓堆,比较麻烦。2.如果用堆这个数据结构,就要把原数组的数据push到堆里面,建成堆之后又要把堆里面的数据拷贝回原数组,这样也很麻烦。因此,我们可以考虑脱离堆这个数据结构自己建堆。

建堆有如下两个方法:

1.向上调整建堆(模拟插入的过程)

向上调整建堆就是模拟插入的过程,最先开始默认数组的第一个数据就是堆中的元素。

第一次循环:将数组的第二个元素插入堆中,从第二个元素开始向上调整成堆。

第二次循环:将数组的第三个元素插入堆中,从第三个元素开始向上调整成堆。

……

第n - 1次循环:将数组的第n个元素插入堆中,从第n个元素开始向上调整成堆。

循环结束。

当循环结束的时候,堆就建成了。

//向上调整建堆
//n是数组长度
for (int i = 1; i < n; i++)
{
	AdjustUp(a, i);
}

 

2.向下调整建堆

由于向下调整有一个前提:左右子树必须是一个堆,才能调整。

因此向下调整是从最后一个结点的父亲开始调整的。只有从最后一个结点的父亲开始调整,下面的子树才能形成一个一个堆。下面的子树形成堆时,才能从这些子树的根节点开始向下调整。

n-1是最后一个结点的坐标,(n-1-1)/2是最后一个结点的父亲的下标。

for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
	AdjustDown(a, n, i);
}

 

 

向上调整建堆的时间复杂度是O(N*logN),向下调整建堆的时间复杂度是O(N),故向下调整建堆的效率比较高。

3.2堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

1.建堆。若是排升序,则建大堆;若是排降序,则建小堆。

2.利用堆删除思想来进行排序。

假设我们要排升序,则我们要建的是大堆。当大堆建好后,堆顶元素就是最大的那一个,定义一个end指针指向数组最后一个位置,然后将堆顶元素和堆中的最后一个元素进行交换,这样就把最大的元素沉到堆的最后,接着通过end调节数组长度,使已经排好序的数据不参与向下调整(相当于删除堆中的元素)。每进行一次循环,就有一个数被排好序,当循环结束的时候,排序也完成了。


void HeapSort(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[end], &a[0]);
		AdjustDown(a, end, 0);

		end--;
	}
}

int main()
{
	int a[10] = { 2,1,5,7,6,8,0,9,4,3 };
	HeapSort(a, 10);
	return 0;
}

3.3TOP-K问题

简单来说,TOP-K问题就是找N个数里最大/最小的前k个。

比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等,这些都是TOP-K问题。

解决TOP-K问题的基本思路如下:

1.用数据集合中前K个元素来建堆;

找前k个最大的元素,则建小堆;
找前k个最小的元素,则建大堆;

2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素。

将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

比如找N个数里最大的前k个:

(1).建一个K个数的小堆。

(2).遍历剩下的N-K个数据,如果比这个数据比堆顶的数据大,就替代它进堆(把这个数据赋值给堆顶数据,然后向下调整)。

(3).最后这个小堆的数据就是最大的前K个。

代码示例:

void PrintTopK(const char* file, int k)
{
	//建堆——用前k个元素建小堆
	int* topk = (int*)malloc(sizeof(int) * k);
	//检查malloc是否失败
	assert(topk);
	
	//打开文件
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return;
	}

	//读k个数据,建堆
	for(int i = 0 ; i < k ; i++)
	{
		fscanf(fout, "%d", &topk[i]);
	}
	for (int i = (k - 2) / 2; i >= 0; i--)
	{
		AdjustDown(topk, k, i);
	}

	//将剩余的n-k个元素依次与堆顶元素交换
	int val = 0;
	int ret = fscanf(fout, "%d", &val);
	while (ret != EOF)
	{
		if (val > topk[0])
		{
			topk[0] = val;
			AdjustDown(topk, k, 0);
		}
		ret = fscanf(fout, "%d", &val);
	}
	for (int i = 0; i < k; i++)
	{
		printf("%d ", topk[i]);
	}
	printf("\n");

	free(topk);
	fclose(fout);
}

void TestTopk()
{
	int n = 10000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}

	//向文件里面写随机数
	for (int i = 0; i < n; i++)
	{
		int x = rand() % 10000;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);

	PrintTopK(file, 10);
}

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值