浅谈“堆排序”

一、“堆”的简要介绍

在给出一个存在多个元素的数组时,逻辑上可以把其看作是一个完全二叉树。而当我们将一个数组视为一颗逻辑上的完全二叉树时,我们可以通过特定的算法将其构建成一个堆。首先,让我们来理解一下堆的基本概念:

是一种特殊的数据结构,通常被视为一个可以被看做一棵树的数组对象。堆总是满足以下性质:

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

根据这些性质,我们可以将堆分为两类:

  • 大堆(大根堆):根节点的值大于或等于其子节点的值。
  • 小堆(小根堆):根节点的值小于或等于其子节点的值。
此时数组还不满足“堆”的定义

想要把一个数组通过堆排序变成有序数组,其前提是必须把数组变成堆,而后在进行排序。

二、算法介绍

(1)堆排序

堆排序是一种高效的排序算法,可以将一个无序的数组转化为有序的形式。下面是堆排序的基本步骤(以升序为例):

  1. 建立大根堆:将无序数组构建成一个最大堆,其中父节点的值大于或等于其子节点的值。这可以通过从最后一个非叶子节点开始,逐步向上调整节点的位置来实现。

  2. 交换根节点和最后一个节点:将最大堆的根节点(即最大值)与数组中的最后一个元素交换。然后将最后一个元素从堆中移除。

  3. 重新调整堆:将剩余的元素重新调整为最大堆。重复步骤2,直到所有元素都被移除。

  4. 重复步骤2和3:继续交换根节点和最后一个节点,然后重新调整堆,直到整个数组有序。 

堆排序是一种基于比较的排序算法,它利用了“堆”这种数据结构的特点来高效地实现排序。在堆排序中,我们首先会构建一个最大堆或最小堆,然后不断地将堆顶元素(最大或最小)与堆尾元素交换,并调整堆的结构,直到整个数组有序。

当我们想要实现数组的升序排序时,我们需要将无序数组构建成一个大根堆。这是因为在最大堆中,父节点的值总是大于或等于其子节点的值。当我们不断地从堆顶取出元素并调整堆的结构时,每次取出的都是当前剩余元素中的最大值,这样就能够保证数组的有序性。

相反,当我们想要实现数组的降序排序时,我们需要将无序数组构建成一个最小堆。因为在最小堆中,父节点的值总是小于或等于其子节点的值。这样,当我们不断地从堆顶取出元素并调整堆的结构时,每次取出的都是当前剩余元素中的最小值,从而实现了数组的降序排序。

总之,堆排序通过利用最大堆和最小堆的特性,实现了高效的数组排序。在升序排序中,我们利用最大堆的性质,每次取出当前剩余元素中的最大值;在降序排序中,我们利用最小堆的性质,每次取出当前剩余元素中的最小值。这样就能够保证排序的正确性和高效性。 

(2)向上调整算法和向下调整算法

将无序数组变为堆,以及堆排序可以使用“向上调整算法”或者“向下调整算法”来实现。下面分别对两种算法进行简要介绍。 

1. 向上调整算法

(1)主要应用场景
  • 堆的插入:当我们在堆的末尾插入一个数据时,需要使用向上调整算法,确保插入后的堆仍然满足堆的性质。
  • 优先队列:优先队列通常基于堆实现,向上调整用于维护优先队列的有序性。
 (2)基本思想

以建小根堆为例:

  1. 将目标结点与其父结点比较。
  2. 若目标结点的值比其父结点的值小,则交换目标结点与其父结点的位置,并将原目标结点的父结点当作新的目标结点继续进行向上调整。
  3. 若目标结点的值比其父结点的值大,则停止向上调整,此时该树已经是小堆了。
(3)时间复杂度

当我们讨论向上调整算法的时间复杂度时,我们需要考虑以下几个因素:

  1. 堆的高度:堆的高度取决于堆中元素的数量。对于一个包含 N 个元素的堆,其高度通常为 log(N)。这是因为堆是一个完全二叉树,其高度不会超过 log(N)

  2. 循环次数:在向上调整算法中,我们从插入的目标节点开始,将其与父节点进行比较,并根据堆的性质进行调整。如果需要交换节点,我们会继续向上调整,直到满足堆的条件或到达根节点。因此,循环的次数取决于目标节点在堆中的深度,即堆的高度。

  3. 每次循环的操作:在每次循环中,我们比较目标节点和其父节点的值,并可能交换它们的位置。这些操作的时间复杂度通常是常数级别的,即 O(1)。

