堆与堆排序

堆(大顶堆)的概念

堆是一种特殊的二叉树,大顶堆就是根节点为最大值的堆,它具有如下的特点:

  1. 堆是完全二叉树
  2. 堆常用数组来实现,也可以用树来实现。
  3. 堆中的每个节点都满足堆的条件:每个节点的关键字都大于(或等于)它的子节点的关键字

完全二叉树:就是指除了最后一层可能不是满节点,其他各层都必须是满节点的树,最后一层的节点遍布在树的左侧。
这个概念理解起来有点困难,但有一个简单的判断标准:一个完全二叉树对应一个没有空洞的数组,如下图所示


完全二叉树与非完全二叉树对应数组.jpg

画了一个极丑的图,凑活着看一下,意思就是非完全二叉树(下图)对应的数组下标8的元素为空洞(对应各种数据类型初始值,这里以空白表示),而一个完全二叉树(上图),其实就是一个大顶堆,满足堆的三个特点,对应了一个没有空洞的数组,这张图也反映了堆与数组的对应关系。

堆的特性与方法

弱序:堆相比于二叉搜索树是弱序的,二叉搜索树每个节点的左子结点的关键字都小于右子节点的关键字,但是堆每个节点的两个子节点关键字大小并无关系,只是约束为小于父节点关键字。

由于堆是弱序的,决定了一些方法是困难的或者无法实现的,比如说遍历、查找,这里所说的遍历和查找是按照树的特性来遍历和查找,时间复杂度为O(logN)。当然如果线性遍历查找对应数组也可以实现遍历和查找的功能,但时间复杂度降为O(N)。

虽然堆是弱序的,但对快速移除最大节点和快速插入新节点来说已经足够了,文章最后将给出堆的时间复杂度,我们先来看一下堆的操作。

移除:由于堆的最大值在根节点,移除最大节点的操作就很简单
maxNode = heapArray[0];
但问题在于移除之后,堆的结构就被破坏了,如何再构建出一个新的堆呢,这个操作叫做下滤

下滤:下滤操作分为三部分
1.把堆的最后一个节点移动到根节点,填补根节点被移除的空洞,称为index节点,数组容量减一
2.比较index节点的两个子节点的关键字,选出值较大的那个节点,称为larger节点,如果只有一个子节点,那就不用比较,直接为larger节点
3.比较index节点与larger节点的关键字,如果index<larger就交换两个节点位置,然后重复步骤2

简单点来说就是先用最后一个节点填补根的空洞(选用最后一个节点是为了保证为一个完全二叉树),然后经过比较把这个节点“沉”到合适的位置:小于父节点大于子节点,即满足堆的特点,这样就又构造了一个堆,结合图来看会比较容易理解。

我又要画图了...


下滤操作示意图.png

插入:将新的节点插入堆的最后,然后通过上滤操作,将新节点“升”到合适的位置
heapArray[N] = newNode;
N++;

上滤:上滤操作相比于下滤操作稍简单些,因为向上构建的话父节点就一个,不用像下滤一样比较两个子节点的关键字大小。
方法:比较新节点与父节点的关键字大小,如果新节点的关键字比较大就交换位置,直到上升到一个合适的位置,比父节点小比子节点大,这样又构建成了一个新的堆。
上滤操作就不画图了,反过来看就行了。

注意:这里上滤和下滤中的交换并不是真的交换(调用swap方法),一次交换需要复制三次数据,因为堆的上滤和下滤的方法具有流动性,可以先把比较的节点拿出来空出位置,这样被比较的节点可以流动填补空缺,最后再将拿出来的节点放回合适的位置,这样可以节省多次复制的操作。可以类比于排序算法中的冒泡和插入,冒泡是需要两两相比较,然后可能会交换位置,而插入排序实现则把待插入的数据拿出来,被比较的数流动填补空洞,最后插入合适的位置,一般来说这会比冒泡排序少了很多次数据复制过程,毕竟能省一点是一点。

堆的实现代码

下面展示堆的实现代码,有三个类,数据节点类,堆的实现方法类还有一个用于测试的App类
在分析代码之前需要先了解堆这个结构中,父节点和两个子节点的关系:

