堆排序

600 阅读4分钟

想要了解什么是堆排序,我们就必须知道数据结构中的堆。 堆总是一棵完全二叉树,将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。

堆的直观展示如下:

堆.webp

堆排序的前提是将数组看成一个堆的形式来进行排序,如下图所示:

image.png

由此可以得到它们之间的一种关系:

堆中一个位置的父节点对应的是数组中的(i - 1)/2位置

堆中一个位置的左孩子对应的是数组中的2 * i + 1位置

堆中一个位置的右孩子对应的是数组中的2 * i + 2位置

当在数组中加入一个数时,也即在堆中末尾加上一个数(下面的代码任何位置都可以,本质是向上调),我们知道堆有大根堆和小根堆,这里展示的是大根堆。每次加入一个数,都将其变成大根堆,这需要将这个数和它的父节点代表的值进行比较,如果大于父节点就将两者交换,如果交换成功就再比较上一个父节点,知道直到不大于位置。

相关的代码是:

//某数的位置在index需要向上移动变成大根堆
public static void heapInsert(int[] arr, int index){
    while (arr[index] > arr[(index - 1) / 2]){
        swap(arr, index, (index - 1) / 2);
        index = (index - 1) / 2;
    }
}

那当我们删除一个数的时候呢,已经排好的大根堆该怎样变化呢,这时因为去掉了一个数,所以heapSize已经减1,由此需要将位于末尾的值交换到要删除的位置,方便heapSize--。在交换完成后,我们需要比较删除位置的左孩子和右孩子谁大,将较大的那一个和换到删除位置的那个值进行比较,如果孩子比它大,就两者交换位置,如果交换了位置,就再看它的左孩子和右孩子,选出最大的和它进行比较,如果孩子大就再次交换直到不大于和到达了heapSize。

相关的代码是:

public static void heapify(int[] arr, int index, int size){
    int left = index * 2 + 1;
    while (left < size){
        int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
        largest = arr[largest] > arr[index] ? largest : index;
        if (largest == index){
            break;
        }
        swap(arr, largest, index);
        index = largest;
        left = index * 2 + 1;
    }
}

介绍完加入数和去掉数,那么正式开始我们的堆排序,首先依次从数组中读取数,调用heapInsert()函数依次形成大根堆的形式。读取数完成后,依次将大根堆的顶点取出来,heapSize--,调用heapify()重新变成大根堆,直到heapSize = -1读完。由此堆排序结束。

整个过程没有拷贝空间,没有使用递归,所以空间复杂度是O(1),heapInsert()形成大根堆的过程时间复杂度是log(1) + log(2) + log(3) + ... +log(n) = O(N * logN)。Heapify()的时间复杂度也同样是O(N*logN),由此堆排序的时间复杂度是O(N * logN)。

堆排序的动态直观展示:

堆排序.gif

堆排序的整体代码是:

    public static void main(String[] args) {
        int[] nums = new int[]{2,1,0,5,6,4,1};
        heapSort(nums);
        for (int i : nums){
            System.out.println(i);
        }
    }
    public static void heapSort(int[] arr) {
        if (arr == null || arr.length < 2){
            return;
        }
        //这个方法传的是arr[i],而我们用的是i,所以应该用下面的for代码
//        for (int i : arr){
//            heapInsert(arr, i);
//        }
        for (int i = 0; i < arr.length; i++) {//O(NlogN)
            heapInsert(arr, i);
        }
        //上面的操作可以更快,那就是用heapify(),因为heapify是向下调整,越往下调整的次数越少
        //因为堆的结构原因,大量的数集中在堆的下面,所以用heapify更快一些,这种情况下会达到O(N)
        for(int i = arr.length - 1; i >= 0, i--){
            heapify(arr, i, arr.length);
        }
        int size = arr.length;
        swap(arr, 0, --size);
        while (size > 0){
            heapify(arr, 0 ,size);
            swap(arr, 0, --size);
        }
    }
    //某数的位置在index需要向上移动变成大根堆
    public static void heapInsert(int[] arr, int index){
        while (arr[index] > arr[(index - 1) / 2]){
            swap(arr, index, (index - 1) / 2);
            index = (index - 1) / 2;
        }
    }
    //去掉一个数后,之后再次形成大根堆
    public static void heapify(int[] arr, int index, int size){
        int left = index * 2 + 1;
        while (left < size){
            int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
            largest = arr[largest] > arr[index] ? largest : index;
            if (largest == index){
                break;
            }
            swap(arr, largest, index);
            index = largest;
            left = index * 2 + 1;
        }
    }
    public static void swap(int[] arr, int i, int j){
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

总结一下堆的相关的性质:

  1. 堆结构就是用数组实现的完全二叉树结构
  2. 大根堆
  3. 小根堆
  4. 堆结构的heapInsert和heapify操作
  5. 堆结构的增大和减少
  6. 优先级队列结构就是堆结构,在java中就是
//默认是小根堆
PriorityQueue<Interger> heap = new PriorityQueue<>();

但是要注意,优先级队列可以实现当你加入一个数的时候(heap.add(x))变成小根堆形式(自动实现),当你弹出一个数的时候(heap.pop())剩下的自动变成小根堆,无论是大根堆还是小根堆,它们的插入和删除的复杂性都是O(height of tree)也就是O(logN)。但是对于其中第几个数突然改变,或者第几个数突然消失它自己实现的效率会不如heapify()和heapInsert(),所以优先级队列只可以使用加入和弹出的功能。

例如这个题可以改成:

    public static void main(String[] args) {
        int[] nums = new int[]{2, 1, 0, 5, 6, 4, 1};
        PriorityQueue<Integer> heap = new PriorityQueue<>();
        for (int i : nums) {
            heap.add(i);
        }
        for (int i = 0; i < nums.length; i++) {
            nums[i] = heap.poll();
            System.out.println(nums[i]);
        }
    }

但是上面的执行时间大约是24ms,而第一种是8ms。

补充一下:因为优先级队列默认是小根堆的,由此可以实现升序排列,那怎样实现大根堆由此实现降序排列呢。这是就需要用到比较器了。比较器的详细讲解

    public static void main(String[] args) {
        int[] nums = new int[]{2, 1, 0, 5, 6, 4, 1};
        PriorityQueue<Integer> heap = new PriorityQueue<>(new DiscendingComparator());
        for (int i : nums) {
            heap.add(i);
        }
        for (int i = 0; i < nums.length; i++) {
            nums[i] = heap.poll();
            System.out.println(nums[i]);
        }
    }
    public static class DiscendingComparator implements Comparator<Integer>{
        @Override
        public int compare(Integer o1, Integer o2) {
            return o2 - o1;
        }
    }