数据结构与算法之美-堆和堆排序

堆和堆排序


如何理解堆

堆是一种特殊的树,只要满足以下两点,这个树就是一个堆。

①完全二叉树,完全二叉树要求除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。

②树中每一个结点的值都必须大于等于(或小于等于)其子树中每个节点的值。大于等于的情况称为大顶堆,小于等于的情况称为小顶堆。


如何实现堆


如何存储一个堆

完全二叉树适合用数组来存储,因为数组中对于下标从1开始的情况,下标为i的节点的左子节点就是下标为i*2的节点,右子节点就是i下标为i*2+1的节点,其父节点时下标为i/2的节点


堆支持哪些操作

往堆中插入一个元素

把新插入的元素放到堆的最后就不符合第二个特性了,所以我们需要进行调整,让其重新满足堆的特性,这个过程我们起了一个名字,就叫作堆化(heapify)。

堆化就是顺着节点所在的路径,向上或者向下,对比,然后交换。我们先使用从下往上的堆化方法。

让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。

public class Heap{
    private int[] data;//数组,从下标1开始存储
    private int maxNum;//数组容量
    private int count;//当前数组成员数量
    //构造器初始化数组,大小和数量
    public Heap(int size){
        data = new int[size + 1];
        maxNum = size;
        count = 0;
    }
    public void Insert(int item){
        //堆满返回
        if (count >= maxNum) return;
        //先将节点插入堆尾
        data[count++] = item;
        int i = count;
        //再自下向上堆化,直到堆顶或者父节点比子节点大为止
        while (i / 2 > 0 && data[i] > data[i / 2]){
            //交换位置
            int temp = data[i];
            data[i] = data[i / 2];
            data[i / 2] = temp;
            //更新下标
            i = i / 2;
        }
    }
}

删除堆顶元素

根据对的第二条定义,堆顶元素存储的就是堆中的最大值或最小值。

这里我们使用从上往下的堆化方法。将最后一个节点放到堆顶,然后利用同样的父子节点对比法,进行互换节点直到父子节点之间满足大小关系为止。

这样移除的就是数组中的最后一个元素,不会破环完全二叉树的定义。

public void RemoveMax(){
    //堆空返回
    if (count == 0) return;
    //将最后一个节点提到堆顶
    data[1] = data[count--];
    //进行堆化
    Heapify(data,count,1);
}
public static void Heapify(int[] data,int n,int i){
    while (true){
        //记录更大节点的位置,初始化为当前节点的位置
        int maxPos = i;
        //如果其左右子节点存在,且比当前节点大,就将左右节点下标设为更大的节点
        if (i * 2 <= n && data[i] < data[i * 2]) maxPos = i * 2;
        if (i * 2 + 1 <= n && data[maxPos] < data[i * 2 + 1]) maxPos = i * 2 + 1;
        //否则就结束循环,堆化结束
        if (maxPos == i) break;
        //节点交换位置
        int temp = data[i];
        data[i] = data[maxPos];
        data[maxPos] = temp;
        //更新当前节点的下标,循环继续与下一个左右子节点比较
        i = maxPos;
    }
}

如何基于堆实现排序

我们借助于堆这种数据结构实现的排序算法,就叫作堆排序。

我们可以把堆排序的过程大致分解成两个大的步骤,建堆和排序。


建堆

首先将数组原地建成一个堆。借助另一个数组,就在原数组上操作。我们要实现从后往前处理数组,并且每个数据都是从上往下堆化的建堆方法。

public static void BuildHeap(int[] data, int n){
    //从下标n/2到1开始进行堆化,n/2就是最后一个叶子节点的父节点。
    for (int i = n / 2; i >= 1; --i)
        Heapify(data,n,i);
}

我们对下标从n/2开始到 111 的数据进行堆化,下标是n/2+1到n的节点是叶子节点,我们不需要堆化。

建堆操作的时间复杂度

排序的建堆过程的时间复杂度是 O(n)。


排序

建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为n的位置。

这个过程有点类似删除堆顶元素的操作,当堆顶元素移除之后,我们把下标为n的元素放到堆顶,然后再通过堆化的方法,将剩下的n-1个元素重新构建成堆。

堆化完成之后,我们再取堆顶的元素,放到下标是的位置,一直重复这个过程,直到最后堆中只剩下标为1的一个元素,排序工作就完成了。

public static void Sort(int[] data,int n){
    //将数组建造为堆
    BuildHeap(data, n);
    //获取堆尾的下标
    int k = n;
    //循环直到k为1
    while (k > 1){
        //交换堆顶和堆尾的元素
        int temp = data[k];
        data[k] = data[1];
        data[1] = temp;
        //将堆尾的下标递减并对1到k的下标的数组成员进行堆化
        Heapify(data,--k,1);
    }
}

堆排序的时间复杂度、空间复杂度以及稳定性

堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是O(n),排序过程的时间复杂度是O(nlogn)所以,堆排序整体的时间复杂度是O(nlogn)。

堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。

测试 

//Main方法
int[] data = new int[] {0,3,5,2,9,4,7 };
Heap.Sort(data,data.Length-1);
for (int i=0;i<data.Length;i++)
    Console.Write(data[i]+",");
//测试结果
0,2,3,4,5,7,9,

数组的第1个成员,即下标0的数据是不作为数据的一部分的,这是为了算法上的方便,如果下标是从0开始,那么左右子节点的下标公式就是i*2+1和i*2+2。


思考

在实际开发中,为什么快速排序要比堆排序性能好?

对于快速排序来说,数据是顺序访问的而对于堆排序来说,数据是跳着访问的。这样对 CPU 缓存是不友好的。

对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

猜你喜欢

转载自www.cnblogs.com/errornull/p/10054391.html