算法学习 (门徒计划)2-2 堆(Heap)与优先队列 学习笔记

前言

4月8日,开课吧门徒计划算法课第五讲学习笔记。
本课讲堆。
课题为:
2-2 堆(Heap)与优先队列

开始学习!

(本课所学的堆结构均为基础的二叉堆)

(关于堆的知识不止于此,学习本课的目的在于开拓思维,并且能够有能力做出笔试算法题目)

先总结本课的核心思想:

  • 概念:堆是基于完全二叉树的一种存储结构
  • 特殊种类:
    • 大根堆:任意结构组中,根节点为最大值(简单记就叫大堆)
    • 小根堆:任意结构组中,根节点为最小值(小堆)
  • 应用场景:维护集合的最值
  • 基础性质
    • 结构性:符合完全二叉树的结构定义
    • 堆序性:符合自身种类的对于数值最值维护的性质
  • 衍生思想:定义数据结构后需要对这个结构的概念进行维护,维护的方式就是需要有底层的实现逻辑。简单概况就是封装思想。

堆(Heap)的概念和基础操作

基础概念

  • 堆是一种基于完全二叉树结构进行数据关联关系存储的的数据结构,可以理解为被特殊封装的一个完全二叉树,在存储空间中经常是连续排布。
  • 堆是一种概念,符合这种概念规定的数据结构,都可以称为堆。

如图是一个完全二叉树,当其存储方式为连续存储时,可以理解为一个堆。
配图:

在这里插入图片描述

(图上是一个大小为5的堆,其树结构的深度为3)

完全二叉树概念

  • 优先填充每一层的左侧剩余节点,并当当前层满时才进入下一层
  • 父子节点之间可以用计算式计算关系(要求根节点初始相对坐标为1)

基础操作

堆除创建外,主要是有2种操作,入堆出堆。由于堆结构的目的是为了维护集合的最值,因此创建堆时要么选择大根堆要么选择小根堆,这两种堆的区别只在于处理数值间关系时采取了相反的策略,但是在基础操作时进行的动作顺序是一致的。

堆内部为维护堆结构有两种基本的操作:

  • 上滤,常用于入堆,具体操作为为当前节点寻找其父节点,对比值,如果不符合堆序则修改
  • 下滤,常用于出堆,具体操作为为当前节点寻找其孩子节点,对比值,如果不符合堆序则修改

以下以小根堆为例

入堆与上滤

通常应用在插入堆元素时,实现方法为在存储结构最后一个元素的末尾创建一个空穴,并在概念中找到其所在二叉树之中的位置。
在接下来的循环循环中判断是否满足其所在区域的堆序性质:

  1. 判断所在二叉树的父节点是否小于当前节点,如果不符合则调换二者的存储内容,并进入下一步
  2. 移动指针使当前节点指向父节点所在位置,重新开始执行步骤1。

(示例代码在下方的自定义类实现堆中的siftUp方法内)

执行完毕后堆总长加1,并且新获得了一个元素,依然保持堆序性。
(需要明确的是,这种入堆动作会导致堆元素增加)

出堆与下滤

出堆时,将弹出最小值,但是直接移出会导致堆结构被破坏(移除根节点),因此需要整合元素(合并两个左右子树),在这过程中所执行的举动就是下滤。
因此出堆时,实际进行的操作为:

  1. 将数组首元素和末尾元素对调,此时在数组末尾元素的值就是最值,获取最值准备返回并删除指针空间。
  2. 此时堆序已经被破坏,起始位置为堆顶,因此从对顶执行下滤动作,将指针指向对顶元素,定义为当前节点。
  3. 将当前节点和其左右子节点进行比较,将其中的最值(符合堆序,小根堆堆)放置当前节点所在位置,并将当前节点与之对调。此时指针指向当前节点元素所在的位置
  4. 重复步骤3,直到当前节点和其左右孩子节点的数值关系符合堆序性
  5. 对外返回步骤1保存的最小值

(示例代码在下方的自定义类实现堆中的siftDown方法内)

(以上步骤中,步骤3和步骤4就是下滤的核心循环节)
(下滤操作常用于合并相同堆序的堆,出堆动作时,就会生成2个相同堆序的堆)

执行完毕后堆总长减1,并且对外输出了一个元素,依然保持堆序性。

自定义类实现堆

下方这个堆的实现只是用作参考,虽然为了结构清晰性能层面依然略有不足,但是用作理解堆运行规律是充分的。

简单测试代码:

    public static void test() throws Exception {
    	
    	MyHeap minHeap = new MyHeap();
    	
    	minHeap.push(5);
    	minHeap.push(9);
    	minHeap.push(4);
    	minHeap.push(8);
    	minHeap.push(2);
    	minHeap.push(7);
    
    	while(minHeap.getHeapSize()!=0) {
    		System.out.println(minHeap.pop());
    	}
    }

类代码:

import java.util.ArrayList;

public class MyHeap {
	private ArrayList <Integer> heap = new ArrayList <Integer>();
    private boolean isBigRoot = false;
    
    public void MyHeap() {
    	//默认创建最小堆
    }
    
