数据结构与算法:学习堆相关算法

       这种数据结构相信大家肯定学过,但是因为在实际工作当中用的比较少而渐渐淡忘了?其实堆用在一些非常经典的场景,这篇文章就来学习一下堆相关内容,最开始我们从堆的底层实现和实际应用举例开始来了解堆这种数据结构的用途,只有知道了某个东西的实际用途再来深入学习这样东西才会更有方向感,然后介绍堆的基本实现,最后用堆来实现找出一篇英文文章中单词出现次数最多的前k个单词的这样的简单功能。

一、堆底层实现以及实际用途

堆的底层实现

       堆是用数组来存储的,但是我们在对堆进行操作的时候实际是将其看成并构建成一颗完全二叉树(如果对完全二叉树不太了解的先去查阅一下资料),之所以用数组来存储是因为完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的,因为我们不需要像构建二叉树节点那样花费内存空间来存储每个节点的左右子节点,可以通过数组下标就直接找到找到一个节点的左右子节点和父节点,我们画图来理解一下:

上图我画了一个大顶堆和小顶堆,这也是堆的两种实现方式,通过图可以很好的理解大顶堆和小顶堆,大顶堆中根节点存储的是集合中最大的元素,它的左右孩子都小于它,同样左右子树也是遵循这样的规律,小顶堆中根节点存储的是集合中最小的元素,它的左右孩子都小于它,同样左右子树也遵循,堆的操作比如插入删除也正是维护这这样的一种关系特性。我们结合数组和完全二叉树来分析一下(注意在用数组实现堆时下标0的位置是没有用到的):根节点8的左右孩子6和7所在数组的位置是不是可以直接通过1*2 = 2 和 1*2 + 1 = 3 直接求得?同样叶子节点3的父节点可以通过6/2 = 3求得?这就是得益于完全二叉树的特性,那普遍来说某个节点所在位置为 i,那它的父节点所在的数组位置就是 i / 2,左右孩子所在位置分别是 i*2 和 i*2+1(当然这个 i 要考虑临界边值问题)。 

       综上述分析堆的两个特性:1)完全二叉树;2)父亲节点的值大于(小于)左右子孩子,同样左右子树也遵循。

堆的实际用途

       通过学习堆的底层实现之后,我们已经初步了解了堆这种的数据结构,我们根据上面堆的结构特性来分析一下具体的实际用途。

应用1:求TopK的问题,比如我们要获取Top100的排行榜、热搜词等等问题。拿Top热搜词来说,大致的实现思路就是我们先得存储所有词的出现次数,然后维护一个大小100的小顶堆,堆顶是当前统计的集合中排行最小的,如果我们新来的词出现的次数比堆顶还小直接抛弃,如果比堆顶大我们就尝试插入到堆中,插入的过程中还没达到最大容量的话就插入,容量达到最大的话就抛弃堆顶元素替换掉堆顶(当然这里还要进行堆结构的维护,也是后面要将的内容),遍历到最终小顶堆中保存的就是所有词中出现次数最多的前100个热搜词。

应用2:求中位数问题,比如我们要知道一个数据集合中,根据某个维度来求中间位置的那个数据,当然相对于静态数据集合来说可以不用堆就很容易实现,但是对于动态数据集合(数据集合中的数据会频繁的增加或者减少)使用堆来实现就非常高效,大概思路就是维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。其实除了求中位数,根据这种实现思路其实还可以实现动态数据集合(可以是任何数据)中任何位置的数据,中位数只是中间的数据嘛。

除了上面两个经典的应用,还有很多应用场景,比如将堆用作优先级队列来合并有序小文件、高性能的定时器等等。

二、堆的基本实现

       上面一节讲了堆的底层实现,我们现在来看下堆的基本实现有哪些,具体到有哪些对堆操作的方法,我们下面都以大顶堆来讲。

1.堆的插入操作

       首先我们要明白的是对堆进行数据的插入和删除都需要满足堆的两个特性,对于我们插入新的节点数据,其实就在数组的尾部进行添加,这样自然会满足堆所构建的完全二叉树结构不会被破坏,但是插入新数据后可能就不满足堆中父亲节点和左右孩子的大小关系了,所以插入新的数据后都需要进行堆化(heapify)操作,堆化有两种形式,一种是从下往上,另一种是从上往下,在插入操作的时候我们很快就能明白肯定是从下往上的,我们具体看下什么是堆化的过程:

