堆排序:为什么说 O(N) 建堆才是降维打击?

目录

一.引言

二.堆排序基础:堆与调整算法

2.1 向上调整算法(AdjustUp)

2.2 向下调整算法(AdjustDown)

三.核心比较:两种建堆方法的效率分析

3.1 向上建堆(O(NlogN))

3.2 向下建堆(O(N))

四.堆排序的实现(O(NlogN))

五.TopK 问题


一.引言

       在众多排序算法中,堆排序(Heap Sort) 凭借其稳定的 O(NlogN) 时间复杂度和 O(1) 的空间复杂度,在学习和实际应用中占据着重要的地位。

       但你真的了解堆排序的核心—— 建堆 过程吗?建堆方法看似简单,却隐藏着一个巨大的时间复杂度陷阱!本篇博客将深入剖析两种建堆方法(向上和向下),并通过严谨的复杂度分析,为你揭示 O(N) 建堆的奥秘,最终带你理解堆排序和它在 TopK 问题中的强大应用。

二.堆排序基础:堆与调整算法

       堆(Heap) 本质上是一种特殊的完全二叉树。它满足堆的性质:

  • 小堆(Min-Heap): 任意父节点的值都小于等于其子节点的值。

  • 大堆(Max-Heap): 任意父节点的值都大于等于其子节点的值。

堆排序主要依赖两个核心操作:向上调整(AdjustUp)和 向下调整(AdjustDown)。

2.1 向上调整算法(AdjustUp)

       向上调整 主要用于在堆中插入新元素时维护堆的性质。新元素通常作为完全二叉树的最后一个叶子节点加入,然后与它的父节点进行比较,若不满足堆的性质,则交换位置,直到根节点或满足性质为止。

我们通常用向上调整来实现向上建堆

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

void AdjustUp(int* arr, int child)
{
	// 通过数组下标关系找到父节点
	int parents = (child - 1) / 2; 
	while (child > 0)
	{
		// 假设建小堆:如果子节点小于父节点,则交换
		if (arr[child] < arr[parents]) 
		{
			Swap(&arr[child], &arr[parents]);
			// 继续向上调整
			child = parents;
			parents = (child - 1) / 2;
		}
		else
		{
			break; // 满足堆性质,停止调整
		}
	}
}

2.2 向下调整算法(AdjustDown)

       向下调整 主要用于在 删除堆顶元素建堆 时维护堆的性质。它从一个节点开始,将该节点与它的子节点比较,选择合适的子节点(大堆选大的,小堆选小的)进行交换,并向下递归,直到到达叶子节点或满足堆性质为止。

向下调整是实现向下建堆堆排序 的核心。

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

void AdjustDown(int* arr, int n, int parents)
{
	// 假设左孩子是最小的,n是数组的有效元素个数
	int child = (parents * 2) + 1; 
	// 建小堆:谁小谁上去
	while (child < n)	
	{
		// 找出左右孩子中较小的那个
		if ((child + 1) < n && arr[child + 1] < arr[child])
		{
			child++;
		}
		
		// 如果孩子比父节点小,则交换
		if (arr[child] < arr[parents])
		{
			Swap(&(arr[child]), &(arr[parents]));
			// 继续向下调整
			parents = child;
			child = (parents * 2) + 1;
		}
		else
		{
			break; // 满足堆性质,停止调整
		}
	}
}

三.核心比较:两种建堆方法的效率分析

3.1 向上建堆(O(NlogN))

       思路: 假设初始数组为空堆,从第二个元素(下标 i=1)开始,依次将其插入到堆中,每次插入都利用 AdjustUp 向上调整。

// 向上调整算法建堆:复杂度O(NlogN)
for (int i = 1;i < n;i++)
{
	AdjustUp(a, i); // 每次插入都进行一次O(logN)的调整
}

       时间复杂度分析:因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。

结论:借助于 向上调整算法 实现的 向上建堆,虽然从思路上好理解,但是由于越靠近底层的节点,其位于的层数越高,调整次数越多,时间复杂度就较高。

3.2 向下建堆(O(N))

       思路: 将初始数组视为一棵完全二叉树,从第一个非叶子节点(下标为 (N - 1 - 1) / 2)开始,向前遍历到根节点,对每一个节点都执行一次 AdjustDown 向下调整。

// 向下算法建堆:复杂度O(N)
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
{
	AdjustDown(a, n, i); // 对所有非叶子节点进行向下调整
}

       时间复杂度分析:因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明。

结论: 借助于 向下调整算法 实现的 向下建堆,时间复杂度为 O(N),相比向上建堆的 O(NlogN) 有了显著的效率提升,是建堆的首选方法。

