二叉堆、优先队列与Top-N问题

前言

今日份的内容很简单,看官可以放心食用。

二叉堆

这一部分完全是大学数据结构课程的复习。

性质

顾名思义,二叉堆(binary heap)就是以二叉树形式存在的堆数据结构,也是最简单的堆。它是由J. Williams在1964年与堆排序算法一同提出的。二叉堆具有以下两个基本性质:

  • 是一棵完全二叉树;
  • 每个节点存储的值要么都小于等于它们的子节点的值,要么都大于等于它们的子节点的值。这两种分别称为最小堆(min-heap)和最大堆(max-heap)。

下面的图分别示出由同一个整数序列构造出的最小堆和最大堆。

基本操作

二叉堆有两种基本操作:插入(insert/sift-up)和弹出堆顶元素(extract/sift-down),在操作的过程中,需要时刻对堆进行调整,以使它总满足堆的性质。

先来看插入操作,假设我们要在上图所示的最大堆中插入元素48:

将48插入堆下层最左侧的空闲位置,由于48比父节点25大,不满足堆性质,故要将48和25的位置交换。交换后,48仍然比父节点36大,需要再次交换。两次交换之后,结果满足堆性质,插入结束。可见,新节点在插入过程中是逐渐上浮的,所以也被称作sift-up。

然后来看弹出堆顶元素操作。取出堆顶元素(即堆内的最大值或最小值)后,要将堆下层最右侧的元素补到堆顶。

如图所示,7在堆顶不满足最大堆的性质,它的子节点中36较大,故要将7和36的位置交换。交换后,子节点25仍然比7大,需要再次交换,此时结果又满足了堆性质。可见,弹出堆顶元素后对堆的调整操作实际上是节点逐渐下降的过程,所以也被称作sift-down。

显然,在极端情况下,一个节点会被sift-up到树的最高层,或者被sift-down到树的最底层,故插入和弹出操作的最坏时间复杂度都是O(logN)。

存储

我们已经知道,完全二叉树可以很方便地用数组存储,所以二叉堆也一样。下图示出一棵有7个节点的完全(满)二叉树的数组存储。

也就是说,如果下标从0开始的话,那么对于二叉堆内下标为i的任意节点,有:

  • 它的子节点下标分别为(2i + 1)和(2i + 2);
  • 它的父节点下标为floor[(i - 1) / 2]。

由于数组占用的是连续的物理内存,当堆的大小非常大并且系统启用虚拟内存时,就会被分散到许多内存页上存储,效率降低。B堆(B-heap)可以解决这个问题,详情参考这里

二叉堆的最主要应用就是实现优先队列,下面来看。

优先队列

顾名思义,优先队列(priority queue)也是一种队列,一端进一端出。与普通队列的先进先出(FIFO)不同的是,优先队列中的每个元素都有一个与之相关的优先级,当执行出队操作时,弹出的是优先级最高的那个元素。一般来讲,优先级就是元素结构中的某个键值,如果键值小的元素优先级高,那么该队列就是升序优先队列;如果键值大的元素优先级高,那么该队列就是降序优先队列。从这个性质可以看出,二叉堆特别用来适合实现优先队列:升序对应最小堆,降序对应最大堆。

多种编程语言都内置了优先队列的实现,Java集合框架中的实现就是java.util.PriorityQueue,它另外还有一个适应并发环境的线程安全的实现:java.util.concurrent.PriorityBlockingQueue。类图如下所示。

下面来简要分析一下PriorityQueue类的源码。

成员变量

private static final int DEFAULT_INITIAL_CAPACITY = 11;
private transient Object[] queue;
private int size = 0;
private final Comparator<? super E> comparator;
private transient int modCount = 0;
  • DEFAULT_INITIAL_CAPACITY:队列的默认初始容量11;
  • queue:存储队列(堆)元素的数组;
  • size:队列的大小;
  • comparator:队列元素键值的比较器。默认的二叉堆是最小堆;
  • modCount:队列发生结构化修改的次数。关于它的用途,见一周前写的这篇文章

构造方法

PriorityQueue有多种构造方法,如下图所示(其中的int为初始容量)。

所有从集合产生优先队列的构造方法最终都会调用以下的方法。

  private void initElementsFromCollection(Collection<? extends E> c) {
    Object[] a = c.toArray();
    // If c.toArray incorrectly doesn't return Object[], copy it.
    if (a.getClass() != Object[].class)
      a = Arrays.copyOf(a, a.length, Object[].class);
    int len = a.length;
    if (len == 1 || this.comparator != null)
      for (int i = 0; i < len; i++)
        if (a[i] == null)
          throw new NullPointerException();
    this.queue = a;
    this.size = a.length;
  }

  private void initFromCollection(Collection<? extends E> c) {
    initElementsFromCollection(c);
    heapify();
  }

  private void heapify() {
    for (int i = (size >>> 1) - 1; i >= 0; i--)
      siftDown(i, (E) queue[i]);
  }

heapify()方法的含义就是“堆化”,亦即将传入的集合整理成堆。siftDown()方法的源码在后面有。

入队操作

入队操作有add()和offer()两个方法实现,前者只是简单地代理了后者。

  public boolean add(E e) {
    return offer(e);
  }

  public boolean offer(E e) {
    if (e == null)
      throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
      grow(i + 1);
    size = i + 1;
    if (i == 0)
      queue[0] = e;
    else
      siftUp(i, e);
    return true;
  }

