挖掘算法中的数据结构:堆排序之 二叉堆(Heapify原地堆排序优化)

Posted 鸽一门

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了挖掘算法中的数据结构:堆排序之 二叉堆(Heapify原地堆排序优化)相关的知识,希望对你有一定的参考价值。

不同于前面几篇O(n^2)或O(n*logn)排序算法,此篇文章将讲解另一个排序算法——堆排序,也是此系列的第一个数据结构—–堆,需要注意的是在堆结构中排序是次要的,重要的是堆结构及衍生出来的数据结构问题,排序只是堆应用之一。

此篇涉及的知识点有:

  • 堆的基本存储
  • Shift Up和Shift Down
  • 基础堆排序和Heapify
  • 优化的堆排序

挖掘算法中的数据结构(一):选择、插入、冒泡、希尔排序 及 O(n^2)排序算法思考
挖掘算法中的数据结构(二):O(n*logn)排序算法之 归并排序(自顶向下、自底向上) 及 算法优化
挖掘算法中的数据结构(三):O(n*logn)排序算法之 快速排序(随机化、二路、三路排序) 及衍生算法


一. 堆结构

1. 优先队列

首先来了解堆的经典应用—–优先队列,此概念并不陌生:

  • 普通队列:先进先出,后进后出。关键为由时间顺序决定出队顺序。
  • 优先队列:出队顺序和入队顺序无关,和优先级相关。

优先队列在OS的使用

优先队列这种机制在计算机中被大量使用,最典型应用就是操作系统执行任务,它需要同时执行多个任务,而实际上是将CPU执行周期划分时间片,在时间片中执行一个任务,每一个任务都有优先级,OS动态选择优先级最高的任务执行所以需要使用优先队列,所有任务进行优先队列,由队列来进行调度需要执行哪个任务。

为什么使用优先队列?

注意“动态”的重要性,如果任务是固定的话,可以将这些任务排序好安装优先级最高到最低依次执行,可是实际处理确要复杂得多。如下图:蓝色任务处理中心就类似CPU,由它来处理所有请求(红色代表Request)。选择执行某个请求后,下一步不是简单地选择另一个请求执行,与此同时可能会来新的任务,不仅如此,旧的任务优先级可能会发生改变,所以将所有任务按优先级排序再依次执行是不现实的。

所以优先队列模型不仅适用于OS,更存在与生活中方方面面,例如大家同时请求某个网页,服务器端需要依次回应请求,回应的顺序通常是按照优先队列决定的。

优先队列处理“静态问题”

前面一直在强调优先队列善于处理“动态”的情况,但其实对于“静态”也是十分擅长,例如在1,000,000个元素中选出前100名,也就是“在N个元素中选出前M个元素”。

在前三篇博文中学习了排序算法后,很快得到将所有元素排序,选出前M个元素即可,时间复杂度为O(n*logn)。但是使用了优先队列,可将时间复杂度降低为O(n *logM)!具体实现涉及到优先队列实现,后续介绍。

优先队列主要操作

  • 入队
  • 出队(取出优先级最高的元素)

优先队列采用的数据结构:

  • 数组:最简单的数据结构实现方式,有两种形式
    • 普通数组:入队直接插入数组最后一个位置,而取出优先级最高的元素需要扫描整个数组。
    • 顺序数组: 维护数组有序性,入队时需要遍历数组找到合适位置,而出队时取出队头即可。
  • 堆:以上两种实现方式有其局限性,无法很好平衡出入对操作。而使用堆这种数据结构虽然出入队时是蛮于前两者的,但是平均而言维持优先队列完成系统任务所用时间大大低于使用数组。

举个例子,对于总共N个请求:

  • 使用普通数组或者顺序数组,最差情况:O(n^2)
  • 使用堆:O(nlgn)


2. 二叉堆(Binary Heap)的基本存储

因此若要实现优先队列,必须采用堆数据结构,下面介绍堆有关知识及如何实现。

(1)概念特征

在以上了解堆中操作都是O(n *logn)级别,应当知道堆相应的是一种树形结构,其中最为经典的是二叉堆,类似于二叉树,每一个节点可以有两个子节点,特点:

  • 在二叉树上任何一个子节点都不大于其父节点。
  • 必须是一棵完全的二叉树,即除了最后一层外,以上层数的节点都必须存在并且狐妖集中在左侧。

注意:第一个特征中说明在二叉树上任何一个子节点都不大于其父节点,并不意味着层数越高节点数越大,这都是相对父节点而言的。例如第三层的19比第二层的16大。

