在 JavaScript 中,并没有内置的堆(heap)数据结构,所以,本文将介绍如何在 JavaScript 中从零开始实现一个堆,并且将使用测试驱动开发(TDD)的方式,借助 vitest 库进行测试。通过本文的学习,你将深入理解堆的原理,并了解堆排序算法的强大之处。最后,文章还将提供一些算法题目,帮助你更好地理解堆排算法。
你可以了解到的内容:
- JavaScript 中堆的概念和原理
- 使用测试驱动开发的方式编写堆的实现
- 通过 vitest 库进行堆的单元测试
- 探讨堆排序算法及其在实际问题中的应用
- 通过算法题目加深对堆排序的理解和应用
无论你是对 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就可以进行调试。
完成大顶堆
增加添加删除接口
为了验证大顶堆的添加和删除功能,我们需要编写相应的测试用例,代码如下:
// 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 - pre,this.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方法即可,在书写过程中,还可以抽取其它函数当辅助函数,但不写也不影响。关键把上浮和下沉写出来,就接近完成了。