对堆、堆排序的认识

222 阅读6分钟

堆的基本概念

堆是什么

堆的结构其实很简单,简而言之堆就是一棵满足一定条件的完全二叉树。

完全二叉树

完全二叉树很好理解,它可以是一个从上到下都排列满的满二叉树,也可以是一棵满二叉树的最下层叶子节点只缺少右边的叶子节点,且最下层的叶子结点集中在树的左部。

image.png

那堆又是一棵具有什么规律的完全二叉树呢? 我们可以通过它两种不同的规律将堆分为大顶堆(大根堆)或小顶堆(小根堆)。

大顶堆

大顶堆,顾名思义就是堆顶是最大的堆,所以要一棵完全二叉树是一个大顶堆需要满足的规律是:对每一个节点而言,该节点本身的值要大于其两个子节点的值。

image.png

如上图,左图是一个标准的大顶堆,因为每一个节点的值要大于其两个子节点的值;而右图中则不是一个大顶堆也不是一个堆,因为它并不满足每一个节点的值要大于其两个子节点的值,需要将3和5交换一下方可满足。

小顶堆

与大顶堆相反,小顶堆要满足的规律是:对每一个节点而言,该节点本身的值要小于其两个子节点的值。

image.png

同样的左图既是一棵二叉树,又满足每一个节点的值要大于其两个子节点的值,所以它是一个小顶堆;右图则不满足,需要将4和1交换一下位置。

堆的存储

一般情况下,我们通常会用链表来存储树形结构的数据,但堆是采用顺序表来存储的,通常我们会利用一个数组来表示一个堆,所以堆一个逻辑上的树形结构。

数组表示的二叉树其实就是二叉树层序遍历的结果,例如:

image.png

为什么要用数组?我们知道顺序表相较于链表就是可以直接用下标来访问数据,且我们约定了堆是一个完全二叉树,所以可以确保每一个下标都有值,这样我们就可以通过计算来得出每个节点的位置,例如:我有一个长度为n的堆,我现在出于第i个位置,我们就可以通过完全二叉树的特点来获得一下信息

  • 当前节点的左孩子位置:i * 2 + 1
  • 当前节点的右孩子位置:i * 2 + 2
  • 当前节点的父节点位置:(i - 1) / 2 向下取整(Math.floor())
  • 最后一个节点的父节点位置:(n - 1) / 2 向下取整(Math.floor())

如果使用链表来存储的话我们通常要存储更多的信息才能获取到某些特定位置(如需获取父节点,则需要多增加一个父节点的指针)。

堆的实现

知道了什么是堆后,就让我们用代码来实现一下堆吧(本文用的是javascript进行实现,并用小顶堆举例,大顶堆类似)。在实现堆之前我们要先了解一个很重要的概念——调整堆。

堆的调整

什么是调整堆呢,就是将一个不符合堆性质的堆节点调整位置,例如

image.png

图中一个值为12的节点肯定是不符合堆的性质的,我们就需要将其与最小的子节点交换,交换后还是不符合,那就再进行一次最小子节点的交换,这样我们就把12这个节点调整到了其应有的位置。我们会有以下一个过程,这样对一个节点进行位置调整的过程就是一次堆的调整。

image.png

虽然经过这次调整后的树还是不符合堆的性质,但是如果我们再进行一次堆调整,它就是一个正确的堆了。

image.png

根据这个过程我们就可以得到以下代码过程

function heapify(arr,n,i){
    let cur = i; // 节点游标
    let left = i * 2 + 1; // 左孩子位置
    let right = i * 2 + 2; // 右孩子位置
    // 如果存在左孩子并且左孩子的值要小于当前节点
    if(left < n && arr[left] < arr[cur]){
        cur = left; // 游标指向左孩子位置
    }
    // 右孩子同理
    if(right < n && arr[right] < arr[cur]){
        cur = right
    }
    // 如果cur不是指向原来的位置,则代表需要进行调整
    if(cur !== i){
        [arr[i],arr[cur]] = [arr[cur],arr[i]]; // 将孩子节点中的最小节点与当前节点交换
        heapify(arr,n,cur); // 递归进行,直到调整到正确位置
    }
}

image.png

创建堆

知道了堆的调整后,创建堆就比较简单了,无非就是将一棵无规则的完全二叉树调整为一个堆,那么问题来了,我们要怎样的进行调整才能得到堆呢?

其实我们只要理解了堆的规律就很好想了,以小顶堆为例,堆的每个节点的值都要小于其孩子节点,那么就可以想到我们只需要将值小的节点尽量往上移动就好了,因此将一个无序的完全二叉树变成堆的方式就是从最后一个节点开始进行堆调整,这样我们就把每一层值小的节点调整到了树的上方,又因为单个节点根本无序调整,所以我们只需要从最后一个节点的父节点开始调整即可。

/**
 * 创建堆
 * @param {number[]} arr 需要转换成堆的数组
 * @returns {number[]} 转换完成的堆
 */
function createHeap(arr){
    // 从最后一个节点的父节点开始往上逐渐往上调整
    for(let i = Math.floor((arr.length-1)/2);i>=0;i--){
        heapify(arr,arr.length,i);
    }
    return arr
}

例如下图是我们利用以上函数将一个大顶堆调整到小顶堆的过程,

image.png

堆排序

根据堆的性质,我们就可以利用它来做一些有意义的事情了,我们这里的堆排序就是一个很好的应用。 以小顶堆为例,我们知道一个小顶堆的根节点一定是这棵树的最小值,那么我们利用这个特点就可以进行堆排序了,过程就是我们每次只取这棵树的根节点,将最后一个节点与根节点交换并断开最后一个节点与堆的联系,再对根节点进行一次堆调整,那么它有是一个小顶堆,就可以进行重复操作了,直到全部完成。

image.png

/**
 * 堆排序
 * @param {number[]} arr 需要排序的数组
 * @returns {number[]} 排序好的数组
 */
function heapSort(arr){
    // 将无序数组转换为堆
    arr = createHeap(arr);
    // 遍历堆的每一个节点
    for(let i=arr.length-1;i>=0;i--){
        // 交换根节点,与最后一个节点的位置
        [arr[i],arr[0]] = [arr[0],arr[i]];
        // 再对根节点进行调整,并让调整的范围缩小一
        heapify(arr,i,0);
    }
    return arr;
}

堆排序的时间复杂度为 O(n log n) ,并且堆排序是在原数组上进行的所以空间复杂度为O(1),它在某些场合下是非常有用的排序算法。