数据结构 | 堆的实现 向上调整算法 向下调整算法 堆排序


一、堆的概念及结构

如果有一个关键码的集合K = { k0,k1,k2, … , kn-1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中。
并满足: ki<=k2i+1 且 ki<=k2i+2 (ki >=k2i+1 且 ki>=k2i+2 ) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆

堆的性质:

  1. 堆中某个节点的值总是不大于或不小于其父节点的值;
  2. 堆总是一棵完全二叉树

了解了堆的概念之后呢,你肯定会有疑惑,大根堆小根堆是什么呢?

在这里插入图片描述

  • 可以看到逻辑结构的表示,就是一颗完全二叉树,只不过是在完全二叉树的基础上划分出了大根堆小根堆两种概念。
  • 但其实内存中的真实样子应该是右边的形式,也就是存储结构的形式,它是用数组的形式来进行存储的。左边的树形结构,也就是逻辑结构是我们想象出来的样子,目的是方便我们理解。
  • 那通过上面的讲解我们可以知道中的各个节点是存在一种关系的,我们都知道树中是有父亲节点孩子节点,那现在我们是否可以由**下标**来表示这两个节点之间的关系呢。

用父亲节点下标求孩子节点下标:

LeftChild = Parent * 2 + 1; //左孩子的节点下标

RightChild = Parent * 2 + 2; //右孩子的节点下标

用孩子节点下标求父亲节点下标:

Parent = (Child - 1) / 2;

在这里插入图片描述

如果觉得难以理解,可以带几个数据去计算一下,你会发现就是如此。

二.向上调整算法

对堆的概念有了了解之后我们要学习一种算法叫做向上调整算法

1.算法图解分析

所谓向上调整,字面意思就是把数不断的和上面的数做调整,那么具体是怎么回事呢?
在这里插入图片描述

  • 看图片上面的情况如果这个新插入的数小于父亲呢就不做调整,此时堆还是一个大根堆。
  • 那我们在看图片下面的情况可以看到我们在我们在堆的末尾空白位置插入了一个数100,那么此时为了保证堆维持一个大根堆,我们必须要把我们这个新插入的数和它的父亲节点作比较,如果这个新插入的数大于父亲,那么就和父亲交换位置
  • 那么我们怎么找到它的父亲节点呢,还记得我们之前提到的的吗。用孩子节点下标求父亲节点下标:Parent = (Child - 1) / 2;
  • 此时100是大于30的,所以我们交换两数的位置,交换完毕后继续用100和他的父亲进行比较,继续调整。

2.具体代码实现

知道了算法的思想我们就需要把它转换为代码,下面我们来看看如何用代码把它表示出来吧。

这里的a是堆这个数据结构的结构体,具体的后面我们会介绍,child是我们插入的数据的下标,我们需要用child和它的父亲去进行比较做出调整。

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>0,因为我们是在做向上调整,向上调整是和自己的父亲节点作比较,如果大于父亲节点就做交换。那么我们必须有父亲节点才能交换啊,如果我此时child的下标为0了那我们不就是堆顶的那个数据了吗,我们已经是最大的了,哪里还有父亲做比较呢,所以我们循环执行的条件为child>0。

交换完之后我们需要更新孩子节点的位置,继续做调整。

child = parent;
parent = (child - 1) / 2;

完整代码实现

void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while(child > 0)//孩子节点的下标大于0我们执行循环.
	{
		if (a[child] > a[parent])如果孩子大于父亲就交换
		{
			Swap(&a[child], &a[parent]);//交换孩子和父亲
			
			//更新孩子和父亲的位置
			child = parent;
			parent = (child - 1) / 2;
		}
		else//如果孩子小于父亲,则不做调整,break退出循环.
		{
			break;
		}
	}
}

//Swap交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType x = *p1;
	*p1 = *p2;
	*p2 = x;
}

三.向下调整算法!!

向下调整算法在实际使用中使用的更多,因此我们需要着重掌握

1.算法图解分析

同样的,向下调整算法字面意思就是把数据和它的孩子节点作比较,然后做出相应的调整。我们这里一样的用大根堆做示例。
在这里插入图片描述
注意:向下调整算法有一个前提【左右子树必须是一个堆,才能调整】
也就是说要执行向下调整必须要保证根节点的左右子树都是小根堆或大根堆我们才能执行向下调整。

  • 我么可以看到图片的第一种情况 此时100大于它的孩子节点,由于我们是要大根堆,所以此时我们不做调整。
  • 接着我们看第二种情况,此时根节点是20,20小于它的的孩子,那么我们就要和它的孩子交换位置,但是具体和哪个孩子交换位置呢?我们需要和两个孩子中大的那个孩子交换,那么我们就需要找到孩子节点的下标,还记得我们上面提到的通过父亲节点找孩子节点的公式吗。
    LeftChild = Parent * 2 + 1; //左孩子的节点下标
    RightChild = Parent * 2 + 2; //右孩子的节点下标