offer()方法首先对入队元素判空并增加modCount。然后,如果队列大小已经到了queue数组的长度,调用grow()方法对数组扩容。最后调用siftUp()方法插入元素到堆中,并维护堆性质。根据有无传入比较器,siftUp()方法有两种实现,下面来看默认比较逻辑下的实现方法。

  private void siftUp(int k, E x) {
    if (comparator != null)
      siftUpUsingComparator(k, x);
    else
      siftUpComparable(k, x);
  }

  private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
      int parent = (k - 1) >>> 1;
      Object e = queue[parent];
      if (key.compareTo((E) e) >= 0)
        break;
      queue[k] = e;
      k = parent;
    }
    queue[k] = key;
  }

siftUpComparable()方法会找到父节点的下标,并调用compareTo()方法比较节点值的自然大小。由于默认是最小堆,当要插入的节点值比父节点小时,就迭代地交换它们的值,直到上浮到合适的位置。

出队操作

出队操作由poll()方法来实现。

  public E poll() {
    if (size == 0)
      return null;
    int s = --size;
    modCount++;
    E result = (E) queue[0];
    E x = (E) queue[s];
    queue[s] = null;
    if (s != 0)
      siftDown(0, x);
    return result;
  }

可见确实是将队列中的最后一个元素(即二叉堆中最右下角的元素)放到最前,并调用siftDown()方法进行下沉。与siftUp()方法相似地,它也有两种逻辑,仍然来看默认的比较逻辑。

  private void siftDown(int k, E x) {
    if (comparator != null)
      siftDownUsingComparator(k, x);
    else
      siftDownComparable(k, x);
  }

  @SuppressWarnings("unchecked")
  private void siftDownComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    int half = size >>> 1;        // loop while a non-leaf
    while (k < half) {
      int child = (k << 1) + 1; // assume left child is least
      Object c = queue[child];
      int right = child + 1;
      if (right < size &&
        ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
        c = queue[child = right];
      if (key.compareTo((E) c) <= 0)
        break;
      queue[k] = c;
      k = child;
    }
    queue[k] = key;
  }

完全二叉树还有一个性质:如果树一共有N个节点,那么叶子节点正好有floor[N / 2]个。siftDown()方法就用这个性质来保证在当前节点不是叶子节点的情况下来循环处理。由于要保持最小堆,每次循环都会取得左、右子节点中较小的那个值,如果子节点的值小于父节点的,则交换它们。

删除操作

注意,这里的删除指的是根据下标删除优先队列中的任意一个节点。

  public boolean remove(Object o) {
    int i = indexOf(o);
    if (i == -1)
      return false;
    else {
      removeAt(i);
      return true;
    }
  }

  private E removeAt(int i) {
    // assert i >= 0 && i < size;
    modCount++;
    int s = --size;
    if (s == i) // removed last element
      queue[i] = null;
    else {
      E moved = (E) queue[s];
      queue[s] = null;
      siftDown(i, moved);
      if (queue[i] == moved) {
        siftUp(i, moved);
        if (queue[i] != moved)
          return moved;
      }
    }
    return null;
  }

由于是从中间删除的,所以在常规的sift-down操作之后,如果发现补位元素的下标并未发生变化,说明(在默认情况下)它的左右子节点都比它大,所以还需要新一轮的sift-up操作。时间已经很晚了,所以这里就不画图了,看官稍微想一想就能理解。

查看队头元素操作

简单粗暴。

  public E peek() {
    return (size == 0) ? null : (E) queue[0];
  }

扩容操作

最后来看看前面提到过的grow()方法,不复杂。

  private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    // Double size if small; else grow by 50%
    int newCapacity = oldCapacity + ((oldCapacity < 64) ?
      (oldCapacity + 2) :
      (oldCapacity >> 1));
    // overflow-conscious code
    if (newCapacity - MAX_ARRAY_SIZE > 0)
      newCapacity = hugeCapacity(minCapacity);
    queue = Arrays.copyOf(queue, newCapacity);
  }

可见,如果当前队列长度小于64,则扩容一倍,反之则扩容一半。接下来调用hugeCapacity()方法做溢出检查,并用Arrays.copyOf()方法复制数组内容,这与ArrayList的扩容是相同的了。

用优先队列解决Top-N问题

优先队列是一种强有力的辅助工具,它可以单独用,也可以配合哈夫曼生成树、Prim算法、Dijkstra算法等以提高效率,看官如果打过算法类竞赛的话应该深有体会。而在互联网世界中,升序优先队列(最小堆)经常用来解决Top-N问题,即“取得所有帖子中阅读量排名前N的帖子”、“取得所有商品中销量排名前N的商品”等。

举个栗子,假设与商品销量相关的POJO如下:

@Getter
@Setter
public class MerchandiseSales {
  private long id;
  private long quantity;
  private long price;
  // ...
}

那么我们就可以通过如下的方法很容易地得出销量Top-N排行榜。

int topN = /*...*/;
PriorityQueue<MerchandiseSales> minHeap = new PriorityQueue(
  topN + 1,
  Comparator.comparingLong(MerchandiseSales::getQuantity)
);

List<MerchandiseSales> sales = /*...*/;
for (MerchandiseSales m : sales) {
  if (minHeap.size() < topN) {
    minHeap.offer(m);
  } else if (minHeap.peek().getQuantity() < m.getQuantity()) {
    minHeap.poll();
    minHeap.offer(m);
  }
}

List<Long> ranking = new ArrayList<>();
for (int k = 0; k < topN && !minHeap.isEmpty(); k++) {
  ranking.add(minHeap.poll().getId());
}

需要注意的是,ranking列表中保存的商品仍然是按销量升序排列的,也就是说在实际展示排行榜时,应该从尾到头取出结果。

The End

甚累,希望12点半能睡吧。

晚安~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343