算法第八记-堆排序

今天来总结一下堆排序的思路以及使用到堆排序思路的算法面试题
首先堆是一棵完全二叉树,它的左子树与右子树同时也都是堆(至于是最大堆还是最小堆。随便),如果你的堆是最大堆的话,那么它的性质每个结点都大于其左右孩子结点的值。如果是最小堆的话,每个结点都小于其左右孩子结点的值。而且对于堆来说每次删除操作都是在堆顶操作的。而插入操作则没有这个限制。我们一般是用数组来存储数据(树的存储形式可以是数组),然后通过将数组进行操作使其满足堆的性质。首先我们来讲讲一个关键操作Max_heapify:

我们发现归并排序,快速排序都有一个关键操作,而堆排序也不例外。那么这个Max_heapify究竟做了什么呢?对于一个堆来讲,当我们从堆顶删除一个元素或者从尾部插入一个元素时都会可能使得堆的性质发生了改变,所以我们在插入删除后需要继续维持这个性质首先当我们删除堆顶元素时我们是这样操作的:通过将数组尾部的元素与当前堆顶进行交换,然后堆的大小减1.由于换到堆顶的元素不一定满足堆的性质,所以我们要去检测如果满足就啥也不做,如果不满足的话就需要调整堆,那么如何调整堆呢?下溯!什么意思呢?我们已经将数组末尾的元素换到了堆顶,此时我们从堆顶开始与其左右孩子进行判断大小,如果比左右孩子都大,则max_heapfiy直接结束,如果比左右孩子中更大的一方小的话,就让这个孩子的值移动到堆顶,然后继续从这个孩子 位置继续往下重复操作,直到出现当前结点比左右孩子都大的情况。那么这里需要注意的是,由于我们是要和左右孩子中更大的一方进行比较,所以自然免不了要有一次左右孩子之间的比较(代码中会体现),同时还有一种情况就是只有左孩子,没有右孩子的情况,那我们就不需要这次左右孩子的比较操作,所以自然而然我们就可以发现这两个条件是&&的关系,只有当前节点有左孩子也有右孩子才进行后面的操作:child <length && arr [child] <arr [child + 1]。

void Max_Heapify(int arr[], int i,int length)
{
	int child=2*i+1;//因为数组存储是从0开始的,所以左孩子应该为2*i+1;
	int val = arr[i];
	int x = i;
	for (; child< length; x = child,child=2*x+1)
	{
		if (child+1< length&&arr[child] < arr[child + 1])
			child = child+1;
		if (arr[child] < val)
			break;
		arr[x] = arr[child];
	}
	arr[x] = val;
}

讲完了max_heapify之后,接下来我要讲讲建堆操作:

如何建堆呢?直观的思路通常是,将一个一个元素插入堆,然后不断调用max_heapify,最终建堆完成。那么这样的效率是多少呢?O(nlogn)!。有没有其他更高效率的建堆方法呢?弗洛伊德建堆法!这种方法是从底往上不断调整,最终的时间复杂度为O(N),关于建堆的时间复杂度分析,我找到了两篇文章挺的棒https://blog.csdn.net/wangqing_199054/article/details/20461877

https://www.cnblogs.com/shytong/p/5364470.html。可以去参考参考。

下面贴上建堆代码:

void build_MaxHeap(int arr[], int length)
{
	for (int i = length / 2-1; i >= 0; i--)//如果是从下标0开始的话 i应该再减1
		Max_Heapify(arr, i, length);
}

最后就是堆排序的代码,其实这是最简单的一部分,我们每次堆调整之后,堆顶都是最大值。我们可以把堆顶元素与尾部进行交换。重新堆调整时,将这个尾部元素排除。这样我们就能最终将序列排好序。

void HeapSort(int arr[], int length)
{
	if (!arr || length <= 0)
		return;
	build_MaxHeap(arr, length);
	for (int i = length-1; i > 0; i--)
	{
		swap(arr[i], arr[0]);
	    Max_Heapify(arr, 0,i);
	}
}

