堆排序以及Top K问题-Java实现

一.问题背景

  如果做过参加过面试或者做过一些面试题,应该知道特别经典的top K问题,比如“找出无序数组中的最大或者最小K个数”:

  这种题可以排序后再输出最大或者最小的几个。但是不论是使用快排还是归并排序,毫无疑问,空间和时间复杂度的开销都是不满足面试官的要求的;而使用“堆”这种数据结构就比较好的解决这种问题,空间开销O(1),时间开销O(N logK)。

  需要注意的是,这里说的“堆”不是指堆栈的堆,而是一种数据结构,更准确的说是“完全二叉树”。

  下面就详细对堆这种数据结构进行介绍,注:本文的内容是在学习浙江大学何钦铭教授的数据结构课程后整理的。

  标注原文地址:https://www.cnblogs.com/-beyond/p/13084115.html

二.堆的介绍

2.1数组和链表实现优先队列

  再说Top K之前,先说一下调度算法。学过操作系统就知道,进程调度有多种算法,而最简单的就是“先到先服务”算法,这种算法可以简单的使用队列来实现,但是存在一个问题就是无法根据进程的优先级调整执行顺序,比如有两个进程,一个进程只是连接打印机打印一张纸,另外一个进程负责核心功能处理,很明显,核心功能处理的进程优先级更高,但是操作系统按照先到先服务算法来调度时,核心功能处理的进程并不是优先调度;

  这个时候可以切换为“按照优先级”进行调度,只需要每次选择最高优先级的进程执行,自己进行实现的话,有多种方式:

  

  仔细想一下,Top K的问题,和这里说的调度优先级其实是一样的问题。

  

2.2堆的介绍

  1.堆是一种树结构,准确的说是“二叉树”,更准确的说是“完全二叉树”,根据完全二叉树的特点,可以使用数组来存储堆。

  2.堆是有序的,任一节点的关键字是其子树所有节点的最大值或者最小值。

  3.如果根节点是最大值的堆,称为“最大堆”或者“大顶堆”、“大根堆”;

  4.如果根节点是最小值的堆,称为“最小堆”、“小顶堆”、“小根堆”;

   

三.堆的各种操作

3.1创建堆

  因为堆满足完全二叉树的特点,所以可以使用数组来存储堆;下面是代码:

package cn.ganlixin.tree.heap;

import java.util.Scanner;

/**
 * 描述:
 * 数据结构-堆(此处为最大堆)
 *
 * @author ganlixin
 * @create 2020-06-09
 */
public class MaxHeap {

    /**
     * 保存堆元素的数组
     */
    private int[] elements;

    /**
     * 堆的大小
     */
    private int size;

    /**
     * 堆的容量
     */
    private int capacity;

    public MaxHeap() {
        this.size = 0;
        this.capacity = 0;
    }

    /**
     * 建堆并调整堆
     */
    public void createMaxHeap() {
        Scanner scanner = new Scanner(System.in);

        System.out.print("请输入堆的最大容量:");
        this.capacity = scanner.nextInt();
        this.size = 0;

        // 数组长度为容量加1,0号元素为哨兵元素
        this.elements = new int[this.capacity + 1];
        this.elements[0] = Integer.MAX_VALUE;

        System.out.print("请输入元素个数:");
        this.size = scanner.nextInt();

        if (this.size > this.capacity) {
            throw new RuntimeException("元素个数不能超过最大容量!");
        }

        System.out.print("请依次输入" + this.size + "个元素:");
        for (int i = 1; i <= this.size; i++) {
            elements[i] = scanner.nextInt();
        }

        buildHeap(); // 构建堆(因为初始状态,数组并不满足堆的有序性特点,所以需要进行调整构建,后面会介绍)
        System.out.println("已经完成堆的建立和调整");
    }
}

  

