概念

  • 堆是一个完全二叉树;(所以用数组存储)
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。(根结点最大就是大顶堆,根节点最小就是小顶堆)

堆的存储:子结点下标/2 = 父节点下标(无论左右子结点)
注意:是从下标1开始存放元素 (不然上式永远也到不了下标为0 元素位置)
在这里插入图片描述

堆的操作

记住:

  1. 插入、删除一个元素就是一次堆化过程,一次堆化时间复杂度为O(logn),那么插入、删除n次就需要O(nlogn)。
  2. 一次堆化关注结点是不会变的,直到这个结点堆化结束

1. 往堆中插入一个元素 (堆化heapify)

重点:堆和其他二叉树的区别
一次插入就是O(logn)因为堆是一种完全二叉树,所以堆不会像其他二叉树可能退化为O(n))!

从下往上堆化: 将插入元素变为堆最后的叶子结点
在这里插入图片描述

代码中也就是将插入元素放在数组最后,然后与其父节点比较、交换:子结点下标/2 = 父节点下标(无论左右子结点)
注意:数组下标以1开始存储:

//大顶堆
public class Heap {
  private int[] a; // 数组,从下标1开始存储数据
  private int n;  // 堆可以存储的最大数据个数
  private int count; // 堆中已经存储的数据个数

  public Heap(int capacity) {
    a = new int[capacity + 1];
    n = capacity;
    count = 0;
  }

  public void insert(int data) {
    if (count >= n) return; // 堆满了
    ++count;
    a[count] = data;
    int i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap()函数作用:交换下标为i和i/2的两个元素
      i = i/2;
    }
  }
  
 }

2. 删除堆顶元素

直接删除结点,然后从上往下填补,就很有可能出现数组空洞,导致堆存储不连续:
在这里插入图片描述
解决办法:同样是从上往下堆化将最后一个叶子结点(数组中最后一位)填补到被删除元素位置, 再以被填元素位置为关注结点进行堆化,那么将不会出现“数组空洞”。
在这里插入图片描述

注意:数组下标以1开始存储:

//大顶堆
//删除堆顶元素:
public void removeMax() {
  if (count == 0) return -1; // 堆中没有数据
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

/**
* 堆化函数是堆重要的共用函数
*/
// 自上往下堆化
private void heapify(int[] a, int n, int i) { //表示在数组a的0~n(包括n)之间堆化 初始堆化关注结点下标为i
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;//保证所换结点是两个子结点中较大的一个
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

如何基于堆实现排序?

堆排序时间复杂度非常稳定,是 O(nlogn),并且它还是原地排序算法。

1. 建堆

  • 方法一:借助插入方法(时间复杂度O(nlog​n))【从下往上】

  • 方法二:从后往前处理数组(时间复杂度 O(n))【从上往下】

  1. 先把数据全部放在数组中
  2. 以最后一个非叶子结点开始为关注结点进行堆化,与其子结点比较

在这里插入图片描述
在这里插入图片描述

堆化函数:一次堆化只能保证关注结点正确堆化:

//大顶堆建堆
private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {//堆化每个非叶子结点
    heapify(a, n, i);
  }
}

//从下往上堆化(一次堆化只能保证关注结点正确堆化 整体堆化就得利用上面的for循环 堆化每个非叶子结点)
private static void heapify(int[] a, int n, int i) {//表示在数组a的0~n(包括n)之间堆化 初始堆化关注结点下标为i
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;//保证所换结点是两个子结点中较大的一个
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

2. 排序(时间复杂度O(nlogn))

不稳定,因为存在互换操作。

由于建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。
思路:

  1. 把堆顶元素跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。
  2. 将剩下的前 n−1 个元素重新构建成堆,重复上述工作。
    在这里插入图片描述

sort默认的前提是已经堆化好的堆,所以能够一次堆化就能确定堆顶元素为堆中最大元素(这一次堆化就类似于删除堆顶元素后的一次堆化):

// n表示数据的个数,数组a中的数据从下标1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

思考题:

一、为什么快速排序要比堆排序性能好?

1,堆排序数据访问的方式没有快速排序友好

堆排序和快速排序虽然都是基于数组存储的,但是

  1. 快速排序过程中,数据是局部顺序访问
  2. 而堆排序过程是跳着访问的,这对CPU缓存并不友好
2,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序

堆排一个数据就要重新建堆,交换次数肯定比快速排序要多。

二、对于完全二叉树来说,下标从 n/2​+1 到 n 的都是叶子节点,这个结论是怎么推导出来的呢?

最后一个页子结点下标为n,其父节点也就是最后一个非叶子结点下标就是n/2。。。

三、我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?

  1. 从大数量级数据中筛选出top n 条数据; 比如:从几十亿条订单日志中筛选出金额靠前的1000条数据

  2. 在一些场景中,会根据不同优先级来处理网络请求,此时也可以用到优先队列(用堆实现的数据结构);比如:网络框架Volley就用了Java中PriorityBlockingQueue,当然它是线程安全的

  3. 可以用堆来实现多路归并,从而实现有序,leetcode上也有相关的一题:Merge K Sorted Lists

堆的应用

其实就是利用堆顶元素是堆中最大(最小)的元素这个特点

一:优先级队列 (Java 的 PriorityQueue相当于一个小顶堆)

小顶堆->大顶堆:PriorityQueue< Integer > heap = new PriorityQueue<>((x,y) -> (y-x));
优先级队列是一个队列。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。

一个堆就可以看作一个优先级队列。 很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。(PriorityQueue是个队列,底层由数组实现)

1. 合并有序小文件

有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。

  1. 同时开100个流对应这100个文件
  2. 维护一个100大小的PriorityQueue
  3. 从这 100 个文件中,各取第一个字符串,放入PriorityQueue中
  4. 删除并得到堆顶元素(< T > remove()方法)后将此元素写入合并文件。
  5. 再从该元素对应的小文件取下一个字符串,add(元素)到PriorityQueue中。
  6. 依次类推,直到PriorityQueue中元素被取空。(注意:将所有文件都读完后,还要把PriorityQueue中元素全部取出)

注:大致思路是这样,但实现起来有点问题:要从字符串中能够知道该字符串对应哪个文件(不行的话只能用Map集合TreeMap<字符串,对应文件>)

2. 高性能定时器

这个…了解(暂时我还真实现不了)
假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。
在这里插入图片描述

我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。

二:利用堆求 Top K

  1. 维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。
  2. 如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;
  3. 如果比堆顶元素小,则不做处理,继续遍历数组。
  4. 数据都遍历完之后,堆中的数据就是前 K 大数据了。
  5. 当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。
  6. 遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)

三:利用堆求中位数(百分位的数据)

  1. 维护两个堆,一个大顶堆,一个小顶堆。
  2. 小顶堆中的数据都大于大顶堆中的数据。(大顶堆存小数据,小顶堆存大数据)
  3. 遍历数据:如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆否则,我们就将这个新数据插入到小顶堆
  4. 遍历完所有数据后,开始移动数据:大顶堆堆顶元素往小顶堆中移,或者小顶堆堆顶元素往大顶堆上移动。(按照百分位平衡两个顶堆数量就可(如中位数就各占50%的数据),最终数据就在顶堆上)

在这里插入图片描述
个人观点:只维护一个大顶堆,将所有数据都堆化到大顶堆后,再将多余的数据依次从堆顶移除(或者移除到一个小顶堆)。此过程少了n次元素对比,效率也许更高。

回顾解答:如何快速求接口的 99% 响应时间?

什么是99% 响应时间?(就是图中99这个值
在这里插入图片描述
维护一个大顶堆、一个小顶堆将数据(当然是无序的)遍历…(相信你懂了😴)

实战

有一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词呢?(要求单机内存为 1GB)

思路:

  1. 先考虑怎么获得Top10 ===> 立马想到
  2. 最热门 ===> 出现次数最多的前十个关键字
  3. 需要计算每个关键字出现的次数
  4. 亿级数据的一次性操作 ===> hash算法

解决办法:

  1. 创建 10 个空文件 00,01,02,……,09。
  2. 遍历这 10 亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。
  3. 对这 10 亿个关键词分片之后,每个文件都只有 1 亿的关键词。
  4. 读取一个文件,构架一个散列表(遇见重复的value+1)(去除掉重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500MB。)
  5. 维护一个大小为10的堆,遍历散列表,与堆顶元素比较、堆化。
  6. 重复4、5,读完每个文件。
  7. 最终堆中10个关键字就是Top 10。

思考题:有一个访问量非常大的新闻网站,我们希望将点击量排名 Top 10 的新闻摘要,滚动显示在网站首页 banner 上,并且每隔 1 小时更新一次。如果你是负责开发这个功能的工程师,你会如何来实现呢?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值