已知父节点对应的数组下标为x,则它的两个子节点对应的数组下标分别为:
左子结点:2x + 1 右子节点:2x + 2
已知一个子节点的数组下标为x,则它的父节点对应的数组下标为:
父节点:(x-1)/2

首先构建一个Node类,代表节点对象,简单起见,Node类就一个整型变量data

public class Node {

    //关键字
    private int data;

    //getter and setter
    public int getData() {
        return data;
    }

    public void setData(int data) {
        this.data = data;
    }

    //构造方法
    public Node(int data) {
        this.data = data;
    }
}

Heap类,这个是主要实现堆的类:

public class Heap {

    //对应数组、数组的最大容量、元素个数
    private Node[] heapArray;
    private int maxSize;
    private int currentSize;

    //构造方法,传入数组最大容量,构造数组,元素个数初始值为0
    public Heap(int maxSize) {
        this.maxSize = maxSize;
        currentSize = 0;
        heapArray = new Node[maxSize];
    }

    //判断堆是否为空的方法,为remove方法准备
    public boolean isEmpty() {
        return (currentSize == 0);
    }

    //插入方法中调用上滤的方法
    public boolean insert(int key) {
        if (currentSize == maxSize) {
            return false;
        }
        Node newNode = new Node(key);
        heapArray[currentSize] = newNode;
        trickleUp(currentSize++);
        return true;
    }

    //上滤方法,注意while循环体的条件一定是index>0,如果index=0则会导致parent求值后数组越界
    public void trickleUp(int index) {
        Node newNode = heapArray[index];    //新插入的节点
        int parent = (index-1)/2;       //index对应节点的父节点下标
        while(index > 0 && heapArray[parent].getData() < newNode.getData()) {
            heapArray[index] = heapArray[parent];   //如果parent关键字小则"下沉"
            index = parent;     //index指针向上移动
            parent = (parent-1)/2;      //相应的parent指针也向上移动
        }
        heapArray[index] = newNode;     //最后将插入的节点填补到合适的位置
    }

    //移除方法,调用下滤方法完成堆的重构
    public Node remove() {
        if (isEmpty()) {
            return null;
        }
        Node root = heapArray[0];
        heapArray[0] = heapArray[--currentSize];
        trickleDown(0);
        return root;
    }

    //下滤方法,while循环体的条件index < currentSize/2是筛选出至少有一个子节点的节点(如果是有一个子节点那一定是左子节点)
    public void trickleDown(int index) {
        int largerChild;        //变量指向两个子节点中关键值较大的那个
        Node top = heapArray[index];    //先把新的根节点拿出来,新的根节点为之前堆的最后一个节点

        while (index < currentSize/2) {     //循环向下探测
            int leftChild = 2*index + 1;    //左子节点的下标
            int rightChild = leftChild + 1;     //右子节点的下标

            //if中第一个条件确定右子节点否存在,第二个条件比较左右两个子节点的大小,找到较大的那一个
            if (rightChild < currentSize && heapArray[leftChild].getData() < heapArray[rightChild].getData()) {
                largerChild = rightChild;
            } else {
                largerChild = leftChild;
            }
            //再比较根节点与较大那个子节点的关键值大小,满足条件则移动位置
            if (top.getData() >= heapArray[largerChild].getData() ) {
                break;
            } else {
                heapArray[index] = heapArray[largerChild];
                index = largerChild;
            }
        }
        //最后将拿出来的新根节点插入到合适的位置
        heapArray[index] = top;
    }
    

    //一个打印堆的方法,让堆显示出来更像一颗二叉树
    public void  displayHeap() {
        System.out.printf("heapArray: ");
        for (int j = 0; j < currentSize; j++) {
            if (heapArray[j] != null) {
                System.out.printf(heapArray[j].getData() + " ");
            } else {
                System.out.printf("-- ");
            }
        }
        System.out.println();

        int nBlanks = 32;
        int itemsPerRow = 1;
        int column = 0;
        int j = 0;
        String dots = "..................................";
        System.out.println(dots + dots);

        while (currentSize > 0) {
            if (column == 0) {
                for (int k = 0; k < nBlanks; k++) {
                    System.out.print(" ");
                }
            }
            System.out.print(heapArray[j].getData());
            if (++j == currentSize) break;

            if (++column == itemsPerRow) {
                nBlanks /= 2;
                itemsPerRow *= 2;
                column = 0;
                System.out.println();
            } else {
                for (int k = 0; k < nBlanks*2 - 2; k++) {
                    System.out.print(" ");
                }
            }
        }

        System.out.println("\n" + dots + dots);
    }
}

