经典排序算法之堆排序

本文介绍了堆排序的基本思想、调整方法、将无序数组调整为最小堆的过程,以及完整堆排序的步骤。堆排序的时间复杂度分析显示,其排序效率在大数据量时优于冒泡和直接插入排序,适用于Topk问题。
摘要由CSDN通过智能技术生成

一、前言

  各种排序算法的掌握对于一个程序猿来说还是有必要掌握的, 所以自己打算对各种排序算法都分析一遍,首先从堆排序开刀。

二、基本思想

  堆的定义如下:n个元素的序列{ k 1 k_1 k1, k 2 k_2 k2, … \ldots , k n k_n kn}, 当且仅当满足下列关系时,称之为堆:
{ k i ≤ k 2 i , k i ≤ k 2 i + 1 , \begin{cases} k_i \leq k_{2i}, \\ k_i \leq k_{2i+1}, \\ \end{cases} {kik2i,kik2i+1,
  或
{ k i ≥ k 2 i , k i ≥ k 2 i + 1 , \begin{cases} k_i \geq k_{2i}, \\ k_i \geq k_{2i+1}, \\ \end{cases} {kik2i,kik2i+1,
  其中,第一种被称做小顶堆,第二种被称做大顶堆。他们的区别就是最顶上的数是最大值还是最小值。值得注意的是,堆只规定了父节点比子结点大或者小,并没有规定左子结点必须比右字节的大或者小。
  那么堆排序是什么呢?如果我们在输出堆顶的最大/最小值之后,使得剩余的n-1个元素的序列又重键成一个堆,则得到n个元素中的次大/小值。如此反复执行,便可以得到一个有序序列,这个过程被称为堆排序。那么问题就简单了,我们现在需要解决两个问题就可以实现堆排序:

  • 把一个无序数组建成堆
  • 输出堆顶元素后将剩余元素调整为一个新的堆

  其实,这两个问题又可以看成一个问题,为什么呢?首先我们先思考第二个方法如何实现,在这里我们以最小堆为例:我们输出堆顶值之后,堆顶值的左右均为最小堆,我们只需要找出左右结点中比较小的那一个去和堆顶值比较,此时有两种情况:

  • 结点中比较小的值仍然比堆顶元素大,即堆顶元素是最小值,此时不需要调整
  • 结点中比较小的值比堆顶元素大(假设是左结点),需要将其与堆顶元素交换位置,同时左子树的堆受到影响,使用递归继续调整,右子树不受影响,无需调整。

  可以看出,我们使用这个方法的基本条件就是:当前结点的左右均为已经调整好的堆,这样我们就能使用递归去将其重新调整为一个新的堆。那么如果只有三个结点,父结点,两个子结点,两个子结点一定是两个堆,因为它根本没有子结点。分析到这里,我们可以得出一个结论:我们可以利用第二个方法,去找无序数组中第一个非叶结点,然后从该结点往根结点,依次调用adjust方法,将无序数组调整为堆。如果不理解这句话,那么从图来看比较直观:
在这里插入图片描述
  1方框的父结点是第一个非叶结点,我们从它开始,对每个红色方框内的结点调用adjust方法,当所有红色方框都调用过这个方法后,所有红色方框都会变成堆。此时,5,6方框就符合当前左右结点均为已经调整好的堆这条规则了,可以继续使用adjust方法,同理,调整好绿色方框后,我们可以继续往上找,直到根结点,这样,就将无序数组调整为了堆。这样,只要我们解决了第二个问题,第一个问题也就迎刃而解了。

三、adjust方法的实现

  我们的函数接受三个参数,第一个就是当前的数组,第二个是开始结点下标和结束结点下标,这里我们数组的第一个元素下标是0,所以我们可以得到,当前结点的左结点的下标是 2 n + 1 2n+1 2n+1,而右结点的下标是 2 n + 2 2n+2 2n+2。得到结点下标后,我们需要判断一下左结点的下标是否已经超出结束结点下标,如果超过意味着这个结点是叶结点,不需要继续调整了。如果没有超过,我们拿到左右结点的值,这里有一个注意的地方是此时还要考虑右结点是否存在,如果不存在我们给他一个正无穷的值。最后就是找出最小结点来和父结点进行比较并递归调整左/右子树。具体代码如下:

    function adjustHead(arr, beg, end) {
        const left = beg*2+1, right = beg*2+2;
        if(left > end) {
            return ;
        }
        const leftVal = arr[left], rightVal = right <= end ? arr[right] : Infinity, nowVal = arr[beg];
        if(leftVal < nowVal && leftVal < rightVal) {
            [arr[left], arr[beg]] = [arr[beg], arr[left]];
            adjustHead(arr, left, end);
        }else if(rightVal < nowVal && rightVal < leftVal) {
            [arr[right], arr[beg]] = [arr[beg], arr[right]];
            adjustHead(arr, right, end);
        }
    }

四、将无序数组调整为最小堆

  根据我们之前的分析,这个问题应该很好解决。此时我们只需要解决一个问题,如何找到第一个非叶结点。我们可以知道,这个结点肯定是倒数第二层的最后一个结点,此时从该结点到根结点一共有 2 k − 1 − 1 2^{k-1}-1 2k11个结点,而最后一层有 2 k − 1 2^{k-1} 2k1个结点,所以其实我们要找的那个结点的坐标就是 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor n/2,那么调整的代码就很简单了:

	let length = arr.length;
    for(let i = parseInt(length/2); i>=0; i--) {
        adjustHead(arr, i, length-1);
    }

五、完整堆排序

  我们最后每次只需要取堆顶元素放到末尾,再对剩下的元素进行调整,就完成了我们的堆排序

function headSort(arr) {
    let length = arr.length;
    for(let i = parseInt(length/2); i>=0; i--) {
        adjustHead(arr, i, length-1);
    }
    for(let i = 0; i < length - 1; i++) {
        [arr[0], arr[length - i -1]] = [arr[length - i - 1], arr[0]];
        adjustHead(arr, 0, length - i - 2);
    }
    function adjustHead(arr, beg, end) {
        const left = beg*2+1, right = beg*2+2;
        if(left > end) {
            return ;
        }
        const leftVal = arr[left], rightVal = right <= end ? arr[right] : Infinity, nowVal = arr[beg];
        if(leftVal < nowVal && leftVal < rightVal) {
            [arr[left], arr[beg]] = [arr[beg], arr[left]];
            adjustHead(arr, left, end);
        }else if(rightVal < nowVal && rightVal < leftVal) {
            [arr[right], arr[beg]] = [arr[beg], arr[right]];
            adjustHead(arr, right, end);
        }
    }
}

let arr = [2, 3, 5, 3, 7, 8, 23, 11, 15]
headSort(arr);
console.log(arr);
//[ 23, 15, 11, 8, 7, 5, 3, 3, 2 ]

六、时间复杂度分析

  如果你仔细了阅读了本篇文章,就能感觉到我们最核心的算法就是adjusetHeap调整函数,我们先来分析一下这个函数的时间复杂度。如果从第i层开始调整,那么最坏的情况就是从该层需要一直调整到最末层,即需要做h-i次调整操作,其中还包含比较操作,但是是常数级别,可以忽略,所以时间复杂度为 O ( h − i ) O(h-i) O(hi),其中h为整颗树的高度。那么下面我们可以分成几个部分来分析整个算法的时间复杂度

  • 建堆时候的时间复杂度:
    我们从倒数第二层开始,对最后一个非叶结点到根结点的每个结点都调用了adjustHeap函数,假设一共有 h h h层,第 h − 1 h-1 h1层有 2 h − 1 − 1 2^{h-1-1} 2h11个结点,所以该层的结点总共的操作次数是 2 h − 1 − 1 ∗ ( h − ( h − 1 ) ) 2^{h-1-1}*(h - (h - 1)) 2h11(h(h1)) h − 2 h-2 h2层的操作次数是 2 h − 2 − 1 ∗ ( h − ( h − 2 ) ) 2^{h-2-1}*(h - (h - 2)) 2h21(h(h2)),第h-1层到第1层总共的操作次数是
    S = 2 h − 1 − 1 ∗ ( h − ( h − 1 ) ) + 2 h − 2 − 1 ∗ ( h − ( h − 2 ) ) + … + 2 1 − 1 ∗ ( h − 1 ) S = 2^{h-1-1}*(h - (h - 1)) + 2^{h-2-1}*(h - (h - 2)) + \ldots + 2^{1-1}*(h-1) S=2h11(h(h1))+2h21(h(h2))++211(h1)
    化简一下为:
    S = 2 h − 2 ∗ ( 1 ) + 2 h − 3 ∗ ( 2 ) + … + 2 1 ∗ ( h − 2 ) + 2 0 ∗ ( h − 1 ) S = 2^{h-2}*(1) + 2^{h-3}*(2) + \ldots + 2^{1}*(h-2) +2^{0}*(h-1) S=2h2(1)+2h3(2)++21(h2)+20(h1)
    这里我们需要用一点数学的小技巧,将该等式整体乘 2 2 2,得到下面的式子:
    2 S = 2 h − 1 ∗ ( 1 ) + 2 h − 2 ∗ ( 2 ) + … + 2 1 ∗ ( h − 1 ) 2S = 2^{h-1}*(1) + 2^{h-2}*(2) + \ldots + 2^{1}*(h-1) 2S=2h1(1)+2h2(2)++21(h1)
    再用式2减去式1,得到
    S = 2 h − 1 + 2 h − 2 + 2 h − 3 + … + 2 − ( h − 1 ) S = 2^{h-1} + 2^{h-2} + 2^{h-3} + \ldots + 2 - (h-1) S=2h1+2h2+2h3++2(h1)
    这是一个我们非常熟悉的等比数列求和,套用公式得出最后的结果是: 2 h − h − 1 2^h-h-1 2hh1。同时,h是树的高度,所以有 h = ⌊ l o g 2 n ⌋ + 1 h = \lfloor log_2n \rfloor + 1 h=log2n+1,我们将其近似为 l o g 2 n log_2n log2n,所以代入式子后得到:
    n − l o g 2 n − 1 n - log_2n-1 nlog2n1
    所以建堆的时间复杂度为 O ( n ) O(n) O(n)
  • 调整时的时间复杂度
    调整时我们值调用了adjustHeap函数,假设此时树的高度为h,那么时间复杂度就是 O ( l o g 2 h ) O(log_2h) O(log2h)
  • 排序时的时间复杂度
    我们排序做的操作是,将堆顶元素替换掉,然后对其进行调整,由上面的分析可知,我们一共需要进行 n n n次操作,为了方便计算,我们假设树的高度不变,为 l o g 2 n log_2n log2n,所以时间复杂度就为 n l o g 2 n nlog_2n nlog2n

  所以堆排序的时间复杂度为 n + n l o g 2 n n+nlog_2n n+nlog2n,即 n l o g 2 n nlog_2n nlog2n

七、结语

  通过分析我们可以看出,堆排序中利用了递归去实现,所以对于数据量比较小的排序,我们可以采取其他方法,比如冒泡,直接插入排序等,性能会比堆排序好。同时因为大多数排序的最坏情况时间复杂度为 n 2 n^2 n2,当数据量比较大时, n 2 n^2 n2 n l o g 2 n nlog_2n nlog2n差异较大,所以堆排序在大数据排序上的表现应该要比小数据量排序好。最后,对于Topk问题,我们也可以采用建立一个最小堆的方法,这样不用将全部数据读入内存,只需要维护这个堆,最后留在堆中的即为前k大的数据。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值