这样的二叉堆又被称为“最大堆”,父节点总是比子节点大,同理而言“最小堆”中父节点总是比子节点小,这里只讲解“最大堆”。

(2)结构实现

对于其具体实现,熟悉树形结构的同学可能认为需要两个指针来实现左、右节点,当然可以这样实现,但是还有一个经典实现方式——通过数组实现,正是因为堆是一棵完全的二叉树。

将这棵二叉树自上到下、自左到右地给每一个节点标上一个序列号,如下图所示。对于每一个父节点而言:

  • 它的左孩子序列号都是本身序列号的 2倍
  • 它的右孩子序列号都是本身序列号的 2倍+1

(这里的根节点下标是由1开始而得出以上规则,但其实由0开始也可得出相应的规则,此部分重点还是放在下标1开始)

(3)基本结构代码实现


template<typename Item>
class MaxHeap

private:
    Item *data;
    int count;

public:

    // 构造函数, 构造一个空堆, 可容纳capacity个元素
    MaxHeap(int capacity)
        data = new Item[capacity+1];
        count = 0;
    

    ~MaxHeap()
        delete[] data;
    

    // 返回堆中的元素个数
    int size()
        return count;
    

    // 返回一个布尔值, 表示堆中是否为空
    bool isEmpty()
        return count == 0;
    
;

// 测试 MaxHeap
int main() 

    MaxHeap<int> maxheap = MaxHeap<int>(100);
    cout<<maxheap.size()<<endl;

    return 0;

以上C++代码并不复杂,只是简单实现了最大堆(MaxHeap)的基本结构,定义了data值,因为不知道值的具体类型,通过模板(泛型)结合指针来定义,提供简单的构造、析构、简单函数方法。


3. 二叉堆中的 Shift Up 和 Shift Down

在完成代码的二叉堆基本结构后,需要实现最重要的两个操作逻辑,即Shift Up 和 Shift Down。

(1)Shift Up

下面就实现在二叉堆中如何插入一个元素,即优先队列中“入队操作”。以下动画中需要插入元素52,由于二叉堆是用数组表示,所以相当于在数组末尾添加一个元素,相当于52是索引值11的元素。

算法思想

注意!其实整个逻辑思想完全依赖于二叉树的特征,因为在二叉堆上任何一个子节点都不大于其父节点所以需要将新插入的元素挪到合适位置来维护此特征:

  • 首先判断新加入的元素(先归到二叉堆中)和其父节点的大小,52比16小,所以交换位置。
  • 52被换到一个新位置,再继续查看52是否大于其父节点,发现52比41大,继续交换。
  • 再继续判断,52比62小,无须挪动位置,插入完成。

代码实现

MaxHeap中新增一个insert方法,传入新增元素在二叉堆中的下标

    //将下标k的新增元素放入到二叉堆中合适位置
    void shiftUp(int k)
        while( k > 1 && data[k/2] < data[k] )//边界&&循环与父节点比较
            swap( data[k/2], data[k] );
            k /= 2;
        
    

    // 像最大堆中插入一个新的元素 item
    void insert(Item item)
        assert( count + 1 <= capacity );
        data[count+1] = item;//注意下标是从1开始,所以新增元素插入位置为count+1,并非count
        count ++;//数量增加1
        shiftUp(count);
    

注意:以上代码中严格需要注意边界问题,因为在创建MaxHeap已设置好数组个数MaxHeap<int> maxheap = MaxHeap<int>(100);,所以在上述insert中使用了assert函数来判断,若超过数组长度则不插入。其实这里有另外一种更好的解决方法,就是超过时动态增加数组长度,由于此篇重点为数据结构,留给各位实现。

测试:

创建一个长度为20的数组,随机数字循环插入,最后打印出来,结果如下:(测试代码不粘贴,详细见源码)


(2)Shift Down

上一部分讲解了如何从二叉堆中插入一个元素,此部分讲解如何取出一个元素,即优先队列中“出队操作”。

算法思想

  • 根据二叉堆的特征,其根节点值最大,所以直接获取下标1的元素,但是根节点值空缺处理,需要重新整理整个二叉树。
  • 将数组中最后一个值替补到根节点,count数组总数量减1。因为在二叉堆上任何一个子节点都不大于其父节点所以需要调节根节点元素,相应的向下移,不同于Shift Up,它可以向左下移或右下移,这里采用的标准是跟元素值较大的孩子进行交换
    • 根节点与16与52、30比较,将16和52进行交换。
    • 将交换后的16与两个孩子28、41比较,与41交换。
    • 交换后的16此时只有一个孩子15,比其大,无需交换。Shift Down过程完成。