    public void MyHeap(boolean isBigRoot) {
    	//默认创建最大堆或者最小堆
    	this.isBigRoot = isBigRoot;
    }
    
    public void MyHeap(ArrayList <Integer> heap,boolean isBigRoot) {
    	//继承一个已经存在的堆(需确保堆序一致)
    	this.isBigRoot = isBigRoot;
    	this.heap = heap;
    }
    
    //新元素入堆
    public void push(int temp) {
    	siftUp(temp,isBigRoot);
    }
    
    //最值出堆
    public Integer pop() {
    	return siftDown(isBigRoot);
    }
    
    public ArrayList <Integer> getHeapDataList() {
    	return heap;
    }
    
    public int getHeapSize() {
    	return heap.size();
    }
    
    private void siftUp(int temp,boolean isBigRoot) {
    	
    	heap.add(temp);//末尾添加元素
    	int index = heap.size(); //取得新元素坐标
    	
   		while (index!=1) {
    		int parentIndex = index /2;
    	    int parentTemp = heap.get(parentIndex-1);
    	    
    		if(isBigRoot) {//大根堆
    			if(parentTemp>=temp) 
        	    	//对于当前结构(父节点大于等于子节点),符合堆序性
        	    	break;
    		}else {	//小根堆
        	    if(parentTemp<=temp) 
        	    	//对于当前结构(父节点小于等于子节点),符合堆序性
        	    	break;
			}    	           	   
	    	//调换元素的顺序,上滤
	    	heap.set(parentIndex-1, temp);
	    	heap.set(index-1, parentTemp);
	    	
	    	index = parentIndex;
    	}
    }
    
    private Integer siftDown(boolean isBigRoot) {

    	int index = heap.size(); //取得末尾元素的坐标
    	if(index == 0) {
    		return null;
    	}else if(index == 1) {
    		int res = heap.get(0);
    		heap.remove(0);
    		return res ;
    	}
    	
    	int temp = heap.get(index-1);    	
    	int res = heap.get(0);
    	
    	//调换元素的顺序,调换位置
    	heap.set(0, temp);
    	//heap.set(index-1, res);
    	//删除末尾元素
    	heap.remove(index-1);
    	int len = --index;
    	
    	//当前指针指向堆顶
		index = 1;
    	
		int chi_l_i = index*2;
		int chi_r_i = index*2+1;
		
		Integer l_t ;
		Integer r_t ;
		
		int needChangeIndex = 1;

		while (chi_l_i<=len) {
			
			l_t = heap.get(chi_l_i-1);

			if(chi_r_i<=len) 
				r_t = heap.get(chi_r_i-1);
			else
				r_t = null;

			if(isBigRoot) {//大堆
				//选出最大的孩子节点跟当前节点互换
				if(r_t==null) {
					if(l_t>temp) {
						needChangeIndex = chi_l_i;
					}		
				}else {
					if(l_t>r_t) {
						if(l_t>temp)
							needChangeIndex = chi_l_i;
					}else {
						if(r_t>temp)
							needChangeIndex = chi_r_i;
					}
				}
				

			}else {//小堆
				//选出最小的孩子节点跟当前节点互换
				if(r_t==null) {
					if(l_t<temp) {
						needChangeIndex = chi_l_i;
					}
				}else {
					if(l_t<r_t) {
						if(l_t<temp)
							needChangeIndex = chi_l_i;
					}else {
						if(r_t<temp)
							needChangeIndex = chi_r_i;
					}
				}
			
			}
			
			if(needChangeIndex!=index) {
		    	heap.set(index-1, heap.get(needChangeIndex-1));
		    	heap.set(needChangeIndex-1, temp);
			}else {
				break;
			}
			
			index = needChangeIndex;			
			chi_l_i = index*2;
		    chi_r_i = index*2+1;
		}
    	
    	return res;
    }
}

优先队列

Java中的堆是用优先队列PriorityQueue实现的。

Queue<Integer> minheap =new PriorityQueue<Integer>();//默认为最小堆
Queue<Integer> maxheap =new PriorityQueue<Integer>((n1, n2) -> n2-n1);//创建最大堆

关于堆或者优先队列,在实际应用中更多的是一种概念,因为其都具备最值管理的性质。
(课上这一块没讲很详细,我也忘记了内容,不是重要的知识略过)

经典例题,堆的基础应用

关于做算法练习题,始终遵循一个原则,第一步先做,第二步思考优化方案,第三步实践或者论证优化的价值

leetcode 剑指 Offer 40. 最小的k个数

来源:https://leetcode-cn.com/problems/zui-xiao-de-kge-shu-lcof/

输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。

解题思路

在没有学习堆之前,可能会想到先排序再获取前4个元素。
但是既然学了堆,就尝试一下用堆来做。用堆也有两种方案,

  • 小堆:全部入堆,再出堆4个元素(堆排序思路)
  • 大堆:尽可能入堆,但是当堆内元素满4个时,入堆前和对顶元素比较,如果外部元素更小则入堆,并出堆1一个元素,最终剩余的堆内4个元素就是目标(最值集合思路)

