【数据结构】C语言实现堆及其应用


一、堆的概念与实现

堆的概念

堆的定义:
如果有一个关键码的集合

K

=

{

k

0

,

k

1

,

k

2

,

.

.

.

,

k

n

1

}

K = { k_0, k_1, k_2, ..., k_{n-1} }

K={k0,k1,k2,...,kn1}, 把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:

K

i

K

2

i

+

1

K_i le K_{2i + 1}

KiK2i+1

K

i

K

2

i

+

2

K_i le K_{2i+2}

KiK2i+2

K

i

K

2

i

+

1

K_i ge K_{2i + 1}

KiK2i+1

K

i

K

2

i

+

2

K_i ge K_{2i+2}

KiK2i+2 ),i = 0,1,2…,则称为小堆(大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

1.堆是一个完全二叉树
2.大堆:树中任何一个节点的父亲都大于或等于孩子。
小堆:树中任何一个节点的父亲都小于或等于孩子。
3.堆的物理结构是数组,逻辑结构是二叉树。逻辑结构方便理解。

堆结构定义

堆的本质是数组,因此定义堆就像定义数组一样:

typedef int HeapDataType;
typedef struct Heap
{
	HeapDataType* a;
	int size;
	int capacity;
}Heap;

堆的初始化与销毁

就是顺序表的操作。

void HeapInit(Heap* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}

void HeapDestroy(Heap* hp)
{
	assert(hp);
	free(hp->a);
	hp->capacity = hp->size = 0;
}

堆的插入与向上调整

1.先将元素插入到堆的末尾,即最后一个孩子之后。
2.插入之后如果堆的性质遭到破坏,则将新插入结点顺着双亲往上调整到合适的位置。

第一步在代码写法上跟顺序表的尾插一样,首先判断是否需要扩容,然后插入数据到末尾。

void HeapPush(Heap* hp, HeapDataType x)
{
	assert(hp);
	// 插入需要检查空间是否足够 是否需要扩容
	if (hp->size == hp->capacity)
	{
		size_t newCapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HeapDataType* tmp = realloc(hp->a, sizeof(HeapDataType) * newCapacity);
		if (tmp == NULL)
		{
			printf("realloc failn");
			exit(-1);
		}

		hp->a = tmp;
		hp->capacity = newCapacity;
	}
	// 插入数据到末尾
	hp->a[hp->size] = x;
	hp->size++;

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

第二步,在插入数据以后需要向上调整。

首先对该函数参数进行说明:第一个参数是数组而不是堆。第二个参数是待向上调整的元素索引。

void AdjustUp(int* a, int child);

实现过程中,所谓的向上调整就是把数据与父节点进行比较,此过程反复迭代,直到放到合适的位置。

结束的条件是:
1.child的值为0,表示迭代到堆顶。
2.或者迭代的中途,child与parent的值没有交换,也就是child在已经在合适的位置了。

void AdjustUp(int* a, int child)
{
	assert(a);
	
    // 1.先找到父节点索引
	int parent = (child - 1) / 2;

	while (child > 0)
	{
        // 小根堆向上调整规则
		if (a[child] < a[parent])
		{
            // 2.满足向上调整规则 继续迭代
			Swap(&a[child], &a[parent]);

			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
            // 2.已经在合适位置,无需向上调整
			break;
		}
	}
}

这个代码需要注意的是大根堆和小根堆的调整规则不同,具体是a[child]a[parent] 的比较方式不同。


大根堆向上调整函数:

// 大根堆向上调整
void AdjustUp_big(int* a, int child)
{
	assert(a);

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

堆的打印、判空、元素个数size、堆顶元素

堆的本质是数组,打印就是遍历数组:

void HeapPrint(Heap* hp)
{
	for (int i = 0; i < hp->size; ++i)
	{
		printf("%d ", hp->a[i]);
	}
	printf("n");
}

判空就是数组 size 成员是否为0:

bool HeapEmpty(Heap* hp)
{
	assert(hp);

	return hp->size == 0;
}

函数HeapSize返回堆中元素个数:

int HeapSize(Heap* hp)
{
	assert(hp);

	return hp->size;
}

函数HeapTop返回堆顶元素:

HeapDataType HeapTop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));

	return hp->a[0];
}

堆的删除与向下调整

删除堆是删除堆顶的数据。将堆顶的数据与最后一个元素交换,然后删除数组最后一个数据(尾删),再进行向下调整算法。

void HeapPop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));

	Swap(&hp->a[0], &hp->a[hp->size - 1]);
	hp->size--;

	AdjustDown(hp->a, hp->size, 0);
}

