二叉堆

69 阅读14分钟

数据结构-堆简介

堆的特点

  • 节点内的数字就是存储的数据
  • 堆中的每个节点最多有两个子节点
  • 树的形状取决于数据的个数
  • 节点的排列顺序为从上到下,同行为从左到由
  • 堆的父节点必须小于子节点
    分析大多以最小堆为基准进行简述,最大堆原理相同

堆的数据存储

在堆中存储数据时子节点必须大于或小于父节点

  • 根节点存储的数据为堆中的最小值
  • 新添加数据时会被放在最底部的靠近左边的位置,当没有多余空间时会另起一行把该数据加在这一行的最左端
  • 在最底部插入数据后会和父节点对比,当父节点的值大于新添加值时应该将父节点和新元素交换位置

堆的数据获取

堆取出数据时,需要将堆进行二次排序,将最后的节点数据移到取出节点的位置,然后再进行节点值大小判断,当父节点的值大于子节点时需要在子节点中找到较小的进行交换,依次循环直到满足堆的特点;

实现堆

  • 类似二叉树的方式用节点表示
  • 使用数组表示,通过索引值检索父节点、左侧和右侧节点的值
    • 叶节点总是位于数组的floor(n/2)和n-1之间
      当前层级所有节点没有填满之前不允许开始填充下一层级
      用数组来实现堆原理是根据 「堆中的节点在数组的位置与它的父节点及子节点的索引之间有一个映射关系」

实现二叉堆.png

实现最小堆
const Compare = {
  LESS_THAN: -1,
  BIGGER_THAN: 1,
  EQUALS: 0,
};
function defaultCompare(a, b) {
  if (a === b) {
    return Compare.EQUALS;
  }
  return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
function swap(array, a, b) {
  /* const temp = array[a];
  array[a] = array[b];
  array[b] = temp; */
  [array[a], array[b]] = [array[b], array[a]];
}
// 封装最小堆类
class MinHeap {
  constructor(compareFn = defaultCompare) {
    // 使用compareFn在没有传入自定义函数的时候进行基本的比较
    this.compareFn = compareFn;
    // 用数组来存储数据
    this.heap = [];
  }

  // 取得左侧子节点索引
  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);
  }

  // 返回堆中元素个数
  size() {
    return this.heap.length;
  }

  // 判断堆是否为空
  isEmpty() {
    return this.size() === 0;
  }

  // 清空最小堆
  clear() {
    this.heap = [];
  }

  // 返回堆中最小值(最小堆)或最大值(最大堆)且不会移除这个值
  findMinimum() {
    return this.isEmpty() ? undefined : this.heap[0];
  }

  // 向堆中插入值
  insert(value) {
    // 插入值非undefined或null
    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(this.heap, parent, index);
      index = parent;
      parent = this.getParentIndex(index);
    }
  }

  // 移除最小值(最小堆)或最大值(最大堆)
  extract() {
    // 堆为空,直接返回undefined
    if (this.isEmpty()) {
      return undefined;
    }
    // 堆中只有一个值,直接移除并返回即可
    if (this.size() === 1) {
      return this.heap.shift();
    }
    // 堆中有不止一个值,移除第一个值并将堆中最后一个元素移动至根部
    const removedValue = this.heap[0];
    this.heap[0] = this.heap.pop();
    // 下移新的根元素直至堆结构正常
    this.siftDown(0);
    return removedValue;
  }

  // 下移操作(堆化),接收下移元素的位置作为参数
  siftDown(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;
    }
    // 同理,比较找出最小子节点索引
    if (
      right < size &&
      this.compareFn(this.heap[element], this.heap[right]) ===
        Compare.BIGGER_THAN
    ) {
      element = right;
    }
    // 只有最小子节点不是自己才和最小子节点交换,并递归重复下移直至次元素被放在正确的位置
    if (index !== element) {
      swap(this.heap, index, element);
      this.siftDown(element);
    }
  }

  // 取得堆对应的数组
  getAsArray() {
    return this.heap;
  }
}
实现最大堆