可以看到堆化操作其实很简单,就是看新增的这个节点和父亲节点的大小关系,在大顶堆中如果新增的节点比父亲节点大就和父亲节点替换位置,然后继续向上直到小于父亲节点或者到达根节点时结束,可以看出在插入新数据经过堆化操作之后就满足了堆的特性,这也就是堆在插入元素的全部过程,咱么看代码,注释非常清楚啦:

public class Heap {
    private int[] nums; // 用数组存储元素,这个可以是任何数据类型(包括自定义的),不要有局限性思维!
    private int count; // 当前堆中元素数量
    private int cap; // 堆的总容量容量

    public Heap(int cap){ // 初始化堆的时候指定堆的容量
        this.nums = new int[cap + 1]; // 注意这里要+1,应该知道原因吧?因为0的位置是没有用到的
        this.cap = cap;
        this.count = 0;
    }

    public void insert(int newNum){
        if (count >= cap)  return; // 如果当前元素个数满了之后,我们这里做直接返回处理

        count++; // 先进行将个数添加,这个时候count是不是就指向了当前可以添加元素的位置?细品,细节
        nums[count] = newNum;

        // 堆化操作
        int i = count; // 复制临时变量,开始进行堆化
        while (i/2 > 0 && nums[i] > nums[i/2]){ // 首先当前节点是否有父节点,其次看是否大于父亲节点
            swap(nums, i, i/2); // 替换位置
            i = i/2; // 继续来
        }
    }

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

2.堆的删除操作

       我们知道堆分为大顶堆或小堆顶,也就是堆顶元素就是集合中最大或者最小的元素,我们一样拿大顶堆来说,当我们删除元素的时候就是删除堆顶的最大值的元素,我们看下如果我们粗暴的将堆顶删除然后从左右自孩子中选一个最大的进行填补,直到叶子节点这种方式会有什么问题,用图看下:

有没有发现这种方式最后导致堆不是一个完全二叉树了?所以这种删除方式是行不通的,正确的思路就是将数组中的最后一个元素放到堆顶进行覆盖,然后丢掉数组末尾的元素,这个时候还没结束,你很快很想到次数的堆是不满足第二特性的,没错,这个时候就要进行从上往下的堆化操作,我们一样用图来分析整个过程:

同样根据图的整个过程发现删除元素也是非常简单的,两个大步骤:1)将数组末尾元素替换掉堆顶元素,然后去掉末尾的元素;2)进行从上往下的堆化操作,也就是拿当前堆顶元素与大于本元素的其中最大的那个孩子节点进行位置替换,同样直到均小于等于左右孩子节点或者到达叶子节点结束。我们来看下代码实现:

    public int removeMax(){ // 大顶堆中也就是删除最大值元素
        if (count == 0) return -1; // 如果没有元素了我们这里直接做返回-1处理
        int curRemoveMax = nums[1];

        nums[1] = nums[count]; // 进行将数组末尾元素替换到堆顶元素
        count--; // 进行末尾元素的去除,这里直接通过count来表明,细品

        // 进行堆化操作
        heapify(nums, count, 1);

        return curRemoveMax;
    }

    private void heapify(int[] arr, int endIndex, int startIndex){ // 从上往下堆化操作
        while (true){
            int finalPos = startIndex; // 记录最终的位置

            // 看左孩子是否存在并且是否大于当前元素,如果大于记录左孩子的位置
            if (startIndex * 2 <= endIndex && arr[startIndex] < arr[startIndex * 2]) finalPos = startIndex * 2;
            // 看右孩子是否存在并且是否大于当前的最大值,如果大于则记录有孩子的位置,这里巧秒的选出左右孩子中最大的那个并且是大于当前节点的位置,细品
            if (startIndex * 2 + 1 <= endIndex && arr[finalPos] < arr[startIndex * 2 + 1]) finalPos = startIndex * 2 + 1;

            if (finalPos == startIndex) break; // 达到最终的位置,借结束掉
            swap(arr, startIndex, finalPos); // 替换位置
            startIndex = finalPos; // 更新当前节点的位置
        }
    }