代码实现


    void shiftDown(int k)
        while( 2*k <= count )
            int j = 2*k; // 在此轮循环中,data[k]和data[j]交换位置
            if( j+1 <= count && data[j+1] > data[j] )
                j ++;
            // data[j] 是 data[2*k]和data[2*k+1]中的最大值

            if( data[k] >= data[j] ) break;
            swap( data[k] , data[j] );
            k = j;
        
    

    // 从最大堆中取出堆顶元素, 即堆中所存储的最大数据
    Item extractMax()
        assert( count > 0 );
        Item ret = data[1];

        swap( data[1] , data[count] );
        count --;
        shiftDown(1);

        return ret;
    

测试

首先设置二叉堆长度为20,使用MaxHeap中的insert方法随机插入20个元素,再调用extractMax方法将数据逐渐取出来,取出来的顺序应该是按照从大到小的顺序取出来的。

// 测试最大堆
int main() 

    MaxHeap<int> maxheap = MaxHeap<int>(100);

    srand(time(NULL));
    int n = 20;    // 随机生成n个元素放入最大堆中
    for( int i = 0 ; i < n ; i ++ )
        maxheap.insert( rand()%100 );
    

    int* arr = new int[n];
    // 将maxheap中的数据逐渐使用extractMax取出来
    // 取出来的顺序应该是按照从大到小的顺序取出来的
    for( int i = 0 ; i < n ; i ++ )
        arr[i] = maxheap.extractMax();
        cout<<arr[i]<<" ";
    
    cout<<endl;

    // 确保arr数组是从大到小排列的
    for( int i = 1 ; i < n ; i ++ )
        assert( arr[i-1] >= arr[i] );

    delete[] arr;
    return 0;

结果




二. 二叉堆优化

1. Heapify

在学习以上二叉堆实现后,发现它同样可用于排序,不断调用二叉堆的extractMax方法,即可取出数据。(从大到小的顺序)

// heapSort1, 将所有的元素依次添加到堆中, 在将所有元素从堆中依次取出来, 即完成了排序
// 无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)
// 整个堆排序的整体时间复杂度为O(nlogn)
template<typename T>
void heapSort1(T arr[], int n)

    MaxHeap<T> maxheap = MaxHeap<T>(n);
    for( int i = 0 ; i < n ; i ++ )
        maxheap.insert(arr[i]);

    for( int i = n-1 ; i >= 0 ; i-- )
        arr[i] = maxheap.extractMax();

(1)测试

所以以下将二叉堆和之前所学到的O(n*logn)排序算法比较测试,分别对

  • 无序数组
  • 近乎有序数组
  • 包含大量重复值数组

以上3组测试用例进行时间比较,结果如下(测试代码查看github源码):

虽然二叉堆排序使用的时间相较于其它排序算法要慢,但使用时间仍在接收范围内。因为整个堆排序的整体时间复杂度为O(nlogn) ,无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)。总共循环n此,每次循环二叉树操作消耗O(logn),所以最后是O(nlogn)

但是还可以继续优化,使性能达到更优以上过程创建二叉堆的过程是一个个将元素插入,其实还有更好的方式——Heapify。

(2)Heapify算法思想

给定一个数组,使这个数组形成堆的形状,此过程名为Heapify。例如以下数组15,17,19,13,22,16,28,30,41,62:

此数组形成的二叉树并非最大堆,不满足特征。但是上图中的叶子节点,即最后一层的每个节点可看作是一个最大堆(因为只有它一个节点)。接着再向上递进一层:

  • 由最后一个节点开始,考察父节点22是否大于孩子62,不满足则交换位置。这样这两个节点组成的子树满足最大堆特征。
  • 再考虑父节点13是否大于孩子30、41,不满足则与最大值的孩子交换位置。
  • 依次类推,其实思想与Shift Down相似。

(3)代码实现

所以,此堆排序的优化就是修改其创建方法,不通过一个一个元素插入来创建二叉堆,而是通过Heapify方法来完成创建,此过程消耗的时间复杂度为O(n),性能更优。

