优先队列、堆、堆排序

  • 优先队列       

       在面对每天的工作时,人们可能会对工作按照工作的紧急程度、重要性等特性进行简单排序。一些工作很重要,很紧急,往往我们会优先处理这些工作,那么这样的工作具有较高的优先级。一些工作可能相对不那么重要,不需要立即得到处理,我们会把这一类的工作往后顺延,这样的工作具有相对低的优先级。人们总是优先处理优先级高的工作,因为优先级越高意味着越重要,越紧急。在计算机的世界里,这种现象适用。例如,对于一台能够运行多个应用程序的移动手机,为每个应用程序的事件分配一个优先级,并总是处理下一个优先级最高的事件。例如,绝大多数手机分配给来电的优先级都比游戏的优先级高。

       在这种情况下,需要一种合适的数据结构应该支持两种操作:删除值(优先级)最大的元素和增加元素。这种类型的数据结构称之为优先队列。优先队列是一种有点类似与不同栈或者队列的抽象数据结构,并且每个元素都关联一个优先级。在优先队列中,一个具有高优先级的元素总是比具有低优先级的元素先处理,如果两个元素具有相同的优先级,处理的顺序取决于元素进入队列的顺序。优先队列的一些重要应用场景包括模拟系统:其中时间的键即为事件发生的时间,系统需要根据时间发生的顺序处理所有事件。任务调度:调度程序根据每个进程的优先级决定调度那个进程执行(显然系统进程的优先级应高于普通用户进程的优先级)。下表给出一个优先队列的API。

methoddescription
MaxPQ()创建一个优先队列
MaxPQ(int max)创建一个最大容量为max的优先队列
MaxPQ(int[] a)用数组a中的元素创建优先队列
void insert(int a)向优先队列中插入元素
int max()优先队列的最大元素
int delMax()删除优先队列的最大元素
boolean isEmpty()返回优先队列是否为空
int size()返回优先队列元素个数

使用优先队列插入一列元素,然后每次删除值最小的元素(值越小优先级越高),可以实现一个排序算法。一种名为堆排序的排序算法就是基于堆的优先队列实现的。为了展示排序算法的加载,考虑如下的问题:在一个N个元素的序列中,找出M个最大的元素。这可能会出现在面试录取中,从1000名面试者中选择综合成绩最高的前100名面试者进行录取。一种可行的办法是对N个元素进行排序,然后取值最大的M个元素。但是即便是性能最好的排序算法也需要O(nlogn)的时间才能完成任务,如果转而使用优先队列,可以在O(nlogm)的时间内完成。下表表示从N个元素中最大的M个元素的成本。

示例时间空间
排序算法的用例NlogNN
基于堆实现的优先队列NlogMM

优先队列的实现:

  1. 使用无序数组,每次删除元素时从数组中找到最大的元素进行删除。
  2. 使用有序数组,直接删除最大的元素,但是添加元素时需要维护数组的有序性。
  3. 使用二叉堆

下表给出了各种实现方式的插入、删除操作的运行时间

数据结构插入元素删除最大元素
无序数组O(1)O(N)
有序数组O(N)O(1)
logNlogN
理想情况

数据结构二叉堆能够很好的实现优先队列的基本操作。通常我们使用二叉树实现堆,在二叉堆中,每个元素都保证大于等于(大于小于这件事情是可以定义的,例如想要实现一个最小堆,可以定义元素值越小越大,元素值越大越小)两个特定位置的元素,这些位置的元素又要保证大于等于其他两个位置的元素,以此类推。对于使用二叉树实现的堆,当其中的每个节点的值都要大于等于其左右孩子节点的值,称之为堆有序,并且实现堆的二叉树是完全二叉树。使用堆是实现优先队列最高效的方式,事实上,无论优先队列如何实现,优先队列经常都和堆关联在一起。在堆中,优先级最高的元素通常被存放在树根。堆不是一个排序结构,但它可以被看做是排序的一部分。对于需要重复删除一组序列中的最大(小)值时,堆是非常有用的数据结构。堆这种数据结构,通常特指二叉堆,是由JWJ Williams在1964介绍的一种用于堆排序的数据结构。堆在一些有效的图算法中也是至关重要的,例如Dijkstra算法。因为二叉堆是一颗完全二叉树,因此每一个节点都是平衡的,并且树的高度是最低的(N个节点的二叉堆的高度为logN+1)。

  • 二叉堆的表示