3.2堆的插入

  新元素,插入堆时,默认是插入到最后一个位置,这样保证满足完全二叉树的特点,但是可能不满足有序性的特点,所以需要进行一些调整;

  对于最大堆来说,任一根节点都比子节点的值大,所以如果插入的元素(默认是在最后),就需要和其父节点进行比较,如果比父节点大,则需要与父节点交换位置;这是一次调整,但是调整完以后,新插入的节点也许还会比新的父节点大,所以还需要继续比较,直到父节点比自己大,才停止比较,此时才找到新增元素应该插入的位置。

/**
 * 向最大堆中新增一个元素<br>
 * 空间复杂度O(1),时间复杂度O(logN)
 *
 * @param newItem 新增的元素值
 */
public void insertElement(int newItem) {
    // 新元素插入的位置,默认为最后一个元素的后面
    int nextIndex = ++this.size;

    // 将新元素放到最后,可以满足完全二叉树的要求,但是有序性不一定能保证,所以需要和父节点进行比较(父节点就是index/2)
    while (elements[nextIndex / 2] < newItem) {
        // 当父节点比新插入的节点小的时候,将父节点移到新节点准备插入的位置
        elements[nextIndex] = elements[nextIndex / 2];

        // 修改新节点准备插入的位置(此时为父节点的旧位置)
        nextIndex /= 2;
    }

    // 到此,nextIndex就指向了应该插入的位置(比子节点都大,比父节点小)
    elements[nextIndex] = newItem;
}

  

3.3堆元素的删除

  堆元素的删除(最大堆),是指将堆的最大值删除(也就是对顶元素给删除),删除对顶元素后,需要进行调整,默认是使用最后一个元素来顶替对顶元素,这样可以满足完全二叉树的特点,但是不一定满足有序性,所以需要调整;

  调整的过程,就是比较堆顶节点(此时已经替换为最后一个节点值)与子节点,当根节点比子节点小的时候,就交换根节点和子节点的位置,知道根节点大于子节点(左右子节点),才能确定根节点应该插入的位置。

/**
 * 删除最大堆的最大值(堆顶元素)
 *
 * @return 堆最大值
 */
public int deleteMaxItem() {
    // 第一个元素就是最大值
    int maxItem = elements[1];

    // 将最后一个元素取出来(删除,size减一),用来替补第一个元素(被删除的最大值)
    int lastItem = elements[size];
    size--;

    // 最后一个元素存放的位置,默认为1,表示第一个位置
    int insertIndex = 1;

    // 最后一个元素不一定是最大的,放到堆顶不一定合适,所以需要调整
    while (insertIndex * 2 <= size) {
        // childIndex默认指向左孩子
        int childIndex = insertIndex * 2;

        // 如果父节点有右孩子,并且左孩子比右孩子小,则childIndex指向较大的元素(也就是右孩子)
        if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
            childIndex++;
        }

        // 当最后一个元素大于指向的元素时,证明找到了插入位置,则中断循环
        if (lastItem >= elements[childIndex]) {
            break;
        } else {
            // 最后一个元素比子节点小(比较大的节点小),则交换较大节点和父节点的位置
            elements[insertIndex] = elements[childIndex];
        }

        // 父节点指向空出来的子节点位置
        insertIndex = childIndex;
    }

    elements[insertIndex] = lastItem;
    return maxItem;
}

  

3.3将无序数组调整堆

  就以top K的问题来说,只需要建立一个堆的数据结构,然后弹出堆顶的K个元素,就是top K。

  但现在的问题是,提供的数组是无序的,怎么讲无序数组转换为堆:

  1.一种方式是从空堆开始一个一个添加元素,添加元素过程中会进行调整,元素插入完毕,堆也就建好了,这样的时间复杂度是N log(N),比较低效;

  2.直接在无序数组上进行调整,将期调整为堆结构,时间复杂度为O(logN);

  下面就介绍一下第二种方式。

  直接在无序数组上调整,不是从对顶元素开始调整,而是从最后一个元素进行调整,调整的过程和插入的过程相似:找到节点的父节点,以父节点为根调整为最大堆(根节点与左右子节点选最大值作为根),如此反复

