欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

探讨堆排序及其对应的大顶堆和小顶堆

最编程 2024-08-14 21:01:08
...


目录

1、前言

2、使用堆的原因

3、堆的特点

4、堆和普通树的区别

5、堆排序的过程

6、堆排序的代码实现




1、前言


堆是一种非线性结构,​可以​把堆看作一个数组,​也可以​被看作一个完全二叉树,通俗来讲堆其实就是​利用完全二叉树的结构来维护的一维数组​但堆并不一定是完全二叉树


按照堆的特点可以把堆分为大顶堆和小顶堆

大顶堆:​每个结点的值都大于或等于其左右孩子结点的值

小顶堆:​每个结点的值都小于或等于其左右孩子结点的值



2、使用堆的原因


如果仅仅是需要得到一个有序的序列,使用排序就可以很快完成,并不需要去组织一个新的数据结构。但是如果我们的需求是对于一个随时会有更新的序列,我要随时知道这个序列的最小值或最大值是什么。显然如果是线性结构,每次插入之后,假设原数组是有序的,那使用二分把它放在正确的位置也未尝不可,但是插入的时候从数组中留出空位就需要O(n)的时间复杂度,删除的时候亦然。

可是如果我们将序列看作是一个集合,我们需要的是这个集合的一个最小值或者最大值,并且,在它被任意划分成为若干个子集的时候,这些子集的最小值或者最大值我们也是知道的,这些子集被不断划分,我们依然知道这些再次被划分出来的子集的最小值或者最大值。而且我们去想办法去保持这样的一个性质,那么这个问题是不是变得非常好解决了呢?那么问题就转换成了一种集合之间的关系,并且是非常明显的一种包含关系,那么最适合于解决这种集合上的关系的数据结构是什么呢?那么就是树,所以就形成了这样的一种树,他的每一个节点都比它的子节点们小或者大。当我们插入一个新的节点的时候,实际上我们需要去调整的大部分时候只是这棵树上的一条路径,也就是决定它在哪一个集合里面,树上的路径长度相对于这个集合,由于是对数级别的,所以非常可以接受,那么这种数据结构也就应运而生,而这个数据结构为什么叫做堆,那就不知道了。



3、堆的特点


谈谈堆排序,大顶堆,小顶堆_数组


谈谈堆排序,大顶堆,小顶堆_子节点_02

我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

大顶堆 arr : ​​50 45 40 20 25 35 30 10 15​

小顶堆 arr : ​​10 20 15 25 50 30 40 35 45​

我们用简单的公式来描述一下堆的定义就是:

大顶堆:​​arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]​

小顶堆:​​arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]​

其中​​arr[2i+1]​​是左节点 ​​arr[2i+2]​​是右节点



4、堆和普通树的区别


内存占用:

普通树占用的内存空间比它们存储的数据要多。你必须为节点对象以及左/右子节点指针分配额外的内存。​堆仅仅使用数组,且不使用指针

平衡:

二叉搜索树必须是“平衡”的情况下,其大部分操作的复杂度才能达到​O(nlog2n)​。你可以按任意顺序位置插入/删除数据,或者使用 AVL 树或者红黑树,但是在堆中实际上不需要整棵树都是有序的。我们只需要满足对属性即可,所以在堆中平衡不是问题。因为堆中数据的组织方式可以保证​O(nlog2n)​ 的性能

搜索:

在二叉树中搜索会很快,但是在堆中搜索会很慢。在堆中搜索不是第一优先级,因为​使用堆的目的是将最大(或者最小)的节点放在最前面,从而快速的进行相关插入、删除操作



5、堆排序的过程


堆排序的基本思想 假设是构建大顶堆

1.将待排序的关键字序列(R1,R2,...Rn)构建大顶堆,此堆为初始的无序区.

2.将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区
(R1,R2,......Rn-1)和新的有序区(Rn),且满足R[1,2...n-1]<=R[n];