可以使用链表来表示堆有序的二叉树,每个节点包含三个指针,分别指向父节点,左孩子节点,右孩子节点。因为二叉堆表示的树是一颗完全二叉树,因此使用可以数组表示堆而不需要链表。为了方便表示,堆元素在数组中的索引从1开始,对于索引为p的元素,父节点的索引为p/2,左孩子节点的索引为p*2,右孩子节点的索引为p*2 +1。根节点在位置1,它的子节点的位置是2,3,而2,3节点的子节点是4、 5 和6、 7。利用数组实现二叉堆的结构是很严格的,但他的灵活性已经足以让我们高效的实现优先队列。使用二叉堆我们能够实现对数级别的插入元素和删除元素操作(下图是一个最大堆)。

  • 堆的算法

为了实现堆,我们使用一个大小为N+1的私有数组pq[]来表示一个大小为N的堆,不使用pq[0]的位置,堆元素存放在pq[1]到pq[N]的位置。对于堆的实现最重的两个操作就是增加元素和删除元素,因为在增加元素和删除元素的同时,我们需要保证整个堆的堆有序,这里会分别使用两个子过程shiftUP和shiftDown来保证即便是删除或增加二叉堆中的元素,新的二叉堆依然能保证堆有序。对内部维护一个变量size用于表示堆中元素个数。

增加元素:对于增加元素,只需要将元素放入到数组中的第一个空的位置即可(可以使用数组直接索引,在O(1)内完成该工作)。新的增加元素可能比它的父节点的元素的值大,这就违背了最大堆的定义(堆中的每个节点的值都不小于它的左右孩子节点的值)。因此插入元素完成后需要做的是重新排序整个堆使得添加元素后的堆也是堆有序的,这步操作称之为由下到上堆有序化(上浮)。具体步骤是:

  1. 判断当前元素是否需要继续进行shiftUp操作(堆顶元素不需要进行shiftUp操作)。如果不需要再继续执行shiftUp操作,退出该过程。如果需要继续执行shiftUp操作则转到步骤2。
  2. 比较当前元素值和父节点元素值。如果当前节点的元素值不大于父节点元素值,直接退出shiftUp操作。如果大于父节点值,交换两个节点元素值,然后从交换后的父节点开始,继续执行步骤1。

对于插入的新元素,需要和其父节点的值进行比较,如果不大于父节点的值,则上浮操作结束;如果大于父节点的值,则交换两者的值,并从新的位置开始继续比较,直到节点已经到达根节点。下图展示了向上图的二叉堆中插入元素70后的二叉堆。

 /**
     * 上浮操作
     * @param index 从指定的位置开始上浮
     */
    private void shiftUp(int index) {
        /*如果index = 1 ,已经到达根节点,不需要再进行shift up操作*/
        while ((index > 1) && (data[index] > data[index / 2])) { 
            swap(data,index,index/2);
            index = index / 2;
        }
    }

删除元素:删除元素就是从二叉堆中删除最大的元素,最大元素就是索引为1位置的元素。删除元素的方式通常是:取出堆顶元素,然后从索引1处开始自顶向下进行shiftDown操作,使得删除最大元素后的新堆也是堆有序的。自顶向下堆有序化(shiftDown,下浮)和自底向上的堆有序化操作(shiftUp,上浮)十分类似。下浮操作的步骤如下:

  1. 首先判断节点是否存在左孩子节点,如果不存在左孩子节点,退出shiftDown操作;如果存在左孩子节点,执行步骤2。
  2. 判断是否存在右孩子,如果有右孩子就找出左右孩子节点中值较大的一个节点。
  3.  

/**
     * 从索引k处开始自顶向下堆有序化,若谷k位置的节点值小于其左右孩子节点中较大的值,则进行交换,否则算法下浮操作结束
     * @param k 开始下浮操作的索引
     */
    private void shiftDown(int k) {
        while (k <= size / 2){  /*待考察的节点有至少有左孩子节点*/
            int t = k * 2;
            /*如果k节点存在右孩子节点,计算两个子节点中值较大的一个节点的索引*/
            if((t + 1 <= size) && (data[t] < data[t+1]))  
                t++;
            /*是否满足交换条件*/
            if(data[k] < data[t]){
                swap(data,k,t);
                k = t;
            }else{
                /*下浮工作完成直接退出*/
                break;
            }
        }
    }