关于堆排序的效率分析:?首先它没有用到额外的空间复杂度,所以是一个原地排序,空间复杂度为O(1)堆排序是不稳定的,为什么呢我们知道堆的结构是节点我的孩子为2 * i和2 * i + 1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n的序列,堆排序的过程是从第n / 2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。当为n / 2 - 1,n / 2 - 2,... 1这些个父节点选择元素时,就会破坏稳定性。有可能第n / 2个父节点交换把后面一个元素交换过去了,而第n / 2 - 1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。

堆排序的时间复杂度为O(nlogn),最好最坏情况都是如此。

1.最小或者最大的ķ个数

答:我们可以建立一个大小为ķ的最小堆,然后遍历剩余的N-K个个数如果当前比堆顶大的话就将堆顶元素删除,插入新元素如果比堆顶小的话就跳过继续遍历。这样我们就会不断提高堆内元素的下界,直到最终堆里将会是最大的ķ个数。同理最小的ķ个数,我们建立一个大小为ķ的最大堆,然后遍历剩余的N-K个数,如果比堆顶元素大的话就跳过,比堆顶元素小的话就删除堆顶元素,并插入新元素。这样我们会不断降低堆内元素的上界,使得最终堆里存储的是最小的ķ个数。


      2.使用堆进行多路归并外排序(假设内存放不下的情况)

答:如果我们的内存放不下我们待排序的数据的话,我们通常会考虑把数据存储在外存中比如一共ķ个文件每个文件内的数据都可以放到内存进行快速排序排好序,然后建立ķ个IO流对象绑定这八个文件。先选取每个文件的第一个数进行建最小堆。这里我的想法是暂时放在内存中的数据以一个结构体类型存储,一个数据域另一个用来标识来自哪个文件。由于放在内存中的数据较少,所以这样一点空间损耗是无关紧要的。然后每次从堆顶出堆一个数。再从这个出堆元素所属的文件,再加入一个新的数然后对进行最小堆调整再继续操作。所以最终的时间复杂度排除掉那些io读写的效率的影响。应该是(n-k)logk + O(k)(建堆时间),最终是O(nlogk)。

      3.使用优先级队列模拟队列和栈


      4.使用不断插入的方法进行建堆与弗洛伊德建堆法的效率分析

 答:使用不断插入的方法建堆的效率分析如下,第一次入堆调整log1,第二次入堆调整log2,第三次入堆调整log3,最终的效率和是s = log1 + log2 + log3 + LOG4 ...... logn.s因此也可以等于日志(1 * 2 * 3 * 4 * ... * N),...,这个序列的上界很明显可以是的log(n的ñ次方),也就是等于nlogn,所以最终的建堆效率为O(nlogn)。

   使用弗洛伊德建堆法我们来分析一下建堆效率:每次调用max_heapify的效率是O(logn)时间时间,Build_MAx_HEAP需要Ñ次这样的调用因此总的时间复杂度为O(nlogn)这个上。界虽然正确,但是并不渐进紧确。我们还可以进一步得到一个更加紧确的界。可以观察到不同高度的结点调用max_heapify的效率是不同的。而且我们知道对于完全二叉树而言,差不多一半以上的结点都在最底部,所以那些结点调用max_heapfiy的效率是很高的因此利用这个性质我们可以得到一个更紧确的界:

    5.为什么建堆要从尺寸/ 2-1减到0而不是从0加到尺寸/ 2-1?

  答:因为我们使用max_heapify的前提是左右子树都是最大堆所以如果左右子树无法保证这个前提的话那么第一次假设我们此时调整堆顶从前往后开始,而此时的左子树比此时的堆顶元素大但却不是左子树中最大的那个。那么我们此时将会这个值放在了堆顶。而堆调整是从前往后的,后面就再也不会从堆顶元素这个位置开始调整。这样会导致我们最终建堆完毕后,堆顶元素不是最大值。从而建堆失败。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值