这里两种方案写法近似,二者的性能区别在于时空复杂度。

  • 空间复杂度对比:堆排序思路,需要的空间大小为全部元素n,而最值集合思路则需要集合大小k(后续n表示元素数量,k表示集合大小)
  • 时间复杂度对比:随着堆内元素逐步增加,堆进行元素增减的时间复杂度也会随着增加,为(logn)的关系,因此集合元素越少,复杂度越低。二者的关系比对近似((n+k)logn)和(nlogk)

从性能角度考虑,最值集合的思路综和性能更好

示例代码

class Solution {

	//堆排序
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0) {
            return new int[0];
        }
        // 使用一个小根堆
        Queue<Integer> heap = new PriorityQueue<Integer>();

        for (int e : arr) {
            heap.offer(e);
        }

        // 将堆中的元素存入数组
        int[] res = new int[k];
        int j = 0;
        for (int i= 0;i<k ;i++) {
            res[j++] = heap.poll();
        }
        return res;
    }

	//最值集合
	public int[] getLeastNumbers2(int[] arr, int k) {
	    if (k == 0) {
	        return new int[0];
	    }
	    // 使用一个最大堆(大顶堆)
	    // Java 的 PriorityQueue 默认是小顶堆,添加 comparator 参数使其变成最大堆
	    Queue<Integer> heap = new PriorityQueue<>(k, (i1, i2) -> Integer.compare(i2, i1));
	
	    for (int e : arr) {
	        // 当前数字小于堆顶元素才会入堆
	        if (heap.isEmpty() || heap.size() < k || e < heap.peek()) {
	            heap.offer(e);
	        }
	        if (heap.size() > k) {
	            heap.poll(); // 删除堆顶最大元素
	        }
	    }
	
	    // 将堆中的元素存入数组
	    int[] res = new int[heap.size()];
	    int j = 0;
	    for (int e : heap) {
	        res[j++] = e;
	    }
	    return res;
	}
}

额外分析

虽然使用了堆来实现,巩固了堆知识的学习,但是本题如果从题目种类考虑,还有更好的方案。

另一个方案为快排。快排本身也是一个集合方案,哨兵左侧是小于哨兵的集合哨兵右侧是大于哨兵的集合,因此本题可以采用快排的思路,当左侧集合数目小于k时,输出左侧到结果集,去右侧找剩余的元素(k-left)个,当左侧集合数目大于k时,抛弃右侧集合,继续在左侧找元素集合。

这个方案的优秀之处在于空间复杂度为O(1)时间复杂度期望为O(n),恶劣条件下为O(2n)

leetcode 1046. 最后一块石头的重量

来源:
https://leetcode-cn.com/problems/last-stone-weight/

有一堆石头,每块石头的重量都是正整数。

每一回合,从中选出两块 最重的 石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块石头。返回此石头的重量。如果没有石头剩下,就返回 0。

解题思路

本题就很适合用堆来解题,本题的核心思路就是集合的有序出入问题。

因为本题有几个关键点:最重、新重量、最后剩下。
所以本题需要有循环节方便汲取最大值,有方案存入新值并且方便后续提取最大值。

堆就是这样一个适合解决这个问题的数据结构模型,实际上也可以使用其他的数据模型比如用链表或者数组进行插入排序,也可以实现有序集合的元素出入问题,但是通常这种情况性能都是不如堆的。

(本次就不讨论了)

示例代码

class Solution {

    public int lastStoneWeight(int[] stones) {
        //大堆
        Queue<Integer> maxheap =new PriorityQueue<Integer>((n1, n2) -> n2-n1);//创建最大堆

        for (int stone : stones) {
            maxheap.offer(stone);
        }

        while (maxheap.size() > 1) {
            int x = maxheap.poll();
            int y = maxheap.poll();
            if (x > y) {
                maxheap.offer(x - y);
            }
        }

        return maxheap.isEmpty() ? 0 : maxheap.poll();
    }
}

leetcode 703. 数据流中的第 K 大元素

来源:https://leetcode-cn.com/problems/kth-largest-element-in-a-stream/

设计一个找到数据流中第 k 大元素的类(class)。注意是排序后的第 k 大元素,不是第 k 个不同的元素。

请实现 KthLargest 类:

  • KthLargest(int k, int[] nums) 使用整数 k 和整数流 nums 初始化对象。
  • int add(int val) 将 val 插入数据流 nums 后,返回当前数据流中第 k 大的元素。

示例:


输入:
["KthLargest", "add", "add", "add", "add", "add"]
[[3, [4, 5, 8, 2]], [3], [5], [10], [9], [4]]
输出:
[null, 4, 5, 5, 8, 8]

解释:
KthLargest kthLargest = new KthLargest(3, [4, 5, 8, 2]);
kthLargest.add(3);   // return 4
kthLargest.add(5);   // return 5
kthLargest.add(10);  // return 5
kthLargest.add(9);   // return 8
kthLargest.add(4);   // return 8

解题思路

题目很简单,但是得读懂

以示例为例,new对象时入参3表示寻找第三大的数字,入参数组表示一个已经存在的数据流。