向下调整函数说明:
1.第一个参数是待调整的数组。
2.第二个参数是待调整的数组大小。
3.第三个参数是待调整数据在数组中的位置,向下调整一般是从堆的根部开始,所以该参数一般是0。

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

所谓的向下调整具体就是跟左右孩子中一个进行交换,然后向下迭代。
小根堆:跟左右孩子中小的那个交换
大根堆:跟左右孩子中大的那个交换

小根堆的结束条件:
1.父节点parent调整到合适位置,其值小于等于左右孩子则停止。
2.或者从上到下调整到叶子结点。(叶子特征没有左孩子,也就是左孩子下标超出数组范围)

void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
    // 循环结束条件1是向下调整到叶子结点
	while (child < n)
	{
		// 1.选出左右孩子中小的那一个
		if (child + 1 < n && a[child + 1] < a[child])
		{
			++child;
		}

		// 2.如果小的孩子小于父亲,则交换,并继续向下调整
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
            // 循环结束条件2是parent在合适的位置
			break;
		}
	}
}

代码说明:
有了parent,我们首先需要找到左右孩子并且找到最小的孩子。
两个问题:第一个问题是调整的过程中,parent可能没有右孩子。第二个问题是有右孩子,找到左右孩子最小值。代码中我们仅定义child一个变量假设作为左孩子。并且使用如下代码很巧妙的避开了右孩子不存在情况,并且右孩子存在时能够找到左右孩子中最小的那个,excellent,同时完美解决了上面两个问题:

if(child + 1 < n && a[child + 1] < a[child]) // 小根堆调整 选出左右孩子中较小的
{
    ++child;
}

child + 1 < n:用于判断右孩子是否存在。

a[child + 1] < a[child]:右孩子存在,并且右孩子较小,则child++后指向右孩子。

经过上面代码,现在我们的child指向较小的孩子,因为我们需要将parent和child比较:
如果child小于parent,则需要继续向下调整。
否则,parent不比child大,就满足了小根堆的性质,调整结束。

// 小根堆调整 如果小的孩子小于父亲,则交换,并继续向下调整
if (a[child] < a[parent])
{
	Swap(&a[child], &a[parent]);
	parent = child;
	child = parent * 2 + 1;
}
else
{
	break;
}

大根堆的向下调整规则:

// 大根堆向下调整
void AdjustDown_big(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child] < a[child + 1])
		{
			++child;
		}

		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

大根堆与小根堆的写法区别

从堆的实现来看,大根堆与小根堆的区别点在向上调整与向下调整。

向上调整的大根堆与小根堆区别:

void AdjustUp(int* a, int child)
{
	assert(a);


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

大根堆向上调整:只需要将 if (a[child] < a[parent]) 改为 if (a[child] > a[parent])


向下调整的大根堆与小根堆区别:

void AdjustDown(int* 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;
		}
	}
}

大根堆向下调整:

首先需要找到左右孩子中最大的那一个:

if (child + 1 < n && a[child] < a[child + 1])
{
	++child;
}

如果大孩子大于父亲,则交换:

if (a[child] > a[parent])
{
	Swap(&a[child], &a[parent]);
	parent = child;
	child = parent * 2 + 1;
}
else
{
	break;
}

堆的两种建立方式

方式1:向上调整法。

void CreateHeap()
{
	int a[] = { 70, 56, 30, 25, 15, 10, 75 };
	// 向上调整法建立堆
	Heap hp;
	HeapInit(&hp);
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); ++i)
	{
		HeapPush(&hp, a[i]);
	}
	HeapPrint(&hp);
	HeapDestroy(&hp);
}

等同于下面写法:

void CreateHeap()
{
	int a[] = { 70, 56, 30, 25, 15, 10, 75 };
	// 向上调整法建立堆
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); ++i)
	{
		AdjustUp(a, i);
	}
	// print a ...
}

使用这种方法本质上是向上调整法。既可以创建大根堆也可以创建小根堆,唯一的区别就是Push函数中调用的向上调整AdjustUp函数的不同。


方式2:向下调整法。

使用向下调整算法的前提是,左右子树都是小堆(或大堆)。

对于给定的数组a,我们可以使用向下调整的方法,其思想:

1.叶子所在的子树不需要向下调整。

2.从数组末尾倒着走到第一个非叶子结点的子树,也就是最后一个结点的父亲假设索引为i。

3.从i结点开始进行向下调整。i–,反复迭代到i为0。这样就完成了整个堆的构建过程。

void CreateHeap1()
{
	int a[] = { 70, 56, 30, 25, 15, 10, 75 };
	int n = sizeof(a) / sizeof(a[0]);
	// 向下调整法建立堆
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
}

n - 1 :最后一个结点索引。
(n - 1 - 1) / 2:倒数第一个非叶子结点,也就是最后一个结点的父节点。