下面给出一个二叉堆的完整实现

public class BinaryHeap {

    /**
     * 私有数组,用于存放元素,为了简单期间,这里使用Integer类型,而没有使用泛型
     */
    private Integer[] data;
    /**
     * 二叉堆中元素个数
     */
    private int size;

    /**
     * 二叉堆的默认大小
     */
    private static final int  DEFAULT_SIZE = 8;
    

    /**
     * 构造一个默认大小的二叉堆
     */
    public BinaryHeap(){
        this(DEFAULT_SIZE);
    }

    /**
     * 存放指定大小元素的二叉堆
     * @param capacity
     */
    public BinaryHeap(int capacity){
        assert capacity > 0;
        //元素从索引为1开始存放,不使用索引为0的位置
        this.data = new Integer[capacity + 1];
        size = 0;
    }

    /**
     * 根据传入的数组构造二叉堆
     * @param array 
     */
    public BinaryHeap(int[] array){
        data = new Integer[array.length+1];
        for (int i = 0; i < array.length; i++) {
            data[i+1] = array[i];
        }
        size=array.length;
    }

    public int getSize(){
        return this.size;
    }

    public boolean isEmpty(){
        return this.size == 0;
    }

    /**
     * 插入元素,每次添加到size的下一个位置,完成插入操作后需要检测数组是否需要扩容
     * @param value
     */
    public void insert(int value){
        data[++size] = value;
        shiftUp(size);
        /*是否需要扩容*/
        if(size == data.length - 1){
            resize(data.length);
        }
    }


    /**
     * 获取二叉堆最大的元素
     * @return
     */
    public int getMax(){
        assert size > 0;
        return data[1];
    }

    /**
     * 删除元素(最大)
     * @return
     */
    public int removeMax(){
        assert size > 0;
        int ret = data[1];
        /*把最后一个节点交换到根节点*/
        data[1] = data[size--];
        data[size+1] = null;
        /*进行自定向下堆有序化*/
        shiftDown(1);
        return ret;
    }


    /**
     * 数组扩容,每次扩容2倍
     * @param length
     */
    private void resize(int length) {
        Integer[] newData = new Integer[(length-1) * 2];
        for (int i = 1; i < data.length; i++) {
            newData[i] = data[i];
        }
        data = newData;
    }

    public static void swap(Integer[] array, int i, int index) {
        int temp =  array[index];
        array[index] =array[i];
        array[i] = temp;
    }

    /*参见上面的代码*/
    private void shiftUp(int index)
    private void shiftDown(int index)
    
}

上面的代码是一个二叉堆的实现,有了二叉堆,实现优先队列也就变得得心应手起来。在前面介绍优先队列时提到了一些优先队列的API,这些API和二叉堆十分类似。优先队列完全可以使用一个二叉堆来实现,存储在数组pq[1....N]中,不使用pq[0]。对于insert(),把size加一并把新元素添加到数组的最后,然后使用shiftUp()恢复堆的秩序。在delMax()中,pq[1]即为要返回的元素,然后把pq[N]的元素移动到pq[1],将N减一并使用shiftDown()恢复堆的秩序。将不再使用的pq[N+1]置为null,以便系统回收它所占用的空间。对于一个由N个元素的基于堆的优先队列,增加元素操作只有不超过(logN+1)次的比较,删除元素的操作需要不超过2logN次比较。

  • 堆排序

前面介绍了堆(二叉堆)以及基于堆实现的优先队列,我们还可以根据优先队列改造成一种排序算法。将所有的元素都插入到一个最大元素的优先队列中,每次从优先队列中删除最大的元素并保持到新的数组中,新的数组即为有序的。下面我们就将介绍这种基于堆实现的经典排序算法——堆排序。

堆排序可分为两个阶段。在堆的构造阶段,遍历数组中的每个元素,调用inser()操作向优先队列中添加元素,利用待排序的数组构造即为一个最大堆。然后对优先队列不断执行removeMax()操作取出最大的元素放回到待排序数组中。

 public void sort(int[] array) {
        /*开辟N+1个空间,不适用data[0]*/
        data = new Integer[array.length + 1]; 
        for (int i = 0; i < array.length; i++) {
            addElement(array[i]);
        }
        /*因为优先队列是最大堆,因此每次删除最大元素并从后向前放入到数组中*/
        for (int i = array.length - 1; i >= 0; i--) {
            array[i] = removeMax();
        }

        //addElement() 、removeMax()参见上面的实现
    }

