基础数据结构(七):堆结构

304 阅读6分钟

认识堆结构

堆的本质是一种特殊的树形数据结构,使用完全二叉树来实现。这里说明一下什么是完全二叉树:在一颗二叉树中,若除最后一层外的其余层都是满的,并且最后一层要么是满的,要么在右边缺少连续若干节点,则此二叉树完全二叉树
堆可以进行很多分类,但是平时使用的基本都是二叉堆。二叉堆又可以划分为最大堆和最小堆:

  • 最大堆:堆中每一个节点都大于等于它的子节点
  • 最小堆:堆中每一个节点都小于等于它的子节点

堆结构的作用

如果有一组数据,需要我们获取最大值或者最小值,或许还需要获取第二大或第二小等等,我们使用什么数据结构来实现效率比较高呢?

  • 数组:假如进行排序,那么时间复杂度最少是O(nlogn),如果不排序,那么每次获取都是O(n)。
  • 二叉搜索树:二叉搜索树的时间复杂度可以降到O(logn),但是二叉搜索树如果遇到大量顺序的值它的效率会降低,而且删除操作比较复杂。
  • AVL树或者红黑树:虽然平衡树可以保证时间复杂度是O(logn),但是这两种数据结构的删除等操作比较麻烦和耗时,有点杀鸡用牛刀的感觉。

最大堆和最小堆就可以非常完美的解决这个问题,它们的堆顶就是我们要的最值,而且每次拿走堆顶的最值,也可以快速地通过浮动将剩余数据中的最值放到堆顶。react中就使用了最小堆来快速获取优先级最高的任务。

最大堆封装

二叉堆用树形结构表示出来是一颗完全二叉树,通常在实现的时候我们底层会使用数组,值得注意的是每个节点在数组中对应的索引值有如下规律:

  • 如果i = 0,它是根节点
  • 父节点索引:floor((i - 1) / 2)
  • 左子结点索引:2i + 1
  • 右子节点索引:2i + 2

先初始化一下MaxHeap的基础属性和方法:

class MaxHeap<T> {
  // 属性
  private data: T[] = [];
  private length: number = 0;
  // 交换i和j位置的元素
  private swap(i: number, j: number) {
    const temp = this.data[i];
    this.data[i] = this.data[j];
    this.data[j] = temp;
  }
  // 获取堆顶元素
  getMax() {
    return this.data[0];
  }
  // 获取堆中元素的个数
  size() {
    return this.length;
  }
  // 判断堆是否为空
  isEmpty() {
    return this.length === 0;
  }
}

插入元素

如果想要插入一个元素,我应该将该元素push到数组的尾部,因为这样不会破坏原来的堆结构,只需要在原来的基础上调整新加入元素的位置。将该元素与父元素比较,如果比父元素大则交换位置,否则不动。如果交换了位置,还要继续判断是否有父元素,有的话继续比较知道找到合适的位置,这个过程我们称之为上浮,具体实现如下:

// 上浮
private shiftUp(index: number) {
  while (index > 0) {
    // 获取父节点的索引
    const parentIndex = Math.floor((index - 1) / 2);
    // 如果父节点的值大于等于当前节点的值 则不需要上浮
    if (this.data[parentIndex] >= this.data[index]) {
      break;
    }
    // 如果父节点的值小于当前节点的值 则交换位置
    this.swap(parentIndex, index);
    // 更新index
    index = parentIndex;
  }
}
// 插入元素
insert(value: T) {
  // 将元素插入到数组的末尾
  this.data.push(value);
  this.length++;
  // 上浮
  this.shiftUp(this.length - 1);
}

取出堆顶元素

当我们取出堆顶元素的时候,为了最大程度的保证堆结构不被破坏,我们应该将最末尾的元素提到堆顶,然后比较其与子节点的大小关系,如果小于子节点则替换位置,直到找到合适的位置,这个操作称之为下沉:

// 下沉
// 下沉
shiftDown(index: number) {
  // 获取左子结点索引
  let leftIndex = index * 2 + 1;
  // 如果左子结点索引大于等于数组长度 则说明没有子结点
  while (leftIndex < this.length) {
    // 获取右节点索引
    const rightIndex = leftIndex + 1;
    // 获取左右节点中较大元素的索引
    let maxIndex = leftIndex;
    if (rightIndex < this.length && this.data[rightIndex] >= this.data[leftIndex]) {
      // 存在右子节点且右子节点大于等于左子节点
      maxIndex = rightIndex;
    } else {
      // 不存在右子节点
      maxIndex = leftIndex;
    }
    // 如果当前节点大于等于左右子节点中最大的值 则不需要下沉
    if (this.data[index] >= this.data[maxIndex]) {
      break;
    }
    // 如果当前节点小于左右子节点中最大的值 则交换位置
    this.swap(index, maxIndex);
    index = maxIndex;
    leftIndex = index * 2 + 1;
  }
}
// 取出堆顶元素
extractMax() {
  if (!this.length) return null;
  // 堆顶最大值
  const max = this.data[0];
  // 获取最后的元素
  const tail = this.data.pop()!;
  this.length--;
  if (this.length >= 1) {
    // 如果堆中还有元素 则将最后一个元素放到堆顶 然后下沉
    this.data[0] = tail;
    this.shiftDown(0);
  }
  return max;
}

