系列文章链接
1 原理
堆排序是利用堆这种数据结构而设计的一种排序算法,用一句话描述它就是
不断利用
堆结构获得最大值,把最大值放在有序区对于有序区这个概念,在本专栏上一篇文章已经讲过,简单来说是无序数组中已排序的部分。
接下来我们看看什么是堆结构?
1.1 堆结构
堆是可以用数组表示的完全二叉树
上面这张图就是用数组表示的完全二叉树,对于数组的第i个节点会符合下面规律
- 左子节点位于
2 * i + 1- 右子节点位于
2 * i + 2- 父节点位于
(i - 1) / 2堆结构可以分为两种:大根堆和小根堆,两者的差别在于节点的排序方式。
(1) 大根堆
刚才的例子就是一个大根堆
在
大根堆中,父节点的值比每一个子节点的值大 大根堆符合下面数学规律
(2) 小根堆
下图数组就是一个小根堆,图中的二叉树是数组的映射结构
在小根堆中,父节点的值比每一个子节点的值小 小根堆符合具有下面规律
1.2 堆排序基本步骤
第一步:构造初始堆,将无序数组构造成一个大根堆,使得最大值位于树的顶部。
比如现在有数组[3,2,4,1,5],一步步把它转化为大根堆
(1)从数组中取出第一个值3
(2) 取第二个值2作为左节点
(3) 取第三个值4作为右节点,因为大根堆的父节点要大于子节点的要求,发现不符合要求。
(4)在上一步的基础上,获得父子节点三个中的最大值,放到父节点的位置,使它符合大根堆
(5)取第四个值1作为左节点
(6) 取第五个值5作为右节点,5比它的父节点的值都要大。
(7) 在上一步的基础上,获得父子节点三个中的最大值,放到父节点的位置,发现仍小于父节点4。
(8)交换父子节点,构造出一个大根堆,使得最大值位于堆顶。
上面通过不断上浮构造一个堆的过程,我们称它为insert。大家在这里留一个记忆点,因为后面会深入讲到。
第二步:将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换......
(1)将堆顶元素与末尾元素进行交换
(2)使得最大值位于末尾
(3)这时候树不再是大根堆,需要对无序区重新构造大根堆。发现顶点2小于子节点,将父子节点三个中的最大值放在原父节点的位置。
(4)通过下沉,使得无序区又变成一个大根堆,有序区的节点5在原位置不动
(5)再一次,将堆顶元素与末尾元素进行交换
(6)将堆中最大值,放到有序区前面,扩展有序区
(7)重复上面的过程,直到无序区为空,完成整个排序过程
在上面步骤中
不断将堆顶节点
下沉的方式构造一个堆。这个过程叫做堆化(heapify)
2. 性能优化
刚才讲到上浮和下沉都可以构建堆。
事实上后者的性能要好于前者,在基本步骤的第1步使用了上浮构造一个堆。
其实,使用自下而上下沉的方式构建堆是更好的选择。
怎么证明了?
先来看看上浮
如果使用上浮的方式把上图这棵树变为大根堆,第三层的节点上浮到最上方最多需要2步,而第二层的节点最多需要1步。
不难发现
使用上浮方式构建大根堆,越是底部的节点,节点越多,执行操作次数也越多
对于一棵完全二叉树,如果节点数量是N,第n个节点所在的层数是log(n) + 1。
上浮到最上方则需要 步,树自下而上的节点数分别有个。
根据上面条件,可以得到时间复杂度就是
简化后大致为
O(Nlog(N))
再看看下沉
如果使用下沉的方式把上图这棵树变为大根堆,第一层的节点下沉到最下方最多需要2步,而第二层需要1步,第三层不需要下沉
通过下沉构建大根堆,越是底部的节点数量越多,但执行操作次数却越少
讲到到这里,可以发现下沉比上浮性能会更好。
我们继续讨论它的时间复杂度。
对于一棵完全二叉树,如果节点数量是N,第n个节点所在的层数是log(n) + 1,下沉到最下方需要log(N) - log(n) 步。
树自下而上的节点数分别有个。
如果节点数有N,时间复杂度就是
约为
O(N)
这里做个小结
- 在所有元素都知道的情况下,优先使用下沉(
heapify)构建大根堆。- 在需要逐个添加元素的情况下,只能使用上浮(
insert)构建大根堆
3 代码实现
function heapify(arr, index, heapSize) {
let left = 2 * index + 1;
while (left < heapSize) {
var largest = left + 1 < heapSize && arr[left] < arr[left + 1] ? left + 1 : left;
var alllargest = arr[largest] > arr[index] ? largest : index;
if (alllargest === index) {
break;
}
swap(arr, index, largest);
index = largest;
left = 2 * index + 1;
}
}
function swap(arr, i, j) {
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
function sort(arr) {
var heapSize = arr.length;
for (var i = arr.length - 1; i >= 0; i--) {
heapify(arr, i, arr.length);
}
while (heapSize) {
swap(arr, 0, --heapSize);
heapify(arr, 0, heapSize);
}
return arr;
}
4 复杂度
4.1 时间复杂度
我们回顾一下刚才讲的堆排序基本步骤
- 构造初始堆,将无序数组构造成一个大根堆,使得最大值位于树的顶部;
- 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换......
-
对于第1步,在
性能优化部分已经讲到,使用上浮构建堆的时间复杂度为O(Nlog(N)),使用下沉构建堆的时间复杂度为O(N)。 -
对于第2步,构建堆是将堆顶元素下沉到底部的过程,下沉步数是
log(N)(树的总层树 - 1),每一次构建堆可以得到无序数组的一个最大值,有多少个元素排序就需要执行多少遍,因此第2步的时间复杂度是Nlog(N)。 一般我们只看最大的时间复杂度,可以得到结果是Nlog(N)。
4.2 额外空间复杂度
排序过程只是数组的元素位置进行交换,没有引入新的空间,所以额外空间复杂度是O(1)。
5. 本章小结
- 堆排序是不断利用
堆结构获得最大值,放在有序区的前面 - 堆是可以用数组表示的完全二叉树,包括
大根堆和小根堆两种 - 堆排序的基本步骤
- 构造初始堆,将无序数组构造成一个大根堆,使得最大值位于树的顶部
- 利用堆结构获得最大值,将堆顶元素与末尾元素进行交换,使末尾元素最大,反复进行交换、堆重建
- 构建堆的方法有两种,分别为
上浮(insert)和下沉(heapify) - 在所有元素都知道的情况下,优先使用下沉构建大根堆;在需要逐个添加元素的情况下,只能使用上浮构建大根堆
- 堆排序的时间复杂度为
O(Nlog(N)) - 堆排序的空间复杂度为
O(1)
如果您对算法或者其它前端知识感兴趣可以添加微信 erencho