最后是一个app测试类,用来演示验证堆的各种方法,HeapApp类:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class HeapApp {

    public static void main(String[] args) throws IOException{
        int value;
        Heap theHeap = new Heap(31);    //构造一个最大容量为31的堆
        boolean success;

        //向堆中插入测试数据
        theHeap.insert(10);
        theHeap.insert(20);
        theHeap.insert(30);
        theHeap.insert(40);
        theHeap.insert(50);
        theHeap.insert(60);
        theHeap.insert(70);
        theHeap.insert(80);
        theHeap.insert(90);

        //提供一个方法入口,用户可以通过输入首字母来对创建的堆进行操作
        while (true) {
            System.out.print("Enter first letter of show, insert, remove, change: ");
            int choice = getChar();
            switch (choice) {
                case 's':   //打印堆
                    theHeap.displayHeap();
                    break;
                case 'i':   //向堆中插入数据
                    System.out.print("Enter value to insert: ");
                    value = getInt();
                    success = theHeap.insert(value);
                    if (!success) {
                        System.out.println("Can't insert, heap full");
                    }
                    break;
                case 'r':   //从堆中移除数据
                    if (!theHeap.isEmpty()) {
                        theHeap.remove();
                    } else {
                        System.out.println("Can't remove;, heap empty");
                    }
                    break;
                default:
                    System.out.println("Invalid entry\n");
            }
        }

    }

    public static String getString() throws IOException {
        InputStreamReader isr = new InputStreamReader(System.in);
        BufferedReader br = new BufferedReader(isr);
        String s = br.readLine();
        return s;
    }

    public static char getChar() throws IOException {
        String s = getString();
        return s.charAt(0);
    }

    public static int getInt() throws IOException {
        String s = getString();
        return Integer.parseInt(s);
    }
}

运行这个程序结果,如下图所示:


堆示例的运行结果.png

堆的效率

堆的操作耗时基本都在上滤和下滤操作的循环方法中,复制操作的次数为树的高度减一,设树的高度为H,则树高与节点数N的关系为:H=log2(N+1),复制次数为:H-1,所以去掉常数,堆操作的时间复杂度为O(logN)

堆排序

基于对以上堆的理解,再来分析堆排序就会相对简单

设想:将一个乱序的数组中的所有元素,调用堆的insert()方法插入堆中,再调用堆的remove()方法逐个返回堆顶元素,这样就可以得到一个有序的数组。
因为insert()和remove()方法的时间复杂度都是O(logN),且都需要执行N次,则堆排序算法的时间复杂度为O(N*logN)

以上的设想是基于我们已经实现了一个堆,将堆作为一个工具来对数组进行排序,但堆作为一个ADT(抽象数据结构),它的底层也是一个数组,是不是可以考虑在待排序的数组之上直接构建堆,这样可以省去一个数组的内存空间。

将一个乱序的数组构建为一个堆,有两种方法:

  1. 可以对数组中的元素遍历调用上滤trickleUp()的方法,这样总共调用了N次上滤的方法,时间复杂度还是O(N*logN)
  2. 但如果换一种思路,采取下滤trickleDown()的方法来构建堆,哪些元素需要下滤呢,有子节点的节点需要下滤,那么所有的叶子节点就可以不用考虑,这样平均是调用了N/2次下滤的方法,虽然总的时间复杂度依然是O(N*logN),但还是要快一些,下面用一组简单极丑的图来说明一下:
数组构建堆的方法比较.png

上图中左图采用方法1,右图采用方法2,可以清晰的观察到右图的效率更高

以下代码我们采用方法2下滤的方式构建堆

堆排序的实现代码

首先实现构建堆的代码,传入一个数组,方法仅需要考虑remove()和trickleDown()方法的实现,外加打印堆和数组的方法:

