详解二叉堆和堆排序

前言

用Dart实现几个基础的排序算法中,只是用注释粗略的分析了一下堆排序的实现过程。本篇算是其的扩展篇。将详细讲一下二叉堆和堆排序。

二叉堆

二叉堆是完全二叉树或者是近似完全二叉树。

一颗完全二叉树

二叉堆满足堆的两个特性:

  • 结构性:堆总是一棵完全树。即除了最底层,其他层的节点都被元素填满,且最底层尽可能地从左到右填入

  • 堆序性:父节点的值总是保持固定的序关系(大于或小于)于任何一个子节点的键值

当父节点的值总是大于或等于任何一个子节点的键值时为“最大堆”。

当父节点的值总是小于或等于任何一个子节点的键值时为“最小堆”。

结构性质

因为完全二叉树是有规律的,所以一般总是用一个数组来表示它。

  1. 如果根节点在数组中的位置是1,第n个位置的子节点分别在2n和2n+1。

  2. 如果存储数组的下标基于0,那么下标为i的节点的子节点是2i+1与2i+2,子节点i的父节点在位置(i-1)/2

下图中的数组对应上图中的完全二叉树

image.png

堆序性

堆序性质让二叉堆快速操作的根本,因为它的父节点总是大于(小于)子节点,所以要找出最大(最小)元素就变的很容易了。

在下图中左边的树是一个堆,而右边的则不是(虚线部分的堆序被破坏了)

image.png

基本的堆操作

插入(上滤策略)

在将一个元素插入X插入堆时,为了保证树是一个完全树,我们需要在下一个可用位置创建一个空穴,然后将元素X放置在该空穴中。如果X在当前位置并没有破坏堆的堆序性,则插入完成。然而,往往事实上并非如此。这时我们就需要将空穴的父节点与当前新创建的节点进行交换。这样不断的与父节点比较,直到X到达它合适的位置,即满足堆序性位置。用图片演示过程如下:

原始树 在下一个可用位置创建一个空穴,然后将元素67放置在该空穴中
image.png image.png
发现不满足堆序性 和父节点交换位置
image.png image.png
发现不满足堆序性 和父节点交换位置
image.png image.png
动画效果如下:

1637385008452321.gif

这种策略叫做上滤,新元素在堆中不断向上找到合适的位置。

使用下面代码很容易实现插入操作:

///插入操作
void insert(int num) {
  ///将数据按顺序向后排序
  heapList.add(num);

  ///开始上滤
  percolateUp(num);
}

///上滤
void percolateUp(int num) {
  ///首先找到最后一位新加入的元素
  ///并不断与其父节点比较。如果大于它的父节点,则将其向上移动
  ///不断将向上交换,直至目标不再大于父节点
  for (var hole = heapList.length - 1;
      heapList[hole].compareTo(heapList[((hole - 1) ~/ 2)]) > 0;
      hole = (hole - 1) ~/ 2) {
    heapList[hole] = heapList[(hole - 1) ~/ 2];
    heapList[(hole - 1) ~/ 2] = num;
  }
}
复制代码
删除根节点(最大或者最小元素)(下滤策略)

找到最大或者最小元素很简单,它就是根节点。困难的是删除它。当删除根节点时,会在根处建立一个空穴。此时堆少了一个元素,那么最后一个元素必须移动到合适的位置。我们可以首先直接把最后一个元素放入到空穴内,然后和其两个儿子进行比较,不断的将目标穴位向下推。类似于插入的方式,其动画效果如下:

1637393470790903.gif

使用下面代码实现二叉堆的删除操作:

///删除操作
void deleteRoot() {
  ///将最后一个节点的元素放入第一个节点,并删除最后一个节点
  heapList[0] = heapList[heapList.length - 1];
  heapList.removeAt(heapList.length - 1);

  ///开始下滤
  percolateDown();
  systemOutList('删除根节点');
}

///下滤
void percolateDown({int targetIndex = 0}) {
  ///首先找到根节点,此时里面已经是放置着删除之前的最后一个元素
  ///并不断与其字节点最小的对比,并将其和大于它的元素中最小的交换
  ///不断将向下交换,直至目标不再小于任意子节点
  var heapLength = heapList.length - 1;
  int targetChild;
  for (var hole = targetIndex; 2 * hole + 1 <= heapLength; hole = targetChild) {
    ///找到左子节点,并默认为目标穴位
    targetChild = 2 * hole + 1;
    ///找到最小的子节点,作为目标穴位
    ///如果左子节点小于右子节点,则目标节点为右子节点
    if (targetChild < heapList.length - 1 &&
        heapList[targetChild].compareTo(heapList[targetChild + 1]) < 0) {
      targetChild++;
    }
    if (heapList[targetChild].compareTo(heapList[hole]) > 0) {
      ///如果目标穴位里的值小于其最小的子节点
      ///那么进行下滤,将二者交换;
      var temp = heapList[hole];
      heapList[hole] = heapList[targetChild];
      heapList[targetChild] = temp;
    } else {
      break;
    }
  }
}
复制代码
数组堆化

当你想把一个数组转换成二叉堆数组时,你可以使用n次插入方法去实现。但是,还有另外一种更好的方法去实现它。

一般的操作时将数据按任意顺序放入树中,此时只保持结构性。此时我们找到最后一个非叶子节点。根据上文中描述的结构性:最后一个叶子节点的父节点就是最后一个非叶子节点,而且子节点i的父节点在位置(i-1)/2,那么第一个非叶子节点就是(length-1)/2。找到最后一个非叶子节点后,对他进行下滤操作。然后遍历所有的非叶子节点,实现较大数据的上滤。

总结步骤如下:

  1. 将数据随意放入树中,但要保持堆的结构性;

  2. 找到第一个非叶子节点,并尝试对它进行下滤操作;

  3. 继续向上寻找上一个非叶子节点,并尝试对它进行下滤操作;

  4. 重复第三步,直到根节点位置。

其动画过程如下:

1637466693305677.gif

代码也很简单:

///将数组堆化
void buildHeap(List<int> list){
  heapList = List.from(list);
  ///beginIndex为第一个非叶子节点
  var beginIndex = heapList.length~/2 -1;
  ///用第一个非叶子节点和它的子节点对比
  ///找出左右两个子节点中大于当前节点的数据并与父节点交换
  ///不断的将最大值推到堆的根部。
  ///注意此处,是将当前位置的值进行下滤
  ///相对的,对于较大的值,是一个上滤的过程
  for (var i = beginIndex; i >= 0; i--) {
    percolateDown(targetIndex: i);
  }
}
复制代码

堆排序

到了这里,堆排序已经呼之欲出了——我们只需要把数组堆化,然后不断的取出根节点就可以了:

///原始的堆排序
List<int> heapSore(){
  var sortList= <int>[];
  ///堆化后的数据
  var rank = heapList.length;
  for(var i = 0;i < rank;i++){
    ///取出跟节点
    sortList.add(heapList[0]);
    deleteRoot();
  }
  print('排序后的数组:$sortList');
  return sortList;
}
复制代码

动画演示一个排序的过程:

Heapsort-example.gif

优化后的排序代码可参考:堆排序

总结和参考

更多排序算法请参考:基础的排序算法

二叉堆动画演示:Binary Heap

相关源码请参考:Demo

Guess you like

Origin juejin.im/post/7032873393410015269