2.具体代码实现

同样的知道了算法的思想,接下来就用代码实现出来。

可以看到向下调整算法传入的是parent父亲节点,这是因为向下调整算法是向下调整的也就是和自己的孩子调整,所以我们传入父亲,而不是孩子。这里的n呢表示数组的大小,也就是堆的大小,上图中n==6。它是我们循环结束需要用到的条件。

void AdjustDown(HPDataType* a, int n, int parent)

通过父亲节点找到孩子,但是为什么只有一个孩子呢?刚刚不是说我们需要和两个孩子中大的那个孩子交换吗?,这是我们是假设child就是最大的孩子,然后进入循环后我们在用右孩子和左孩子进行比较,然后选出较大的那个,更新child的值。

int child = parent * 2 + 1;//此时假设左孩子就是最大的孩子

//child+1<n 是判断孩子存不存在,如果child+1==n就说明越界了
if (child + 1 < n && a[child+1] > a[child])
{
			++child;
}

如果大的那个孩子大于父亲,我们就交换它们的位置,然后更新父亲节点的位置,继续做出调整,注意我们这里的循环条件child<n,也就是child的值最大为n-1,也就是堆的最后一个元素,如果child大于等于n就越界访问了。

while (child < n)
	{
		// 选出左右孩子中大的那一个
		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;
		}

具体代码实现

// 左右子树都是大堆/小堆
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		// 选出左右孩子中大的那一个
		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;
		}
	}
}

三.堆的实现

堆本质上是一个数组,size指的是堆的数据个数,capacity指的是对的容量。

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

1.堆的初始化

我们初始化给堆开辟四个字节的大小

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

	php->a = (HPDataType*)malloc(sizeof(HPDataType)*4);
	if (php->a == NULL)
	{
		perror("malloc fail");
		return;
	}

	php->size = 0;
	php->capacity = 4;
}

2.堆的插入

堆的插入其实就是往数组中插入一个数,当数组的元素个数等于容量时我们就需要扩容了。除了扩容堆的插入还需要用到向上调整算法,这里的【php->size - 1】指的是数组的最后一个元素,也就是我们新插入的这个元素,当我们往堆里插入数据后,需要判断堆是否还是大根堆还是小根堆,我们就需要对这个新插入的数做向上调整。

void HeapPush(HP* php, HPDataType x)
{
	assert(php);

	if (php->size == php->capacity)
	{
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * php->capacity*2);
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}
		php->a = tmp;
		php->capacity *= 2;
	}

	php->a[php->size] = x;
	php->size++;

	AdjustUp(php->a, php->size - 1);
}

3.堆的删除!!

有插入的操作那肯定就有删除的操作,但是我们想想删除的是 [堆顶] 的数据还是[堆尾]的数据呢?我们先来看代码。

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);
	return php->a[0];
}

可以看到我们交换了堆顶和堆尾的数据,然后【php->size–】删除堆尾的元素,接着执行了向下调整算法【AdjustDown(php->a, php->size, 0)】,把堆顶0号下标的元素向下调整。
在这里插入图片描述

5.取堆顶元素

【php->a[0]】就是堆的第一个元素,也就是堆顶。

HPDataType HeapTop(HP* php)
{
	assert(php);
	return php->a[0];
}

6.判断堆是否为空

当【php->size == 0】成立时 堆为空。

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

7.返回堆的数据个数

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

7.堆的销毁

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

	free(php->a);
	php->a = NULL;
	php->capacity = php->size = 0;
}

8.堆的构建

堆的构建有两种方法,一种是向上调整建堆,另一种是向下调整建堆

首先我们来看第一种,向上调整建堆。这里我们插入一个数据就对这个插入数据做一个向上调整算法。

/*建堆*/
void HeapInitArray(Hp* php, HpDataType* a, int n)
{
	assert(php);
	HeapInit(hp);
	for (int i = 0; i < n; ++i)
	{
		HeapPush(hp, a[i]);
	}
}

接下来是第二种,向下调整建堆。可以看到我们这里不是像上一种方法插入一个数做一次调整,而是对这个堆整体做一个调整。

void HeapInitArray(HP* php, int* a, int n)
{
	assert(php);

	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		perror("malloc fail");
		return;
	}

	php->size = n;
	php->capacity = n;

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

但是要使用向下调整就要注意 向下调整算法有一个前提【左右子树必须是一个堆,才能调整】
在这里插入图片描述

