面试加分系列: 堆排序的实现与原理

1,496 阅读8分钟

虽然在前端面试中, 算法考察并不会特别为难人, 但是基本的排序算法, 需要我们烂熟于心, 排序算法的原理讲不出来, 可能会是一件减分的事情. 因为它是我们作为程序员的一种基本素养, 算法提高了我们解决问题的能力. 所以无论面试与否, 掌握它一定对你的职业生涯有很大的好处(💰). 毕竟排序算法可以说是一切算法的基本功.😄

实际上我看完《算法4》的排序算法章节后, 发现堆排序应该是几种经典排序算法中比较难以理解的一种, 但是它特别巧妙, 有趣. 这勾起了我对算法的兴趣, 也希望能勾起你的兴趣.😝

  • 本文讲了什么?
  1. 堆有序二叉树结构的定义与实现
  2. 一个优先队列是怎样的?
  3. 我们如何通过优先队列推演出经典算法堆排序?
  • 其他排序算法源码戳这里

  • 后续我会陆续更新其他算法的图解, 并且计划从下个月开始更新Vue2.6的源码系列. 当然一切都会围绕面试展开欢迎关注点赞❤️

堆排序

要理解堆排序, 首先要理解他的数据结构堆有序的二叉树 堆有序二叉树

  • 这个词需要拆分开理解, "堆有序" 和 "二叉树".

二叉树

二叉树, 简单的来说就是 "一个要求每个树枝都只能分叉一次的树"

  • 这个图就很nice, 是一个标准的二叉树✅

image.png

  • 这个就不对, 他是一个多叉树❌

image.png

堆有序

  • 首先我们来看一下算法第四版中的定义,

定义 当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。 假设我们有这么几个数字: 10, 5, 6, 1, 4, 3, 我们把它当成一个数组, 但是为了方便计算, 我们不考虑0位, 那么他就长这样子

const arr = [ <1 empty item>, 10, 5, 6, 1, 4, 3 ]

我们做两个约定:

  1. 我们假设当前的索引ik, 就有2k, 为当前 k 的子节点.

比如: 当前索引 K 为 1, 那么他的子节点的索引就是2.

  1. 我们假设当前的索引ik, 就有k / 2, 为当前 k 的父节点.

比如: 当前索引 K 为 4 那么他的父节点就是 2. 但是也有一些奇数节点, 为了方便计算, 我们再加上一个约束.我们每次找父节点, 都用Math.floor() 去包裹一下.

如果一个节点的索引是 3, 我们这样计算他的父节点. 这样就可以防止索引会变成一个小数(1.5)的情况

Math.floor( k / 2 )

好了, 到这里我们就可以通过上述的2条约定, 画出来一个这样的图👇👇👇

image.png

请仔细看这个图, 你似乎会发现一个规律, 每个子节点好像都一定会小于他的父节点, 并且他好像一个二叉树的结构??

是的这就是满足了, 我们上述的定义, 在这里再回顾一下当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。

示例图:

image.png

这样的一个数组, 我们就可以把它当成一个堆有序的二叉树(从这开始, 我们将其简称为二叉堆), 没错, 用一个数组就可以用来表示一个二叉堆.

仔细看上图, 你会发现, 如果一个数组, 满足一个二叉堆. 那么它的根节点就总会是这个数组里面最大的. 这就构成了我们接下来要讲的堆排序.


到这里, 我们就形成了一个思路. 只要每次将数组的根节点提取出来, 然后将它重新堆有序化, 这样每次根节点就一定都是最大的值了.开造😝

如何将一个数组堆有序化?

首先, 我们要有一个方法, 用来将数据插入到一个数组中.

const pq = [] // 假设这是一个二叉堆

function insert (value) {
    pq[pq.length] = v
}

我们插入一个元素后, 他就有可能打乱这个二叉堆, 比如说: 我们往4号位插入一个10, 而当前的2号位也就是4号位的父节点可能是2. 这就不满足二叉堆的条件了.

第二步, 我们每次插入元素, 都要重新让其堆有序化, 这相当于自底向上的堆有序化. exch()方法我们就简单的理解为交换位置就可以了

    const pq = [] // 假设这是一个二叉堆

    function insert (value) {
        pq[pq.length] = v
+       swim(pq.length) // 定义一个上浮方法, 传入当前值的索引
    }
    // 自底向上的堆有序化
+   function swim (k) {
+       let pJ = Math.floor( k / 2 ) // 拿到父节点的索引
+       while(k > 1 && pq[k] >= pq[pJ]) {
+           exch(k, pJ)
+           k = pJ // 继续向上寻找
+       }  
+   }
  • 好了, 到这里添加元素以及让元素堆有序化的操作就完成了