3.由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,......Rn-1)调整为新堆,
然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2....Rn-2)和新的有序区(Rn-1,Rn)。
不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。

举例如下

1.给一个无序序列如下

​int a[6] = {7, 3, 8, 5, 1, 2}​

2.根据数组将完全二叉树还原出来

谈谈堆排序,大顶堆,小顶堆_数组_03

现在我们要做的事情就是要把​​7,3,8,5,1,2​​变成一个有序的序列,如果想要升序就是​​1,2,3,5,7,8​​如果想要降序就是​​8,7,5,3,2,1​​这两种就是我们要的最终结果,然后我们就可以根据我们想要的结果来选择

适合类型的堆来进行排序


  • 升序----使用大顶堆

  • 降序----使用小顶堆

3.为什么升序要用大顶堆呢

大顶堆的特点:每个结点的值都大于或等于其左右孩子结点的值,我们把大顶堆构建完毕后根节点的值一定是最大的,然后​把根节点和最后一个元素(也可以说最后一个节点)交换位置​,那么末尾元素此时就是最大元素了

4.图解交换过程

先要找到最后一个非叶子节点,数组的长度为6,那么最后一个非叶子节点就是:​长度/2-1​,也就是​​6/2-1=2​​,然后下一步就是比较该节点值和它的子树值,如果该节点小于其左\右子树的值就交换(意思就是将最大的值放到该节点)

8只有一个左子树,左子树的值为2,8>2不需要调整

谈谈堆排序,大顶堆,小顶堆_数组_04

下一步,继续找到下一个​非叶子节点

(其实就是​当前坐标-1​就行了这里为2-1=1),该节点的值为3小于其左子树的值5,交换值,交换后该节点值为5,大于其右子树的值1,不需要交换

谈谈堆排序,大顶堆,小顶堆_数组_05


谈谈堆排序,大顶堆,小顶堆_数组_06

交换后

下一步,继续找到下一个非叶子节点​​1-1=0​​,该节点的值为7,大于其左子树的值,不需要交换,再看右子树,该节点的值小于右子树的值8,需要交换值

谈谈堆排序,大顶堆,小顶堆_数组_07


谈谈堆排序,大顶堆,小顶堆_子节点_08

交换后

