纯数据结构Java实现(6/11)(二叉堆&优先队列)

堆其实也是树结构(或者说基于树结构),一般可以用堆实现优先队列。

二叉堆

堆可以用于实现其他高层数据结构,比如优先队列

而要实现一个堆,可以借助二叉树,其实现称为: 二叉堆 (使用二叉树表示的堆)。

但是二叉堆,需要满足一些特殊性质:

其一、二叉堆一定是一棵完全二叉树 (完全二叉树可以用数组表示,见下面)
完全二叉树缺失的部分一定是在右下方。(每层一定是从左到右的顺序优先存放)

  • 完全二叉树的结构,可以简单理解成按层安放元素的。(所以数组是不错的底层实现)

其二、父节点一定比子节点大 (针对大顶堆的情况)。

二叉堆实现

因为二叉堆是完全二叉树,又因为完全二叉树可以用数组的方式实现

(数组编号/索引,正好对应数组的索引/下标)

故而这里完全二叉树的实现就可以绕过二叉树的定义(不用使用 left, right 这样的定义)。

?

这样表示的好处是可以根据索引来判断父子关系(而不用 left, right关系):

具体关系,可以用数学归纳法证明。

注意一下,0 这个位置要空出来,如果从 0 开始存储的话,规律是这样的:

数组实现:

基本的框架很简单,大致如下:

package maxheap;

import array.AdvanceDynamicArray;

public class MaxHeap<E extends Comparable<E>> {

    //用动态数组进行实现
    private AdvanceDynamicArray<E> data;

    //构造函数
    public MaxHeap(int capacity) {
        data = new AdvanceDynamicArray<>(capacity);
    }

    public MaxHeap() {
        data = new AdvanceDynamicArray<>();
    }

    //2个简单的方法
    public int getSize() {
        return data.getSize();
    }

    public boolean isEmpty() {
        return data.isEmpty();
    }

    //三个辅助函数,根据索引计算父,子存储位置索引
    private int parent(int index) {
        if (index == 0) {
            //根索引没有父亲
            throw new IllegalArgumentException("index 0 没有父亲节点");
        }
        return (index - 1) / 2;
    }

    private int leftChild(int index) {
        return index * 2 + 1;
    }

    private int rightChild(int index) {
        return index * 2 + 2;
    }

    //存取元素
    //外部的 add,对于堆来说就是 sift up (上浮)
}

但是增删的时候,涉及到重新调整树结构,需要分析一下。

直接说结论: 先添加,后调整。

首先、只是放上去存储,则比较简单,见下图:

  • 树的视角,本层放的下,则放在本层(从左至右的末尾);本层放不下,那么就放在下一层。
  • 数组的角度,就是放在index末尾,图上就是 index 为 10 的地方。

但是放上去还没有完。

其次、一般还要进行相关的调整,否则不满足二叉堆的第二个性质: 父节点大于子节点。

怎么调整?上浮

即、只需要调整新节点的父节点,父节点的父节点。。。这一条线上的父节点即可。

(这里最后一次和根节点比较,不用交换)

也就是说,总共过程就两个:

  • 1.末尾添加
  • 2.不停的交换,直到不再大于其父节点

代码如下(就是一个循环替换,比较的过程):

    //外部的 add,对于堆来说就是 sift up (上浮)
    public void add(E e) {
        data.append(e); //先向数组末尾添加元素

        //维护上浮 (队数组进行交换)
        siftUp(data.getSize()-1);
    }

    private void siftUp(int index) {
        //给定 index 的元素不断和父节点比较
        while (index > 0 && data.get(parent(index)).compareTo(data.get(index)) < 0) {
            //父节点比子节点小,交换 (上浮)
            data.swap(parent(index), index);

            //然后再向上找
            index = parent(index);
        }
    }

删除元素(取出元素):

这里的取出元素比较特殊,为了保证堆的高效,一般定义只能取出顶部的元素

拿走堆顶的元素固然简单,但是剩余的两颗子树就要进行融合,过程就复杂了。

直接说结论: 摘顶之后,先上浮末尾元素,然后调整(下沉)合适位置。