你可以不用理解这个图是怎么比较的, 只需要感受它的流程即可

image.png

第三步, 提取根节点.

  • 删除一个元素很简单, 这里就不过多赘述, 我们只需要在这个函数中将顶部元素与最后一位作交换, 然后将这个索引置空即可
function delMax () {
    const max = pq[1]
    exch(1, pq.length - 1)
    pq[pq.length - 1] = null // 清空
    return max
}

这样做也会产生一个问题, 就是处于底层的节点被挪到了根上, 这时候就需要自顶向下堆有序化这个数组

    function delMax () {
        const max = pq[1]
        exch(1, pq.length - 1)
        pq[pq.length - 1] = null // 清空
+       this.sink(1) // 堆有序化
        return max
    }
    // 自顶向下堆有序化
+   function sink (k) {
+       while(k < pq.length) {
+           let j = 2 * K // 子节点
+           if (j < pq.length && pq[j] < pq[j + 1]) j++;
+           if(pq[k] > pq[j]) break;
+           exch(k, j) // 否则交换着
+           k = j // 继续向下查找
+       }
+   }

image.png

好了, 到这里实际上我们就梳理完了一个优先队列类的全部功能, 上述代码为了更加友好, 将他们拆分成了一个个功能化函数, 完整的类可以看这个优先队列MaxPQ

两种操作的流程图

image.png

现在我们明白了有两种方法可以让一个数组变成一个二叉堆, 分别是 sink()swim(), 这时候我们就要考虑, 在我们接下来的堆排序算法中用哪个更合适.

  1. 从左至右循环扫描元素并调用swim(), 使得整个数组有序,
  2. 从右至左去调用sink()方法, 下沉每个元素.

显然第二种做法要更明智, 为什么?

因为第二种做法我们只需要扫描数组的前半段, 我们可以假设每一个数组元素都是一个堆有序的子节点, 而对于数组的后半段来说, 它们都是处在树的最底部的. (比如一个长度为10的数组, 6号节点显然不可能有12号节点作为子节点)


堆排序的原理

理解了堆排序的执行过程, 我们需要理解一下堆排序的原理.

  1. 将给定数组转化为一个二叉堆
  2. 定义一个指针,为数组的最后一位
  3. 将二叉堆的根节点(最大值)与当前指针位交换, 随后指针往左挪一位
  4. 将数组索引0开始到指针这调用一次sink(), 重新堆有序化

步骤3是堆排序的核心, 实际上就是把最大值放到数组最后一位, 下次堆有序的时候它不再参与进来. 因为我们是在一个数组上完成排序与堆有序化, 需要一个指针来区分已经排好序的元素和待排序的元素

  1. 将给定数组转化为一个二叉堆
  2. 定义一个指针,为数组的最后一位
function sortArray (nums: number[]) {
    let N  = nums.length - 1
    // 从中位开始, 指针i, 向左移动
    for(let i = Math.floor(N / 2); i >= 0; i--) {
        // 将 i 至 N 位元素堆有序直到数字组首位
        sink(nums, i, N)
    } 
}
  1. 将二叉堆的根节点(最大值)与当前指针位交换, 随后指针往左挪一位
  2. 将数组索引0开始到指针这调用一次sink(), 重新堆有序化

需要复制跳转堆排序

+ function sink (nums: number[], k: number, N: number) {
+    while (k * 2 <= N) {
+         let j = k * 2 // 子节点  
+         if (j < N && nums[j] < nums[j + 1]) j++
+         if (nums[k] >= nums[j]) break;
+         exch(nums, k, j)
+         k = j
+     }
+  }
  function sortArray (nums: number[]) {
      let N  = nums.length - 1
      // 从中位开始, 指针i, 向左移动
      for(let i = Math.floor(N / 2); i >= 0; i--) {
          // 将 i 至 N 位元素堆有序直到数字组首位
          sink(nums, i, N)
      } 
+     while (N > 0) {
+         exch(nums, 0, N--) // 交换首位与尾部, 并且指针左移
+         sink(nums, 0, N) // 指针移动后向堆有序化
+     }
+     return nums
  }

注: 在讲优先队列的各个 API(函数) 的时候, 我们让实例数组的第一位空了出来, 是为了方便计算和更友好的生成二叉树, 但是在堆有序算法里, 我没有这么做. 这会导致根节点下可能只有一个节点(使得树会多一个层级), 但是简洁起见, 我忽略了这一点. 如:

let arr = [3, 2, 1, 5]
// 堆有序
let suckArr = [ 5, 3, 1, 2 ]

image.png

本文图片均出自《算法》 第四版

感谢😘


如果觉得文章内容对你有帮助: