性能还能更好?你需要了解的堆排序

973 阅读7分钟

系列文章链接

1 原理

堆排序是利用这种数据结构而设计的一种排序算法,用一句话描述它就是

不断利用堆结构获得最大值,把最大值放在有序区 对于有序区这个概念,在本专栏上一篇文章已经讲过,简单来说是无序数组中已排序的部分。

接下来我们看看什么是堆结构?

1.1 堆结构

堆是可以用数组表示的完全二叉树
上面这张图就是用数组表示的完全二叉树,对于数组的第i个节点会符合下面规律

  • 左子节点位于 2 * i + 1
  • 右子节点位于 2 * i + 2
  • 父节点位于 (i - 1) / 2 堆结构可以分为两种:大根堆小根堆,两者的差别在于节点的排序方式。

(1) 大根堆

刚才的例子就是一个大根堆

大根堆中,父节点的值比每一个子节点的值大 大根堆符合下面数学规律

arr[i]>=arr[2i+1]arr[i]>=arr[2i+2]arr[i] >= arr[2i+1] 且 arr[i] >= arr[2i+2]

(2) 小根堆

下图数组就是一个小根堆,图中的二叉树是数组的映射结构

在小根堆中,父节点的值比每一个子节点的值小 小根堆符合具有下面规律

arr[i]<=arr[2i+1]arr[i]<=arr[2i+2]arr[i] <= arr[2i+1] 且 arr[i] <= arr[2i+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

上浮到最上方则需要log(n)+11=log(n)log(n) + 1 - 1 = log(n) 步,树自下而上的节点数分别有N2N4...1\frac{N}{2}、\frac{N}{4}、...、1个。

根据上面条件,可以得到时间复杂度就是

O=N2logN+N4(logN1)+...+21+10O = \frac{N}{2}logN + \frac{N}{4}(logN-1) + ... + 2 * 1 + 1 * 0 简化后大致为O(Nlog(N))

再看看下沉
如果使用下沉的方式把上图这棵树变为大根堆,第一层的节点下沉到最下方最多需要2步,而第二层需要1步,第三层不需要下沉

通过下沉构建大根堆,越是底部的节点数量越多,但执行操作次数却越少

讲到到这里,可以发现下沉上浮性能会更好。

我们继续讨论它的时间复杂度。

对于一棵完全二叉树,如果节点数量是N,第n个节点所在的层数是log(n) + 1,下沉到最下方需要log(N) - log(n) 步。

树自下而上的节点数分别有N2N4...1\frac{N}{2}、\frac{N}{4}、...、1个。

如果节点数有N,时间复杂度就是

O=N20+N41+...+2(log(N)1)+1log(N)O = \frac{N}{2} * 0 + \frac{N}{4} * 1 + ... + 2 * (log(N) - 1) + 1 * log(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. 构造初始堆,将无序数组构造成一个大根堆,使得最大值位于树的顶部;
  2. 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换......
  • 对于第1步,在性能优化部分已经讲到,使用上浮构建堆的时间复杂度为O(Nlog(N)),使用下沉构建堆的时间复杂度为O(N)

  • 对于第2步,构建堆是将堆顶元素下沉到底部的过程,下沉步数是log(N)(树的总层树 - 1),每一次构建堆可以得到无序数组的一个最大值,有多少个元素排序就需要执行多少遍,因此第2步的时间复杂度是Nlog(N)。 一般我们只看最大的时间复杂度,可以得到结果是Nlog(N)

4.2 额外空间复杂度

排序过程只是数组的元素位置进行交换,没有引入新的空间,所以额外空间复杂度是O(1)

5. 本章小结

  1. 堆排序是不断利用堆结构获得最大值,放在有序区的前面
  2. 堆是可以用数组表示的完全二叉树,包括大根堆小根堆两种
  3. 堆排序的基本步骤
    • 构造初始堆,将无序数组构造成一个大根堆,使得最大值位于树的顶部
    • 利用堆结构获得最大值,将堆顶元素与末尾元素进行交换,使末尾元素最大,反复进行交换、堆重建
  4. 构建堆的方法有两种,分别为上浮(insert)和下沉(heapify)
  5. 在所有元素都知道的情况下,优先使用下沉构建大根堆;在需要逐个添加元素的情况下,只能使用上浮构建大根堆
  6. 堆排序的时间复杂度为O(Nlog(N))
  7. 堆排序的空间复杂度为O(1)

如果您对算法或者其它前端知识感兴趣可以添加微信 erencho