3.堆的构建

       我们上面讲了堆的插入和删除,在构建一个堆的过程中我们可以通过循环将所有元素一个个的插入到堆中,最后也就构建成了一个堆,不过我们还可以直接将一个数组原地构建成堆,不需要额外开辟空间然后进行循环插入的方式构建。原地构建堆的思路我们细想一下,是不是可以从数组尾部开始,然后一个一个元素进行从下往上堆化操作,最后到达第一个元素之后我们的数组是不是就成了一个堆?当然这是一种实现方式,其实我们可以有一种更优的方式:从数组后往前找到第一个非叶子节点,根据完全二叉树第一个非叶子节点的下标位置就是n/2,n/2+1到n都是叶子节点,我们只需要对n/2及前面的元素进行从上往下堆化即可,细品!我们来看下代码实现:

    public void buildHeap(int[] arr, int curCount){
        for (int i = curCount / 2; i >= 1; i--){ // 从第一个非叶子节点开始堆化
            heapify(arr, curCount, i);
        }
    }

    private void heapify(int[] arr, int endIndex, int startIndex){ // 从上往下堆化操作
        while (true){
            int finalPos = startIndex; // 记录最终的位置

            // 看左孩子是否存在并且是否大于当前元素,如果大于记录左孩子的位置
            if (startIndex * 2 <= endIndex && arr[startIndex] < arr[startIndex * 2]) finalPos = startIndex * 2;
            // 看右孩子是否存在并且是否大于当前的最大值,如果大于则记录有孩子的位置,这里巧秒的选出左右孩子中最大的那个并且是大于当前节点的位置,细品
            if (startIndex * 2 + 1 <= endIndex && arr[finalPos] < arr[startIndex * 2 + 1]) finalPos = startIndex * 2 + 1;

            if (finalPos == startIndex) break; // 达到最终的位置,借结束掉
            swap(arr, startIndex, finalPos); // 替换位置
            startIndex = finalPos; // 更新当前节点的位置
        }
    }

4.堆的排序实现

       堆的排序可以有两种,第一种就是我们可以循环去删除堆顶元素从而得到一个有序的结果集,另外一种方式就是原地排序,我们知道堆用数组存储的,但是并不是天然有序的,我们进行原地排序就是将这个数组构建成有序的数组,不过就不是堆了,我们看下实现思路:我们同样对大顶堆进行讲解,大顶堆堆顶的元素是最大的,我们可以将数组末尾元素和堆顶元素进行替换,这个时候数组末尾是当前数据集合中最大的元素,然后将新的堆顶元素进行堆化操作,当然此时的结尾节点是数组末尾位置的前一个位置,进行堆化之后再将堆顶和当前数组末尾位置元素进行替换,一直循环直到只剩下堆顶位置,这样就实现了原地排序的操作,我就不画图了,直接上代码非常直观:

    public void sort(int[] arr, int curCount){
        buildHeap(arr, curCount); // 先将数组进行堆化
        int k = curCount;
        while (k > 1){
            swap(arr, k, 1); // 当前末尾元素和堆顶元素替换
            k--; // 更新末尾元素下标
            heapify(arr, k, 1); // 将前面数组进行堆化操作
        }
    }

    private void heapify(int[] arr, int endIndex, int startIndex){ // 从上往下堆化操作
        while (true){
            int finalPos = startIndex; // 记录最终的位置

            // 看左孩子是否存在并且是否大于当前元素,如果大于记录左孩子的位置
            if (startIndex * 2 <= endIndex && arr[startIndex] < arr[startIndex * 2]) finalPos = startIndex * 2;
            // 看右孩子是否存在并且是否大于当前的最大值,如果大于则记录有孩子的位置,这里巧秒的选出左右孩子中最大的那个并且是大于当前节点的位置,细品
            if (startIndex * 2 + 1 <= endIndex && arr[finalPos] < arr[startIndex * 2 + 1]) finalPos = startIndex * 2 + 1;

            if (finalPos == startIndex) break; // 达到最终的位置,借结束掉
            swap(arr, startIndex, finalPos); // 替换位置
            startIndex = finalPos; // 更新当前节点的位置
        }
    }

上面代码是片段性的,我在这里将完整的代码贴出来方便大家整体阅读:

public class Heap {
    private int[] nums; // 用数组存储元素,这个可以是任何数据类型(包括自定义的),不要有局限性思维!
    private int count; // 当前堆中元素数量
    private int cap; // 堆的总容量容量

    public Heap(int cap){ // 初始化堆的时候指定堆的容量
        this.nums = new int[cap + 1]; // 注意这里要+1,应该知道原因吧?因为0的位置是没有用到的
        this.cap = cap;
        this.count = 0;
    }