因此本题是一个最大集合,并且求这个集合最小值的方式。遇到这种情况还是用堆来解题,
方案很简单,准备一个最小堆,约定堆计划大小为k,然后元素尽量入堆,当堆要超过约定大小时,判断准备入堆的元素和堆顶值的关系,只有这个值能够大于堆顶值才允许入堆,先执行出堆,再入堆期望入堆元素。

每轮动作执行完毕后返回堆顶当前值。

(代码略)

leetcode 373. 查找和最小的K对数字

来源:https://leetcode-cn.com/problems/find-k-pairs-with-smallest-sums/

给定两个以升序排列的整形数组 nums1 和 nums2, 以及一个整数 k。

定义一对值 (u,v),其中第一个元素来自 nums1,第二个元素来自 nums2。

找到和最小的 k 对数字 (u1,v1), (u2,v2) … (uk,vk)。


示例 1:

输入: nums1 = [1,7,11], nums2 = [2,4,6], k = 3
输出: [1,2],[1,4],[1,6]
解释: 返回序列中的前 3 对数:
     [1,2],[1,4],[1,6],[7,2],[7,4],[11,2],[7,6],[11,4],[11,6]
示例 2:

输入: nums1 = [1,1,2], nums2 = [1,2,3], k = 2
输出: [1,1],[1,1]
解释: 返回序列中的前 2 对数:
     [1,1],[1,1],[1,2],[2,1],[1,2],[2,2],[1,3],[1,3],[2,3]
示例 3:

输入: nums1 = [1,2], nums2 = [3], k = 3 
输出: [1,3],[2,3]
解释: 也可能序列中所有的数对都被返回:[1,3],[2,3]

解题思路

(做了几道堆的题目,现在这题还想要用堆做吗?如果还想,说明思维定式了)

本题虽然也是个最值问题,但是本题的入参是两个有序数组,因此只需要使用2个指针进行符合条件的遍历即可,除了返回值几乎无需额外空间,并且时间复杂度也是线性的。

具体的方法为,比较当前数对的下一位的递增幅度,选择幅度小的先凑对输出。

(作为堆的练习题,也勉强考虑一下堆的方案)
如果考虑堆的方案,则是将求和的结果入堆,制作最大堆,约定堆期望大小,当新的元素试图入堆且堆达到期大小时,和堆顶比较,如果小于则出堆并入堆新元素,最后堆内元素就是目标期望。

(本题如果改为两个无序的整形数组才有用堆的价值,但也未必也有可能先快排再遍历性能更好,需要讨论才能得知)

leetcode 215. 数组中的第K个最大元素

来源:https://leetcode-cn.com/problems/kth-largest-element-in-an-array/

在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

解题思路

这种题目类型:第K大(或者小)只有两种优秀的解法:

  • 管理起来方便的最值集合堆
  • 性能高效的变种快速排序

由于堆方案本文在上方已经讲过了,并且有示例代码,所以接下来采用快排的方案做一遍这题。

示例代码

(快排法-改)

class Solution {
    public int findKthLargest(int[] nums, int k) {
        if(nums.length ==1)
            return nums[0];


        return findKthLargest(nums,0,nums.length,k);
    }

    public int findKthLargest(int[] nums,int start ,int end, int k) {
/*
        System.out.println("start:"+start+",end:"+end);

        for(int i = start ;i<end;i++){
            System.out.print(nums[i]);
        }
        System.out.println("++");*/

        //基准值
        int key = nums[start];

        if(start +1 >=end){
            return key;
        }

        int right = 0; 

        //快排
        int i= start,j = end-1;
        for(;i<j;){

            //j向左移,直到遇到比key小的值
			while(nums[j]>=key && i<j){
				j--;

			}
            //i向右移,直到遇到比key大的值
			while(nums[i]<=key && i<j){
				i++;
			}
            //i和j指向的元素交换
			if(i<j){
				int temp=nums[i];
				nums[i]=nums[j];
				nums[j]=temp;
			}
        }

        nums[start]=nums[i];
		nums[i]=key;

        right = end - i;

        if(right  == k){
            return key;
        }else if (right  < k){
            k = k - right ;
            return findKthLargest(nums,start,i,k);
        }else {
            return findKthLargest(nums,i+1,end,k);
        }

    }
}

经典例题,堆的进阶应用

leetcode 355. 设计推特

来源:https://leetcode-cn.com/problems/design-twitter/

设计一个简化版的推特(Twitter),可以让用户实现发送推文,关注/取消关注其他用户,能够看见关注人(包括自己)的最近十条推文。你的设计需要支持以下的几个功能:

  1. postTweet(userId, tweetId): 创建一条新的推文
  2. getNewsFeed(userId): 检索最近的十条推文。每个推文都必须是由此用户关注的人或者是用户自己发出的。推文必须按照时间顺序由最近的开始排序。
  3. follow(followerId, followeeId): 关注一个用户
  4. unfollow(followerId, followeeId): 取消关注一个用户

示例:


Twitter twitter = new Twitter();

// 用户1发送了一条新推文 (用户id = 1, 推文id = 5).
twitter.postTweet(1, 5);

// 用户1的获取推文应当返回一个列表,其中包含一个id为5的推文.
twitter.getNewsFeed(1);

