JavaScript 数据结构(8)- 二叉堆和堆排序

68 阅读7分钟

1669725719225.jpg 学习 JavaScript 数据结构代码 git 仓库地址:gitee.com/zhangning18…

在 10 中学习了树数据结构。这里学习一种特殊的二叉树,也就是 数据结构,也叫做二叉堆。二叉堆是计算机科学中一种非常著名的数据结构,由于它能高效、快速地找出最大值和最小值,常被应用于 优先队列。它也被用于著名的 堆排序算法 中。

主要学习内容:

      • 二叉堆数据结构
      • 最大和最小堆
      • 堆排序算法

11.1 二叉堆数据结构

二叉堆是一种特殊的二叉树,有以下两个特性

    • 它是一颗完全二叉树,表示树的每一层都有左侧和右侧子节点(除了最后一层的叶节点),并且最后一层的叶节点尽可能都是左侧子节点,这叫做结构特性
    • 二叉堆不是最小堆就是最大堆。最小堆允许你快速导出树的最小值,最大堆允许你快速导出树的最大值。所有的节点都大于等于(最大堆)或小于等于(最小堆)每个它的子节点。这叫做 堆特性

尽管二叉堆是二叉树,但并不一定是二叉搜索树(BST)。在二叉堆中,每个子节点都要大于等于父节点(最小堆)或小于等于父节点(最大堆)。然而在二叉搜索树中,左侧子节点总是比父节点小,右侧子节点也总是最大。

11.1.1 创建最小堆类

MinHeap

export class MinHeap {
  constructor(compareFn = defaultCompare) {
    this.compareFn = compareFn;
    this.heap = [];
  }
}
  1. 二叉树的数组表示

二叉树有两种表示方式。第一种是使用一个动态的表示方式,也就是指针(用节点表示),在上一章使用过。第二种是使用一个数组,通过索引值检索父节点、左侧和右侧子节点的值。

要访问使用普通数组的二叉树节点,我们可以用下面的方式操作 index。

对于给定位置 index 的节点

  • 它的左侧子节点的位置是 2 * index + 1 (如果位置可用)
  • 他的右侧子节点的位置是 2 * index + 2 (如果位置可用)
  • 它的父节点位置是 index / 2 (如果位置可用)

用上面的方法来访问特定节点,可以把下面的方法接入 MinHeap 类:

  // 获取左侧子节点位置
  getLeftIndex(index) {
    return 2 * index + 1;
  }

  // 获取右侧子节点位置
  getRightIndex(index) {
    return 2 * index + 2;
  }

  // 获取父节点位置
  getParentIndex(index) {
    if (index === 0) {
      return undefined;
    }
    return Math.floor((index - 1) / 2);
  }
  1. 向堆中插入值

向堆中插入值是指将值插入堆的底部叶节点再执行 siftUp 方法,表示将要将这个值和它的父节点进行交换,直到父节点小于这个插入的值。

  // 向堆中插入新值
  insert(value) {
    if (value != null) {
      this.heap.push(value);
      this.siftUp(this.heap.length - 1);
      return true;
    }
    return false;
  }

  // 接收插入值的位置作为参数。同样需要获取其父节点的位置
  siftUp(index) {
    let parent = this.getParentIndex(index);
    while (
      index > 0 &&
      this.compareFn(
        this.heap[parent],
        this.heap[index]) === Compare.BIGGER_THAN
      ) {
      // 如果插入的值小于他的父节点(在最小堆中,或在最大堆中比父节点大),
      // 那么就执行 swap 将这个元素和父节点交换。我们重复这个过程直到堆的根节点也经过了交换节点和父节点的位置操作
      swap(this.heap, parent, index);
      index = parent;
      parent = this.getParentIndex(index);
    }
  }

交换数组中值的函数

// 交换数组中的两个值
const swap = (array, a, b) => {
  // 需要一个辅助变量来复制要交换的第一个元素
  const temp = array[a];
  // 将第二个元素赋值到第一个元素的位置。
  array[a] = array[b];
  // 最后,将复制的第一个元素的值覆盖到第二个元素的位置
  array[b] = temp;
  // 也可以使用 es 6 的语法重写 swap 函数
  // const swap = (array, a, b) => [array[a], array[b]] = [array[b], array[a]];
};

2345 中插入1 的操作

const heap = new MinHeap();
heap.insert(2);
heap.insert(3);
heap.insert(4);
heap.insert(5);
// 上图为插入 1 的步骤
heap.insert(1);
console.log({heap});

打印结果

  1. 从堆中找到最小值和最大值

在最小堆中,最小值总是位于数组的第一个位置

  // 堆 大小
  size() {
    return this.heap.length;
  }

  // 是否是空的
  isEmpty() {
    return this.size() === 0;
  }

  // 发现最小堆的值
  findMinimum() {
    return this.isEmpty() ? undefined : this.heap[0];
  }

在最大堆中,数组的第一个元素保存了最大值,可以使用同样的代码

  1. 导出堆中的最小值和最大值