    public int removeMax(){ // 大顶堆中也就是删除最大值元素
        if (count == 0) return -1; // 如果没有元素了我们这里直接做返回-1处理
        int curRemoveMax = nums[1];

        nums[1] = nums[count]; // 进行将数组末尾元素替换到堆顶元素
        count--; // 进行末尾元素的去除,这里直接通过count来表明,细品

        // 进行堆化操作
        heapify(nums, count, 1);

        return curRemoveMax;
    }

    public void buildHeap(int[] arr, int curCount){
        for (int i = curCount / 2; i >= 1; i--){ // 从第一个非叶子节点开始堆化
            heapify(arr, curCount, i);
        }
    }

    public void sort(int[] arr, int curCount){
        buildHeap(arr, curCount); // 先将数组进行堆化
        int k = curCount;
        while (k > 1){
            swap(arr, k, 1); // 当前末尾元素和堆顶元素替换
            k--; // 更新末尾元素下标
            heapify(arr, k, 1); // 将前面数组进行堆化操作
        }
    }

    private void heapify(int[] arr, int endIndex, int startIndex){ // 从上往下堆化操作
        while (true){
            int finalPos = startIndex; // 记录最终的位置

            // 看左孩子是否存在并且是否大于当前元素,如果大于记录左孩子的位置
            if (startIndex * 2 <= endIndex && arr[startIndex] < arr[startIndex * 2]) finalPos = startIndex * 2;
            // 看右孩子是否存在并且是否大于当前的最大值,如果大于则记录有孩子的位置,这里巧秒的选出左右孩子中最大的那个并且是大于当前节点的位置,细品
            if (startIndex * 2 + 1 <= endIndex && arr[finalPos] < arr[startIndex * 2 + 1]) finalPos = startIndex * 2 + 1;

            if (finalPos == startIndex) break; // 达到最终的位置,借结束掉
            swap(arr, startIndex, finalPos); // 替换位置
            startIndex = finalPos; // 更新当前节点的位置
        }
    }

    public void insert(int newNum){
        if (count >= cap)  return; // 如果当前元素个数满了之后,我们这里做直接返回处理

        count++; // 先进行将个数添加,这个时候count是不是就指向了当前可以添加元素的位置?细品,细节
        nums[count] = newNum;

        // 堆化操作
        int i = count; // 复制临时变量,开始进行堆化
        while (i/2 > 0 && nums[i] > nums[i/2]){ // 首先当前节点是否有父节点,其次看是否大于父亲节点
            swap(nums, i, i/2); // 替换位置
            i = i/2; // 继续来
        }
    }

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

到这里堆涉及到的相关操作已经讲解完啦,是不是并不难?我们现在来看下用堆来实现开篇说的简单的应用:找出一篇英文文章中单词出现次数最多的前k个单词的功能。

三、实际例子运用:找出一篇英文文章中单词出现次数最多的前k个单词的功能

       实现的功能非常简单,并且忽略了很多边界值的考虑,大家主要看实现思路即可,如果在实际的工作当中要实现一个这样的功能要考虑的东西是非常多的,远远没有这么简单,比如还需要将英文组词也纳入统计的范围,这个时候实现的负责度就更高了,大家可以在评论区给出思路,我们来看下我的简单实现:

public class Top {
    /**
     * 获取字符串中出现次数最多的前k个单词
     * @param str 英文串
     * @param k 前k个
     * @return
     */
    private World[] getTopWorld(String str, int k){
        if (str.length() < 1) return null;
        Map<String, World> record = new HashMap<>(); // 用一个hash散列表来记录单词出现的次数,因为散列表这种数据结构查询时间复杂度为O(1)
        int len = str.length();

        // 统计每个单词出现的次数
        int curStartIndex = 0; // 记录每个单词的起始位置
        for (int i = 0; i < len; i++){
            char curChar = str.charAt(i);
            //if ((curChar - 'a' >= 0 && 'z' - curChar >= 0) || (curChar - 'A' >= 0 && 'Z' - curChar >= 0)) continue;
            // 我这里就考虑这写分隔符的场景了,实际当中肯定不是这么来分隔的
            if (curChar != ' ' && curChar != ',' && curChar != '.' && curChar != ';' && curChar != ':' && curChar != '!' && curChar != '?') continue;
            String curWorld = str.substring(curStartIndex, i).toLowerCase(); // 统一处理成小写,避免大小写区分
            if (!record.containsKey(curWorld)){
                World newWorld = new World(curWorld); // 自定义的类结构,用来保存单词和出现的次数
                record.put(curWorld, newWorld);
            }
            record.put(curWorld, record.get(curWorld).incr());

            int j = i + 1;
            for (; j < len; j++){ // 每个单词间隔中怕有多个空格或者分隔符,所以维护一下
                char nextChar = str.charAt(j);
                if (nextChar == ' ' || nextChar == ',' || nextChar == '.' || nextChar == ';' || nextChar == ':' || nextChar == '!' || nextChar == '?') continue;
                else break;
            }
            curStartIndex = j; // 更新下一个单词的其实位置
            i = j - 1; // 这里要-1因为for循环会进行一次++操作
        }

        // 利用堆求出现次数最多的前k个
        TopWorldHeap heap = new TopWorldHeap(k);
        for (Map.Entry<String, World> entry : record.entrySet()){
            heap.insert(entry.getValue()); // 进行插入操作,具体看下面的堆的插入实现
        }

        heap.sort();
        return heap.getWorlds();
    }

