废话不多说,先上图。如图,是一个堆的基本结构,也是一棵完全二叉树。
和普通的二叉树相比,堆满足一个性质。
大顶堆任意节点的子节点不大于它本身, 因此第一层,堆元素就是整个堆的最大值, 也称最大堆。
小顶堆任意节点的子节点不小于它本身, 因此第一层,堆元素就是整个堆的最小值, 也称最小堆
作为一个数据结构, 构建出来之后,还要维护它,保持它的性质。
堆又称优先队列, 因为实际上在操作一个堆的时候,我们是按队列来操作的, 而维护它的时候,把他当做一棵树。而,完全二叉树可以根据索引找到对应的父子节点, 因此一个数组就可以建堆。
下面按最小堆,来说明堆的出入操作以及维护。
出堆 和出队操作一样,剔除堆顶元素shift。不同的是,后续的维护, 要让堆尾元素补到堆顶的位置,然后向下和子节点比较,和最小的节点进行交换。交换后,继续与新的子节点进行比较交换,直到子节点都不小于其本身,或者抵达最后一层。
入堆 和入队操作一样, 从堆尾入堆push。然后,和其父节点相比较,如果父节点大于它,就交换。然后,继续与新的父节点进行比较交换,直到父节点都不大于其本身,或者抵达堆顶。
下面这种实现,是简单的维护了堆的性质,限制了堆的大小,但不保证是最小或最大的那一部分。
基础版堆
class Heap {
/* 这里当然是数组建堆, 作为完全二叉树 可以利用索引找到每个节点的父节点和子节点
索引为 n 它的父节点的索引就是(n-1)>>1 子节点的索引就是 2n+1 2n +2
compare 函数决定这是个最大堆 还是最小堆
*/
constructor(arr, compare = (a, b) => a - b > 0, size = 63) {
// 初始数据建堆 是从顶部开始的, 虽然这是个金字塔 ,但是地基我们最后打
this.data = [...arr]; // 深拷贝一下
this.maxLen = size;
this.compare = compare
for (let i = 1; i < this.size; i++) {
this.bubleUp(i)
}
// 这里可以用数学归纳法来验证, 向一个已经建好的堆添加新的元素 ,肯定是放在最下面,然后逐个,冒泡调整上去,这样维持了堆的性质,它仍然是一个堆,
// 这里是先建堆的第一层 只有一个元素 ,肯定是个堆, 然后第二层依次与第一个元素冒泡,这样一个三元最小堆就完成, 下面的就一样了
if (this.size > size) {
/* 这种写法是直接截取金字塔的最上面几层, 没有排序达不到求最小最大k个数 */
this.data.length = size
}
}
get size() { return this.data.length }
bubleUp(i) {
if (i == 0) return
// let pInd = (i- 1) >>1
// if (this.compare(this.data[pInd], this.data[i]) ){
// this.swap(pInd, i)
// }
// this.bubleUp(pInd);
// 迭代
while (i) {
let pInd = (i - 1) >> 1
if (this.compare(this.data[pInd], this.data[i])) {
this.swap(pInd, i)
i = pInd
} else {
break
}
}
}
bubleDown(i) {
const [l, r] = [2 * i + 1, 2 * i + 2]
if (l >= this.size) {
return
}
// 这里就是要保证i这个位置的值是三者最小 不过 右孩子不一定存在 , 先记录索引比较完成之后只交换一次即可
/* let min = i
if (this.compare(this.data[i] , this.data[l])){
min = l
}
if (r < this.size && this.compare(this.data[i] , this.data[r])){
min = r
}
if (min !== i){
this.swap(min,i)
this.bubleDown(min)
} */
//迭代
let cur = i
while (2 * cur + 1 < this.size) {
const [l, r] = [2 * cur + 1, 2 * cur + 2]
let min = cur
if (l<this.size && this.compare(this.data[i], this.data[l])) {
min = l
}
if (r < this.size && this.compare(this.data[i], this.data[r])) {
min = r
}
if (min !== i) {
this.swap(min, i)
cur = min;
} else {
break
}
}
}
swap(a, b) {
[this.data[a], this.data[b]] = [this.data[b], this.data[a]]
}
// 堆又称又称优先队列 就是因为 堆都是从底部进去 从顶部出去 就像 做蛋糕的挤奶油那样
// 这里限制一下堆的大小就和队列差不多了
push(el) {
this.data.push(el)
this.bubleUp(this.size - 1)
/* 先进来再出队 这样就不用外部判断 */
if (this.size > this.maxLen) {
this.pop()
}
}
// 堆排序就是通过不断pop出堆的最值堆顶元素来实现的 ,pop完了之后 维持住堆的性质
// pop了 之后 顶部空缺 尾部来补 这样数据的变动最小
pop() {
if (this.size === 0) return
const ret = this.data.shift()
this.data.unshift(this.data.pop())
this.bubleDown(0)
return ret
}
get top() { return this.data[0] } // 堆顶元素就是0
}
function getRandArr(n) {
const arr = []
for (let i = 0; i < n; i++) {
arr.push(Math.random() * 90 + 10 | 0);
}
return arr
}
复杂度分析
尝试分析一波复杂度。
先假设,我们的堆不限空间,两种建堆方式都需要遍历整个数组,并且遍历的过程中,对其进行向下向上的冒泡维护操作。这个冒泡操作,最多也就是log2(n)-1),所以维护操作的时间复杂度是O(logn)的再乘上遍历。建堆的时间复杂度就是O(n* logN). 后续的每次维护操作是(logn)。
因此如果用堆排序, 先建堆然后不断的出堆,那么时间复杂度就是(n* logN) + (n* logN),也就是 O((n* logN)).
适用场景
求最值。 用堆求最值不需要全排序, 尤其是后续数据变动,维护起来方便。
刷题版堆
而这种实现就保证了,一定是一堆数据中最小或最大的那一部分。
class Heap2 {
/* 默认最小堆 */
constructor(data = [], k = 0, compare = (a, b) => a > b) {
this.data = [];
this.limit = k;
this.compare = compare
for (let i = 0; i < data.length; i++) {
this.push(data[i])
}
}
/* 建堆从最高层开始 ,从堆尾入堆, */
bubleUp(index) {
/* 父节点的索引为 n/2 -1 */
let pInd = (index - 1) / 2 | 0;
while (index) {
if (this.comp(pInd, index)) {
this.swap(pInd, index)
index = pInd;
pInd = (index - 1) / 2 | 0
} else {
break
}
}
}
bubleDown(index) {
/* 先比较然后直接与最小的进行交换*/
let left = index * 2 + 1, right = index * 2 + 2, min = index;
while (left < this.data.length || right < this.data.length) {
if (left < this.data.length && this.comp(min, left)) {
min = left
}
if (right < this.data.length && this.comp(min, right)) {
min = right
}
if (min !== index) {
this.swap(min, index)
index = min;
left = index * 2 + 1;
right = index * 2 + 2;
min = index;
} else {
break
}
}
}
comp(a, b) {
return this.compare(this.data[a], this.data[b])
}
swap(a, b) {
let t = this.data[a];
this.data[a] = this.data[b]
this.data[b] = t
}
push(v) {
this.data.push(v);
this.bubleUp(this.data.length - 1)
if (this.data.length > this.limit) {
this.pop()
}
}
pop() {
/* 优先队列 队首出队 队尾补到队首 */
let last = this.data.pop()
const res = this.data[0];
this.data[0] = last
this.bubleDown(0)
return res
}
get top() { return this.data[0] }
}
打印完全二叉树
顺便附上,打印完全二叉树的方法,便于观察。下面是两位数为基准的,所以空格也是两个空格。如果数据的位数,不固定无法保证打印结果的直观性。
// 输出二叉树 默认数组是层序遍历完全二叉树
function logBinaryTree(arr = []) {
let len = arr.length;
// 计算深度从0开始 高度为depth+ 1
let depth = 0, yushu = len
while (yushu >= 1 << depth) {
yushu = yushu - (1 << depth);
depth++
}
let maxLen = 1 << depth;
console.log('层数', depth);
for (let index = 0; index <= depth; index++) {
const l = (1 << index) - 1, r = (1 << index) + l;
// 补位是也是翻倍 2^n -1 间距是翻倍的
let diff = depth - index + 1, gap = (1 << diff) - 1, pre = (1 << (diff - 1)) - 1
// console.log('间距', gap, '补位', pre, l, r);
console.log()
let temp = arr.slice(l, r)
if (index) {
console.log(' '.repeat(pre) + temp.join(' '.repeat(gap)))
} else {
console.log(' '.repeat(pre) + temp.join(''))
}
}
}