目录
1、前言
2、使用堆的原因
3、堆的特点
4、堆和普通树的区别
5、堆排序的过程
6、堆排序的代码实现
1、前言
堆是一种非线性结构,可以把堆看作一个数组,也可以被看作一个完全二叉树,通俗来讲堆其实就是利用完全二叉树的结构来维护的一维数组但堆并不一定是完全二叉树
按照堆的特点可以把堆分为大顶堆和小顶堆
大顶堆:每个结点的值都大于或等于其左右孩子结点的值
小顶堆:每个结点的值都小于或等于其左右孩子结点的值
2、使用堆的原因
如果仅仅是需要得到一个有序的序列,使用排序就可以很快完成,并不需要去组织一个新的数据结构。但是如果我们的需求是对于一个随时会有更新的序列,我要随时知道这个序列的最小值或最大值是什么。显然如果是线性结构,每次插入之后,假设原数组是有序的,那使用二分把它放在正确的位置也未尝不可,但是插入的时候从数组中留出空位就需要O(n)的时间复杂度,删除的时候亦然。
可是如果我们将序列看作是一个集合,我们需要的是这个集合的一个最小值或者最大值,并且,在它被任意划分成为若干个子集的时候,这些子集的最小值或者最大值我们也是知道的,这些子集被不断划分,我们依然知道这些再次被划分出来的子集的最小值或者最大值。而且我们去想办法去保持这样的一个性质,那么这个问题是不是变得非常好解决了呢?那么问题就转换成了一种集合之间的关系,并且是非常明显的一种包含关系,那么最适合于解决这种集合上的关系的数据结构是什么呢?那么就是树,所以就形成了这样的一种树,他的每一个节点都比它的子节点们小或者大。当我们插入一个新的节点的时候,实际上我们需要去调整的大部分时候只是这棵树上的一条路径,也就是决定它在哪一个集合里面,树上的路径长度相对于这个集合,由于是对数级别的,所以非常可以接受,那么这种数据结构也就应运而生,而这个数据结构为什么叫做堆,那就不知道了。
3、堆的特点
我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
大顶堆 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.根据数组将完全二叉树还原出来
现在我们要做的事情就是要把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不需要调整
下一步,继续找到下一个非叶子节点
(其实就是当前坐标-1就行了这里为2-1=1),该节点的值为3小于其左子树的值5,交换值,交换后该节点值为5,大于其右子树的值1,不需要交换
交换后
下一步,继续找到下一个非叶子节点1-1=0
,该节点的值为7,大于其左子树的值,不需要交换,再看右子树,该节点的值小于右子树的值8,需要交换值
交换后
下一步,检查调整后的子树,是否满足大顶堆性质,如果不满足则继续调整(这里因为只将右子树的值与根节点互换,只需要检查右子树是否满足,而7>2
刚好满足大顶堆的性质,就不需要调整了。如果运气不好整个数的根节点的值是1,那么就还需要调整右子树
到这里大顶堆的构建就算完成了
然后下一步交换根节点8
与最后一个元素2
交换位置(将最大元素"沉"到数组末端),此时最大的元素就归位了,然后对剩下的5个元素重复上面的操作
这里用紫色来表示已经归位的元素
剩下只有5个元素,最后一个非叶子节点是5/2-1=1
,该节点的值5
大于左子树的值3
也大于右子树的值1
,满足大顶堆性质不需要交换
找到下一个非叶子节点,该节点的值2
小于左子树的值5
,交换值,交换后左子树的2不再满足大顶堆的性质再调整左子树,左子树满足要求后再返回去看根节点,根节点的值5
小于右子树的值7
,再次交换值
得到新的大顶堆,再把根节点的值7
与当前数组最后一个元素值1
交换,再重构大顶堆->交换值->重构大顶堆->交换值····,直到整个数组都变成有序序列
最后升序
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)
作者:高思阳
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。