class Heap {
        //传入数组,构建成堆
        public void build2Heap(int[] arr) {
            int size = arr.length;
            //j的初始值对应最后一个有子节点的节点,然后开始下滤操作
            for (int j = size/2 - 1; j >= 0; j--) {
                trickleDown(arr,size,j);
            }
        }

        //下滤操作没有什么变化,注意循环条件和判断条件即可
        public void trickleDown(int[] arr, int size, int index) {
            int largerChild;
            int current = arr[index];

            while (index < size/2) {
                int leftChild = index*2 + 1;
                int rightChild = leftChild + 1;
                if (rightChild <= size && arr[rightChild] > arr[leftChild]) {
                    largerChild = rightChild;
                } else {
                    largerChild = leftChild;
                }
                if (current > arr[largerChild]) {
                    break;
                } else {
                    arr[index] = arr[largerChild];
                    index = largerChild;
                }
            }
            arr[index] = current;
        }

        //remove操作注意传入数组的size已经截取有序的部分
        public int remove(int[] arr, int size) {
            int top = arr[0];
            arr[0] = arr[--size];
            trickleDown(arr, size, 0);
            return top;
        }

        //用树的形式来打印堆的方法
        public void displayHeap(int[] arr) {
            int size = arr.length;
            int nBlanks = 32;
            int itemsPerRow = 1;
            int column = 0;
            int j = 0;
            String dots = "..................................";
            System.out.println(dots + dots);

            while (size > 0) {
                if (column == 0) {
                    for (int k = 0; k < nBlanks; k++) {
                        System.out.print(" ");
                    }
                }
                System.out.print(arr[j]);
                if (++j == size) break;

                if (++column == itemsPerRow) {
                    nBlanks /= 2;
                    itemsPerRow *= 2;
                    column = 0;
                    System.out.println();
                } else {
                    for (int k = 0; k < nBlanks*2 - 2; k++) {
                        System.out.print(" ");
                    }
                }
            }

            System.out.println("\n" + dots + dots);
        }

        //打印堆对应数组的方法
        public void displayArray(int[] arr) {
            for (int j = 0; j < arr.length; j++) {
                System.out.print(arr[j] + " ");
            }
            System.out.println();
        }
    }

以上堆的操作中省略了insert()方法和trickleUp()方法,因为采用方法2的堆排序用不到这些方法

HeapSort类主要在heapSort()方法中实现了将数组构建成堆,然后再调用堆的remove()方法得到有序数组
下面直接给出全部堆排序的代码,上面的Heap部分是其中的一个内部类,HeapSort:

public class HeapSort {

    private int[] arr;

    //构造方法,传入数组
    public HeapSort(int[] arr) {
        this.arr = arr;
    }

    //堆排序的方法
    public void heapSort() {
        int size = arr.length;
        int currentSize = size;     //currentSize初始值为数组长度
        int j;
        System.out.print("unSorted: ");     //先打印出未排序的数组
        displayArr(arr);

        Heap theHeap = new Heap();      //创建一个堆对象

        theHeap.build2Heap(arr);        //构建堆
        System.out.print("Heap: ");     //打印堆与对应数组
        theHeap.displayArray(arr);
        theHeap.displayHeap(arr);

        //堆构建完成后,通过remove方法来得到有序数组
        //这里注意只有一个数组,取出来的最大值放在哪里呢?正好堆重新构建后,数组的最后一位空出来了
        //我们可以合理利用空间,将最大值循环的插入数组最后
        for (j = size-1; j>=0; j--) {
            int biggest = theHeap.remove(arr,currentSize--);    //这里传入的currentSize已经将有序部分截断了
            arr[j] = biggest;
        }

        //打印排序之后的数组
        System.out.print("Sorted: ");
        displayArr(arr);
    }

    //打印数组的方法
    public void displayArr(int[] arr) {
        for (int j = 0; j < arr.length; j++) {
            System.out.print(arr[j] + " ");
        }
        System.out.println();
    }

    //内部类,堆的实现
    class Heap {
        //传入数组,构建成堆
        public void build2Heap(int[] arr) {
            int size = arr.length;
            //j的初始值对应最后一个有子节点的节点,然后开始下滤操作
            for (int j = size/2 - 1; j >= 0; j--) {
                trickleDown(arr,size,j);
            }
        }

