数据结构:堆排序

引言

如果有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个数据放在内存里面,维护一个小顶堆,比堆顶大的就进来,然后重新调整堆即可

不过大家可以从这个结构中看出来,大顶堆我们是把大的放在最后,所以升序排列用的是大顶堆,降序排列用的是小顶堆。

这篇文章到这里就结束了!!!希望可以帮助大家理解堆排序~~~

相关推荐
原来是猿1 小时前
为什么 C++ 需要区分左值和右值?
开发语言·c++
珊瑚里的鱼1 小时前
C++的强制类型转换
android·开发语言·c++
星恒随风2 小时前
C++ 类和对象入门(二):默认成员函数、构造函数和析构函数详解
开发语言·c++·笔记·学习
一个不知名程序员www2 小时前
算法学习入门---算法题DAY5
c++·算法
San813_LDD2 小时前
[量化]《虚函数调用时间复杂度完全解析:为什么是 O(1) 以及它的真实代价》
java·数据结构·算法
牛油果子哥q2 小时前
【C++ this指针】C++ this指针深度精讲:this底层本质、存储位置、调用机制、const this指针、空指针调用、面试坑点与工程实战
开发语言·c++·面试
起个破名想半天了2 小时前
算法与数据结构之Floyd算法
数据结构·算法
小七在进步2 小时前
数据结构:线性表之顺序表
c语言·数据结构·算法
坚果派·白晓明2 小时前
[鸿蒙PC三方库移植适配] 使用 AtomCode + Skills 自动完成spdlog鸿蒙化适配
c++·华为·ai编程·harmonyos·skills·atomcode