值得注意的是一定不要使用shift来移除堆顶的元素,因为那样会打乱其他所有节点在最大堆中的相互位置关系。

原地建堆

原地建堆是指建立堆的过程中,不使用额外的内存空间,直接在原有数组上进行操作,使该数组变成一个堆结构。原地建堆有两种方式,一种是使用上浮操作,一种是下沉,由于下沉的效率更高,所以我们一般使用下沉操作。下沉法的原理就是从最后一个非叶子节点到根节点倒序每个节点做一次下沉操作即可:

// 原地建堆
buildHeap(arr: T[]) {
  this.data = arr;
  this.length = arr.length;
  // 从最后一个非叶子节点开始下沉 即最后一个元素的父节点开始
  for (let i = Math.floor((this.length - 2) / 2); i >= 0; i--) {
    this.shiftDown(i);
  }
}

最小堆

最小堆跟最大堆的封装几乎一样,区别就在于上浮和下沉的条件相反,读者可以试着封装一下最小堆来巩固一下堆的封装。

通用堆

既然最大堆跟最小堆封装的区别非常小,那么我们可以封装成一个公用的堆,通过构造函数传参来区分最大堆和最小堆:

enum HeapType {
  MIN = 0, // 默认是最小堆
  MAX = 1, // 最大堆
}
class Heap<T> {
  // 属性
  private data: T[] = [];
  private length: number = 0;
  constructor(private type: HeapType = 0, initArr: T[] = []) {
    initArr.length && this.buildHeap(initArr);
  }
  // 交换i和j位置的元素
  private swap(i: number, j: number) {
    const temp = this.data[i];
    this.data[i] = this.data[j];
    this.data[j] = temp;
  }
  // 比较a和b位置的元素
  compare(a: number, b: number) {
    if (this.type) {
      // 如果是最大堆
      return this.data[a] >= this.data[b];
    } else {
      // 如果是最小堆
      return this.data[a] <= this.data[b];
    }
  }
  // 上浮
  private shiftUp(index: number) {
    while (index > 0) {
      // 获取父节点的索引
      const parentIndex = Math.floor((index - 1) / 2);
      // 如果是最大堆且父节点的值大于等于当前节点的值 则不需要上浮
      // 如果是最小堆且父节点的值小于等于当前节点的值 则不需要上浮
      if (this.compare(parentIndex, index)) {
        break;
      }
      // 如果父节点的值小于当前节点的值 则交换位置
      this.swap(parentIndex, index);
      // 更新index
      index = parentIndex;
    }
  }
  // 插入元素
  insert(value: T) {
    // 将元素插入到数组的末尾
    this.data.push(value);
    this.length++;
    // 上浮
    this.shiftUp(this.length - 1);
  }
  // 下沉
  shiftDown(index: number) {
    // 获取左子结点索引
    let leftIndex = index * 2 + 1;
    // 如果左子结点索引大于等于数组长度 则说明没有子结点
    while (leftIndex < this.length) {
      // 获取右节点索引
      const rightIndex = leftIndex + 1;
      // 最大/最小值的索引
      let extremeIndex = leftIndex;
      if (rightIndex < this.length && this.compare(rightIndex, leftIndex)) {
        // 存在右子节点且满足比较条件
        extremeIndex = rightIndex;
      } else {
        // 不存在右子节点
        extremeIndex = leftIndex;
      }
      // 如果是最大堆且当前节点大于等于左右子节点中最大的值 则不需要下沉
      // 如果是最小堆且当前节点小于等于左右子节点中最小的值 则不需要下沉
      if (
        (this.type && this.data[index] >= this.data[extremeIndex]) ||
        (!this.type && this.data[index] <= this.data[extremeIndex])
      ) {
        break;
      }
      // 如果当前节点小于左右子节点中最大的值 则交换位置
      this.swap(index, extremeIndex);
      index = extremeIndex;
      leftIndex = index * 2 + 1;
    }
  }
  // 取出堆顶元素
  extractTop() {
    if (!this.length) return null;
    const min = this.data[0];
    const tail = this.data.pop()!;
    this.length--;
    if (this.length >= 1) {
      // 如果堆中还有元素 则将最后一个元素放到堆顶 然后下沉
      this.data[0] = tail;
      this.shiftDown(0);
    }
    return min;
  }
  // 原地建堆
  buildHeap(arr: T[]) {
    this.data = arr;
    this.length = arr.length;
    // 从最后一个非叶子节点开始下沉 即最后一个元素的父节点
    for (let i = Math.floor((this.length - 2) / 2); i >= 0; i--) {
      this.shiftDown(i);
    }
  }
  // 获取堆顶元素
  getTop() {
    return this.data[0];
  }
  // 获取堆中元素的个数
  size() {
    return this.length;
  }
  // 判断堆是否为空
  isEmpty() {
    return this.length === 0;
  }
  // 打印
  print() {
    console.log(this.data);
  }
}

以上就是堆的常用方法及封装,有收获记得点赞哦