堆排序

353 阅读6分钟

之前一直以为手撕排序里面快排和归并排序会考的多一些,因此自己写了这两个排序的代码。对于堆排序,是只在数据结构里看过其原理,从未自己实现过。谁曾想拼多多面试的时候让现场手撕堆排序,这个不太熟有点尴尬,然后和面试官商量了换成了快排。后面李颖面试又被问到堆排序,看来堆排序被问到的概率还挺高。那么就自己来实现堆排序吧。

1. 堆排序的原理

堆排序和选择排序很像,都是从待排序序列中选取最大(或最小)的元素加入已排序部分中。不同的是,堆排序是借助二叉堆这个数据结构来选取最大元素,从而在时间效率上优于选择排序。因此在介绍选择排序前先介绍二叉堆这个数据结构。

1.1 完全二叉堆

我认为完全二叉堆最大的特点是它的存储结构是vector,而逻辑结构则是一颗类似于二叉树的二叉堆结构,该结构不像二叉树维护完全的顺序性,而仅维持相对的偏序性。为了实现顺序存储和逻辑结构的对应,二叉堆的结构必须是完全二叉堆,意思是任意节点的平衡因子只能为0或者+1。这样规定的目的是使得对于存在vector中的元素能够快速地通过下标索引到其父节点和子节点。具体怎么索引用不着记住,需要的时候画一棵完全二叉树观察规律就可以了。
那么偏序性又是什么意思呢?这主要是和二叉搜索树对比而言的,二叉搜索树的顺序是严格的,通过前序遍历能够还原出一个排序的序列。而二叉堆只能告诉你这个序列中最大的元素(大根堆是最大,小根堆是最小),并不保证其他元素的顺序性。显然二叉堆实现的功能弱于二叉树,因此其开销也必须小于二叉树才有存在的意义,事实也是如此。
二叉堆的偏序性的定义是所有父节点的值大于其子节点(大根堆),那么堆顶就是该堆最大的元素。那么对于一个乱序序列如何建造这样一个完全二叉堆呢?不断地上滤和下滤是可以达到的,而实际中往往使用的下滤,这是因为下滤的时间复杂度更低。产生这种结果的原因是,对于一颗完全二叉堆,其形状犹如一个三角形,大多数节点集中在底层,少部分节点在高层,对于上滤而言意味着有大量的底层节点需要做很多步的上滤。这比让小部分节点做多步下滤的开销大得多。因此一般二叉堆都采取下滤操作。

1.2 完全二叉堆的建堆示例

考虑有这样一个序列,需要我们建堆:

1 5 3 7 6 8

它存储在一个数组中,但是其逻辑结构可以视为:

      1  
    /   \  
   5      3  
  / \    /  
 7   6  8  

对每一个非叶子节点,都有可能不满足必须大于其子树的约束,因此对每个非叶子节点都需要执行下滤操作。从在vector中索引最大的非叶子节点3开始,该节点不满足堆序性,需要进行下滤操作,和其子节点8进行互换操作:

      1  
    /   \  
   5      8  
  / \    /  
 7   6  3

然后是节点5,它也不满足堆序性,需要进行下滤,和其较大的子节点进行交换:

      1  
    /   \  
   7      8  
  / \    /  
 5   6  3

然后是节点1,它也需要进行下滤:

      8  
    /   \  
   7      1  
  / \    /  
 5   6  3

显然1节点下滤一层后,在新的位置上还是不满足堆序性,需要再下滤一次:

      8  
    /   \  
   7      3  
  / \    /  
 5   6  1

最终所有的非叶子节点都执行了完整的下滤操作,使得整棵树满足堆序性,最大的元素被放置到了堆顶。这些交换操作在示例中是树的节点的交换,实际上是元素在vector中的交换,建堆后在vector中的存储如下:

8 7 3 5 6 1

2. 利用二叉堆进行堆排序

如前所述示例,该序列进过建堆操作后,最大元素被放在了堆顶,也就是vector中第0个元素,那么选择最大的元素就是选择第0个元素,并将其放在最末尾,表示其已排序。

1 7 3 5 6 8

其中8表示已排序部分,剩下未排序的部分长度为5,除了交换过去的1,其他的节点是满足堆序性的,那么只要对1进行下滤操作,就可以使得这个长度为5的序列满足堆序性,称为一个新的完全二叉堆。

      1  
    /   \  
   7      3  
  / \      
 5   6   
      7  
    /   \  
   1      3  
  / \      
 5   6  
      7  
    /   \  
   6      3  
  / \      
 5   1  

执行完对1节点的下滤后,最大的元素7又被放到了堆顶,再次取出堆顶元素加入已排序部分:

7 6 3 5 1 8

交换堆顶:

1 6 3 5 7 8

已排序部分又拓展了1,重复建堆和交换的操作,每次选择堆顶加入已排序部分,知道未排序部分长度为1,整个序列就全部为已排序。

3. 代码实现

#include<iostream>
#include<vector>

using namespace std;

//下滤操作
void goDown(vector<int>& nums, int len, int i) {
	int leftIndex = i * 2 + 1;
	int rightIndex = (i + 1) * 2;
	if (leftIndex < len && nums[leftIndex] > nums[i] && (rightIndex >= len || nums[leftIndex] > nums[rightIndex])) {
		swap(nums[i], nums[leftIndex]);
		goDown(nums,len,leftIndex);
	}
	else if (rightIndex < len && nums[rightIndex] > nums[i] && (leftIndex >= len || nums[rightIndex] > nums[leftIndex])) {
		swap(nums[i], nums[rightIndex]);
		goDown(nums, len, rightIndex);
	}
}

//建堆操作
void makeHeap(vector<int>& nums) {
	int len = nums.size();
	//最后一个非叶子节点
	int i = len / 2 - 1;
	for (; i >= 0; i--) {
		goDown(nums,len, i);
	}
}

void heapSort(vector<int>& nums) {
	//先建堆
	makeHeap(nums);
	//选择堆顶最大元素加入已排序部分
	int len = nums.size();
	while (len > 1) {
		swap(nums[0], nums[len - 1]);
		goDown(nums, --len, 0);
	}
}

int main() {
	vector<int> nums = { 1,5,3,7,6,8 };
	heapSort(nums);
	for (auto num : nums) {
		cout << num << " ";
	}
	cout << endl;
	system("pause");
}

以上代码经经过简单的测试,如果存在bug,欢迎评论指出。