引言
如果有100万亿个数据,我想选出其中最大的100个数据,应该怎么做?100万亿个数据十分的庞大,在内存中完全存储不下,所以我们需要一个动态的数据结构,这个数据结构不仅仅可以比较大小,还可以动态调整。
因此我们联想到了二叉树,而大顶堆和小顶堆就是建立在这个上面。
数据结构
我们本篇文章拿大顶堆举例子。大顶堆的意思就是根节点比下面所有节点的值都是要大的。
而我们在构建一个大顶堆的时候,使用数组来维护的。其实这个数据结构就是一个数组,但是我们可以用二叉树的概率来理解,可能从代码上来看,也就是对数组的一顿操作,实际上是基于二叉树的理论知识
我们既然有要比较父节点和孩子节点的过程,所以我们也需要知道一些有必要的知识。假设父节点是n,那么它所对应的孩子节点就是n * 2和n * 2 + 1。然后就是动态构建大顶堆的过程。这个就放在代码里面解释
代码
向下调整法
这个方法的本质是从叶子结点出发
我们的数组是从1~n,那n作为最后一个结点,一定是叶子结点,所以我们从n/2开始往回遍历。为什么不从n开始呢?因为n肯定是叶子节点,作为叶子结点,他没有孩子,所以没有比较的必要,而n/2 ~ n之间的数也都是叶子结点。所以我们从n/2开始。
cpp
// 向下调整法 原地建堆
// 依次分别向下调整,以a[n/2] ~~ a[1]为根的子树即可
for(int i = n / 2; i >= 1; i--) {
DownAdjust(a, i, n);
}
首先我们要记录当前的结点,然后与它的孩子进行比较,因为一个大顶堆那么它的子树也一定是一个大顶堆,所以我们从最小的子树开始,now代表的就是父节点,而next代表的就是子孩子。然后我们的操作是选出now和两个子孩子之中最大的那一个,放到根节点上,也就是那两个数据进行交换。然后这就完了吗?当然没有!!交换了之后只能保障这3个结点是大顶堆,但是那个交换下去的结点一定比之前那些所有的结点都要大吗?我看不见得。所有我们还要继续向下比较,直到全部调整完成,然后我们才可以继续向上交换。
而我们这里全部用的数组,所以根节点和子节点的表示方式都是用下标表示法。
所以这一顿操作之后可以保证一个点,就是我的根节点是最大的。其他的顺序不确定,因为左孩子和右孩子的大小不确定,但是根节点一定最大。然后这个操作的时间复杂度是O(logn),计算的方法是最底层的调整次数是0,倒数第二层是1,以此类推。。。。
cpp
// 时间复杂度O(n)
void DownAdjust(int a[], int i, int n) {
// 向下调整
int now = i; // 当前结点
int next; // 值最大的孩子
while(2 * now <= n) { // now的左子树存在,也就是说当前结点至少有一个孩子
next = 2 * now;
if(2 * now + 1 <= n && a[2 * now + 1] > a[next]) {
// 右子树存在,且右孩子值更大
next = 2 * now + 1;
}
if(a[now] < a[next]) { // 父亲比孩子小
std::swap(a[now], a[next]);
now = next; // 再继续向下调整
}else { // 如果父亲比孩子大,说明已经是大顶堆了,不需要调整了
break;
}
}
}
向上调整法
这个方法的本质是变插入边调整,所以要插入多少个结点就要调整多少次。所以我们从1开始遍历
cpp
// 向上调整,边插入边调整
for(int i = 1; i <= n; i++) { // 枚举被插入的结点
// 把a[i]插入到堆中,此时堆的范围是a[1]~~a[i]
UpAdjust(a, i, n);
}
因为是边插入边调整,我们插入的限制在for循环。但是对于调整来说,我们永远是先对一个小子树进行调整,然后不断地向上调整,所以我们需要想象每一次插入地都是叶子结点,然后和父节点不断地比较,如果比父节点大,那么就把这个数据和父节点交换一下,然后继续这个操作。那这个方法为什么和上面那个方法不太一样呢?主要一个是遍历地顺序,一个是我们每次插入结点地时候,原先这个结构已经是大顶堆了,所以我们每次结点地比较,只需要和父节点进行比较,然后即使原先的父节点被交换下去了,也不需要担心,因为原先的性质不变,不会因为交换下去数据变小导致需要重新调整。
这个方法的时间复杂度是O(nlogn)。我们可以感性的理解一下为什么这个时间复杂度这么高。因为我们是从上往下插入的,所以越往下,需要调整的次数就越多,而越往下,结点数量越多。
cpp
// 时间复杂度O(nlogn)
void UpAdjust(int a[], int i, int n) {
// 在插入的堆中,插入了第i个结点
int now = i; // now指向当前被调整的结点
int next; // now的父亲结点
while(now > 1) { // 只要有父亲,就要和父亲比较调整
next = now / 2;
if(a[now] > a[next]) {
std::swap(a[now], a[next]);
now = next;
} else { // now当前的位置没有比父亲大,所以满足大顶堆
break;
}
}
}
堆排序
刚刚的两个方法都是构建一个堆,目的就是找到乱序区里面最大的那一个,而我们的目的就是每一次取这个数组里面最大的那一个。这个最大的就是在索引下标1的位置。所以我们是直接拿走就可以吗?不是!!!因为如果我们拿走的话,那么数组就变成了2-n,之后就是3-n、4-n。。。完全的打乱了顺序表的性质,所以我们一般都是从末尾拿走元素,也就是1~n、1~n-1、1~n-2。。。那么这样子我们就需要交换头和尾部的元素。然后我们怎么样表示我们拿走了那个元素呢?其实只需要把右区间减减就可以。至于为什么有n个元素,只调整n-1次,因为我们每次都拿的是乱序区里面最大的元素,所以剩下的最后一个一定是最小的啦~~~
注意,我们交换了之后,头部的元素变成了最小的,所以我们需要重新维护大顶堆的性质,进行重新的调整。
这一整个的时间复杂度是(nlogn)
cpp
// 堆排序
int k = n; // 记录乱序区域的有边界 也是堆的范围
for(int i = 1; i <= n - 1; i++) { // 执行n-1次排序,因为最后剩的元素肯定是最小的,不需要调整
// 第i趟排序 乱序区[1,k]刚好是大顶堆
// 把乱序区的最大值 交换到乱序区的最右边
// 因为乱序区是大顶堆,最大值一定是a[1],所以a[1]和a[k]交换,k--
std::swap(a[1], a[k]);
k--;
// 重新调整堆,保持大顶堆的性质
DownAdjust(a, 1, k);
}
总结
所以开头那个问题大家会回答了吗?就是把100个数据放在内存里面,维护一个小顶堆,比堆顶大的就进来,然后重新调整堆即可
不过大家可以从这个结构中看出来,大顶堆我们是把大的放在最后,所以升序排列用的是大顶堆,降序排列用的是小顶堆。
这篇文章到这里就结束了!!!希望可以帮助大家理解堆排序~~~