使用这种方法本质上是向下调整法。既可以创建大根堆也可以创建小根堆,唯一的区别就是调用的向下调整AdjustDown函数的不同。

建堆的时间复杂度推导

因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果)

假设树的高度为

h

h

h:我们采用向下调整法建立堆的思路:考虑最坏情况,每次向下调整都需要移动到叶子节点。

第h-1层,

2

h

2

2^{h-2}

2h2 个结点,需要向下移动1层

第h-2层,

2

h

3

2^{h-3}

2h3 个结点,需要向下移动2层

第2层,

2

1

2^1

21 个结点,需要向下移动h-2层

第1层,

2

0

2^0

20 个结点,需要向下移动h-1层

所以移动结点的总移动步数:
在这里插入图片描述

由于:

n

=

2

h

1

n = 2^{h} - 1

n=2h1,所以:
在这里插入图片描述

因此:建堆的时间复杂度是

O

(

N

)

O(N)

O(N)

二、堆的应用

1.堆排序时间复杂度

O

(

N

l

o

g

N

)

O(NlogN)

O(NlogN)

2.topK

3.优先级队列。

堆的应用–topK

在N个数中找出最大的前K个。

方式1:先排降序,前K个就是最大的。时间复杂度:

O

(

N

l

o

g

N

)

O(N log N)

O(NlogN)

方式2:N个数依次插入大堆,也就是构建N个数的大根堆。Pop K次,每次取堆顶的数据,取K次,需要向下调整K次。
构建一次N个数的大堆:

O

(

N

)

O(N)

O(N),Pop K次:

O

(

K

l

o

g

N

)

O(K logN)

O(KlogN)
时间复杂度:

O

(

N

)

+

O

(

K

l

o

g

N

)

O(N) + O(K logN)

O(N)+O(KlogN)

方式3:假设N非常大,内存中存不下这些数,他们存在磁盘中。则方式1和方式2都不能用。

1.用前K个数建立一个K个数的小堆。
2.剩下的N-K个数,依次跟堆顶的数据进行比较。如果比堆顶数据大,就替换堆顶数据,再向下调整。
3.最后堆里面的K哥数就是最大的K个数。

想不通就画图测试。

excellent

构建K个数的小堆:

O

(

K

)

O(K)

O(K)
N-K个数的向下调整:

O

(

(

N

K

)

l

o

g

K

)

O((N-K)logK)

O((NK)logK)
时间复杂度:

O

(

K

)

+

O

(

(

N

K

)

l

o

g

K

)

O(K) + O((N-K)logK)

O(K)+O((NK)logK)

K

<

<

N

K << N

K<<N 时,显然方式3算法更优。

下面我们使用方式3实现topK问题:

void PrintTopK(int* a, int n, int k)
{
	Heap hp;
	HeapInit(&hp);
	// 1.创建一个K个数的小堆
	for (int i = 0; i < k; ++i)
	{
		HeapPush(&hp, a[i]);
	}

	// 2.剩下的N-K个数跟堆顶的数据比较,比堆顶数据大,就替换堆顶数据进堆
	for (int i = k; i < n; ++i)
	{
		if (a[i] > HeapTop(&hp))
		{
            // 这两行代码不如下面的写法更简洁
			HeapPop(&hp);
			HeapPush(&hp, a[i]);
		}
	}

	HeapPrint(&hp);
	HeapDestroy(&hp);
}

其中调整的两步作用等价于:

hp.a[0] = a[i];
AdjustDown(hp.a, hp.size, 0);

使用数据进行测试:

void TestTopk()
{
	int n = 1000000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand((unsigned int)time(0));
	for (int i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;
	}
	// 设置10个比100w大的数
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[5355] = 1000000 + 3;
	a[51] = 1000000 + 4;
	a[15] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9999] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 10;
    
	PrintTopK(a, n, 10);
}

使用方式3的思想,求前 K 个最小值:

1.用前K个数建立一个K个数的大堆。
2.剩下的N-K个数,依次跟堆顶的数据进行比较。如果比堆顶数据小,就替换堆顶数据,再向下调整。
3.最后堆里面的K哥数就是最小的K个数。

想不通就画图测试。