(也就是说,末尾元素替换/覆盖顶部元素,然后 sift down 调整)

举个例子:

替换/覆盖后:

然后,此时看到,并没有归为合适的位置。需要下沉。

如何下沉,每次和它的两个孩子中最大的元素进行交换(因为大顶堆一定是根最大)。

什么时候终止: 当前节点 >= 其两个子节点(前提是其存在子节点)。

(叶子节点,没有子节点,不需要调整了)

简单实现:

    public E findMax() {
        if (isEmpty()) {
            throw new IllegalArgumentException("这是个空堆");
        }
        return data.get(0);
    }

    public E extractMax() {
        E ret = findMax();
        data.swap(0, getSize() - 1); //覆盖堆顶元素
        data.pop(); //删除末尾的元素
        siftDown(0); //只下沉根元素下浮调整
        return ret;
    }

    private void siftDown(int index) {
        //1.叶子节点时不用再交换了(数组越界了,说明其就是叶子节点了)
        //2.当前节点还是小于 `max(其子节点)`
        //有左孩子,内部再检查有无有孩子
        while (leftChild(index) < getSize()) {
            //找到孩子中的大的一个 (三个元素中找最大,顺便交换)

            int maxIndex = leftChild(index); //默认先认为左变大
            //如果右孩子存在,那就和右边比一下
            int rightIndex = rightChild(index);
            if (rightIndex < getSize() && data.get(rightIndex).compareTo(data.get(maxIndex)) > 0) {
                //说明确实右边大
                maxIndex = rightIndex;
            }

            //此时 maxIndex 就代表了孩子中大的一方的索引
            if (data.get(index).compareTo(data.get(maxIndex)) >= 0) {
                break; //不用比了,已经是大顶堆了
            }
            data.swap(index, maxIndex);
            index = maxIndex; //接着进行下一轮比较
        }
    }

测试一下放入 100 个数据,然后不断的拿出大的来,放入数组,最后检查这个数组是否是降序的。有点类似堆排序 (但借助了额外的数组):

    public static void main(String[] args) {
        int n = 1000000; //100万

        MaxHeap<Integer> maxHeap = new MaxHeap<>();
        Random random = new Random();
        //放入堆中 (需要不断的 sift up)
        for(int i = 0; i < n; i++) {
            maxHeap.add(random.nextInt(Integer.MAX_VALUE));
        }

        //然后取出来放入 arr 中
        int[] arr = new int[n];
        for(int i = 0; i< n; i++) {
            arr[i] = maxHeap.extractMax();
        }

        //检查一下这个 arr 是否是降序的
        for(int i = 1; i< n; i++) {
            if(arr[i-1] < arr[i]) {
                //说明不是降序的,堆实现有问题
                throw new IllegalArgumentException("Error");
            }
        }
        //全部检查完毕还没有异常,就说明 OK
        System.out.println("OK");
    }

复杂度分析

主要分析 add 和 extractMax, 其实还是 O(logn),因为交换的层级是高度 h,即 logn(因为是完全二叉树)。

但是构建或者说存储一棵大顶堆,复杂度是 O(nlogn)。

构建大顶堆的优化

上面已经说了,构建一个大顶堆,需要 O(nlogn),如何优化?

  • heapify优化 (任意数组整理成堆的存储)。

直接说结论,用 sift down 替代 add 构建大顶堆

上面的 add 方法,慢慢构建一个大顶堆,步骤如下:

  • 添加到末尾
  • 慢慢 sift up 调整

这里有一个非常重要的默认思想,那就是,一个元素一个元素的放入数组,慢慢构建大顶堆。

如果,现在假定存储的数组就是一棵完全二叉树(意思是,按照完全二叉树那样子,进行编号),举个例子,见下图:

(只是完全二叉树,但并不能称为堆)

然后叶子节点先不管(因为叶子节点没有孩子,而后面要进行 sift down 下沉操作,需要孩子节点),对所有的非叶子节点进行 sift down。从第一个非叶子节点向根节点走(也就是数组末端开始),图:

  • 数组最大索引处可以定位第一个非叶子节点(getSize()-1)的 parent
  • 最后一层的叶子节点,可以忽略 (这样至少减少了一半的工作量) --- 这是关键
  • siftDown方法里面就包含了对叶子的过滤,即只对非叶子节点进行siftDown