/**
 * 建立最大堆<br>
 * 两种方案<br>
 * 方案一:建立空堆,N个数,N次插入,时间复杂度O(N*logN),舍弃!<br>
 * 方案二:先顺序输入,满足完全二叉树要求,再进行调整堆,满足有序性,时间复杂度O(N)
 */
private void buildHeap() {
    for (int i = size / 2; i > 0; i--) { // size/2是最后一个元素的父节点位置
        adjustHeap(i);
    }
}

/**
 * 以index指向的节点作为根,将该子堆调整为最大堆
 *
 * @param root 子堆的根节点
 */
private void adjustHeap(int root) {

    // 取出根节点存的值
    int rootVal = elements[root];

    // insertIndex指向根节点值应该插入的位置
    int insertIndex = root;
    while (insertIndex * 2 <= size) {
        int childIndex = insertIndex * 2;
        if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
            childIndex++;
        }

        // 如果根节点的值大于子节点,则证明找到了插入的位置
        if (rootVal > elements[childIndex]) {
            break;
        } else {
            elements[insertIndex] = elements[childIndex];
        }

        insertIndex = childIndex;
    }

    elements[insertIndex] = rootVal;
}

  

四.完成代码

  封装在MaxHeap.java中(最大堆)

package cn.ganlixin.tree.heap;

import java.util.Scanner;

/**
 * 描述:
 * 数据结构-堆(此处为最大堆)
 * 完全二叉树,使用数组存储
 *
 * @author ganlixin
 * @create 2020-06-09
 */
public class MaxHeap {

    /**
     * 保存堆元素的数组
     */
    private int[] elements;

    /**
     * 堆的大小
     */
    private int size;

    /**
     * 堆的容量
     */
    private int capacity;

    public MaxHeap() {
        this.size = 0;
        this.capacity = 0;
    }

    /**
     * 建堆并调整堆
     */
    public void createMaxHeap() {
        Scanner scanner = new Scanner(System.in);

        System.out.print("请输入堆的最大容量:");
        this.capacity = scanner.nextInt();
        this.size = 0;

        // 数组长度为容量加1,0号元素为哨兵元素
        this.elements = new int[this.capacity + 1];
        this.elements[0] = Integer.MAX_VALUE;

        System.out.print("请输入元素个数:");
        this.size = scanner.nextInt();

        if (this.size > this.capacity) {
            throw new RuntimeException("元素个数不能超过最大容量!");
        }

        System.out.print("请输入" + this.size + "个元素:");
        for (int i = 1; i <= this.size; i++) {
            elements[i] = scanner.nextInt();
        }

        buildHeap();
        System.out.println("已经完成堆的建立和调整");
    }

    /**
     * 建立最大堆<br>
     * 两种方案<br>
     * 方案一:建立空堆,N个数,N次插入,时间复杂度O(N*logN),舍弃!<br>
     * 方案二:先顺序输入,满足完全二叉树要求,再进行调整堆,满足有序性,时间复杂度O(N)
     */
    private void buildHeap() {
        if (isEmpty()) {
            throw new RuntimeException("堆为空,无法完成建堆操作");
        }

        for (int i = size / 2; i > 0; i--) {
            adjustHeap(i);
        }
    }

    /**
     * 以index指向的节点作为根,将该子堆调整为最大堆
     *
     * @param root 子堆的根节点
     */
    private void adjustHeap(int root) {

        // 取出根节点存的值
        int parentVal = elements[root];

        // parentIndex指向根节点值应该插入的位置
        int parentIndex = root;
        while (parentIndex * 2 <= size) {
            int childIndex = parentIndex * 2;
            if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
                childIndex++;
            }

            // 如果根节点的值大于子节点,则证明找到了插入的位置
            if (parentVal > elements[childIndex]) {
                break;
            } else {
                elements[parentIndex] = elements[childIndex];
            }

            parentIndex = childIndex;
        }