// 用户1关注了用户2.
twitter.follow(1, 2);

// 用户2发送了一个新推文 (推文id = 6).
twitter.postTweet(2, 6);

// 用户1的获取推文应当返回一个列表,其中包含两个推文,id分别为 -> [6, 5].
// 推文id6应当在推文id5之前,因为它是在5之后发送的.
twitter.getNewsFeed(1);

// 用户1取消关注了用户2.
twitter.unfollow(1, 2);

// 用户1的获取推文应当返回一个列表,其中包含一个id为5的推文.
// 因为用户1已经不再关注用户2.
twitter.getNewsFeed(1);

解题思路

这题并不难但是写起来比较麻烦,先读懂题意。

要有一个存储结构能够根据你存入的时间戳有序的获取若干个最近存入的数据。并且根据你的(关注)动作,还需要关联其他的存储结构获取最近的存入数据,上限均为十个。

乍得一看,最近的数据,应该用栈来实现,但是本题的问题是,只保存十个,多了不要。

所以本题可以用队列思维的双向链表实现,每一个角色有长度为10的数据链表,新发布的推文置入表尾,从表头去除一个旧元素。

当需求获取最近的10条记录时,从维护的关注列表里依次检索10个最新的内容。

(我认为这个方案的性能比较高,但是需要比较多的指针域)

(我考虑了一下堆方案,感觉性能不佳,本题不应该出现在堆学习的习题中)

leetcode 692. 前K个高频单词

来源:https://leetcode-cn.com/problems/top-k-frequent-words/

给一非空的单词列表,返回前 k 个出现次数最多的单词。

返回的答案应该按单词出现频率由高到低排序。如果不同的单词有相同出现频率,按字母顺序排序。

示例 1:

输入: ["i", "love", "leetcode", "i", "love", "coding"], k = 2
输出: ["i", "love"]
解析: "i" 和 "love" 为出现次数最多的两个单词,均为2次。
    注意,按字母顺序 "i" 在 "love" 之前。

示例 2:


输入: ["the", "day", "is", "sunny", "the", "the", "the", "sunny", "is", "is"], k = 4
输出: ["the", "is", "sunny", "day"]
解析: "the", "is", "sunny" 和 "day" 是出现次数最多的四个单词,
    出现次数依次为 4, 3, 2 和 1 次。

解题思路

本题有两个步骤,一个是实现所有单词出现频率的统计,另一个是根据出现频率进行排序,将出现次数多的几个输出。

本题可以用堆解,并且需要用TreeMap进行配合(因为期望相同出现次数的单词按自字典序排序),首先需要用map进行出现频率的统计,统计完毕后遍历输出,获取所存储所有元素的出现次数。

下一步就是根据次数进行排序了,可以用堆,也可以自己写快排的变形。

(我自己做的时候想尝试利用TreeMap自带的排序功能,尝试了一下发现还是用堆写起来简便)

示例代码

(以下为堆的方案)

class Solution {
    public List<String> topKFrequent(String[] words, int k) {

        //统计出现次数

        Map<String, Integer> count = new HashMap();
        for (String word: words) {
            count.put(word, count.getOrDefault(word, 0) + 1);
        }

        //创建最小堆,约定规则元素的比较规则
        PriorityQueue<String> heap = new PriorityQueue<String>(
                (w1, w2) -> count.get(w1).equals(count.get(w2)) ?
                w2.compareTo(w1) : count.get(w1) - count.get(w2) );

        //尝试入堆
        for (String word: count.keySet()) {
            heap.offer(word);
            if (heap.size() > k) heap.poll();
        }

        //输出
        List<String> ans = new ArrayList();
        while (!heap.isEmpty()) {
            String temp = heap.poll();
            ans.add(temp);
            //System.out.println(temp);
        }
        //逆序比插头开销要小
        Collections.reverse(ans);
        return ans;
    }
}

(以下为我想的有待优化的Map方案,但是性能没啥区别)

class Solution {
    public List<String> topKFrequent(String[] words, int k) {
        TreeMap <String , Integer> hmp = new TreeMap <String , Integer> ();

        //统计次数
        for(String wd : words){
            Integer num = hmp.get(wd);
            if(num == null) num = 0;
            num ++ ;
            hmp.put(wd,num);
        }

        TreeMap <Integer , ArrayList <String>> tmp = 
            new TreeMap <Integer , ArrayList <String>>();

        for(String key : hmp.keySet()){
            //获取出现次数
            Integer num = hmp.get(key);

            //获取出现单词次数相同的集合
            ArrayList <String> wordsArr = tmp.get( num);
            if(wordsArr == null)
                wordsArr = new ArrayList<String>();

            //将单词存入出现次数相同的集合
            wordsArr .add(key);

            tmp.put(num ,wordsArr);
    	    //System.out.println(key+"="+hmp.get(key));
    	}

        ArrayList <ArrayList<String> >  arr =  
            new ArrayList <ArrayList<String> > (); 

        for(Integer keyNum : tmp.keySet()){
            
            //获取出现元素的集合,从低到高排序
            ArrayList<String> wordsArr = tmp.get(keyNum);

            //存入总排序结果集合
            arr.add(wordsArr);
            
    	}
        ArrayList <String > res = new  ArrayList <String > ();
        for(int i = arr.size() - 1;i>=0;i--){
            ArrayList<String> wordsArr = arr.get(i);
            int index = 0;
            for(String word : wordsArr ){
                if(k == 0)
                    return res;
                res.add(word);
                
                k--;
                index++;

                //System.out.println(word);
            }  
        }        

        return res;
    }
}