        //下滤操作没有什么变化,注意循环条件和判断条件即可
        public void trickleDown(int[] arr, int size, int index) {
            int largerChild;
            int current = arr[index];

            while (index < size/2) {
                int leftChild = index*2 + 1;
                int rightChild = leftChild + 1;
                if (rightChild <= size && arr[rightChild] > arr[leftChild]) {
                    largerChild = rightChild;
                } else {
                    largerChild = leftChild;
                }

                if (current > arr[largerChild]) {
                    break;
                } else {
                    arr[index] = arr[largerChild];
                    index = largerChild;
                }
            }
            arr[index] = current;
        }

        //remove操作注意传入数组的size其实已经截取有序的部分
        public int remove(int[] arr, int size) {
            int top = arr[0];
            arr[0] = arr[--size];
            trickleDown(arr, size, 0);
            return top;
        }

        //用树的形式来打印堆的方法
        public void displayHeap(int[] arr) {
            int size = arr.length;
            int nBlanks = 32;
            int itemsPerRow = 1;
            int column = 0;
            int j = 0;
            String dots = "..................................";
            System.out.println(dots + dots);

            while (size > 0) {
                if (column == 0) {
                    for (int k = 0; k < nBlanks; k++) {
                        System.out.print(" ");
                    }
                }
                System.out.print(arr[j]);
                if (++j == size) break;

                if (++column == itemsPerRow) {
                    nBlanks /= 2;
                    itemsPerRow *= 2;
                    column = 0;
                    System.out.println();
                } else {
                    for (int k = 0; k < nBlanks*2 - 2; k++) {
                        System.out.print(" ");
                    }
                }
            }

            System.out.println("\n" + dots + dots);
        }

        //打印堆对应数组的方法
        public void displayArray(int[] arr) {
            for (int j = 0; j < arr.length; j++) {
                System.out.print(arr[j] + " ");
            }
            System.out.println();
        }
    }

    //main函数
    public static void main(String[] args) {
        //一个乱序的数组
        int[] arr = {10,40,20,90,50,70,30,80,60};
        //创建堆排序对象,传入数组
        HeapSort sort = new HeapSort(arr);
        //调用堆排序方法
        sort.heapSort();
    }
}

最终测试结果如下图所示:


堆排序结果.png

堆排序的效率

之前已经分析过,堆排序的时间复杂度为O(NlogN),与快速排序的时间复杂度一样,一般来说堆排序没有快速排序速度快,部分原因是堆排序在下滤操作中的循环比较复杂,而快速排序采用分治法的循环操作比较简单,但堆排序对初始数据分布不敏感,在数组基本有序的情况下,快速排序的时间复杂度会降到O(N^2),而堆排序对于任意排列的数据,其时间复杂度都是O(NlogN)

参考文章

《Data Structures & Algorithms in Java》 Robert Lafore著

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,884评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,212评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,351评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,412评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,438评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,127评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,714评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,636评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,173评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,264评论 3 339
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,402评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,073评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,763评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,253评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,382评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,749评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,403评论 2 358

推荐阅读更多精彩内容

  • 0.目录 1.优先队列ADT 2.几种实现 3.二叉堆 4.d-堆 5.左式堆 6.斜堆 7.二项队列 8.斐波那...
    王侦阅读 3,101评论 1 2
  • 6. 1 模型 优先队列是允许至少下列两种操作的数据结构: insert(插入),它的作用是显而易见的;以及del...
    好好学习Sun阅读 597评论 0 0
  • 原文链接 堆排序可以做什么 首先应该弄清楚堆排序可以解决什么问题,答案是显而易见的:排序。说得通俗点儿就是对一组无...
    只为此心无垠阅读 585评论 0 0
  • 堆就是用数组实现的二叉树,所以它没有使用父指针或者子指针。堆根据“堆属性”来排序,“堆属性”决定了树中节点的位置。...
    唐先僧阅读 249,020评论 21 252
  • 本周重心主要放在了茶艺师培训课程上了。三次课程每晚上完9点三十分,到家再回听群里的学习,所以这周关于写作方面的事做...
    简书时间煮雨阅读 190评论 0 0