【Javascript】大顶堆小顶堆要怎么写?一文帮你解决所有同类算法题!

130 阅读9分钟

在 JavaScript 中,并没有内置的堆(heap)数据结构,所以,本文将介绍如何在 JavaScript 中从零开始实现一个堆,并且将使用测试驱动开发(TDD)的方式,借助 vitest 库进行测试。通过本文的学习,你将深入理解堆的原理,并了解堆排序算法的强大之处。最后,文章还将提供一些算法题目,帮助你更好地理解堆排算法。

你可以了解到的内容:

  1. JavaScript 中堆的概念和原理
  2. 使用测试驱动开发的方式编写堆的实现
  3. 通过 vitest 库进行堆的单元测试
  4. 探讨堆排序算法及其在实际问题中的应用
  5. 通过算法题目加深对堆排序的理解和应用

无论你是对 JavaScript 数据结构感兴趣的新手,还是想深入了解堆排序算法的算法爱好者,本文都将带给你全新的视角和知识。让我们一起深入探索 JavaScript 中堆的世界吧!

测试环境

快速搭建

在本教程中,我们将使用 vitest 库来搭建测试环境,以便更高效地进行堆的测试驱动开发。 首先,你可以按照以下步骤快速搭建测试环境:

mkdir myheap
cd myheap
npm install pnpm -g
pnpm init
pnpm add vitest -D

接着,在 package.json 文件中进行如下配置:

  "scripts": {
    "test": "vitest"
  },

接下来,编写堆的测试用例 MyHeap.test.js:

// MyHeap.test.js
import { expect, test } from 'vitest'
import { MyHeap } from './MyHeap'

test('创建MyHeap实例', () => {
  const myHeap = new MyHeap()
  expect(myHeap.size()).toBe(0)
})

然后,编写堆的源代码 MyHeap.js:

// MyHeap.js
export class MyHeap {
  constructor() {
    this.queue = []
  }
  size() {
    return this.queue.length
  }
}

最后,执行测试:

pnpm test

调试方法

在进行调试时,只需要设置断点,然后打开 vscode 的调试面板,输入pnpm test就可以进行调试。 Pasted image 20231231231810.png

完成大顶堆

增加添加删除接口

为了验证大顶堆的添加和删除功能,我们需要编写相应的测试用例,代码如下:

// MyHeap.test.js

test('向myHeap中添加数据', () => {
  const myHeap = new MyHeap()
  myHeap.push(1)
  myHeap.push(2)
  expect(myHeap.size()).toBe(2)
})

test('从myHeap中取出元素', () => {
  const myHeap = new MyHeap()
  myHeap.push(1)
  myHeap.push(2)
  myHeap.pop()
  expect(myHeap.size()).toBe(1)
})

接下来,我们将完善大顶堆的基本框架,包括添加和删除接口。代码如下:

export class MyHeap {
	...
  push(value) {
    this.queue.push(value)
    // 上浮
  }
  pop() {
    const result = this.queue[0]
    if (this.queue.length > 1) {
      this.queue[0] = this.queue.pop()
      // 下沉
    } else {
      this.queue.pop()
    }
    return result;
  }
}

实现大顶堆上浮

我们还需要编写测试用例来验证大顶堆的上浮操作,代码如下:

test('实现大顶堆上浮1', () => {
  const myHeap = new MyHeap()
  const test = [3, 1, 2, 5, 4, 8, 6, 7]
  for (const t of test) {
    myHeap.push(t)
  }
  expect(myHeap.queue).toEqual([8, 7, 6, 4, 3, 2, 5, 1])
})
test('实现大顶堆上浮2', () => {
  const myHeap = new MyHeap()
  const test = [1, 3]
  for (const t of test) {
    myHeap.push(t)
  }
  expect(myHeap.queue).toEqual([3, 1])
})
test('实现大顶堆上浮3', () => {
  const myHeap = new MyHeap()
  const test = [4, 1, 5, 6]
  for (const t of test) {
    myHeap.push(t)
  }
  expect(myHeap.queue).toEqual([6, 5, 4, 1])
})

代码实现:从尾部添加子元素,并不断与树上的父节点进行交换,直到小于父节点。这里为了获得父节点,编写了 getLeft 和 getParent 函数。在数组上获取节点的左子节点的公式是 2*index+1,倒推可得获取父节点的公式 Math. floor ((index-1)/2)。

// MyHeap.js
  push(value) {
    this.queue.push(value)
    // 上浮
    let cur = this.queue.length - 1;
    let par = this.getParent(cur)
    while (par != -1 && this.queue[cur] > this.queue[par]) {
      [this.queue[cur], this.queue[par], cur, par] = [this.queue[par], this.queue[cur], par, this.getParent(par)]
    }
  }
  getLeft(index) {
    // [0, 1, 2, 3, 4]
    // 1-> 3,4
    return index * 2 + 1;
  }
  getParent(index) {
    return Math.floor((index - 1) / 2)
  }

单元测试会自动执行: ![[Pasted image 20231231174634.png]]

实现大顶堆下沉

最后,我们需要编写测试用例来验证大顶堆的下沉操作,代码如下:

test('实现大顶堆下沉1', () => {
  const myHeap = new MyHeap()
  const test = [1, 3]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort((a, b) => b - a))
})
test('实现大顶堆下沉2', () => {
  const myHeap = new MyHeap()
  const test = [3, 1, 2, 5, 4, 8, 6, 7]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort((a, b) => b - a))
})

代码实现:取出第一个元素,然后把最后一个元素放在堆顶,并不断与最大的子元素比较和交换(下沉)。这里需要注意子元素可能为空的情况。

  pop() {
    const result = this.queue[0]
    if (this.queue.length > 1) {
      this.queue[0] = this.queue.pop()
      // 下沉
      let cur = 0
      let next = this.getNext(cur)
      while (this.queue[next] && this.queue[next] > this.queue[cur]) {
        [this.queue[cur], this.queue[next], cur, next] = [this.queue[next], this.queue[cur], next, this.getNext(next)]
      }
    } else {
      this.queue.pop()
    }
    return result;
  }
  getNext(index) {
    let left = this.getLeft(index)
    let right = this.getLeft(index) + 1
    let next = 0
    if (this.queue[left] && this.queue[right]) {
      next = this.queue[left] > this.queue[right] ? left : right
    } else {
      next = this.queue[left] ? left : right
    }
    return next;
  }

全部代码:

export class MyHeap {
  constructor() {
    this.queue = []
  }
  size() {
    return this.queue.length
  }
  push(value) {
    this.queue.push(value)
    // 上浮
    let cur = this.queue.length - 1;
    let par = this.getParent(cur)
    while (par != -1 && this.queue[cur] > this.queue[par]) {
      [this.queue[cur], this.queue[par], cur, par] = [this.queue[par], this.queue[cur], par, this.getParent(par)]
    }
  }
  getLeft(index) {
    // [0, 1, 2, 3, 4]
    // 1->3,4
    return index * 2 + 1;
  }
  getParent(index) {
    return Math.floor((index - 1) / 2)
  }
  pop() {
    const result = this.queue[0]
    if (this.queue.length > 1) {
      this.queue[0] = this.queue.pop()
      // 下沉
      let cur = 0
      let next = this.getNext(cur)
      while (this.queue[next] && this.queue[next] > this.queue[cur]) {
        [this.queue[cur], this.queue[next], cur, next] = [this.queue[next], this.queue[cur], next, this.getNext(next)]
      }
    } else {
      this.queue.pop()
    }
    return result;
  }
  getNext(index) {
    let left = this.getLeft(index)
    let right = this.getLeft(index) + 1
    if (this.queue[left] && this.queue[right]) {
      return this.queue[left] > this.queue[right] ? left : right
    } else {
      return this.queue[left] ? left : right
    }
  }
}

通过以上步骤,我们成功实现了大顶堆的添加和删除接口,并通过测试用例验证了其正确性。接下来,我们将继续优化和完善堆的功能,以及探讨堆排序算法的应用。

实现小顶堆

我们已经完成了大顶堆的实现,接下来只需稍作修改,就可以实现小顶堆,甚至斐波那契堆。通过传入构造函数的参数来决定是大顶堆还是小顶堆,该参数将被定义在 compareFn 属性上,是一个比较函数。该函数接收一个父节点和一个子节点,当返回值大于 0 时,进行交换。在大顶堆中,如果父元素小于子元素,需要交换,有 cur > par,cur - par > 0。

const maxHeap = new MyHeap((par, cur) => cur - par)
const minHeap = new MyHeap((par, cur) => par - cur)

改造构造函数

首先,我们需要改造构造函数,使其接受一个比较函数作为参数,并将其赋值给 compareFn 属性,compareFn的返回值如果大于零,那么就会交换父子节点。如果未传入比较函数,则默认使用一个大顶堆的比较函数:在后面,如果compareFn=cur-pre > 0 ,即 cur > pre 便发生交换(故,如不传参,默认大顶堆)。

  constructor(compareFn=(pre,cur)=>cur-pre) {
    this.queue = []
    this.compareFn=compareFn
  }

构建小顶堆,需要我们传递函数:(pre,cur)=>pre-cur。当pre>cur时,交换节点,保证父子节点中,越接近根元素的节点越小。

改造上浮过程

在 push 的上浮过程中,我们只需判断 compareFn 返回值大于 0,就执行交换操作。

  push(value) {
    this.queue.push(value)
    // 上浮
    let cur = this.queue.length - 1;
    let par = this.getParent(cur)
    while (par != -1 && this.compareFn(this.queue[par], this.queue[cur]) > 0) {
      [this.queue[cur], this.queue[par], cur, par] = [this.queue[par], this.queue[cur], par, this.getParent(par)]
    }
  }

改造下沉过程

对于 pop 方法,和 push 类似,只需要修改 while 的条件就可以了。

  pop() {
    const result = this.queue[0]
    if (this.queue.length > 1) {
      this.queue[0] = this.queue.pop()
      // 下沉
      let cur = 0
      let next = this.getNext(cur)
      while (this.queue[next] && this.compareFn(this.queue[cur], this.queue[next]) > 0) {
        [this.queue[cur], this.queue[next], cur, next] = [this.queue[next], this.queue[cur], next, this.getNext(next)]
      }
    } else {
      this.queue.pop()
    }
    return result;
  }

改造辅助函数

注意在 getNext 中也有比较,所以需要修改。在原来的代码中,大顶堆对应的判断条件是 this.queue[left]>this.queue[right],即 this.queue[left]-this.queue[right]>0,而大顶堆的函数是 (pre, cur) => cur - prethis.compareFn(this.queue[right], this.queue[left]) = this.queue[left]-this.queue[right],所以,条件为 this.compareFn(this.queue[right], this.queue[left]) > 0

  getNext(index) {
    let left = this.getLeft(index)
    let right = this.getLeft(index) + 1
    if (this.queue[left] && this.queue[right]) {
      return this.compareFn(this.queue[right], this.queue[left]) > 0 ? left : right;
      // return this.queue[left] > this.queue[right] ? left : right
    } else {
      return this.queue[left] ? left : right
    }
  }

单元测试用例

新增测试用例来验证小顶堆的功能和正确性。