四.堆排序的实现(O(NlogN))

堆排序主要分为两个阶段:

  • 建堆: 使用 O(N) 的向下调整算法将初始数组建成一个堆。

  • 排序: 循环执行以下操作 N−1 次:

    • 将堆顶元素(最大或最小)与数组末尾元素交换。

    • 将堆的大小减一。

    • 对新的堆顶元素执行 O(logN) 的 向下调整算法,恢复堆的性质。

重点:

排序的过程类似于堆删除的思想。

②建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

③堆排序的机制是每次取出堆顶元素(大堆取最大值,小堆取最小值)放到数组末尾,为了最终实现升序排列,你需要不断取出最大值(建大堆)放到后面;反之,要实现降序排列,你需要不断取出最小值(建小堆)放到后面。

void HeapSort(int* a, int n)
{
	// 阶段一:O(N) 向下调整算法建堆(此处建小堆)
	for (int i = (n - 1 - 1) / 2;i >= 0;i--)
	{
		AdjustDown(a, n, i);
	}
	
	// 阶段二:O(NlogN) 排序过程
	int end = n - 1;
	while (end > 0)
	{
		// 1. 交换堆顶(最小元素)和当前有效数组的最后一个元素
		Swap(&a[0], &a[end]);
		// 2. 有效数组长度减一
		// 3. 对新的堆顶元素进行向下调整(O(logN))
		AdjustDown(a, end, 0); 
		end--;
	}
}

       时间复杂度分析:堆排序在排序阶段的时间复杂度为 O(N log N),因为虽然每次“向下调整”最多需要 O(log N) 时间,但各层节点数不同、调整次数分布与向上建堆类似(节点多的层调整次数多,节点少的层调整次数少),整体求和仍为 O(N log N)。

五.TopK 问题

题目描述:从 N 个数据中找出最大的 K 个数,或最小的 K 个数。

问题分析:如果我们使用完整的排序算法(如归并或快排),时间复杂度为 O(NlogN)。如果我们对所有数据建堆,再依次借助堆删除的操作执行K次,那空间复杂度为O(N)。这样做效果都不好。

解决方法:若找最大的 K 个数,则建立一个容纳 K 个元素的小堆(若找最小,则建大堆)。

  • 遍历 N 个数据,如果堆未满,直接插入。

  • 如果堆已满,将当前数据与堆顶(堆中最小的元素)比较:

    • 如果数据大于堆顶,则弹出堆顶,并插入新数据,然后进行 O(logK) 的调整。

    • 如果数据小于等于堆顶,则忽略。

  • 最终,这个小堆中剩下的 K 个元素就是最大的 K 个数。

void FindTopK(int* a, int n, int k)
{
    if (k <= 0 || k > n)
    {
        // K 值不合法
        return; 
    }
    
    // 1. 创建大小为 K 的数组作为 TopK 堆
    int* topKHeap = (int*)malloc(sizeof(int) * k);
    if (topKHeap == NULL) return;

    // 2. 初始化堆:将原始数组的前 K 个元素放入 TopK 堆
    for (int i = 0; i < k; i++)
    {
        topKHeap[i] = a[i];
    }
    
    // 3. 构建初始小堆 (O(K)):用于存储当前最大的 K 个元素
    // 注意:AdjustDown(arr, size, parent)
    for (int i = (k - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(topKHeap, k, i);
    }
    
    // 4. 遍历剩余的 N-K 个元素 (O((N-K) log K))
    for (int i = k; i < n; i++)
    {
        // 如果当前元素比堆顶(K个元素中最小的那个)大
        if (a[i] > topKHeap[0])
        {
            // 替换堆顶
            topKHeap[0] = a[i];
            // 重新向下调整,恢复小堆性质
            AdjustDown(topKHeap, k, 0);
        }
    }
    
    // 5. 最终 topKHeap 中存储的就是最大的 K 个元素
    printf("最大的 %d 个元素是: ", k);
    for (int i = 0; i < k; i++) 
    {
        printf("%d ", topKHeap[i]);
    }
    printf("\n");
    
    free(topKHeap);
}

时间复杂度:O(NlogK)

  • 遍历 N 个元素,最多对堆进行 N 次操作(插入或替换)。

  • 每次操作(堆的调整)的时间复杂度是 O(logK),因为堆的大小始终保持为 K。

  • 总时间复杂度为 O(NlogK)。

空间复杂度:O(K)

  • 算法只需要额外的空间来存储一个大小为 K 的堆。

  • 与 O(N) 的输入规模相比,这是一个非常高效的常数级空间开销。


       感谢各位读者阅读,大家多多点赞、收藏、+++关注!!!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值