综上所述,堆的向上调整算法的时间复杂度为 O(log(N))。这意味着无论堆中有多少元素,我们最多需要进行 log(N) 次循环来调整堆的结构,以满足堆的性质。

// 向上调整算法
void AdjustUp(HPDataType* a, int c_subs)
{
	int pa_subs = (c_subs - 1) / 2;
	while (c_subs > 0)// 孩子结点下标最多传到根节点下标,以此作为判断条件
	{
		if (a[c_subs] < a[pa_subs])//此处是将堆作为小跟堆进行判定,大跟堆判定条件与之相反
		{
			Swap(&a[c_subs], &a[pa_subs]);// 交换父节点和子节点的元素
			c_subs = pa_subs;// 将原父节点的下标赋值给原子节点下标
			pa_subs = (c_subs - 1) / 2;// 重新赋值父节点下标,以便进行下一次判定
		}
		else
		{
			break;
		}
	}
}

2.向下调整算法

(1)主要应用场景
  1. 堆排序:堆排序是一种基于堆的排序算法,它使用向下调整来构建一个小堆或大堆,然后通过不断调整堆顶元素,逐步排成升序或降序的序列。

  2. Top-K问题:Top-K问题是指从一个数据集合中找出前K个最大或最小的元素。例如,寻找世界500强企业、专业前10名、游戏中前100的活跃玩家等。堆可以高效地解决Top-K问题,通过维护一个大小为K的小堆或大堆,不需要对整个数据集进行排序。

(2)基本思想

以建小根堆为例:

前提是已有堆的结构:我们已经有一个数组 a,表示一个堆。这个堆可能是部分有序的,但不满足堆的性质(例如,小根堆中父节点的值小于或等于其子节点的值)。

  • 从根节点开始,选出左右孩子中值较小的孩子。
  • 将较小的孩子与其父节点进行比较。
  • 如果较小的孩子比父节点还小,交换它们的位置,并将原较小孩子的位置作为新的父节点继续向下调整,直到调整到叶子节点为止。
  • 如果较小的孩子比父节点大,说明已经成堆,停止调整。
(3)时间复杂度

当我们讨论向下调整算法的时间复杂度时,我们需要考虑以下几个因素:

  1. 堆的高度:堆的高度取决于堆中元素的数量。对于一个包含 N 个元素的堆,其高度通常为 log(N)。这是因为堆是一个完全二叉树,其高度不会超过 log(N)

  2. 循环次数:在向下调整算法中,我们从父节点开始,将其与子节点进行比较,并根据堆的性质进行调整。如果需要交换节点,我们会继续向下调整,直到满足堆的条件或到达叶子节点。因此,循环的次数取决于堆的高度。

  3. 每次循环的操作:在每次循环中,我们比较父节点和子节点的值,并可能交换它们的位置。这些操作的时间复杂度通常是常数级别的,即 O(1)。

综上所述,堆的向下调整算法的时间复杂度为 O(log(N))。这意味着无论堆中有多少元素,我们最多需要进行 log(N) 次循环来调整堆的结构,以满足堆的性质。

// 向下调整算法
void AdjustDown(int* a, int n, int pa_subs)
{
	int c_subs = 2 * pa_subs + 1;// 只定义一个左孩子子节点下标

	while (c_subs < n)// 孩子结点下标只能在堆的有效长度范围内进行判定
	{
		//此处是将堆作为小跟堆进行判定,大跟堆判定条件与之相反
		// 首先假设父节点的左孩子比小

		if (c_subs + 1 < n && a[c_subs + 1] < a[c_subs])// 判定条件为真则表明左孩子节点比右孩子节点大,换成右孩子节点下标
		{
			c_subs++;
		}

		if (a[pa_subs] > a[c_subs])
		{
			Swap(a + pa_subs, a + c_subs);
			pa_subs = c_subs;// 将原子节点的下标赋值给原父节点下标
			c_subs = 2 * pa_subs + 1;// 重新赋值子节点下标,以便进行下一次判定
		}
		else
		{
			break;
		}
	}
}

        请注意,上面两种算法时间复杂度是基于平均情况和最坏情况的分析。在实际应用中,“向上调整算法”和“向下调整算法”通常非常高效,因为堆的高度相对较小。

三、堆排序实现无序数组有序化

 (1)无序数组变为“堆”

* 关于使用何种算法建堆的讨论

1. 使用“向上调整算法”将无序数组变为“堆”
思路:

代码实现: 
	// 向上调整算法建堆
	// 使用向上调整算法建堆时,从数组的第二个元素即a[1]开始向后遍历
	for (int i = 1; i < len; i++)
	{
		AdjustUp(a, i);
	
	}