function reverseCompare(compareFn) {
  return (a, b) => compareFn(b, a);
}
// 通过继承实现最大堆类
class MaxHeap extends MinHeap {
  constructor(compareFn = defaultCompare) {
    super(compareFn);
    // this.compareFn = compareFn
    // 将比较反转,不将a和b进行比较,而是将b和a进行比较
    this.compareFn = reverseCompare(compareFn);
  }
}

堆选择排序

  1. 步骤
    • 从最后一层开始,如果孩子节点的值比父节点大,那么久交换位置;
    • 一层一层向上推找,直到根节点最大;
    • 时间复杂度O(n*logn),空间复杂度O(1)

    需要建立调整堆的函数和排序堆的函数

堆的选择排序.webp

基本概念

工具函数

const Compare = {
  LESS_THAN: -1,
  BIGGER_THAN: 1,
  EQUALS: 0,
};
function defaultCompare(a, b) {
  if (a === b) {
    return Compare.EQUALS;
  }
  return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
function reverseCompare(compareFn) {
  return (a, b) => compareFn(b, a);
}
function swap(array, a, b) {
  /* const temp = array[a];
  array[a] = array[b];
  array[b] = temp; */
  // 数组解构的方式进行交换
  [array[a], array[b]] = [array[b], array[a]];
}

逻辑结构

二叉堆(Binary Heap)是一种特殊的完全二叉树,即每个节点都大于等于或者小于等于子节点,兄弟节点的相对大小是不重要的;
由于具备完全二叉树的特性,二叉堆的底层物理结构一般用数组实现,采用数组存储方式是空间利用率最高的存储方式,也可以通过链表的方式进行存储; 数组实现二叉堆.png i节点位置 n节点数量

  • 如果 i = 0 ,它是根节点
  • 如果 i > 0 ,它的父节点的索引为 floor( (i – 1) / 2 )
  • 如果 2i + 1 ≤ n – 1,它的左子节点的索引为 2i + 1
  • 如果 2i + 1 > n – 1 ,它无左子节点
  • 如果 2i + 2 ≤ n – 1 ,它的右子节点的索引为 2i + 2
  • 如果 2i + 2 > n – 1 ,它无右子节点
  • 若计算出的索引值大于数组的长度,则当前节点没有子节点
  • 若计算出的索引值不是一个有效的数组索引(小于0时),则当前节点没有父节点
  • 当前层级所有对的节点未填满之前不允许在下一层进行填充
// 获取左子节点的位置
protected getLeftIndex(index: number): number {
  return 2 * index + 1;
}

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

// 获取父节点的位置
protected getParentIndex(index: number): number | undefined {
  if (index === 0) {
      return undefined;
  }
  return Math.floor((index - 1) / 2);
}

分类

  1. 最大堆:所有节点都大于等于他的子节点
  2. 最小堆:所有节点都小于等于他的子节点

二叉堆分类案例.png

特点

  1. 对于数组中的元素a[i],其左子节点为a[2i+1],右子节点为a[2i+2],父节点为a[(i-1)/2]。
  2. 其堆序性质为每个节点都大于等于或者小于等于子节点-最大堆、最小堆;
  3. 二叉堆可以高效、快速的找出最大值和最小值常被用于优先队列和堆排序算法;

向堆中插入数据

基本逻辑

向堆中插入数据是指将数据插入到底部叶节点再执行上移操作,按照目标堆类型将新节点和父节点进行交换,直到满足目标堆的基础要求;

insert步骤解析
  • 进行新数据的空值和目标堆空值判断
  • 满足判断后将数据添加到目标堆的末尾
  • 掺入完成后进行上移操作,直到满足目标堆的要求
function insert() {

}
上移操作的实现
  • 参数:插入元素数据对的索引位置-index
  • 根据索引位置获取父节点的位置
  • 判断父节点和新节点的值大小,按照目标堆的要求交换两个节点
  • 更新index和节点值后,继续交换位置直到满足目标堆的要求
交换的实现
  • 参数:要交换的数组数据、交换元素的位置、被交换元素的位置
  • 声明一个临时变量,赋值交换的元素
  • 交换的元素赋值为被交换的元素
  • 被交换的元素赋值为临时变量
  • 释放临时变量
  • 也可以通过解构的方式进行交换 [arr[max],arr[i]] = [arr[i],arr[max]];

插入数据到二叉堆.gif

二叉堆的最值操作

获取最值问题

  • 最小堆中数组中的0号元素就是堆的最小值
  • 最大堆的数组中的0号位置元素就是堆的最大值

导出堆中的最值

通用思路

导出堆中的最值问题就是移除数组中的第一个元素,即堆的根节点,在移除后需要将对的最后一个元素移动到根节点并执行下移操作,目的是将根节点删除和末尾节点移动到根节点后继续保持堆结构的正常;

  1. 基础判断
    • 判断堆是否为空,为空则直接返回undefined
    • 如果堆的长度为1,则直接返回堆顶节点;
    • 否则先保存堆顶节点,然后删除堆顶节点和移动堆末节点到堆顶,然后执行下移操作调整堆结构,调整完成后返回刚保存的堆顶元素;
  2. 下移操作实现
    • 参数为需要调整的元素位置-index
    • 缓存临时下标值,用于判断是否要交换节点
    • 根据index获取左子节点、右子节点和堆的大小-size
    • 判断当前节点和左右节点的值大小,根据目标堆的结构特点调整缓存的临时下标值
    • 当缓存的临时下标值和index不相等时就交换两个节点的位置,继续执行下移操作,直到index等于临时下标值后停止下移操作,此时表明堆结构已经符合要求;
      在删除根节点后。会在根节点处建立一个空穴,此时堆中少了一个节点,那么最后一个节点必须移动到合适的位置(直接移动最后一个节点代价也是最小的),因此将最后一个节点移动到根节点,并执行下移操作重构堆结构

删除根节点-下移.gif

数组堆化

基本思路

  • 将数组转换为二叉堆数组时们可以使用n次插入的方法去实现,但是可以通过堆的基本操作-建堆 & 下沉 & 上浮来实现;
  • 可以将数组中的数据按照顺序放入到树中,只需要保持该有的结构性即可;此时可以找到最后一个非叶子节点-最后一个叶子节点的父节点就是最后一个非叶子结点(length-1)/2;找到后进行下沉操作即可,然后遍历所有非叶子节点进行大数据的上浮即可;

基本步骤

  • 在保证堆的结构性的前提下降数组的数据随意放入到树中
  • 找到第一个非叶子节点,并尝试对其进行下沉操作
  • 继续寻找上一个非叶子结点,尝试下沉操作
  • 重复第三个步骤,直到根节点位置

数组堆化.gif

调整完全二叉树中的树为一个最大堆

仅限于局部树的堆化实现:从某一个节点出发,找到它的左子树和右子树,将当前节点(局部根节点)与两棵子树进行大小比较,找到较大的一方,将其与父节点进行交换,交换完毕后当前节点所在的树就是一个最大堆(大小比较时可以进行位置记录,当存在交换时就更改缓存的最大index,最后根据index与父节点的index对比,不一样则进行交换)。

/*
* 1. 从一个节点出发
* 2. 从它的左子树和右子树中选择一个较大值
* 3. 将较大值与这个节点进行位置交换 - 交换后再进行递归判断操作
* 上述步骤,就是一次heapify的操作
* */

// n为树的节点数,i为当前操作的节点 (找到这颗树里的最大节点)
const heapify = function (tree, n, i) {
  if(i >= n){
      // 结束递归
      return;
  }
  // 找到左子树的位置
  let leftNode = 2 * i + 1;
  // 找到右子树的位置
  let rightNode = 2 * i +2;

  /*
     1. 找到左子树和右子树位置后,必须确保它小于树的总节点数
     2. 已知当前节点与它的左子树与右子树的位置,找到最大值
  */
  // 设最大值的位置为i
  let max = i;
  // 如果左子树的值大于当前节点的值则最大值的位置就为左子树的位置
  if(leftNode < n && tree[leftNode] > tree[max]){
      max = leftNode;
  }
  // 如果右子树的值大于当前节点的值则最大值的位置就为右子树的位置
  if(rightNode < n && tree[rightNode] > tree[max]){
      max = rightNode;
  }

  /*
  * 1. 进行大小比较后,如果最大值的位置不是刚开始设的i,则将最大值与当前节点进行位置互换
  * */
  if(max !== i){
      // 交换位置
      swap(tree,max,i);
      // 递归调用,继续进行heapify操作
      heapify(tree,n,max)
  }
};
// maxHeapify(i) {
//   let max = i;

//   if(i >= this.size){
//     return;
//   }
//   // 当前序号的左节点
//   const l = i * 2 + 1;
//   // 当前需要的右节点
//   const r = i * 2 + 2;

//   // 求当前节点与其左右节点三者中的最大值
//   if(l < this.size && this.data[l] > this.data[max]){
//     max = l;
//   }
//   if(r < this.size && this.data[r] > this.data[max]){
//     max = r;
//   }

//   // 最终max节点是其本身,则已经满足最大堆性质,停止操作
//   if(max === i) {
//     return;
//   }

//   // 父节点与最大值节点做交换
//   swap(tree,max,i)

//   // 递归向下继续执行
//   return maxHeapify(max);
// }
// 交换数组位置函数
function swap(arr,max,i) {
  /* const temp = array[max];
  array[max] = array[i];
  array[i] = temp; */
  [arr[max],arr[i]] = [arr[i],arr[max]];
}

将乱序数组转化为一个堆(最大堆)

思路:从最后一个节点的父节点出发,进行循环heapify操作,直到当前操作节点为数组0号位置元素即可;

/*
* 将完全二叉树构建成堆
* 1. 从树的最后一个父节点开始进行heapify操作
* 2. 树的最后一个父节点 = 树的最后一个子结点的父节点
* */
const buildHeap = function (tree, n) {
  // 最后一个节点的位置 = 数组的长度-1
  const lastNode = n - 1;
  // 最后一个节点的父节点
  const parentNode = Math.floor((lastNode - 1) / 2);
  // 从最后一个父节点开始进行heapify操作
  for (let i = parentNode; i >= 0; i--) {
    heapify(tree, n, i);
  }
};
// rebuildHeap(){
//   // 叶子节点
//   const L = Math.floor(this.size / 2);
//   for(let i = L - 1; i >= 0; i--){
//     this.maxHeapify(i);
//   }
// }
const dataArr = [23,15,34,11,23,4,19,80]; 
buildHeap(dataArr,dataArr.length); 
console.log(dataArr,123);
[
  80, 23, 34, 15,
  23,  4, 19, 11
] 123

也可以将最后一个节点和根节点不断交换位置,然后判断根节点所在树的堆性,判断交换后再进行下一轮循环比较替换;

sort() {
  for(let i = this.size - 1; i > 0; i--){
    swap(this.data, 0, i);
    this.size--;
    this.maxHeapify(0);
  }
}

堆化后排序

只需要将数组堆化后,不断的取出根节点即可

const heapSort = function (tree, n) {
  // 构建堆
  buildHeap(tree, n);
  // 从最后一个节点出发
  for (let i = n - 1; i >= 0; i--) {
    // 交换根节点和最后一个节点的位置
    swap(tree, i, 0);
    // 重新调整堆
    heapify(tree, i, 0);
  }
};

const dataArr = [23,15,34,11,23,4,19,80]; 
heapSort(dataArr,dataArr.length); 
console.log(dataArr);
// [
//   4, 11, 15, 19,
//  23, 23, 34, 80
// ]

基本操作与实现

三个基本操作是 -- 建堆 & 下沉 & 上浮,三个操作的目的都是堆化(Heapify)

建堆

建堆的目的是将一组数据转化为满足堆性质的数据结构,建堆分为原地建堆非原地建堆原地建堆是指将一个数组原地堆化的过程,实现方式有从下往上堆化从上往下堆化;而非原地建堆是指将数据一个个的添加到数组的过程,其实就是不断的添加元素执行下沉的操作。

从下往上堆化

这种方法现将下标为0的第一个元素视为大小为1的堆,随后将下标从1到count-1得到元素依次执行上浮操作;这个过程相当于不断向这个初始化大小为1的堆里添加元素;

从上往下堆化

这种方式是将叶子节点视为大小为1的堆,随后将下标从(count/2)-1递减到0的节点执行下沉操作;

原地建堆.png

复杂度分析
  1. 时间复杂度
    • 上浮和下沉:是沿着根节点到叶子节点的路径进行比较和替换,一个包含n个节点的二叉树高度为lgn,所以时间复杂度为O(lgn);
    • 建堆:O(n)
  2. 空间复杂度
    • 堆化的过程只是用到了常量级别的变量,因此空间复杂度为O(1);

堆的相关应用

堆排序算法

基本步骤

  1. 用数组创建一个最大堆用作源数据;
  2. 在创建最大堆后,最大的值会被存储在堆的第一个位置,然后将其替换为堆的最后一个值,将堆的大小减一;
  3. 循环将堆的根节点下移并重复操作步骤2直到堆大小为1; 注意点
  4. 在进行最大堆的构建时可以只对后半部分进行下移操作,前半部分会自动进行下移操作进行排序 代码实现
function heapSort(array, compareFn = defaultCompare) {
  let heapSize = array.length;
  buildMaxHeap(array, compareFn); // 步骤 1 
  while (heapSize > 1) {
    swap(array, 0, --heapSize); // 步骤 2 
    heapify(array, 0, heapSize, compareFn); // 步骤 3 
  }
  return array;
}

function buildMaxHeap(array, compareFn) {
  for (let i = Math.floor(array.length / 2); i >= 0; i -= 1) {
    heapify(array, i, array.length, compareFn);
  }
  return array;
}

heapify(array) {
  if (array) {
    this.heap = array;
  }
  const maxIndex = Math.floor(this.size() / 2) - 1;
  for (let i = 0; i <= maxIndex; i++) {
    this.siftDown(i);
  }
  return this.heap;
}

图解分析 image.png

高效的查找

  • 可以快速找出最大值和最小值;
  • 查找第K个最大(小)元素
    • 构建一个最小堆,将元素一次插入到堆中;
    • 当堆的容量超过K,就删除堆顶;
    • 插入结束后,堆顶就是第K个最大元素;
var findKthLargest = function (nums, k) {
  // 维护一个大小是 K 的小顶堆
  let minHeap = new Heap((a, b) => a < b)
  for (let num of nums) {
    minHeap.push(num)
    if (minHeap.size > k) {
      minHeap.pop()
    }
  }
  return minHeap.peek()
};

class Heap {
  constructor(compare) {
    this.arr = [0]; // 忽略 0 这个索引,方便计算左右节点
    this.compare = compare ? compare : (a, b) => a > b; // 默认是大顶堆
  }
  get size() {
    return this.arr.length - 1;
  }
  // 新增元素
  push(item) {
    this.arr.push(item);
    this.shiftUp(this.arr.length - 1);
  }
  // 元素上浮,k 是索引
  shiftUp(k) {
    let { arr, compare, parent } = this;
    // 当前元素 > 父元素,则进行交换
    while (k > 1 && compare(arr[k], arr[parent(k)])) {
      this.swap(parent(k), k);
      k = parent(k); // 更新 k 的位置为父元素的位置(交换了位置)
    }
  }
  // 弹出堆顶
  pop() {
    if (this.arr.length == 1) return null;
    this.swap(1, this.arr.length - 1);// 将结尾元素和堆顶元素交换位置
    let head = this.arr.pop(); // 删除堆顶
    this.sinkDown(1); // 再做下沉操作
    return head;
  }
  // 元素下沉
  sinkDown(k) {
    let { arr, compare, left, right, size } = this;
    while (left(k) <= size) {
      // 1. 拿到左右节点的最大值
      let child = left(k);
      if (right(k) <= size && compare(arr[right(k)], arr[child])) {
        child = right(k);
      }
      // 2. k 满足大顶堆或小顶堆,什么都不做
      if (compare(arr[k], arr[child])) {
        return;
      }
      // 3. 交换位置后,继续下沉操作
      this.swap(k, child);
      k = child; // 更新位置
    }
  }
  // 获取堆顶元素
  peek() {
    return this.arr[1];
  }
  // 获取左边元素节点
  left(k) {
    return k * 2;
  }
  // 获取右边元素节点
  right(k) {
    return k * 2 + 1;
  }
  // 获取父节点
  parent(k) {
    return Math.floor(k >> 1);
  }
  // i、j 交换位置
  swap(i, j) {
    [this.arr[i], this.arr[j]] = [this.arr[j], this.arr[i]];
  }
}

文章推荐

前端-二叉堆算法系列 - zxhnext