        elements[parentIndex] = parentVal;
    }

    /**
     * 向最大堆中新增一个元素<br>
     * 空间复杂度O(1),时间复杂度O(logN)
     *
     * @param newItem 新增的元素值
     */
    public void insertElement(int newItem) {
        if (isFull()) {
            throw new RuntimeException("堆已满,无法再添加元素");
        }

        // 新元素插入的位置,默认为最后一个元素的后面
        int nextIndex = ++this.size;

        // 将新元素放到最后,可以满足完全二叉树的要求,但是有序性不一定能保证,所以需要和父节点进行比较(父节点就是index/2)
        while (elements[nextIndex / 2] < newItem) {
            // 当父节点比新插入的节点小的时候,将父节点移到新节点准备插入的位置
            elements[nextIndex] = elements[nextIndex / 2];

            // 修改新节点准备插入的位置(此时为父节点的旧位置)
            nextIndex /= 2;
        }

        // 到此,nextIndex就指向了应该插入的位置(比子节点都大,比父节点小)
        elements[nextIndex] = newItem;
    }

    /**
     * 删除最大堆的最大值(堆顶元素)
     *
     * @return 堆最大值
     */
    public int deleteMaxItem() {
        if (isEmpty()) {
            throw new RuntimeException("堆为空,不能进行删除操作");
        }

        // 第一个元素就是最大值
        int maxItem = elements[1];

        // 将最后一个元素取出来(删除,size减一),用来替补第一个元素(被删除的最大值)
        int lastItem = elements[size];
        size--;

        // 最后一个元素存放的位置,默认为1,表示第一个位置
        int insertIndex = 1;

        // 最后一个元素不一定是最大的,放到堆顶不一定合适,所以需要调整
        while (insertIndex * 2 <= size) {
            // childIndex默认指向左孩子
            int childIndex = insertIndex * 2;

            // 如果父节点有右孩子,并且左孩子比右孩子小,则childIndex指向较大的元素(也就是右孩子)
            if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
                childIndex++;
            }

            // 当最后一个元素大于指向的元素时,证明找到了插入位置,则中断循环
            if (lastItem >= elements[childIndex]) {
                break;
            } else {
                // 最后一个元素比子节点小(比较大的节点小),则交换较大节点和父节点的位置
                elements[insertIndex] = elements[childIndex];
            }

            // 父节点指向空出来的子节点位置
            insertIndex = childIndex;
        }

        elements[insertIndex] = lastItem;
        return maxItem;
    }

    /**
     * 打印排序后的堆
     */
    public void printSortedHeap() {
        for (int i = 1; i <= this.size; i++) {
            System.out.print(elements[i] + " ");
        }
        System.out.println();
    }

    /**
     * 判断堆是否已经满了(size>=capacity)
     *
     * @return true堆已满;false堆未满
     */
    public boolean isFull() {
        return this.size >= capacity;
    }

    /**
     * 判断堆是否为空
     *
     * @return true堆为空;false堆不为空
     */
    public boolean isEmpty() {
        return this.size == 0;
    }
}

  测试:

package cn.ganlixin.tree.heap;

/**
 * 描述:
 * 测试最大堆
 *
 * @author ganlixin
 * @create 2020-06-09
 */
public class Main {
    public static void main(String[] args) {
        MaxHeap maxHeap = new MaxHeap();

        // 输入元素,并调整堆
        maxHeap.createMaxHeap();

        System.out.print("输出堆:");
        maxHeap.printSortedHeap();

        int deleteMaxItem = maxHeap.deleteMaxItem();
        System.out.println("删除堆中最大元素:" + deleteMaxItem);
        System.out.print("输出堆:");
        maxHeap.printSortedHeap();
    }
}

  输出:

请输入堆的最大容量:10
请输入元素个数:6
请依次输入6个元素:8 5 9 6 4 2
已经完成堆的建立和调整
输出堆:9 6 8 5 4 2 
删除堆中最大元素:9
输出堆:8 6 2 5 4 

  

