堆排序总结与实现

本博客总结学习堆排序算法,以一个数组为例,采用大根堆进行升序排序,附有代码实现。

堆排序的思想

堆排序的逻辑是建立在完全二叉树的基础上。

有两个概念必须要了解:

  • 大根堆:每个结点值都大于等于左右孩子结点值
  • 小根堆:每个结点值都小于等于左右孩子结点值

以大根堆为例,将根结点与最后一个结点交换,弹出根结点,即可得到整个树中的最大值。继续,将剩下的n-1个结点的树再调整为大根堆,再弹出根结点,以此类推,可得到一个有序序列。

问题的关键在于,如何进行堆调整?

我们把二叉树中每一簇“父结点、左孩子、右孩子”当成一个三元组,从二叉树底层开始,由下往上,依次对每一个三元组进行调整,套一两层循环,即可完成堆调整。这是直观的总体思路。

存在一个问题:如何根据父或子结点快速获取三元组?

说白了就是需要建立父结点和孩子结点之间的联系。可通过完全二叉树的性质来解决。完全二叉树中,若按照层序遍历对每个结点进行编号(从1开始),父节点为 k ,则左右孩子结点编号一定为 2 * k 和 2 * k + 1 。根据此性质可在父子结点之间快速互相访问。

把待排序的数组看做完全二叉树层序遍历的结果,即可应用这个性质。如下图所示:
image

注意:由于数组下标从0开始,所以代码中左右孩子结点分别为2 * k+1和2 * k+2,与上图不太一样

记住以下两个重要性质即可:

  • 长度为len的数组中,最后一个三元组的根节点下标为len / 2 - 1;
  • 下标为k的结点,其左右孩子结点下标分别为2 * k+1和2 * k+2

代码示例

先上代码:

    private void heapSort(int[] arr) {
        int len = arr.length;
        //将乱序数组调整为大根堆
        for (int i = len / 2 - 1; i > -1; --i) {
            heapAdjust(arr, i, len);
        }
        //元素出堆、循环堆调整
        for (int i = len - 1; i > 0; --i) {
            //交换i和0两个元素,使用位运算完成
            swap(arr, i, 0);
            //堆调整,注意,这里第三个参数是长度不是下标,表示调整至下标为i-1的元素为止
            heapAdjust(arr, 0, i);
        }
        //arr排序完毕
    }
    /**
     * 交换数组中两个数,使用位运算
     */
    private void swap(int[] arr, int i, int j) {
        arr[i] ^= arr[j];
        arr[j] ^= arr[i];
        arr[i] ^= arr[j];
    }
     /**
     * 堆调整
     */
    private void heapAdjustOld(int[] arr, int s, int length) {
        for (int i = 2 * s + 1; i < length; i = 2 * i + 1) {
            if (i + 1 < length && arr[i + 1] > arr[i]) {
                ++i;
            }
            if (arr[s] > arr[i]) break;
            swap(arr, s, i);
            s = i;
        }

    }
    /**
     * 堆调整优化方法
     */
    private void heapAdjust(int[] arr, int s, int length) {
        int temp = arr[s];
        for (int j = 2 * s + 1; j < length; j =  j * 2 + 1) {
            if (j + 1 < length && arr[j + 1] > arr[j]) {
                ++j;
            }
            if (temp > arr[j]) break;
            arr[s] = arr[j];
            s = j;
        }
        arr[s] = temp;
    }

上述代码,堆调整方法的参数不太好理解,为了方便记忆,我修改了一版代码,如下:

    private void heapSort(int[] arr) {
        for (int idx = arr.length / 2 - 1; idx > -1; --idx) {
            heapAdjust(arr, idx, arr.length - 1);
        }
        for (int high = arr.length - 1; high > 0; --high) {
            swap(arr, 0, high);
            heapAdjust(arr, 0, high - 1);
        }
    }

    private void swap(int[] arr, int i, int j) {
        arr[i] ^= arr[j];
        arr[j] ^= arr[i];
        arr[i] ^= arr[j];
    }

    // start和end分别为初始和结束下标,表示这个范围内进行堆调整
    private void heapAdjust(int[] arr, int start, int end) {
        int parent = start;
        int rootVal = arr[parent];
        for (int son = 2 * parent + 1; son <= end; son = 2 * son + 1) {
            if ((son + 1) <= end && arr[son + 1] > arr[son]) ++son;
            if (rootVal >= arr[son]) break;
            arr[parent] = arr[son];
            parent = son;
        }
        arr[parent] = rootVal;
    }

堆排序流程

1.将乱序数组调整为大根堆

对于一个杂乱无章的数组而言,一层循环不足以将其调整为大根堆,需要两层。

  • 外层循环:相当于从下往上遍历所有的三元组;
  • 内层循环:用子函数heapAdjust实现。按照直观思路,此处不应该有循环,直接调整三元组即可(将父结点与某个孩子结点交换)。但是,每次调整后,孩子结点的值发生改变,该孩子结点值可能比下层结点小。因此需要循环对每一个发生改变的孩子结点的下层三元组进行修正。

2.元素出堆、循环堆调整

交换根节点与最后一个结点,把最大值移到了数组的末尾。再对前 n-1 个数进行堆调整,再次将最大值移到末尾,依次循环,即可得到升序排序结果。

注意:此处的堆调整不需要第一步中的两层循环,只需要一层,调用heapAdjust即可。因为前 n-1 个数中,只有arr[0]这一个位置不正确,并不是完全乱序,只需要调整这一个位置即可。

堆调整

堆调整是本算法中最核心的部分。即调整以 s 为根的三元组为正确的大根堆/小根堆,并对下层结点进行循环修正。

注意:此方法并不会遍历整颗二叉树,也不能将一棵杂乱的二叉树调整为大/小根堆

本部分代码很巧妙,需要细细品读。每次调整时,并不是直接交换父结点值和子结点值,那样会徒增赋值次数。

堆排序特点

  • 时间复杂度:最坏O(Nlog2N),平均性能接近最坏性能;空间复杂度O(1);
  • 不稳定排序;
  • 只能用于顺序结构,不能用于链式结构;
  • 初始建堆比较次数较多,记录少时不宜采用,记录较多时比较高效。
posted @ 2020-06-30 11:33  数小钱钱的种花兔  阅读(322)  评论(0编辑  收藏  举报