leetcode 面试题 17.20. 连续中值 (困难)

来源:https://leetcode-cn.com/problems/continuous-median-lcci/
随机产生数字并传递给一个方法。你能否完成这个方法,在每次产生新值时,寻找当前所有值的中间值(中位数)并保存。

中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。

例如,

[2,3,4] 的中位数是 3

[2,3] 的中位数是 (2 + 3) / 2 = 2.5

设计一个支持以下两种操作的数据结构:

  • void addNum(int num) - 从数据流中添加一个整数到数据结构中。
  • double findMedian() - 返回目前所有元素的中位数。

示例:

addNum(1)
addNum(2)
findMedian() -> 1.5
addNum(3) 
findMedian() -> 2

解题思路

这是一道困难题
(这会极大的给我积极性,至少我自认为如此)

本题的核心是中位数,说明题目需要管理两个有序(或者可以获取极值的)队列,而且这两个队列长度相差不能大于1,并且小值队列所有值都要小于大值队列,新值加入时和两个队列的极值比较并且动态的的调整两个队列的长度,使得队列长度相差不大于1。

当需要获取返回值时,从小值队列末端和大值队列前端取数,根据队列长度选择不同的取值方式。

动态调整的方案为,当差值为2时,取较长一方的极值给另一方并且依旧使得符合规则(小值队列所有值都要小于大值队列)。

读题完毕,设计需求已知现在考虑实现方案。

首先,队列长度需要减少吗?需要,当差值为2时需要转移极值,但是这个需要转移的极值和需要比较的极值是同一个吗,是的。
(第一个要点,获取元素和提取元素可以采用同一通路)

其次,队列新增元素后需要对外有序吗?不需要,只需要能够管理极值,新元素加入后是否是极值不重要。

综上,提出方案

本题用两个堆来实现,一个用最大堆管理小值队列,一个用最小堆管理大值队列,并且需要时刻关注两个堆的深度,当一个堆比另一个堆大小大2时,元素较多的堆,出堆一个元素,将这个元素加入元素较少的堆中。

而堆元素只在加入时产生,因此入堆前在符合规则(小值队列所有值都要小于大值队列)的前题下优先给元素更少的堆添加元素可以有更好的性能。

(另一个方案是做一个有序队列,用插值法加入元素进行管理,取值时取中值)

示例代码

(我的方案如下,堆方案)

class MedianFinder {

    /** initialize your data structure here. */

    //创建最大堆和最小堆
    private Queue<Integer> maxheap;
    private Queue<Integer> minheap;
    public MedianFinder() {
        maxheap = new PriorityQueue<>((x,y) -> y-x);
        minheap = new PriorityQueue<>();
    }
    
    //将元素加入合适的堆中
    public void addNum(int num) {

        //较小的中值在最大堆中
        Integer min_mid = maxheap.peek();
        //较大的中值在最小堆中
        Integer max_mid = minheap.peek();

        //优先入堆
        if(min_mid == null && max_mid ==null){
            //都为空,先放到大值集合中
            minheap.add(num);
            return ;
        }else{
            if(min_mid == null) min_mid = max_mid -1;
            if(max_mid == null) max_mid = min_mid +1;
        }

        if(num<=min_mid){
            maxheap.add(num);
        }else if(num>=min_mid){
            minheap.add(num);
        }else{ //当新元素在两个堆极值之间时
            //投放给长度更大的堆,然后出堆一个元素,保持平衡
            //默认情况下,期望大值集合元素更多
            if(maxheap.size() > minheap.size()){
                maxheap.add(num);
                minheap.add(maxheap.poll());
            }else{
                minheap.add(num);
                maxheap.add(minheap.poll());
            }
        }

        //长度差为2时调节一下
        if(maxheap.size() > minheap.size()+1){
            minheap.add(maxheap.poll());
        }else if(maxheap.size() +1 < minheap.size()){
            maxheap.add(minheap.poll());
        }

    }
    
    //取最值使用
    public double findMedian() {
        if(maxheap.size() > minheap.size()){
            return maxheap.peek();
        }else if(minheap.size() > maxheap.size()){
            return minheap.peek();
        }else{
            return (maxheap.peek() + minheap.peek()) / 2.0;
        }
    }
}


/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder obj = new MedianFinder();
 * obj.addNum(num);
 * double param_2 = obj.findMedian();
 */

(近似题) leetcode 295. 数据流的中位数

来源:https://leetcode-cn.com/problems/find-median-from-data-stream/

题目描述近似,解法一致,思路略

经典例题,堆方案的思维延伸

leetcode 264. 丑数 II

来源:https://leetcode-cn.com/problems/ugly-number-ii/

