阅读 297

数据结构与算法之 —— 堆(Heap)和堆排序

0x01 基本概念

堆 是一种特殊的树,满足以下条件即为堆

  • 完全二叉树

除了最后一层,其他层的节点个数都是满的,最后一层的节点都集中在左部连续位置

  • 堆中每一个节点的值都必须大于等于(或小于等于)其左右子节点的值

  • 每个节点都大于等于其子树节点的堆叫“大顶堆“,根是最大值

  • 每个节点都小于等于其子树节点的堆叫“小顶堆“,根是最小值

因为堆的要求不像二叉搜索树那么严格。他只要求某个节点的子节点大于或小于该节点,因此同一组数据,可以构建多种不同形态的堆

堆的表示

堆是完全二叉树,大部分时候都是使用数组来存储堆

规律

  • i 结点的父结点 par = floor((i-1)/2) 「向下取整」

  • i 结点的左子结点 2 * i +1

  • i 结点的右子结点 2 * i + 2

0x02 堆的操作

堆的操作主要有两种:插入、删除

不管是插入还是删除后都有可能不再满足堆的定义即:

  1. 堆是一颗完全二叉树

  2. 堆中每个节点都必须大于等于(或小于等于)其左右子节点

在插入或删除操作后需要进行调整,让其重新满足堆的特性,这个调整的过程叫做堆化(heapify)

2.1 堆化分为两种

  • 从下往上(上浮)

  • 当前元素不断向上和父节点比较大小:

  • 大顶堆:当前元素比父节点大,交换,让大的节点上去

  • 小顶堆:当前元素比父节点小,交换,让小的节点上去

  • 从上往下(下沉)

  • 当前元素不断向下和两个孩子节点比较大小

  • 大顶堆:当前元素与子节点中较大的比,比子节点小交换,让小的节点下去

  • 小顶堆:当前元素与子节点中较小的比,比子节点大交换,让大的节点下去

2.2 插入

从下往上的堆化

在堆的尾部插入,满足完全二叉树条件,再进行堆化。

从下往上堆化

让新插入的节点与父节点对比大小。如果新插入的节点大于父节点,我们就互换两个节点。一直重复这个过程,直到满足堆的条件。

class Heap {
    var a[]; // 数组,从下标1开始存储数据 
    var n; // 堆可以存储的最大数据个数 
    var count; // 堆中已经存储的数据个数
    
    Heap(capacity) { 
        a = new Array[capacity + 1]; 
        n = capacity; 
        count = 0; 
    }
    //插入元素
    insert(data) {
      if (count >= n) return; // 堆满了
        ++count;
        a[count] = data; // 最后一位插入
        const i = count;
        swim(a, count,i)
      }
    }
    //从下往上堆化
    swim(a,n,i) { 
      while (i/2 > 0 && a[i] > a[i/2]) { // 存在父节点,且子节点大于父节点
          swap(a, i, i/2); // swap():交换下标为i和i/2的两个元素
          i = i/2;
        }
    }
}
复制代码

2.3 删除堆顶元素

从上往下的堆化

堆顶元素存储的就是堆中数据的最大值或者最小值。删除了堆顶元素后,可以把最后一个元素移到根节点的位置,满足完全二叉树的条件,再进行堆化

从上往下的堆化

最后一个节点放到堆顶,在子节点中找出较大(大顶堆)的那个对比。小于子节点时,互换两个节点,并且重复进行这个过程。这就是从上往下的堆化方法。

//删除顶部元素
removeMax() {
  if (count == 0) return -1; // 堆中没有数据
  a[1] = a[count];//最后一个元素移到根节点的位置
  --count;
  sink(a, count, 1); 
}
// 自上往下堆化
sink(a, n, i) { 
  while (true) {
    const maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1; // 需要在两个子节点中找大的出来
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}
复制代码

2.4 堆化时间复杂度

堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)

2.5 插入和删除时间复杂度

插入数据和删除堆顶元素的主要逻辑就是堆化,所以时间复杂度都是 O(logn)

0x03 堆排序

堆排序步骤

“大顶堆”用于升序排列

“小顶堆”用于降序排列

实现堆排序可分解为两个步骤,建堆和排序

1、建堆

数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作

方式一(从前往后处理数组)

从前往后依次处理数组元素,数据插入堆中时,都用从下往上堆化。直到最后一个元素处理完成。

假设,起初堆中只包含一个数据,就是index=1 的元素6

  • Step1:6这个元素,只有一个,不需要比较;

  • Step2:调用前面讲的插入操作,将index=2的元素8插入堆中。

  • 堆化:这里8大于堆顶6,堆化后8与6交换位置

  • Step3:将index=3的元素3插入堆中。

  • 堆化:这里3小于index 3/2=1的位置上的元素8,不需要交换;

  • Step4:将index=4的元素10插入堆中。

  • 堆化:这里10大于index 4/2=2上的元素6,交换,再与index=2/2=1上的8比,比8大,交换

  • Step5:……

  • Step6:将index从 2 到 n 的数据依次插入到堆中,完成建堆。

除了第一个节点,都需要堆化。即对n-1个节点进行了堆化

方式二(从后往前处理数组)

从后往前处理数组,使用从上往下堆化,直到第一个元素处理完成。

对于完全二叉树来说,下标从 n/2+1 到 n 的都是叶子节点。所以第一个非叶子节点为n/2

