新年新气象——理解堆与堆排序(基于JavaScript实现)

842 阅读13分钟

前言

堆(Heap),这个名词大家并不陌生,因为我们在学JavaScript的引用类型的时候,大家都知道了引用类型保存在堆内存中,与其他语言的不同是,你不可以直接访问堆内存空间中的位置和操作堆内存空间,只能操作对象在栈内存中的引用地址。但是堆内部是怎么样运作的呢,本文就详细说说这个问题,了解堆的实现细节,本文采用JavaScript实现堆,因此可能有一定的语言特点。

1、堆的一些性质

1、堆通常是一个可以被看做一棵树的数组对象.

2、堆总是一棵完全二叉树

3、从堆的根节点到任意节点画路径,总能得到从小到大(最小堆)的顺序或者从大到小(最大堆)的顺序。

4、由于堆是一颗完全二叉树,对于一个索引为k的节点,那么其左右儿子的索引则分别为2k+1和2k+2(若存在)。

最大堆: 堆.png 最小堆 最小堆.png 以下情况不是堆: 不是堆(非完全二叉树).png 上图不满足完全二叉树,所以不是堆(值为19的节点缺失右儿子不是堆(不满足从小到大或者从大到小的顺序).png 另外,这个也不是堆,堆一定满足从根节点画任意路径到一节点,其节点值总满足从大到小或者从小到大的顺序。

2、堆的实现

由于我们采用JavaScript实现堆,因其数组是可以无限增长的,因此我们不需要为堆设置最大容量,我们的堆也不会满。但对于其它语言,需要如果想实现这个操作还需要实现一个动态扩容的数组才可以。

老规矩,我们还是先上代码,然后对代码的运作流程进行详解。

/*
 * 最大堆
 */
class MaxHeap {

    /**
     * 定义哨兵的最大值,所有插入堆的元素都必须比这个值小
     */
    #MAX_VAL = 1000

    /**
     * 定义一个存储数据的内存空间
     */
    #data = []

    /**
     * 当前堆的元素个数
     */
    #size = 0

    constructor(...nums) {
        // 设置哨兵
        this.#data[0] = this.#MAX_VAL
        // 初始化数组元素
        nums.forEach((v, i) => {
            this.#data[i + 1] = v
            this.#size++
        })
        this.buildHeap()
    }

    isEmpty() {
        return this.#size === 0
    }

    /**
     * 向堆中插入一个合法值
     * @param {number} val 
     */
    insert(val) {
        if (this.MAX_VAL <= val) {
            throw `can not insert val bigger than ${this.#MAX_VAL}`
        }
        // 堆的容量扩充1
        this.#size++
        // 让i指向当前新位置
        let i = this.#size
        // 因为有哨兵的关系,不需要添加约束条件 i > 0
        while (this.#data[Math.floor(i / 2)] < val) {
            this.#data[i] = this.#data[Math.floor(i / 2)]
            i = Math.floor(i / 2)
        }
        this.#data[i] = val
    }

    deleteMax() {
        if (this.isEmpty()) {
            console.warn("can not delete max from empty heap")
            return
        }
        // 取出堆顶的元素
        let maxVal = this.#data[1]
        // 取出堆最后一个元素 取出来之后则把堆的规模减小
        let temp = this.#data[this.#size--]
        let parent, child
        // 起始从根节点开始
        // 如果 parent * 2 已经越界 说明已经到了叶节点了
        // 迭代条件是把parent往下沉
        for (parent = 1; parent * 2 <= this.#size; parent = child) {
            // 假设左儿子比较大
            child = parent * 2
            // 不能越界 并且 右儿子大于左儿子
            if (child != this.#size && this.#data[child + 1] > this.#data[child]) {
                // 认定右儿子比较大
                child++
            }
            if (temp == this.#data[child]) {
                break
            } else {
                // 把左右儿子中教大的元素往上提
                this.#data[parent] = this.#data[child]
            }
        }
        // 在当前父节点上放入temp
        this.#data[parent] = temp
        // JavaScript语言需要进行这一步,让数组的规模缩小,释放空间
        this.#data.length--
        return maxVal
    }
    
    /* 下滤:将堆中以堆data[p]为根的子堆调整为最大堆 */
    percDown(p) { 
        let parent, child;
        let temp = this.#data[p]; /* 取出根结点存放的值 */
        for (parent = p; parent * 2 <= this.#size; parent = child) {
            child = parent * 2;
            if (child != this.#size && this.#data[child] < this.#data[child + 1]) {
                child++;  /* child指向左右子结点的较大者 */
            }
            /* 找到了合适位置 */
            if (temp >= this.#data[child]) {
                break;
            }
            else {
                /* 下滤X */
                this.#data[parent] = this.#data[child];
            }
        }
        this.#data[parent] = temp;
    }

    /*----------- 建造最大堆 -----------*/
    buildHeap() {
        /* 调整data中的元素,使满足最大堆的有序性  */
        /* 这里所有size个元素已经存在data[]中 */
        /* 从最后一个结点的父节点开始,到根结点1 */
        for (let i = Math.floor(this.#size / 2); i > 0; i--) {
            this.percDown(i);
        }
    }

}

2.1、向最大堆中插入一个值

1、哨兵

在本文的实现中,对于数组中的第一个元素(下标为0)并没有利用,而是将其作为一个哨兵位,拥有这个哨兵,我们可以在插入的时候减少一次判断从而提高程序的效率。另外,因为有哨兵的影响,我们的左右儿子的下标索引也有变化,分别为2k和2k+1(若有)

2、插入

首先,当我们想要向堆中插入一个元素(元素值小于我们预先设定的1000),堆的容量肯定先增加。

但是,因为堆中元素需要满足有序性,我们插入的值不一定就是这条路径中最小的(本例是最大堆),那么我们需要为我们的元素找到合适的位置。 根据前文提到的堆的性质,我们可以确定当前节点的父节点所在位置是i/2对于JavaScript语言来说,需要向下取整

整个过程如下图所示:

扩展堆的容量

插入过程1.png 沿着父节点一直比较,直到找到合适的位置,这一过程中i一直不断的往上提 插入过程2.png 最终找到了合适的位置 插入过程3.png 完成插入 插入过程4.png

假设上述过程中,我们要换做插入101,为什么我们没有增加循环退出的限制条件i>0呢,当我们的i走到1这个位置,此刻父节点是0,因为父节点比101大,所以此刻自动就实现了i>0的这个限制条件,但是在循环中因为我们少了一个判断条件,程序效率肯定会有一定的提升,这便是哨兵的好处。

对于了解过排序算法的同学,对于这个过程是否感觉有些似曾相识?没错,这个过程不就是直接插入排序嘛,哈哈哈。

3、删除最大值 在堆中我们删除我们不会随意的删,我们只会删除一个最值。但是删除了这个最值之后,我们要如何把我们剩下的元素从新调整成一个堆呢,接下来向大家分析这个过程。

首先,如果堆是空的话,那么,肯定不允许删除元素,函数直接return。

如果堆不空,那么,我们先把堆的第一个有效元素取出来,用以供函数的返回。因为堆的规模缩小了,因此我们需要将size减小,但是肯定不能直接减小,毕竟之前这个位置上有元素,因此我们还得用一个temp把这个元素记下来,以后再将其插入到合适的地方。

现在我们就从1这个位置出发,将剩余的元素调整成堆。同样,还是根据堆的有序性,我们需要从左右儿子中找一个比较大的值放在当前位置。如果说parent * 2 > this.#size越界的话,说明当前节点肯定就没有左右儿子了,那么就比较简单了,我们可以直接将temp放在这个位置上。 如下图: 删除节点(简单).png 此刻i=1,删除100后size为1,i*2为2,说明此时不用再继续循环,可以将temp放在parent所在的位置上了。

如果不是这种简单的情况,我们需要将左右儿子较大的值放在当前根节点的位置,首先我们需要知道左儿子(parent*2),并且假设左儿子就是当前比较大的那个,如果我们能够找到右儿子(child != this.#size,试想一下,假设现在左儿子就已经和size相等了,那么右儿子就越界了,自然就没有右儿子了),并且右儿子比左儿子大,那么则取右儿子(child++

如果我们之前取出来的temp值大于等于当前左右儿子值较大者,说明我们可以把temp放在当前根节点的位置了,如果不成立的话,那么把左右儿子较大值存放在当前父节点,然后将parent向下滤(parent=child)

当做完这个过程之后我们就可以知道temp存放的位置了,temp就是当前父节点所在的位置。

这一系列的过程,大概如下图所示:

删除过程1.png 先将31作为临时变量存放,移除堆中的一个节点,然后从根节点,准备开始确定temp存放的位置。 删除过程2.png 删除过程3.png 一直向下滤,为temp找合适的插入位置。 删除过程4.png 因为已经没有左右儿子了,所以当前父节点就是temp最终需要存放的位置 这个例子是以当前父节点没有左右儿子节点为退出条件的,读者也可尝试构造35后面还有子节点的情况进行思考,体会算法的执行流程。

其中有一行代码为JavaScript语言专属:this.#data.length--,如果不将数组的length缩小,那么这个脏数据不会被回收,从而产生浪费,而手动调整length,数组管理的规模将会缩小,这也算是JavaScript的一个奇技淫巧吧,但是对于Vue这类框架来说,这种操作无法触发更新,因此需要留意。而对于如C#、Java等语言,数组给定之后长度就不变了,不存在这个问题,反正堆管理的内容就这么大一块,便无关紧要。

3、构建堆

在我们掌握删除堆最值的操作之后,其实构建堆就变得容易了,我们刚才删除元素之后不就在从新构建堆吗?哈哈哈。

假设我们一个父节点是p,其左右子树已经形成左右子堆,那么,只需要把p节点的位置调整好,就可以将其调整为一个堆。

对于我们任意一个数组来说,我们该如何入手呢?答案是从最后一个节点的父节点开始,然后依次对这个父节点之前的节点依次应用构建堆的操作,当我们调整到第1个节点的时候,整个堆就构建完成了。

这便是上述代码的实现思路。

3、堆的应用场景之一———堆排序

本文只聊一聊堆应用场景之一的堆排序。 因为我们已经知道了怎么将一个数组构建成一个堆。

很自然而然的就会想到,先将数组构建成一个堆,然后一直对堆执行操作deleteMax(或者deleteMin)操作,但是因为在排序算法中,我们只能针对数组所管理的这快内存区域进行操作,显然,我们还需要用一个临时数组把删除最值的操作先存起来,然后再把这个临时数组的依次导回到原来的数组中去。

其代码大致如下:

/**
 * 将数组中 以 size 长度 以p位置为根节点调整为最大堆
 * @param {Array<number>} arr 
 * @param {number} p 
 * @param {number} size 
 */
function percDown(arr, p, size) {
    /* 需要注意的是 注意在堆排序里面没有哨兵 需要注意区别 */
    /* 将size个元素的数组中以arr[p]为根的子堆调整为最大堆 */
    let parent, child
    let temp = arr[p]
    for (parent = p; parent * 2 + 1 < size; parent = child) {
        child = parent * 2 + 1
        if (child != size - 1 && arr[child + 1] > arr[child]) {
            child++
        }
        if (temp >= arr[child]) {
            break
        } else {
            arr[parent] = arr[child]
        }
    }
    arr[parent] = temp
}

/**
 * 从堆中删除一个最大值
 * @param {Array<number>} heap 
 * @param {number} length 
 * @returns 
 */
function deleteMax(heap, length) {
    /* 从最大堆H中取出键值为最大的元素,并删除一个结点 */
    let parent, child;
    if (!Array.isArray(heap) || length === 0) {
        console.warn("最大堆已为空");
        return
    }
    // 因为在排序中用户可不知道有哨兵的存在,因此下标需要从0开始到length-1
    let size = length - 1
    let maxVal = heap[0];
    let temp = heap[size - 1];
    // 根节点从0开始
    for (parent = 0; parent * 2 + 1 <= size; parent = child) {
        child = parent * 2 + 1;
        if (child != length && heap[child] < heap[child + 1]) {
            child++;
        }
        if (temp >= heap[child]) {
            break;
        }
        else {
            heap[parent] = heap[child];
        }
    }
    heap[parent] = temp;
    return maxVal
}

/**
 * 将数组调整成最大堆
 * @param {Array<number>} arr 
 */
function buildHeap(arr) {
    /* 建立最大堆 */
    for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) {
        percDown(arr, i, arr.length);
    }
}

/**
 * 将数组进行堆排序
 * @param {Array<number>} arr 
 * @returns 
 */
function heapSort(arr) {
    if (!Array.isArray(arr)) {
        return
    }
    buildHeap(arr)
    let tempArr = []
    for (let i = 0; i < arr.length; i++) {
        tempArr.push(deleteMax(arr, arr.length - i))
    }

    for (let i = 0; i < tempArr.length; i++) {
        arr[i] = tempArr[i]
    }
}

上述代码中,需要尤其注意的一个点是因为我们第二节中的堆中存在哨兵元素,但是在排序算法中用户可不知道有哨兵,因此只能从0的索引开始排序。当根节点从0开始,其中左儿子的索引则是2*parent+1,这一点是需要注意的。

另外,还有一个问题是,当我们在将用户传入的数组构建成堆的过程中,我们是不能改变数组的长度的,因此,此时我们只能手动传入length去控制堆所能管理的内存范围

但是,冷静下来分析,上述算法有个明显的问题,就是有一个额外的临时数组,相当于本来你能排的最大数据是1G,因这个临时数组的关系(额外空间复杂度O(N)),你现在只能排一半了,这肯定不是一个好的解决方案。

我们在脑袋里面想象一下其它排序方案,比如选择排序

export function selectionSort(arr) {
    if (!Array.isArray(arr)) {
        return
    }
    let temp = null
    for (let i = 0; i < arr.length; i++) {
        // 认定从0-i片段为有序片段,i+1-length-1的片段为无序片段
        for (let j = i + 1; j < arr.length; j++) {
            // 从无序片段中找出一个最值放在当前位置,继续处理无序片段,直到完成
            if (arr[i] > arr[j]) {
                temp = arr[j]
                arr[j] = arr[i]
                arr[i] = temp
            }
        }
    }
}

ma sa ka !!! 马萨卡.webp 好像我们也可以像选择排序那样呀,只不过要换一下方向,因为堆每次都是从头到尾的,所以我们排序的过程是从尾到头,每次我们取出堆中最值放到堆尾巴元素的下一个位置上去,然后再把剩余元素构建成一个新的最大堆,依次类推,直到我们的堆没有元素可以构建,那不就是完成了嘛,哈哈哈,nice!。

其流程大致如下图所示: 第一次排序.png 第二次排序.png 然后这样一直做,直到最后堆不再管理元素,则排序完成。 排序完成.png

其代码实现如下所示:

/**
 * 将数组中 以 size 长度 以p位置为根节点调整为最大堆
 * @param {Array<number>} arr 
 * @param {number} p 
 * @param {number} size 
 */
function percDown(arr, p, size) {
    /* 需要注意的是 注意在堆排序里面没有哨兵 需要注意区别 */
    /* 将size个元素的数组中以arr[p]为根的子堆调整为最大堆 */
    let parent, child
    let temp = arr[p]
    for (parent = p; parent * 2 + 1 < size; parent = child) {
        child = parent * 2 + 1
        if (child != size - 1 && arr[child + 1] > arr[child]) {
            child++
        }
        if (temp >= arr[child]) {
            break
        } else {
            arr[parent] = arr[child]
        }
    }
    arr[parent] = temp
}

/* 堆排序 */
function heapSort(arr) {
    if (!Array.isArray(arr)) {
        return
    }
    /* 建立最大堆 */
    for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) {
        percDown(arr, i, arr.length);
    }
    for (let i = arr.length - 1; i > 0; i--) {
        /* 删除最大堆顶 */
        let temp = arr[0]
        arr[0] = arr[i]
        arr[i] = temp
        percDown(arr, 0, i);
    }
}

时间和空间复杂度分析:

定理: 堆排序处理N个不同元素的随机排列的平均比较次数是2NLogN-O(NLog(LogN))

所以堆排序的复杂度可以写成O(NLogN),而且比这个复杂度要把O(NLogN)略好一些。

4、总结

本文内容来源于中国大学慕课浙江大学开授的《数据结构》树(下)和排序(上),其算法运行过程图经过笔者的思考绘制,并转化为JavaScript的实现的。

本文中读者可以看到哨兵在实际开发中的意义。 在堆和堆排序中需要注意有无哨兵元素的区别,有无哨兵将直接导致左右儿子的索引求法不同。

另外在堆排序过程中尤其需要注意的是堆所管理的长度并不是数组的长度,而需要我们手动控制。

堆除了用于排序以外还有一些用途,如:

利用堆求TOP K;利用堆求中位数。(我的感受就是,当你想保证有序,但是又不想排序(因为无论哪个排序时间复杂度都不可能好过O(N*logN))的场景,而我们可以在线性的时间复杂度内将数组调整成一个堆,所以这时使用堆是一个比较好的选择)

本文不讨论这些应用,有兴趣的读者请自行查阅资料。

在本文中读者可以看到数据结构的知识是相辅相成(插入排序选择排序)的,如果把已有的知识点掌握,再接纳新的知识点的时候也会变得相对容易了。对于数据结构这门课来说,比较抽象,可能不太容易理解,一定要动脑又动手,一定会有提高的。千里之行,始于足下,2022年已经开始了,笔者在此给大家拜个晚年,祝各位读者在2022年取得更加优异的成绩。

本文画图工具采用的是excalidraw.com/ 有兴趣的读者可以mark一下。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。