移除最小值(最小堆)或最大值(最大堆)表示移除数组中的第一个元素(堆的根节点)。移除后将堆的最后一个元素移动至根部并执行 siftDown 函数,表示将交换元素直到堆的结构正常。

  // 导出堆中的最小值或最大值
  extract() {
    // 如果堆为空,没有值可以导出
    if (this.isEmpty()) {
      return undefined;
    }
    // 如果堆中只有一个值,可以直接移除并返回
    if (this.size() === 1) {
      return this.heap.shift();
    }
    // 下面的代码是堆中不止一个值
    // 将第一个移除
    const removedValue = this.heap.shift();
    // 执行 siftDown 下移操作(堆化)
    this.siftDown(0);
    return removedValue;
  }

  // 下移操作(堆化),接收移除元素的位置作为参数
  siftDown(index) {
    // 将 index 复制到 element 变量中.同样要获取左侧子节点和右侧子节点的值.
    let element = index;
    // 获取左侧子节点和右侧子节点
    const left = this.getLeftIndex(index);
    const right = this.getRightIndex(index);
    // 堆大小
    const size = this.size();
    // 下移操作表示将元素和最小子节点(最小堆)和最大子节点(最大堆)进行交换

    // 如果元素比左侧子节点小且 index 合法,就交换元素和他的左侧子节点
    if (
      left < size &&
      this.compareFn(this.heap[element], this.heap[left]) ===
      Compare.BIGGER_THAN
    ) {
      element = left;
    }
    // 如果元素小于它的右侧子节点且 index 合法,我们就交换元素和它的右侧子节点
    if (
      right < size &&
      this.compareFn(this.heap[element], this.heap[right]) >
      Compare.BIGGER_THAN
    ) {
      element = right;
    }
    // 在找到最小子节点位置后,检验它的值是否和 element 相同(传进来的index)--因为和自己交换没有意义
    // 如果不是,就将它和最小的 element 交换,并且重复这个过程直到 element 被放在正确的位置上
    if (index !== element) {
      swap(this.heap, index, element);
      this.siftDown(element);
    }
  }

11.1.2 创建最大堆类

MaxHeap 类的算法和 MinHeap 类的算法一样,不同之处在于要把所有 > (大于)的比较换成 <(小于)的比较。

这里直接继承 MinHeap 然后反转比较方法就可以了

import {MinHeap} from './MinHeap.js';
import {defaultCompare} from './util.js';

export class MaxHeap extends MinHeap {
  constructor(compareFn = defaultCompare) {
    super(compareFn);
    this.compareFn = reverseCompare(compareFn);
  }
}

// 直接反转就可以
const reverseCompare = (compareFn) => {
  return (a, b) => compareFn(b, a);
};

11.2 堆排序算法

学完二叉堆数据结构之后,可以创建一个非常著名的排序算法:堆排序算法。

含有下面三个步骤:

  1. 用数组创建一个最大堆用作元数据
  2. 在创建最大堆后,最大的之会被存储在对的第一个位置。要将它替换为堆的最后一个值,将堆的大小减 1
  3. 最后将堆的根节点下移并重复 2 直到堆的大小为 1

用最大堆得到一个升序排列的数组。如果想要这个数组按降序排列,可以用最小堆代替

//堆排序
function heapSort(arr) {
  //构造大根堆
  heapInsert(arr);
  let size = arr.length;
  while (size > 1) {
    //固定最大值
    swap(arr, 0, size - 1);
    size--;
    //构造大根堆
    heapify(arr, 0, size);
  }
  return arr;
}

//构造大根堆(通过新插入的数上升)
function heapInsert(arr) {
  for (let i = 0; i < arr.length; i++) {
    //当前插入的索引
    let currentIndex = i;
    //父结点索引
    let fatherIndex = (currentIndex - 1) / 2;
    //如果当前插入的值大于其父结点的值,则交换值,并且将索引指向父结点
    //然后继续和上面的父结点值比较,直到不大于父结点,则退出循环
    while (arr[currentIndex] > arr[fatherIndex]) {
      //交换当前结点与父结点的值
      swap(arr, currentIndex, fatherIndex);
      //将当前索引指向父索引
      currentIndex = fatherIndex;
      //重新计算当前索引的父索引
      fatherIndex = (currentIndex - 1) / 2;
    }
  }
}

//将剩余的数构造成大根堆(通过顶端的数下降)
function heapify(arr, index, size) {
  let left = 2 * index + 1;
  let right = 2 * index + 2;
  while (left < size) {
    let largestIndex;
    //判断孩子中较大的值的索引(要确保右孩子在size范围之内)
    if (arr[left] < arr[right] && right < size) {
      largestIndex = right;
    } else {
      largestIndex = left;
    }
    //比较父结点的值与孩子中较大的值,并确定最大值的索引
    if (arr[index] > arr[largestIndex]) {
      largestIndex = index;
    }
    //如果父结点索引是最大值的索引,那已经是大根堆了,则退出循环
    if (index == largestIndex) {
      break;
    }
    //父结点不是最大值,与孩子中较大的值交换
    swap(arr, largestIndex, index);
    //将索引指向孩子中较大的值的索引
    index = largestIndex;
    //重新计算交换之后的孩子的索引
    left = 2 * index + 1;
    right = 2 * index + 2;
  }
}


//交换数组中两个元素的值
function swap(arr, i, j) {
  let temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

const array = [7, 6, 3, 5, 4, 1, 2];
console.log('Before sorting: ', array);
console.log('After sorting: ', heapSort(array));

最大堆函数会重新组织数组的顺序。归功于要进行的所有比较,我们只需要对后半部分数组执行 heapify (下移)函数(前半部分会自动排好序,所以不需要对已经知道排好序的部分执行函数)

下图展示了堆排序算法

堆排序算法不是一个稳定的排序算法,也就是说如果数组没有排好序可能会得到不一样的结果。后面会详细学习排序算法,加油!!!

11.3 小结

该文章可以学习到二叉堆数据结构和他的两个变体:最小堆和最大堆。

还学习了怎样用 堆数据结构 来创建堆排序算法。