void PrintTopK1(int* a, int n, int k)
{
	Heap hp;
	HeapInit(&hp);
	/*
	// 1.向下调整建立K个数的大堆
	hp.a = realloc(hp.a, k * sizeof(HeapDataType));
	for (int i = 0; i < k; ++i)
	{
		hp.a[i] = a[i];
		hp.size++;
	}
	for (int i = (k - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown_big(hp.a, k, i);
	}
	*/
	// 1.向上调整建立K个数的大堆
	hp.a = realloc(hp.a, k * sizeof(HeapDataType));
	for (int i = 0; i < k; ++i)
	{
		hp.a[i] = a[i];
		hp.size++;
	}
	for (int i = 0; i < k; ++i)
	{
		AdjustUp_big(hp.a, i);
	}


	// 剩下的N-K个数跟堆顶最大的数据比较,比他小,就替换堆顶数据进堆
	for (int i = k; i < n; ++i)
	{
        // 注意这里的比较规则不同!!!
		if (a[i] < HeapTop(&hp))
		{
			hp.a[0] = a[i];
			AdjustDown_big(hp.a, hp.size, 0);
		}
	}

	HeapPrint(&hp);
	HeapDestroy(&hp);
}

需要注意的是,在剩下 N-K 个数替换堆顶元素进堆时的比较规则不同。

如果是topK大的:a[i] > HeapTop(&hp)
如果是topK小的:a[i] < HeapTop(&hp)

堆的应用–堆排序

很自然想到以下方法:

void HeapSort1(int* a, int n)
{
	Heap hp;
	HeapInit(&hp);
	// 建议N个元素的小堆
	for (int i = 0; i < n; ++i)
	{
		HeapPush(&hp, a[i]);
	}

	// Pop N 次
	for (int i = 0; i < n; ++i)
	{
		a[i] = HeapTop(&hp);
		HeapPop(&hp);
	}

	HeapDestroy(&hp);
}

这种方法用了额外空间,下面要求只在原数组空间中进行堆排序。


方法1:建立小根堆,根为最小元素。找次小元素需要对除根以外的元素重新建立小根堆。

对于数组a,进行堆排序:(升序)

初始i=0,n=size-1
1.遍历数组a下标从 [i, n] 的每个元素,依次插入到堆中,构建一个小根堆。此时根处元素最小。
2.i++,返回到1处。

void HeapSort2(int* a, int n)
{
	int* arr = a;
	int arrSize = n;

	while (arrSize > 1)
	{
		// 1.向上调整建立小根堆
		for (int i = 1; i < arrSize; ++i)
		{
			AdjustUp(arr, i);
		}
		// 2.数组剩余元素需要重新建立小根堆
		arr += 1;
		arrSize -= 1;
	}
}

循环内建堆时间复杂度:

O

(

N

+

(

N

1

)

+

(

N

2

)

+

.

.

.

+

1

)

=

O

(

N

2

)

O(N + (N - 1) + (N - 2) + ... + 1) = O(N^2)

O(N+(N1)+(N2)+...+1)=O(N2)

测试:

void HeapSortTest()
{
	int a[] = { 70, 56, 30, 25, 15, 10, 75 };
	int n = sizeof(a) / sizeof(a[0]);

	HeapSort2(a, n);

	for (int i = 0; i < n; ++i)
	{
		printf("%d ", a[i]);
	}
	printf("n");
}

方法2:建立大根堆,然后删除根,此时最后一个元素为最大值,向下调整去除最后一个元素的剩余元素,这样就取出了次大值很快建立了大根堆。继续上面的删除。

void HeapSort3(int* a, int n)
{
	// 1.使用向下调整建立大根堆
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown_big(a, n, i);
	}

	// 2.交换堆顶和末尾 大根堆的堆顶最大值放在数组末尾 重新调堆
	// O(N*logN)
	for (int end = n - 1; end > 0; --end)
	{
		Swap(&a[end], &a[0]);

		// 再调堆,选出次小的数
		AdjustDown_big(a, end, 0);
	}
}

还可以使用上面方式2的测试查看堆排序效果。

注意到该函数的实现:建立大根堆和堆调整函数统一,都是用的是AdjustDown_big。excellent。

总的时间复杂度:

O

(

N

l

o

g

N

)

O(N log N)

O(NlogN)

上面的代码很容易改成降序:排序的思想没变,只不过使用的是小根堆。

void HeapSort3(int* a, int n)
{
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
    
	for (int end = n - 1; end > 0; --end)
	{
		Swap(&a[end], &a[0]);

		AdjustDown(a, end, 0);
	}
}

切换大小根堆就能切换升序降序,excellent!

优先级队列–待更新

源码

Gitee-Heap

总结

1.函数AdjustUp和AdjustDown的改变控制者大小根堆的切换。

2.大小根堆的切换可以决定堆排序的升序和降序。

3.大小根堆的切换以及进堆时比较规则的切换可以决定topK大和topK小。

4.建堆的时间复杂度:

O

(

N

)

O(N)

O(N)

5.堆排序时间复杂度:

O

(

N

l

o

g

N

)

O(N log N)

O(NlogN)

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>