需要修改MaxHeap中的构造函数,传入参数为无序的数组和数组长度,首先开辟空间,下标从1开始将数组元素值赋值到新数组中,再结合Shift Down方法层层递进。

   // 构造函数, 通过一个给定数组创建一个最大堆
    // 该构造堆的过程, 时间复杂度为O(n)
    MaxHeap(Item arr[], int n)
        data = new Item[n+1];
        capacity = n;

        for( int i = 0 ; i < n ; i ++ )
            data[i+1] = arr[i];
        count = n;

        for( int i = count/2 ; i >= 1 ; i -- )
            shiftDown(i);
    

template<typename T>
void heapSort2(T arr[], int n)
    //优化后的创建二叉堆构造函数
    MaxHeap<T> maxheap = MaxHeap<T>(arr,n);
    for( int i = n-1 ; i >= 0 ; i-- )
        arr[i] = maxheap.extractMax();

(4)测试

通过优化后的创建二叉堆构造函数再次测试,结果如下:

可明显看出优化创建二叉堆构造函数后,堆排序使用时间更少

结论

将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn),而使用Heapify的过程,算法复杂度为O(n)


2. 原地堆排序

不同于其他排序算法,在堆排序中需要将数组元素放入“堆”中,需要开辟新的数组,相当于开了额外的O(n)空间,其实可以继续优化不适用空间原地对元素进行排序。

引出第二个优化 —— 原地堆排序,事实上,按照堆排序的思想,可以原地进行排序,不需要任何额外空间。

算法思想

其思想也很简单,通过之前构造堆这个类的过程已知一个数组可以看成是队列。因此将一个数组构造“最大堆”:

  • 其第一个元素v就是根节点(最大值),在具体排序过程中最大值应在末尾位置w,将两个值互换位置,此时最大值v在数组末尾。
  • 那么此时包含w在内的橘黄色部分就不是最大堆了,将w位置的值进行Shift Down操作。
  • 橘黄色部分再次成为“最大堆”,最大值仍在第一个位置,那堆末尾的元素(即倒数第二个位置)与第一个元素交换位置,再进行Shift Down操作。
  • 依次类推

这样所有的元素逐渐排序好,直到整个数组都变成蓝色。使用的空间复杂度是O(1),但是这里需要注意的是,如此一来下标是从0开始并非1,所以规则需要进行相应的调整:

代码实现

// 优化的shiftDown过程, 使用赋值的方式取代不断的swap,
// 该优化思想和我们之前对插入排序进行优化的思路是一致的
template<typename T>
void __shiftDown2(T arr[], int n, int k)

    T e = arr[k];
    while( 2*k+1 < n )
        int j = 2*k+1;
        if( j+1 < n && arr[j+1] > arr[j] )
            j += 1;

        if( e >= arr[j] ) break;

        arr[k] = arr[j];
        k = j;
    

    arr[k] = e;


// 不使用一个额外的最大堆, 直接在原数组上进行原地的堆排序
template<typename T>
void heapSort(T arr[], int n)

    // 注意,此时我们的堆是从0开始索引的
    // 从(最后一个元素的索引-1)/2开始
    // 最后一个元素的索引 = n-1
    for( int i = (n-1-1)/2 ; i >= 0 ; i -- )
        __shiftDown2(arr, n, i);

    for( int i = n-1; i > 0 ; i-- )
        swap( arr[0] , arr[i] );
        __shiftDown2(arr, i, 0);
    

测试:

分别测试原始Shift Down堆排序Heapify堆排序原地堆排序的时间消耗。

从结构得知优化后的原地堆排序快于之前原始Shift Down堆排序Heapify堆排序因为新的算法不需要额外的空间,也不需要对这些空间赋值,所以性能有所提高。



所有以上解决算法详细代码请查看liuyubo老师的github:
https://github.com/liuyubobobo/Play-with-Algorithms


前三篇博文介绍的排序算法及以上讲解完的堆排序完成,意味着有关排序算法已讲解完毕,下面篇博文对这些排序算法进行比较总结,并且学习另一个经典的堆结构,处于二叉堆优化之上的索引堆。

若有错误,虚心指教~

以上是关于挖掘算法中的数据结构:堆排序之 二叉堆(Heapify原地堆排序优化)的主要内容,如果未能解决你的问题,请参考以下文章

转白话经典算法系列之七 堆与堆排序

白话经典算法系列之七 堆与堆排序

重温基础算法内部排序之堆排序法

重温基础算法内部排序之堆排序法

排序算法之堆排序

堆排序:什么是堆?什么是最大堆?二叉堆是什么?堆排序算法是怎么样的?PHP如何实现堆排序?