这么一来其实就很简单了,大概的复杂度也就是 O(n),比一个个添加(O(n*logN))的好处在于,上来就抛弃了所有的叶子节点,这个将近减少了一半的工作量。(实际减少的数目可以根据最大索引计算)

简单实现如下(对着数组看即可):

    public MaxHeap(E[] arr) {
        data = new AdvanceDynamicArray<>(arr);
        //把数组折腾成大顶堆
        for (int i = parent(getSize() - 1); i >= 0; i--) {
            siftDown(i);
        }
    }

其实在大数量级下, n 和 nlongn 近乎一致,相差不了太多。(虽然是不同的数量级)
(再次注意: 这里的 heapify 的时间复杂度是 O(n) 级别。)


优先队列

构建优先队列不一定要用堆,但是底层用堆实现效率比较高

  • 普通队列: 先进先出,后进后出
  • 出队顺序和入队顺序无关,只和优先级有关(出队的时候要看)

随时根据新入队的元素调整优先级:

  • 优先级的意义可以自己定义,比如每次值最大的元素先出队
  • 优先级,一般都是作用于出队

接口定义

因为优先队列也是队列,所以接口还是 Queue,即:

interface Queue<E> {
    void enqueue(E);
    E dequeue(); //拿到优先级最高的元素
    E getFront(); //拿到优先级最高的元素

    int getSize();
    boolean isEmpty();
}

堆实现

用线性结构,此时不论是有序线性结构还是无序线性结构,入队和出队总是保持在 O(1),O(n); 如果用 BST 实现的话,它最好的情况保持在 O(logn),但最坏的情况可能会退化到 O(n)。

堆可以保证,在最差的情况都是 O(logn) 水平。

也就是说,在 PrioryQueue 内部封装一个堆即可。(堆兼容所有的队列接口)

代码试下如下:

package maxheap;

import queque.Queue;

public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {
    //内部成员
    private MaxHeap<E> maxHeap;

    //构造函数
    public PriorityQueue() {
        maxHeap = new MaxHeap<>();
    }

    @Override
    public boolean isEmpty() {
        return maxHeap.isEmpty();
    }

    @Override
    public int getSize() {
        return maxHeap.getSize();
    }

    @Override
    public E dequeue() {
        return maxHeap.extractMax(); // 已经对空的情况做了处理
    }

    @Override
    public E getFront() {
        //获取优先级最大的元素
        return maxHeap.findMax(); //已经对空的情况作了处理
    }

    @Override
    public void enqueue(E o) {
        maxHeap.add(o);
    }
}

Java中的优先队列

重新整理一下,Java中的优先队列

Java 的 PriorityQueue 是最小堆(顶部始终存储的是优先级最低的)。

但是小顶堆是默认存储优先级最低的元素,但优先级是自己定义的,所以当你反写 compareTo 或者比较器时,那么即便是小顶堆,那么实际上存储还是大顶堆的方式。(堆顶拿到的也就是优先级最大的元素)

这里应用就太多了,什么N个元素中选出前M个,什么出现频率最多的x个等,再就是要求构建堆的时候采用 heapify 的方式(表现在要求复杂度优于O(nlogn)) 等,大多都在此范畴内。

比如 Leetcode 347 就可以用 java.util.PriorityQueue,参考代码:

import java.util.TreeMap;
import java.util.PriorityQueue;
import java.util.LinkedList;
//import java.util.List;

class Solution {
    //放入 PriorityQueue 中的元素
    private class Freq implements Comparable<Freq> {
        public int e, freq;
        //构造器
        public Freq(int key, int freq) {
            this.e = key;
            this.freq = freq;
        }

        //由于 java.util.PriorityQueue 是小顶堆,正常些比较逻辑
        @Override
        public int compareTo(Freq another) {
            return this.freq - another.freq;
        }
    }    