给你一个整数 n ,请你找出并返回第 n 个 丑数 。

丑数 就是只包含质因数 2、3 和/或 5 的正整数。
**
示例 1:**

输入:n = 10
输出:12
解释:[1, 2, 3, 4, 5, 6, 8, 9, 10, 12] 是由前 10 个丑数组成的序列。

示例 2:

输入:n = 1
输出:1
解释:1 通常被视为丑数。

提示:

  • 1 <= n <= 1690

解题思路 (队列)

本题让我回想起了似乎曾经用队列做过,
算法学习 (门徒计划)1-2 队列 学习笔记:中的题目:第K个数 leetcode—面试17.09)
(当初的题目似乎是要求输出质因数为3,5,7的数列)本题则是要求输出质因数为2,3,5的数列.

当初的解法是,分别准备3个指针,从1开始行进,每一个轮次每一个指针都根据自身当前所处的位置乘以质因数,3个指针乘以的质因数为各自的指针号,此时从3个结果中选出最小的一位,将结果填入队列,而计算结果最小的指针前进一位(如果有多个指针都计算出最小值则都前进一位)

示例代码(队列)

(搬过来改改能用,的确思路相同)

class Solution {
    public int nthUglyNumber(int n) {
        return getKthMagicNumber(n);
    }

    public int getKthMagicNumber(int k) {
        ArrayList <Integer> arr = new ArrayList<Integer>();

        int index2=0;
        int index3=0;
        int index5=0;
        arr.add(1);
        while(--k>0){
            int num2 = arr.get(index2);
            int num3 = arr.get(index3);
            int num5 = arr.get(index5);

            num2 *=2; 
            num3 *=3;
            num5 *=5;

            int addnum;

            addnum = Math.min(num2,num3);
            addnum = Math.min(addnum,num5);

            arr.add(addnum);

            if( addnum == num2)
                index2 ++;
            if( addnum == num3)
                index3 ++;
            if( addnum == num5)
                index5 ++;

        }
        int len = arr.size();

        return arr.get(len-1);
    }
}

解题思路 (堆)

队列的解法之前就会了,接下来讨论堆的解法。

首先堆的特性值得回顾,堆的特性为管理最值,而本次实际上就是要获取最值(从所有的丑数中获取最小值,并依次输出)

因此本次可以进行穷举,每一个最小的数都会生成3个丑数,将新生成丑数入堆,然后每一个轮次取出一个最小数。从而确保堆中的数字都是丑数。

但是这样需要关注一个问题,是否会出现重复入堆的情况,比如2生成新一轮丑数时会生成10,5生成新一轮丑数时也能生成10,这样就会导致重复入堆,虽然可以出堆元素时,持续出堆使得不讨论重复情况,但是实际上这个现象可以被规避。

规避的方案为,每个数只允许与比自己构成最大因数的更大因数或者本身因数生成下一轮丑数

比如因数包含5的数值只能再和5生成新的数,因数包含3的数只能和3或5生成新的数。

原因在于以10为例,可以由2或者5生成,如果不允许5用2或者3生成新一轮数字,就不会生成10,但是2在生成数时会和2、3、5相乘,因此不会出现遗漏

(描述不太成功)

解题思路 (堆)

(性能全方位的不如队列)

class Solution {
    public int nthUglyNumber(int n) {

        Queue<Long> minheap = new PriorityQueue<>();

        minheap.add (1L);

        long temp=1L;

        for(;n>0;n--){

            temp = minheap.poll();

            if(temp%5 == 0){
                minheap.add(temp *5);
            }else if (temp%3 == 0){
                minheap.add(temp *3);
                minheap.add(temp *5);
            }else {
                minheap.add(temp *2);
                minheap.add(temp *3);
                minheap.add(temp *5);
            }
        }

        return (int)temp;
    }
}

拓展 超级丑数

来源:https://leetcode-cn.com/problems/super-ugly-number/

编写一段程序来查找第 n 个超级丑数。

超级丑数是指其所有质因数都是长度为 k 的质数列表 primes 中的正整数。
示例:

输入: n = 12, primes = [2,7,13,19]
输出: 32 
解释: 给定长度为 4 的质数列表 primes = [2,7,13,19],前 12 个超级丑数序列为:[1,2,4,7,8,13,14,16,19,26,28,32] 。

说明:

  • 1 是任何给定 primes 的超级丑数。
  • 给定 primes 中的数字以升序排列。
  • 0 < k ≤ 100, 0 < n ≤ 106, 0 < primes[i] < 1000 。
  • 第 n 个超级丑数确保在 32 位有符整数范围内。
解题思路

举一反三的道理,关键问题在于如何管理丑数因数数组,其余写法是一致的。
具体的方案为,用循环进行管理,队列方案创建一个数组,管理指针的下标。堆方案则是创建一个循环,循环尝试取余,在根据循环次数选择质数集合进行生成丑数。
(想想都觉得队列方案性能好)
(代码略)

leetcode 1753. 移除石子的最大得分

来源 : https://leetcode-cn.com/problems/maximum-score-from-removing-stones/