可以看到2的左右子树都不是一个堆,1和5的左右子树也不是一个堆。因此我们需要从倒数第二层开始调整,也就是最后一个非叶子节点开始调整,也就是5的位置。那我们怎么找到这个节点呢,我们是知道最后一个元素的位置的也就是n-1,那我们知道了孩子节点要求父亲节点,上面我么提到过的公式 Parent = (Child - 1) / 2; 可以求出最后一个非叶子节点的下标为 (n-1)-1/2 ;

四.两种调整算法的时间复杂度

这里我们不做分析了,直接给出结论。
【向上调整算法】,它的时间复杂度为O(NlogN);
【向下调整算法】,它的时间复杂度为O(N);
所以平时要优先使用向下调整算法。

五.堆的应用

1.堆排序

堆排序–时间复杂度:O(Nlog2N);
排升序–建大堆
排降序–建小堆

// 排升序 -- 建大堆 -- O(N*logN)
void HeapSort(int* a, int n)
{
	// 建堆 -- 向上调整建堆 -- O(N*logN)
	/*for (int i = 1; i < n; ++i)
	{
		AdjustUp(a, i);
	}*/

	// 建堆 -- 向下调整建堆 -- O(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;
	}
}

2.TOP-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能
数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  • 用数据集合中前K个元素来建堆
    前k个最大的元素,则建小堆
    前k个最小的元素,则建大堆

  • 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
    将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

void PrintTopK(const char* file, int k)
{
	// 1. 建堆--用a中前k个元素建小堆
	int* topk = (int*)malloc(sizeof(int) * k);
	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);
	}

	// 2. 将剩余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);
}

//随机生成10000000个数,存储到文件中
//求该数据中前K个数据。
void CreateNDate()
{
	// 造数据
	int n = 10000000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}

	for (size_t i = 0; i < n; ++i)
	{
		int x = rand() % 10000;
		fprintf(fin, "%d\n", x);
	}

	fclose(fin);
}

总结

  • 本次我们学习到了堆的相关知识,堆本质上就是一个数组。
  • 然后我们又学习了【向上调整算法】和【向下调整算法】,知道了它们的时间复杂度【向上调整算法】它的时间复杂度为O(NlogN),【向下调整算法】它的时间复杂度为O(N); 所以平时要优先使用向下调整算法。
  • 接着我们又学习了堆排序,它是八大排序算法之一,它的时间复杂度为O(NlogN);
  • 最后我们学习了TOP-K问题–即求数据结合中前K个最大的元素或者最小的元素。

以上就是本文的全部内容了,若有问题请在评论区留言,觉的好的话就留下你的三连吧。

  • 18
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
堆排序是一种基于数据结构排序算法,其中包括向下调整向上调整两个关键步骤。 1. 向下调整(AdjustDown):在堆排序中,向下调整用于将一个元素下沉到合适的位置,以维持的性质。具体步骤如下: - 首先,将当前节点标记为根节点。 - 比较根节点与其左右子节点的值,找到最大(或最小)的节点。 - 如果根节点的值小于(或大于)最大(或最小)的子节点的值,则交换根节点与最大(或最小)子节点的值。 - 将当前节点更新为最大(或最小)子节点的位置,并重复上述步骤,直到当前节点不再有子节点或满足的性质。 2. 向上调整(AdjustUp):在堆排序中,向上调整用于将一个元素上浮到合适的位置,以维持的性质。具体步骤如下: - 首先,将当前节点标记为叶子节点。 - 比较叶子节点与其父节点的值,如果叶子节点的值大于(或小于)父节点的值,则交换叶子节点与父节点的值。 - 将当前节点更新为父节点的位置,并重复上述步骤,直到当前节点不再有父节点或满足的性质。 以下是一个示例代码,演示了堆排序中的向下调整向上调整的过程: ```python def adjust_down(arr, n, i): largest = i left = 2 * i + 1 right = 2 * i + 2 if left < n and arr[i] < arr[left]: largest = left if right < n and arr[largest] < arr[right]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] adjust_down(arr, n, largest) def adjust_up(arr, i): parent = (i - 1) // 2 if parent >= 0 and arr[parent] < arr[i]: arr[parent], arr[i] = arr[i], arr[parent] adjust_up(arr, parent) # 示例数据 arr = [4, 10, 3, 5, 1] n = len(arr) # 向下调整示例 adjust_down(arr, n, 0) print("向下调整后的结果:", arr) # 输出:[10, 5, 3, 4, 1] # 向上调整示例 arr.append(7) n += 1 adjust_up(arr, n-1) print("向上调整后的结果:", arr) # 输出:[10, 7, 3, 5, 1, 4] ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值