    public List<Integer> topKFrequent(int[] nums, int k) {

        //首先把数组放入 map 统计频次
        TreeMap<Integer, Integer> map = new TreeMap<>();
        for(int num : nums) {
            if(map.containsKey(num)) {
                map.put(num, map.get(num) + 1);
            } else {
                map.put(num, 1);
            }
        }
        PriorityQueue<Freq> queue = new PriorityQueue<>(); //不使用比较器
        //遍历 map 放入 PriorityQueue 中
        for(int key : map.keySet()) {
            //前 k - 1 个元素直接放进去
            if(queue.size() < k) {
                queue.add(new Freq(key, map.get(key)));
            } else if(map.get(key) > queue.peek().freq) {
                //替换最小的
                queue.remove();
                queue.add(new Freq(key, map.get(key)));
            }
        }

        //把 queue 中的结果整理出来,放入结果集中
        LinkedList<Integer> res = new LinkedList<>();
        while(!queue.isEmpty()) {
            res.addFirst(queue.remove().e); //因为现出来的是频率相对低的
        }
        return res;
    }
}

然后,代码优化以下(采用传入比较器,Lambda表达式代替匿名类捕获外部map):

  • 可以捕获外部 map,所以逻辑自然简洁了(但是不容易想到)

不难看出,小顶堆用的还是蛮多的。

扩展

想让树的层次变少,那么久使用 K叉堆

但是 K 叉堆(K可以取值3以上的值)需要考虑的孩子多余两个,此时 sift up 或者 sift down 需要的比较孩子,交换根与孩子的策略就需要重写一下。

也就是说,调整的时候需要考虑的逻辑会多一些。

其他堆: 比如索引堆(可以操作除堆顶元素之外的元素),二项堆,斐波那契堆。

(一般相关语言实现的堆,就是最常用最常见,最有用的堆)



对于堆的认识,我也仅停留在最基本的堆。

不多言了,还是把代码仓库贴一下吧 gayhub



纯数据结构Java实现(6/11)(二叉堆&优先队列)

原文地址:https://www.cnblogs.com/bluechip/p/self-pureds-maxheap.html

时间: 2024-11-07 11:08:23

纯数据结构Java实现(6/11)(二叉堆&优先队列)的相关文章

纯数据结构Java实现(4/11)(BST)

个人感觉,BST(二叉查找树)应该是众多常见树的爸爸,而不是弟弟,尽管相比较而言,它比较简单. 二叉树基础 理论定义,代码定义,满,完全等定义 不同于线性结构,树结构用于存储的话,通常操作效率更高.就拿现在说的二叉搜索树(排序树)来说,如果每次操作之后会让剩余的数据集减少一半,这不是太美妙了么?(当然不当的运用树结构存储,也会导致从 O(logN) 退化到 O(n)). 值得说明,O(logN) 其实并不准确,准确来说应该说 O(树的高度) 定义&性质&行话 树里面常见的二叉树: BST,

纯数据结构Java实现(5/11)(Set&amp;Map)

纯数据结构Java实现(5/11)(Set&Map) Set 和 Map 都是抽象或者高级数据结构,至于底层是采用树还是散列则根据需要而定. 可以细想一下 TreeMap/HashMap, TreeSet/HashSet 的区别即可 只定义操作接口(操作一致),不管具体的实现,所以即便底层是 BST 亦可(只是效率不高) (我还是直说了吧,如果不要求有序,尽量用 Hash 实现的吧) 集合(Set) 二分搜索树不存放重复元素,所以 BST 就是一个很好的用于实现集合的底层结构 常见应用 其实主要

纯数据结构Java实现(2/11)(栈与队列)

栈和队列的应用非常多,但其起实现嘛,其实很少人关心. 虽然苹果一直宣传什么最小年龄的编程者,它试图把编程大众化,弱智化,但真正的复杂问题,需要抽丝剥茧的时候,还是要 PRO 人士出场,所以知根知底,实在是必要之举(而非无奈之举). 大门敞开,越往里走越窄,竞争会越激烈. 栈 基本特性 就一条,FILO.但是用在其他复杂数据结构,比如树,或者用在其他应用场景的时候,比如记录调用过程中的变量及其状态等,超有用. 应用举例 比如 撤销操作: 用户每次的录入都会入栈,被系统记录,然后写入文件:但是用户撤