1. for (int i = 1; i < len; i++):for 循环从数组的第二个元素(索引为 1)开始,一直到数组的最后一个元素(索引为 len - 1)。因为堆的根节点通常是数组的第一个元素(索引为 0),所以从第二个元素开始遍历是合理的。

2. AdjustUp(a, i);:在循环的每次迭代中,都会调用 AdjustUp 函数,并将数组 a 和当前索引 i 作为参数传递。AdjustUp 函数会负责将索引 i 对应的节点及其子树调整为满足堆的性质。如果 a[i] 比其父节点大(对于最大堆)或小(对于最小堆),则 a[i] 会与其父节点交换位置。这个过程会向上递归,直到 a[i] 的值不大于(对于最大堆)或不小于(对于最小堆)其父节点的值,或者已经到达了堆的根节点。

最终,这段代码的效果是将整个数组 a 转换为一个满足堆性质的堆结构。这个向上调整的过程确保了每个节点都满足堆的性质,即每个节点都大于或等于(对于最大堆)或小于或等于(对于最小堆)其子节点。

需要注意的是,这段代码通常是在初始化一个堆或者在向堆中插入新元素后使用的。如果数组 a 初始时已经是堆结构,那么这段代码不会改变数组的内容。如果数组 a 不是堆结构,那么这段代码会将其转换为一个堆结构。

 时间复杂度:

 2. 使用“向下调整算法”将无序数组变为“堆”
思路:

代码实现:
	// 向下调整算法建堆
	// 使用向下调整算法建堆时,首先二叉树里的所有叶子节点不动,从二叉树中的最后一个节点的父节点开始向前遍历
	for (int i = (len - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, len, i);
	}

这段代码的主要思路是从最后一个非叶子节点开始,向前遍历每个节点,并对每个节点执行向下调整操作,以确保每个节点满足堆的性质。

1. for (int i = (len - 1 - 1) / 2; i >= 0; i--)

  • len 是数组 a 的长度。
  • (len - 1 - 1) / 2 计算的是最后一个非叶子节点的索引。对于一个长度为 len 的数组,最后一个元素的索引是 len - 1,而最后一个叶子节点的索引是 (len - 1) / 2(因为叶子节点在二叉树中是没有子节点的,所以它们位于数组的中间位置)。
  • i-- 表示从最后一个非叶子节点开始,向前遍历每个节点。

2. AdjustDown(a, len, i);

  • AdjustDown 函数用于执行向下调整操作。给定一个数组 a、数组的长度 len 和当前节点的索引 i,该函数会确保以 a[i] 为根的子树满足堆的性质。
  • 在向下调整过程中,如果 a[i] 比其子节点小(对于最大堆)或大(对于最小堆),a[i] 会与其较大的(或较小的)子节点交换位置,然后继续向下调整,直到整个子树都满足堆的性质。
时间复杂度:

3. 总结 

上面我们分析了在最坏情况下使用向上调整算法和向下调整算法建堆。根据两种算法的时间复杂度(向下调整算法建堆的时间复杂度小于向上调整算法建堆的时间复杂度)我们可以得出结论 :使用向下调整算法建堆更合适。

(2)数组排序 

代码实现:

// 2.排序
int end = len - 1;
while (end > 0)
{
	Swap(&a[0], &a[end]);
	AdjustDown(a, end, 0);
	--end;
}

1. "end" 初始化为 "len - 1",表示数组的最后一个元素索引。  
2. "while" 循环中,首先将堆顶元素(即数组的第一个元素 "a[0]")与堆尾元素("a[end]")交换。  
3. 然后调用 "AdjustDown" 函数,将新的堆顶元素(即原来的堆尾元素)下沉到正确的位置,以维持堆的性质。  
4. "end" 每次减1,因为每次循环都会将堆尾元素放到已排序序列的末尾。 

时间复杂度:

根据上面的代码来分析它的时间复杂度:

        外层循环:这个循环从 len - 1 运行到 1,因此循环次数为 len - 1。在每次循环中,执行了两个操作:"Swap" 和 "AdjustDown"。

        Swap(&a[0], &a[end]):这是一个交换操作,它将数组的第一个元素和最后一个元素交换。这个操作的时间复杂度是O(1),因为它只涉及两个元素的交换。
        AdjustDown(a, end, 0):这是堆排序中的下沉操作。对于每个外层循环的迭代,AdjustDown 函数都会被调用一次。这个函数会遍历从给定节点开始的所有子节点,确保堆属性被维护。在最坏的情况下(即当堆完全不是堆时),AdjustDown 可能会遍历到叶子节点。对于一个高度为 h 的完全二叉树,叶子节点的数量是 2^h。但由于这是一个数组,实际的叶子节点数量可能小于 2^h。考虑到这一点,AdjustDown 的时间复杂度在最坏情况下是 O(log N),其中 N 是数组的长度。

        因此,整个循环的时间复杂度是(len - 1) * (O(1) + O(log N)),即O(N·log N),其中N是数组a的长度。时间复杂度主要取决于 AdjustDown 函数的调用次数。在最坏情况下,AdjustDown 函数的调用次数是 O(N·log N),因此整个堆排序的时间复杂度也是 O(N·log N)。这使得堆排序在处理大型数据集时非常高效,尤其适用于外部排序(例如磁盘上的大文件排序) 。

(3)堆排序实现无序数组有序化的总时间复杂度

核心代码:

void HeapSort(int* a, int len)
{
	// 想要把一个数组通过堆排序变成有序数组,其前提是必须把数组变成堆,而后在进行排序
	// 升序数组 --- 建大堆
	// 降序数组 --- 建小堆

	// 1.建堆
	// 向下调整算法建堆
	// 使用向下调整算法建堆时,首先二叉树里的所有叶子节点不动,从二叉树中的最后一个节点的父节点开始向前遍历
	for (int i = (len - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, len, i);
	}

	// 2.排序
	int end = len - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);

		// 再调整,选出次小的数
		AdjustDown(a, end, 0);

		--end;
	}
}

这段代码实际上是一个堆排序的核心部分,用于将一个无序数组有序化。

  1. 首先,我们有一个数组 a,其中包含了需要排序的元素。
  2. len 是数组 a 的长度。

接下来,逐步分析一下这段代码:

  • 建堆

    • 在堆排序中,首先我们需要将无序数组转换为一个堆。这个堆可以是最大堆(升序排序)或最小堆(降序排序)。
    • 代码中的注释提到了两种情况:
      • 升序数组:我们需要构建一个大堆,其中父节点的值大于或等于子节点的值。
      • 降序数组:我们需要构建一个小堆,其中父节点的值小于或等于子节点的值。
    • 使用向下调整算法来构建堆。具体步骤如下:
      • 从二叉树中的最后一个非叶子节点开始,向前遍历。这个节点的索引是 (len - 1 - 1) / 2
      • 对于每个节点,我们调用 AdjustDown(a, len, i) 函数,将该节点及其子节点调整为满足堆的性质。这一步的时间复杂度是 O(n),其中 n 是当前堆的大小。
  • 排序

    • 排序阶段,我们从堆中取出最大(或最小)元素,并将其放到有序数组的末尾。然后,我们对剩余的堆进行调整,以保持堆的性质。
    • 代码中的循环:
      • 我们使用 Swap(&a[0], &a[end]) 来交换堆顶元素(根节点)和当前堆的最后一个元素。
      • 然后,我们再次调用 AdjustDown(a, end, 0) 函数,将根节点及其子节点调整为满足堆的性质。
      • 最后,我们将 end 减小 1,以排除已经有序的元素。
    • 这个过程会一直重复,直到 end 变为 0,即整个数组都被有序化。

时间复杂度: 

当使用堆排序(Heap Sort)将无序数组有序化时,我们需要考虑以下几个方面的时间复杂度:

  1. 构建堆(Heap Construction)

    • 首先,我们需要将无序数组转换为一个堆。这一步的时间复杂度是 O(n),其中 n 是数组的长度。
    • 在构建堆的过程中,我们使用了 max_heapifycreate_heap 函数。其中,create_heap 函数的时间复杂度也是 O(n),因为它遍历了数组的一半长度来构建堆。
  2. 排序过程

    • 在堆排序的排序阶段,我们从堆中取出最大(或最小)元素,并将其放到有序数组的末尾。然后,我们对剩余的堆进行调整,以保持堆的性质。
    • 这一步的时间复杂度是 O(n log n),因为我们需要执行 n 次取出最大元素和调整堆的操作。每次操作的时间复杂度是 O(log n)

因此,堆排序的总时间复杂度为 O(n) + O(nlogn) ≈ O(nlogn)

值得注意的是,堆排序的空间复杂度为 O(1),因为它只使用了常数级别的额外空间。此外,堆排序是一种不稳定的排序算法,即相等的元素在排序后可能会改变其相对顺序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值