五.再说Top K问题

  其实上面介绍完堆的各种操作后,对于Top K的问题已经能够解决了,此处以最大的top K问题为例:

  需要注意的是,在建堆的时候,并不是将整个数组的N个元素都调整,而是只调整前K个元素,让前K个元素保持堆的结构,也就是说,堆的容量,是K,而不是N。步骤如下:

  1.将前K个元素调整为最小堆(也称“小顶堆”、“小根堆”);

  2.依次将K+1后面的元素(看做新元素),与堆顶元素(堆的最小值)进行比较:

a.如果堆顶元素比新元素要大,则新元素不用入堆,忽略;

b.如果堆顶元素比新元素要小,则将新元素替换掉堆顶元素,然后进行调整堆(始终保证堆顶元素是堆中元素的最小值);

  3.不断重复步骤2,直至比较完N-K个元素;

  4.比较完后,堆中的元素就是最大的K个元素。

  说直白点,就是进行N-K+1次调整堆,整个流程的时间复杂度为O(N logK)

  如果需要按照排序输出K个元素,则进行K次删除堆顶元素即可(每次删除都会调整堆,保证堆顶最小)。

  下面是代码:

package cn.ganlixin.tree.heap;

import java.util.Scanner;

/**
 * 描述:
 *
 * @author ganlixin
 * @create 2020-06-10
 */
public class TopK {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.print("请输入元素总个数:");
        int capacity = scanner.nextInt();

        System.out.print("请输入要找最大的几个数:");
        int k = scanner.nextInt();

        // 申请一个K+1的数组(因为建立的堆包含K个元素,而不是N个元素,0号元素用来做哨兵)
        int[] arr = new int[k + 1];
        arr[0] = Integer.MAX_VALUE;

        System.out.print("请输入全部元素:");

        // 先前K个元素进行建堆调整
        for (int i = 1; i <= k; i++) {
            arr[i] = scanner.nextInt();
        }

        // 先将前K个元素进行调整为最小堆(小顶堆)
        adjustHeap(arr, 1, k);

        // 继续处理后面的n-k个元素,和堆顶元素进行比较,如果比堆顶元素大,则替换堆顶元素,并进行调整堆
        for (int i = k + 1; i <= capacity; i++) {
            int newItem = scanner.nextInt();
            if (newItem > arr[1]) {
                arr[1] = newItem; // 替换为新元素

                // 每次整个堆都调整
                adjustHeap(arr, 1, k);
            }
        }

        System.out.print("最大的" + k + "个数是:");
        for (int i = 1; i <= k; i++) {
            System.out.print(arr[i] + " ");
        }
    }

    /**
     * 将数组调整为满足最小堆的结构
     *
     * @param arr   要调整的数组
     * @param start 要调整的开始位置(index)
     * @param end   要调整的结束为止(index)
     */
    private static void adjustHeap(int[] arr, int start, int end) {
        for (int i = end / 2; i > 0; i--) { // end/2是最后一个节点的父节点
            int parentVal = arr[i];
            int parentIndex = i;

            while (parentIndex * 2 <= end) {
                int childIndex = parentIndex * 2; // 左孩子节点

                // childIndex指向两个子节点中较小的一个
                if (childIndex != end && arr[childIndex] > arr[childIndex + 1]) {
                    childIndex++;
                }

                // 比较父节点和较大一个子节点的值,小顶堆需要父节点比子节点小
                if (parentVal < arr[childIndex]) {
                    break;
                } else {
                    arr[parentIndex] = arr[childIndex];
                }

                parentIndex = childIndex;
            }

            arr[parentIndex] = parentVal;
        }
    }
}

  测试:

请输入元素总个数:14
请输入要找最大的几个数:5
请输入全部元素:20 5 2 8 10 3 23 5 99 24 0 -7 8 100
最大的5个数是:20 23 100 24 99 

  

原文地址:https://www.cnblogs.com/-beyond/p/13084115.html