你正在玩一个单人游戏,面前放置着大小分别为 a​​​​​​、b 和 c​​​​​​ 的 三堆 石子。

每回合你都要从两个 不同的非空堆 中取出一颗石子,并在得分上加 1 分。当存在 两个或更多 的空堆时,游戏停止。

给你三个整数 a 、b 和 c ,返回可以得到的 最大分数 。

示例 1:

输入:a = 2, b = 4, c = 6
输出:6
解释:石子起始状态是 (2, 4, 6) ,最优的一组操作是:
- 从第一和第三堆取,石子状态现在是 (1, 4, 5)
- 从第一和第三堆取,石子状态现在是 (0, 4, 4)
- 从第二和第三堆取,石子状态现在是 (0, 3, 3)
- 从第二和第三堆取,石子状态现在是 (0, 2, 2)
- 从第二和第三堆取,石子状态现在是 (0, 1, 1)
- 从第二和第三堆取,石子状态现在是 (0, 0, 0)
总分:6 分 。

示例 2:

输入:a = 4, b = 4, c = 6
输出:7
解释:石子起始状态是 (4, 4, 6) ,最优的一组操作是:
- 从第一和第二堆取,石子状态现在是 (3, 3, 6)
- 从第一和第三堆取,石子状态现在是 (2, 3, 5)
- 从第一和第三堆取,石子状态现在是 (1, 3, 4)
- 从第一和第三堆取,石子状态现在是 (0, 3, 3)
- 从第二和第三堆取,石子状态现在是 (0, 2, 2)
- 从第二和第三堆取,石子状态现在是 (0, 1, 1)
- 从第二和第三堆取,石子状态现在是 (0, 0, 0)
总分:7 分 。

示例 3:

输入:a = 1, b = 8, c = 8
输出:8
解释:最优的一组操作是连续从第二和第三堆取 8 回合,直到将它们取空。
注意,由于第二和第三堆已经空了,游戏结束,不能继续从第一堆中取石子。

解题思路

本题虽然有堆,但是初步也不知道是否一定要用堆来管理。

先明确最优解的生成理论,最优解的生成方式为尽可能的让一个最小数组为空,同时让剩下两个数组之间元素的插值最小,最终的结果就是最小数组操作为空时的操作数x和剩下的两个数组中最小的数组大小y的和(x+y)

到此为止,我从逻辑上获取了一个方案,先将获取的整数排序,然后从逻辑层面上获取最小元素和剩余两个元素,每一个轮次都减小最小元素和剩余两个元素中的最大值,持续循环只到最小元素耗尽。这个方案一定可行,但是一定不是最优解。

基于这个逻辑方案,我可以做一重批量处理的管理,在理论上整体逻辑循环必然分为2种情况。

  • 情况1 在一段时间内,最大元素都大于次大的元素
  • 情况2 在某一时刻,最大元素等于次大元素

情况1可以转换为情况2,但是情况2不会变回情况1,因此只需要对于情况1的截止条件进行讨论,讨论完毕后再考虑情况2的最终结果即可。

假定a最小,b其次,c最大(如果不是这个顺序则调换元素值使得满足条件)

在保持情况1的过程中
此时的截止条件就是(c-b)和a比较,如果不小于a,那么最终得分就是(a+b)否则执行(c-b)个轮次后生成新的数为:
[a+b-c,b,b]进入情况2

一旦发生情况2
此时的数为(x,y,y) x一定小于等于y,此时输出的结果为(y+x/2)的整数运算结果。

综上
先判断属于什么情况,然后执行对于情况2的计算即可。

(本题需要用堆解吗??)

(回顾了课上的方案,也和堆无关,本题就是用于练习思考的,无关学习内容)

示例代码

(0ms和35.1MB 性能非常好,舒服)

class Solution {
    public int maximumScore(int a, int b, int c) {

        int [] arr = new int [3];
        arr[0] = a;
        arr[1] = b;
        arr[2] = c;

        Arrays.sort(arr);

        if(arr[1] == arr[2]){
            return mode_2(arr[0],arr[1]);
        }

        return  mode_1(arr[0],arr[1],arr[2]);
    }

    public int mode_1 (int a, int b, int c) {
        if(a+b<=c){
            return a+b;
        }

        return c-b + mode_2 (a+b-c,b);
    }

    public int mode_2 (int x, int y) {
        return y+x/2;
    }
}

结语

今天是5月11日,如今的我面临严峻的挑战,我精力不足了。
目前剩余2课时没有发布笔记,我尽快完成。

关于堆的学习,从java层面考虑没有特别深的价值,对于不了解堆的我来说,我学会了新的内容,我很高兴,仅此而已。

关于写笔记博客的目的,主要是为了训练自身的知识梳理能力,同时培养良好的学习习惯,当前我遇到不会的内容我会有强烈的愿望去学会,然后写博客记录下来。但是想法是好的,执行层面上,如果我需要学习的内容过多,我的精力会跟不上,对此我希望我继续努力做出调节,最终实现一个极为优秀的学习策略并且掌握丰富的知识。

我的目标始终包含了掌握极为高效的知识梳理和运用能力的需求,但是我没能实现,继续练习!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值