堆排序详解

489 阅读4分钟

前言

前段时间研究排序算法时,发现堆排序普遍都是先生成一次大顶堆,然后再循环生成大顶堆进行堆顶尾交换排序,刚开始很不理解为什么不统一放在循环中处理,要单独先生成一次大顶堆?在后面深入研究了算法之后才明白其中道理,在大顶堆的基础上生成大顶堆要比每次都重新生成更快捷 在这里做一下总结。

堆排序

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

预备知识

堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

Heap.png

同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

Heap1.png

左右叶子节点

      0
    /   \
   1     2          # 1 = 2*0+1    2 = 2*0+2
 /  \   /  \        
3    4 5    6       # 3 = 2*1+1    4 = 2*1+2    5 = 2*2+1    6 = 2*2+2

# leftChildIdx = 2 * parentIdx + 1
# rightChildIdx = 2 * parentIdx + 2

假设当前节点 index = i , 则:

  • 左叶子节点 2 * index + 1;

  • 右叶子节点 2 * index + 2;

最后一个非叶子节点

假设当前堆长度为n,则最后一个非叶子节点有两种情况:

  1. 只有左叶子节点(此时列表长度为偶数)

      lastNodeIdx = n-1 = 2 * lastUnLeftNodeIdx + 1;
                       
      lastUnLeftNodeIdx = (n - 2) / 2;
                        = n / 2 - 1;
    
  2. 包含左右叶子节点(此时列表长度为奇数)

      lastNodeIdx = n-1 = 2 * lastUnLeftNodeIdx + 2;
    
      lastUnLeftNodeIdx = (n - 3) / 2;
                        = (n - 1) / 2 - 1;
    

其中 (n - 1) / 2 可以通过 Math.floor(n - 1) 替代,根据上面公式可以推导出

  lastUnLeftNodeIdx = Math.floor(n / 2) - 1; 

大顶堆 & 小顶堆

#   大顶堆                小顶堆

      6                     0
    /   \                 /   \
   5     4               1     2
 /  \   /  \           /  \   /  \     
3    2 1    0         3    4 5    6

我们用简单的公式来描述一下堆的定义就是:

  • 大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

  • 小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

排序思想

HeapSort.gif

  1. 构造大顶堆(一般升序采用大顶堆,降序采用小顶堆);
  2. 交换堆顶与最后一个叶子节点,此时数组最大值为最后一项;
  3. 剩余未排序数组重复步骤 1、2 至整个数组有序;

代码实现

实现原理:

heap-sort.gif

  1. 构造初始堆。从最后一个 非叶子节点 开始,从右至左,从下到上处理,将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
  2. 交换堆顶与最后一个元素,将最大元素沉至数组末端
  3. 从堆顶开始,重新构建大顶堆(第一步已经构建过大顶堆,所以)
  4. 重复步骤2、3,至序列有序

基本工具方法:生成单位大顶堆 heaping

关于方法的说明:

  • 从根开始,保证左右节点均小于根,实现最基本的大顶堆
  • 当元素交换时,将会影响到子节点所构成的堆,所以交换的子节点需要调用方法本身生成新的大顶堆
  • 当以叶子节点为根重新生成大顶堆的时候,不会因为叶子节点的改变而反过来重新生成上层的堆,所以要想生成大顶堆,就需要保证左右叶子节点必须为大顶堆!

上面解释有点复杂,直接画图来说明:

 情况1: 每个叶子节点所构成的堆均为大顶堆,则成功生成大顶堆
       0                     6                     6 
     /   \                 /   \                 /   \
    5     6      ==>      5     0      ==>      5     4       
  /  \   /  \           /  \   /  \           /  \   /  \     
 3    2 1    4         3    2 1    4         3    2 1    0

 情况2: 每个叶子节点所构成的堆并非大顶堆,则无法生成大顶堆
       0                     2                     2 
     /   \                 /   \                 /   \
    1     2      ==>      1     0      ==>      4     6      
  /  \   /  \           /  \   /  \           /  \   /  \     
 3    4 5    6         3    4 5    6         3    1 5    0
/**
 * 生成单位大顶堆
 * @param arr 需要处理的数组
 * @param i 指定当前大顶堆根节点的idx
 * @param len 指定需要处理的数组范围(长度)
 */
function heaping(arr: number[], begin: number, end: number) {
  let largestIdx = begin;      // 设置最大值index, 默认当前i
  const leftIdx = 2 * begin + 1, rightIdx = 2 * begin + 2;
  if (leftIdx <= end && arr[leftIdx] > arr[largestIdx]) largestIdx = leftIdx;
  if (rightIdx <= end && arr[rightIdx] > arr[largestIdx]) largestIdx = rightIdx;
  if (largestIdx !== begin) { // 最大值idx发生变化,则表示堆结构变化,更新largestIdx并回调验证堆
    // 将最大值移到堆顶
    [arr[largestIdx], arr[begin]] = [arr[begin], arr[largestIdx]];
    // 由于当前堆改变,将影响到对应子堆,
    heaping(arr, largestIdx, end);
  }
}

方法一(bad): 将构建大顶堆放入循环中处理

// 循环生成大顶堆,每次循环将堆顶(最大值)和堆尾进行交换
function heapSort(arr: number[]) {
  // 从 arr.length - 1 (数组最后一项)开始,记录当前未排序的元素最后一项下标
  for(let i = arr.length - 1; i >= 0; i--) {
    // 构建大顶堆,从最后一项非叶子节点开始至根节点,循环调用heaping
    for (let j = Math.floor(arr.length/2) - 1; j >= 0; j--) {
      heaping(arr, j, i);
    }
    // 交换最大值位置,每次循环将最后一位(index = i)与堆顶元素交换位置,最大值始终插入最后(index = i)至数组有序
    [arr[0], arr[i]] = [arr[i], arr[0]];
  }
  return arr;
}

方法二(good):先生成大顶堆,然后每次循环再次构建大顶堆

方法一中,构建大顶堆的步骤其实不需要每次循环都从最后一项非叶子节点开始重新生成,只要先生成一次大顶堆,后续每次从根节点生成即可

// 创建大顶堆
function buildMaxHeap(arr: number[]) {
  const len = arr.length;
  // Math.floor(len / 2) - 1 为当前完全二叉树的最后一项非叶子节点
  // 从最后一个非叶子节点开始,从右往左,自下而上调用heaping方法,【见上面👆解释,从尾部生成大顶堆则保证整个堆为大顶堆】
  for (let i = Math.floor(len / 2) - 1; i > 0; i--) {
    heaping(arr, i, len - 1);
  }
}

// 排序
function heapSort(arr: number[]) {
  // 首先建立大顶堆,这样之后每次排序则不需要重新从数组尾部开始构建大顶堆
  buildMaxHeap(arr);
  for(let i = arr.length - 1; i > 0; i--) {
    // 将最大值交换至数组尾部
    [arr[0], arr[i]] = [arr[i], arr[0]];
    // 构建大顶堆,此处直接从根节点开始生成大顶堆即可,因为根的左右子节点所构成的堆均为大顶堆, 【见上面👆解释】
    heaping(arr, 0, i - 1);
  }
  return arr;
}

heapSort([2,9,3,1,4,5,6,7,8])

参考文献

堆排序