下一步,检查调整后的子树,是否满足大顶堆性质,如果不满足则继续调整(这里因为只将右子树的值与根节点互换,只需要检查右子树是否满足,而​​7>2​​刚好满足大顶堆的性质,就不需要调整了。如果运气不好整个数的根节点的值是1,那么就还需要调整右子树

谈谈堆排序,大顶堆,小顶堆_数组_09


到这里大顶堆的构建就算完成了

然后下一步交换根节点​​8​​与最后一个元素​​2​​交换位置(将最大元素"沉"到数组末端),此时最大的元素就归位了,然后对剩下的5个元素重复上面的操作

这里用紫色来表示已经归位的元素

谈谈堆排序,大顶堆,小顶堆_子节点_10


剩下只有5个元素,最后一个非叶子节点是​​5/2-1=1​​​,该节点的值​​5​​​大于左子树的值​​3​​​也大于右子树的值​​1​​,满足大顶堆性质不需要交换

谈谈堆排序,大顶堆,小顶堆_子节点_11

找到下一个非叶子节点,该节点的值​​2​​小于左子树的值​​5​​,交换值,交换后左子树的2不再满足大顶堆的性质再调整左子树,左子树满足要求后再返回去看根节点,根节点的值​​5​​小于右子树的值​​7​​,再次交换值

谈谈堆排序,大顶堆,小顶堆_数组_12


谈谈堆排序,大顶堆,小顶堆_子节点_13


谈谈堆排序,大顶堆,小顶堆_子节点_14


谈谈堆排序,大顶堆,小顶堆_子树_15


谈谈堆排序,大顶堆,小顶堆_子树_16

得到新的大顶堆,再把根节点的值​​7​​与当前数组最后一个元素值​​1​​交换,再重构大顶堆->交换值->重构大顶堆->交换值····,直到整个数组都变成有序序列

谈谈堆排序,大顶堆,小顶堆_子节点_17


谈谈堆排序,大顶堆,小顶堆_子节点_18

最后升序



6、堆排序的代码实现


将堆排序的过程分成了两部分,构建一个大顶堆,就沉下去最大值,然后断开与最大值的链接,重新构建大顶堆

// 构建堆
function buildHeap(arr){
// 最后一个非叶子节点
for(let i = arr.length/2 -1; i >= 0; i--) {
headAdjust(arr, i, arr.length)
}
}
// 调整函数
function headAdjust(arr, i, n) {
// 最后一个非叶子节点
let largest = i // largest表示此时最大值的位置
// left root
let l = 2*i + 1
let r = 2*i + 2
if (l < n && arr[l] > arr[largest]) {
largest = l
}
if (r < n && arr[r] > arr[largest]) {
largest = r
}
if(largest != i) {
let swap = arr[i]
arr[i] = arr[largest]
arr[largest] =swap
headAdjust(arr, largest, n )
}
}
// 堆排序
function sort(arr){
buildHeap(arr)
console.log(arr)
let n = arr.length;
// 依次把最大值沉下去。从右到左断开最后一个元素,重新构造大顶
console.log(arr.length)
for (let i = arr.length - 1; i >= 0; i--) {
console.log(11)
let temp = arr[0]
arr[0]= arr[i]
arr[i] = temp
headAdjust(arr, 0, i)

}
return arr
}
let arr = [7, 3, 8, 5, 1, 2 ]
console.log(sort(arr))
// console.log(arr)

堆排序时间复杂度

堆排序的时间复杂度,主要在​初始化堆过程​和每次​选取最大数后重新建堆的过程​;

初始化建堆过程时间:O(n)

推算过程:

首先要理解怎么计算这个堆化过程所消耗的时间,可以直接画图去理解;

假设高度为k,则从倒数第二层右边的节点开始,这一层的节点都要执行子节点比较然后交换(如果顺序是对的就不用交换);倒数第三层呢,则会选择其子节点进行比较和交换,如果没交换就可以不用再执行下去了。如果交换了,那么又要选择一支子树进行比较和交换;

那么总的时间计算为:


s = 2^( i - 1 ) * ( k - i );


其中 i 表示第几层,2^( i - 1) 表示该层上有多少个元素,( k - i) 表示子树上要比较的次数,如果在最差的条件下,就是比较次数后还要交换;因为这个是常数,所以提出来后可以忽略;


S = 2^(k-2) * 1 + 2(k-3)*2.....+2*(k-2)+2(0)*(k-1) ===> 因为叶子层不用交换,所以 i 从 k-1 开始到 1;


这个等式求解,我想高中已经会了:等式左右乘上2,然后和原来的等式相减,就变成了:


S = 2^(k - 1) + 2^(k - 2) + 2^(k - 3) ..... + 2 - (k-1)


除最后一项外,就是一个等比数列了,直接用求和公式:S = {a1[ 1- (q^n) ] } / (1-q);


S = 2^k -k -1;


又因为k为完全二叉树的深度,所以


(2^k) <= n < (2^k -1 )


总之可以认为:k = logn (实际计算得到应该是 log(n+1) < k <= logn );

综上所述得到:S = n - longn -1,所以时间复杂度为:O(n)

更改堆元素后重建堆时间:O(nlogn)

推算过程:

循环 n -1 次,每次都是从根节点往下循环查找,所以每一次时间是 logn,总时间:


logn(n-1) = nlogn - logn ;


综上所述:建堆的时间复杂度是O(n)(调用一次);调整堆的时间复杂度是lgn,调用了n-1次,所以堆排序的时间复杂度是O(n)+O(nlgn) ~ O(nlgn)


作者:高思阳