叶子节点往下堆化只能自己跟自己比较,所以从最后一个非叶子节点index=4开始堆化

  • Step1:index=4的元素8小于子节点16,交换位置

  • Step2:index=3的元素19小于子节点20,交换位置

  • Step3:index=2的元素5小于子节点16,交换位置,再次小于子节点13,交换位置

  • Step4:index=1的元素7小于子节点20,交换位置,再次小于子节点19,交换位置。完成建堆。

下标从 n/2 开始到 1 的节点进行堆化。

结论

  • 堆对比方式一的n-1个节点堆化,采用从后往前处理数组的方式较好

时间复杂度

因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始,每个节点堆化的过程中,需要比较和交换的节点个数是与它的高度k成正比的

建堆的时间复杂度是 O(n)

2、排序

排序是建立在已经构建一个堆的基础上的。数组中的第一个元素就是堆顶,也就是最大或最小的元素。我们可以利用删除堆顶元素的思路来进行堆排序。

  • Step1:将堆顶元素9与第n个元素(index=5)交换,此时最大值位于5(数组最后位),下标为5的元素位于堆顶

  • 堆化:5小于子节点中较大的6,与6交换位置

  • Step2: 再取此时的堆顶元素6 与n-1个元素(index=4)交换

  • 堆化:1小于子节点中较大的5,与5交换位置

  • Step3: 再取堆顶元素5 与n-2个元素(index=3)交换

  • 堆化:3大于子节点1,不交换

  • Step4: 再取堆顶元素3 与n-3个元素(index=2)交换

  • Step5: 再取堆顶元素1,发现没有可比较的子节点了,堆排序结束

排序过程中需要对n个元素进行堆化,堆化的时间复杂度是O(logn),所以排序过程的时间复杂度是 O(nlogn)

排序结果

  • 大顶堆:升序数组

  • 小顶堆:降序数组

堆排序复杂度

  • 时间复杂度

  • 堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)

  • 空间复杂度

  • 排序不需要占用额外的空间,只需要交换元素的需要一个临时变量,所以堆排序的空间复杂度为O(1)。

0x04 堆应用

优先级队列

优先级队列虽然也叫队列,但是和普通的队列还是有差别的。普通队列出队顺序只取决于入队顺序,而优先级队列的出队顺序总是按照元素自身的优先级。可以理解为,优先级队列是一个排序后的队列。

堆是实现优先级队列最直接、最高效的方法。因为,堆和优先级队列非常相似,一个堆就可以看作一个优先级队列。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素(大顶堆--最大值;小顶堆--最小值)

使用场景

  • 高性能定时器

假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个触发执行时间点。

普通定时器

定时器设定轮询时间(比如 1 秒),扫描一遍任务,看是否有任务到达设定的执行时间。到达了,就拿出来执行。

缺点

  • 最近的任务的执行时间可能都离当前时间还有很久,前面很多次扫描其实是徒劳的。

  • 每次轮询都扫描整个任务列表,如果任务list很大,势必会比较耗时。

用优先级队列优化

  • Setp1:按照任务设定的执行时间,将这些任务存储在优先级队列中。队列首部(堆顶)存储的是最先执行的任务,所以建立的是一个小顶堆。

  • Setp2:拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T, 定时器就可以设定在 T 秒之后,来执行队首任务。这样从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。

  • Setp3:T 秒时间过去之后,定时器取优先级队列中队首的任务执行,不用遍历任务list。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。

优化后

定时器既不用间隔 1 秒就轮询一次,也不用遍历整个任务list

利用堆求 Top K

Top K:在一堆数据里面找到前 K 大(或前 K 小)的数

使用场景

  • 数组 [4,5,3,7,1,8],取前3大元素

用小堆顶解

  • Setp1:取前3大元素是降序,所以选择小堆顶。取出前三个数组[4,5,3],建小顶堆,得到 [3,4,5];

  • Setp2:遍历数组到元素7,比堆顶元素3大,将3移除,将7放入堆中,堆化后,小顶堆变为 [4,5,7]

  • Setp3:接着遍历数组到元素1,比堆顶元素4小,不处理

  • Setp4:接着遍历数组到元素8,比堆顶元素4大,将4移除,将8放入堆中,堆化后,小顶堆变为 [5,8,7],此时遍历结束,顶堆中的元素就是前 3 大元素

时间复杂度

假设数组长度为,取前K大元素

堆排序:遍历数组时间复杂度O(n) ,堆的大小至多为 k所以一次堆操作时间复杂度 O(logK),最坏情况每个元素都需要对操作,时间复杂度为 O(nlogk)

利用堆求动态数据集合的中位数

中位数,就是处在有序数组中间位置的那个数。如果数据个数是奇数,第 2/n+1 个数据就是中位数;如果数据的个数是偶数的话,中位数有两个,第 2/n 个和第 2/n+1 个数据,任取一个

  • 维护两个堆,一个大顶堆,一个小顶堆。

  • n 个数据,n 是偶数。前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中,保证小顶堆中的数据都大于大顶堆中的数据。

  • n 是奇数。大顶堆就存储 n/2+1 个数据,小顶堆中就存储 n/2 个数据

  • 新添加一个数据的时候,如果新加入的数据小于等于大顶堆的堆顶元素,将这个新数据插入到大顶堆;否则,将这个新数据插入到小顶堆。

  • 两个堆中的数据个数不符合约定数量时,数量少的堆将堆顶元素移动到另一个堆,直至平衡

  • 大顶堆中的堆顶元素就是我们要找的中位数

时间复杂度

插入数据时间复杂度为 O(logn),中位数直接取大顶堆的堆顶元素,时间复杂度就是 O(1)

文章分类
前端