【qbxt!预习】二叉堆

qxbt的老师发消息来说让自己预习,本来想中考完之后认真学(颓)习(废)  没办法 0. 数据结构图文解析系列 1. 二叉堆的定义 二叉堆是一种特殊的堆,二叉堆是完全二叉树或近似完全二叉树.二叉堆满足堆特性:父节点的键值总是保持固定的序关系于任何一个子节点的键值,且每个节点的左子树和右子树都是一个二叉堆.当父节点的键值总是大于或等于任何一个子节点的键值时为最大堆. 当父节点的键值总是小于或等于任何一个子节点的键值时为最小堆. 2. 二叉堆的存储 二叉堆一般使用数组来表示.请回忆一下二叉树的性质,

《Algorithms算法》笔记:优先队列(2)——二叉堆

二叉堆 1 二叉堆的定义 堆是一个完全二叉树结构(除了最底下一层,其他层全是完全平衡的),如果每个结点都大于它的两个孩子,那么这个堆是有序的. 二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不用数组的第一个位置) 2 二叉堆的性质 最大的元素在a[1] (root结点) 每个k的父亲在k/2 每个k的孩子在k*2和k*2+1 3 二叉堆的操作 3.1 上浮(孩子大于父亲)--对应插入操作 循环,每次比较自己和父亲,如果比父亲大就交换,直到root. 3.2 插入 先把元

POJ 2010 - Moo University - Financial Aid 初探数据结构 二叉堆

考虑到数据结构短板严重,从计算几何换换口味= = 二叉堆 简介 堆总保持每个节点小于(大于)父亲节点.这样的堆被称作大根堆(小根堆). 顾名思义,大根堆的数根是堆内的最大元素. 堆的意义在于能快速O(1)找到最大/最小值,并能持续维护. 复杂度 push() = O(logn); pop() = O(logn); BinaryHeap() = O(nlogn); 实现 数组下标从1开始的情况下,有 Parent(i) = i >> 1 LChild(i) = i << 1 RChi

D&amp;F学数据结构系列——二叉堆

二叉堆(binary heap) 二叉堆数据结构是一种数组对象,它可以被视为一棵完全二叉树.同二叉查找树一样,堆也有两个性质,即结构性和堆序性.对于数组中任意位置i上的元素,其左儿子在位置2i上,右儿子在左儿子后的单元2i+1中,它的父亲在[i/2](向下取整)中. 因此,一个数据结构将由一个数组.一个代表最大值的整数.以及当前的堆的大小组成.一个典型的优先队列(priority queue)如下: 1 #ifndef _BinHeap_H 2 struct HeapStruct; 3 type

《数据结构与算法分析:C语言描述》复习——第五章“堆”——二叉堆

2014.06.15 22:14 简介: 堆是一种非常实用的数据结构,其中以二叉堆最为常用.二叉堆可以看作一棵完全二叉树,每个节点的键值都大于(小于)其子节点,但左右孩子之间不需要有序.我们关心的通常只有堆顶的元素,而整个堆则被封装起来,保存在一个数组中. 图示: 下图是一个最大堆: 实现: 优先队列是STL中最常用的工具之一,许多算法的优化都要利用堆,使用的工具就是优先队列.STL中的优先队列通过仿函数来定义比较算法,此处我偷懒用了“<”运算符.关于使用仿函数的好处,我之后如果有时间深入学习S

数据结构 之 二叉堆(Heap)

注:本节主要讨论最大堆(最小堆同理). 一.堆的概念 堆,又称二叉堆.同二叉查找树一样,堆也有两个性质,即结构性和堆序性. 1.结构性质: 堆是一棵被全然填满的二叉树.有可能的例外是在底层.底层上的元素从左到右填入.这种树称为全然二叉树(complete binary tree).下图就是这样一个样例. 对于全然二叉树,有这样一些性质: (1).一棵高h的全然二叉树,其包括2^h ~ (2^(h+1) - 1)个节点.也就是说.全然二叉树的高是[logN],显然它是O(logN). (2).全然