这种实现方式的不足之处在于需要开辟额外的空间,这种额外的空间销毁是可以避免的。对于待排序的数组,可以在该数组之上进行堆有序化操作(索引从 (length-1)/2 到索引0,对每个元素进行shiftDown操作,就能使得一个随机的数组被堆有序化成一个最大(小)堆),使得待排序的数组成为一个最大堆。对于一个索引为p的节点,它的父节点的索引(p-1)/2,左孩子节点的索引2*p+1,右孩子节点的索引2*p+2。

  1. 设置一个索引index,index指向的位置应该是数组[0.....index]中的元素组成的堆中的最大元素在排序完成应该存放的位置。
  2. 交换索引index和索引1的元素,index-1,然后对数组[1......index]中的元素进行堆有序化操作。
  3. 重复步骤2直到index=0,退出算法,排序完成。
  public void sort(int[] array) {
        /*索引(array.length-1)/2处正是第一个二叉堆中非叶子节点的节点索引,递减到0,并对每个元素进行
        shiftDown操作使得整个待排序输出堆有序化成最大堆*/
        for (int i = (array.length - 1) / 2; i >= 0; i--) {
            shiftDown(array,i,array.length);
        }
        /*索引i表示[0,i]中的元素组成的二叉堆的最大元素应该存放的位置*/
        for (int j = array.length - 1; j > 0 ; j--) {
            swap(array,0,j);
            /*交换后进行shiftDown使得[0,i-1]中的元素维持堆的秩序*/
            /*length 的值随着j变化*/
            shiftDown(array,0,j);
        }
    }

    /**
     * 
     * @param array 
     * @param i 进行shiftDown的索引
     * @param length  数组的长度
     */
    private void shiftDown(int[] array, int i,int length) {
        while (2 * i + 1< length){ /*存在左孩子节点*/
            int j = i * 2 + 1;
            if ((j+1 < length) && (array[j] < array[j+1])){
               j++;
            }
            if(array[i] < array[j]){
                swap(array,i,j);
                i = j;
            }else {
                break;
            }
        }
    }

至此,我们已经完整的实现了一个完成的堆排序算法。下面是对堆排序的性能的简单测试,性能表现还是令人满意,时间复杂度同样为O(nlogn)。不过堆排序运用最多的还是在求一组N个序列中的前M个最大(小)的元素,此时堆排序能够在在O(NlogM)内完成工作。

Test for random array ,size = 5000000
mergesort 排序 5000000 个元素共耗时:0.925139945s
排序结果:true
heapsort 排序 5000000 个元素共耗时:1.149068247s
排序结果:true
quickSortWays3 排序 5000000 个元素共耗时:0.791017267s
排序结果:true
----------------------------------------------------------------------------------

Test for nearly ordered array ,size = 5000000,swap times = 100
mergesort 排序 5000000 个元素共耗时:0.355537266s
排序结果:true
heapsort 排序 5000000 个元素共耗时:0.387859558s
排序结果:true
quickSortWays3 排序 5000000 个元素共耗时:0.488372343s
排序结果:true
----------------------------------------------------------------------------------

Test for random array ,size = 5000000,random range [0,20]
mergesort 排序 5000000 个元素共耗时:0.564448488s
排序结果:true
heapsort 排序 5000000 个元素共耗时:0.417046491s
排序结果:true
quickSortWays3 排序 5000000 个元素共耗时:0.118444163s
排序结果:true
----------------------------------------------------------------------------------
  • 堆相关的问题

使用堆可以实现优先队列。对于操作系统,每次选择优先级最高的进程进行执行,同时还可以使用堆的一种变种索引堆修改堆中进程的优先级来保证所有进程都能够得到执行。对于归并排序,通常我们都是将数组或者链表划分为两部分进行分别排序然后在归并,使用堆可以实现多路归并排序。对于多路归并排序,将数组换分成n个子数组进行排序时,此时归并排序就退化成了堆排序。堆还有很多变种,例如索引堆、D叉堆、二项堆、斐波那契堆等,对于堆还有很多值得探索的地方,一起加油^_^。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值