test('实现大顶堆下沉结果3', () => {
  const compareFn = (a, b) => b - a;
  const myHeap = new MyHeap(compareFn)
  const test = [3, 1, 2, 5, 4, 8, 6, 7]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort(compareFn))
})
test('实现大顶堆下沉结果4', () => {
  const compareFn = (a, b) => b - a;
  const myHeap = new MyHeap(compareFn)
  const test = [3, 1]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort(compareFn))
})
test('实现小顶堆下沉结果1', () => {
  const compareFn = (a, b) => a - b;
  const myHeap = new MyHeap(compareFn)
  const test = [3, 1, 2, 5, 4, 8, 6, 7]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort(compareFn))
})
test('实现小顶堆下沉结果2', () => {
  const compareFn = (a, b) => a - b;
  const myHeap = new MyHeap(compareFn)
  const test = [3, 1]
  for (const t of test) {
    myHeap.push(t)
  }
  const result = []
  while (myHeap.size()) {
    result.push(myHeap.pop())
  }
  expect(result).toEqual(test.sort(compareFn))
})

通过以上修改和新增的测试用例,我们成功实现了小顶堆,并验证了其功能和正确性。接下来,我们将简单探讨堆排序算法的应用。

堆排实践

347 . 前 K 个高频元素

题目:

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按任意顺序返回答案。

示例 1:

输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
示例 2:

输入: nums = [1], k = 1
输出: [1]

题解: 我们只需要将每个数字的频次统计出来,如【1,出现 2 次】,然后将这些对添加到大顶堆中,再取出即可。

    // 统计出现频率
    const h = new MyHeap((a, b) => b[1] - a[1])
    const map = new Map()
    for (const n of nums) {
        map.set(n, (map.get(n) | 0) + 1)
    }
    // 添加进大顶堆
    const en = Array.from(map.entries())
    for (let item of en)h.push(item)
    const ans = []
    // 取出前k个元素并返回
    while(k--)ans.push(h.pop()[0])
    return ans;

1962 . 移除石子使总数最小

题目:

给你一个整数数组 piles ,数组下标从 0 开始,其中 piles[i] 表示第 i 堆石子中的石子数量。另给你一个整数 k ,请你执行下述操作恰好 k 次:
选出任一石子堆 piles[i] ,并从中移除 floor (piles[i] / 2) 颗石子。

注意:你可以对同一堆石子多次执行此操作。
返回执行 k 次操作后,剩下石子的最小总数。
floor (x) 为小于或等于 x 的最大整数。(即,对 x 向下取整)。

示例 1:
输入:piles = [5,4,9], k = 2
输出:12
解释:可能的执行情景如下:
- 对第 2 堆石子执行移除操作,石子分布情况变成 [5,4,5] 。
- 对第 0 堆石子执行移除操作,石子分布情况变成 [3,4,5] 。
剩下石子的总数为 12 。

示例 2:
输入:piles = [4,3,6,7], k = 3
输出:12
解释:可能的执行情景如下:
- 对第 2 堆石子执行移除操作,石子分布情况变成 [4,3,3,7] 。
- 对第 3 堆石子执行移除操作,石子分布情况变成 [4,3,3,4] 。
- 对第 0 堆石子执行移除操作,石子分布情况变成 [2,3,3,4] 。
剩下石子的总数为 12 。

题解: 这是一道贪心的题目。我们只需要每次取含石子数量最多的石子堆进行操作,就可以保证剩下石子数最少。问题是我们要怎么样保证每次都能拿到数量最多的石子堆?使用堆可以解决这个问题。 把所有石子堆都放到堆中,每次取出堆顶进行操作,操作后再放回堆中,就可以保证每次取出的都是最大值。操作完毕后,统计剩余石子的数量。

    // 放入石子
    const heap = new MyHeap((a, b) => b - a);
    for (let v of piles) {
        heap.push(v)
    }
    // 取出最大进行操作并放回
    while (k--) {
        let n = heap.pop()
        n -= Math.floor(n / 2)
        heap.push(n)
    }
    // 统计石子数
    return heap.queue.reduce((p, c) => p + c)

总结

创建堆非常简单,只要实现一个构造函数和pop、push方法即可,在书写过程中,还可以抽取其它函数当辅助函数,但不写也不影响。关键把上浮和下沉写出来,就接近完成了。