    class TopWorldHeap{ // 堆
        private World[] worlds; // 堆的实现结构
        private int count; // 元素数量
        private int cap; // 堆的容量
        public TopWorldHeap(int cap){
            this.worlds = new World[cap + 1];
            this.cap = cap;
            this.count = 0;
        }

        public void insert(World world){
            if (count >= cap) { // 如果当前元素个数满了之后,需要剔除和替换
                if (worlds[1].compareTo(world) == 0){ // 小顶堆中堆顶中最小的比当前添加单词的出现次数少,则替换掉
                    worlds[1] = world;
                    heapify(worlds, count, 1); // 进行堆化
                }
                return;
            }

            count++;
            worlds[count] = world;
            int i = count;
            while (i/2 > 0 && worlds[i].compareTo(worlds[i/2]) == 0){
                swap(worlds, i, i/2);
                i = i/2;
            }
        }

        private void heapify(World[] arr, int n, int i){
            while (true){
                int minPos = i;
                if (i*2 <= n && arr[i].compareTo(arr[i*2]) == 1) minPos = i*2;
                if (i*2+1 <= n && arr[minPos].compareTo(arr[i*2+1]) == 1) minPos = i*2+1;
                if (minPos == i) break;
                swap(arr, i, minPos);
                i = minPos;
            }
        }

        private void sort(){
            int k = count;
            while (k > 1){
                swap(worlds, 1, k);
                k--;
                heapify(worlds, k, 1);
            }
        }

        public World[] getWorlds(){
            return worlds;
        }

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



    }

    class World implements Comparable<World>{
        private String worldName;
        private int count; // 初始出现次数均为0
        public World(String worldName){
            this.worldName = worldName;
            this.count = 0;
        }
        public World incr(){ // 每次次数加1
            this.count += 1;
            return this;
        }
        public int getCount(){
            return count;
        }
        public String getWorldName(){
            return this.worldName;
        }
        @Override
        public int compareTo(World o) { // 因为是自定义数据类,需要实现大小比较的方法
            return this.count >= o.count ? 1 : 0;
        }
    }

    public static void main(String[] args) {
        Top topWorld = new Top();
        String str = " Since I went to school, the teachers always told us to love our motherland, because we were born here and" +
                " its culture was very profound, so that we also should be proud of being part of it. I kept this in " +
                "my heart, but one day, I read some negative information from foreign websites. These articles criticized " +
                "China and the government. What’s more, there were so many foreigners making bad comments about the Chinese " +
                "government, some even to criticized the people. But as our country became stronger and more foreigners came to visit, " +
                "they started to change their idea and fell in love with this big old country. I realized that no country was perfect, even " +
                "America faced many problems. We have the long history and different culture, which make this country so attractive. " +
                "I love my country.";
        str = str.trim(); // 整理一下前后的空格
        World[] result = topWorld.getTopWorld(str, 10);
        for (int i = 1; i < result.length; i++){
            System.out.print(result[i].getWorldName() + ":" + result[i].getCount());
            System.out.println();
        }
    }

到这里堆的相关内容就介绍结束啦,感谢你看到这里!

 

  • 19
    点赞
  • 71
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值