Lucene排序取TopN源码分析--PriorityQueue

PriorityQueue 实现一优先队列框架,实例非常简单,只需实现lessThan(Object a, Object b)方法即可,通过该方法可以控制大优先或小优先

 通过分析lucene源码可知,lucene每命中一个结果,就调用一次collector.collect(doc)方法,由Collector把结果保存到一个PriorityQueue中。对于常见的取TopN的情况,通常实例化一个org.apache.lucene.search.TopDocsCollector的子类对象A,这些类之间的关系如下:

(一)org.apache.lucene.util.PriorityQueue的内部实现

    这个优先级队列被用来实现堆排序。堆用一个一位数组表示,长为size()+1,第0个元素没有实际用途,第1个元素就是堆顶元素。每次出堆,要把堆顶元素移出(按堆排序原理,pop后可以放数组后面,从后往前放,最终使数组有序;但lucene提供的这个类没这么做,只是把堆尾置null,size--,数组是private的,外部无法直接访问)。如果需要获取N个元素从大到小的序列,只需把它们全部入堆,再全部出堆,使用小顶堆比较合适。特别需要注意的是,除了add()方法之外,还有一个方法insertWithOverflow()

  public T insertWithOverflow(T element) {

    if (size < maxSize) {

      add(element);

      returnnull;

    elseif (size > 0 && !lessThan(element, heap[1])) {

      T ret = heap[1];

扫描二维码关注公众号,回复: 1832929 查看本文章

      heap[1] = element;

      updateTop();

      return ret;

    else {

      return element;

    }

  }

    这个方法很重要,它把想要入堆的元素和堆顶元素做比较,决定是否该入堆,从而使取topN时,堆的空间复杂度控制在O(N),降低了开销。

    到底该使用大顶堆还是小顶堆呢?分析如下:

    小顶堆:如果按从大到小取topN的话,只需初始化堆大小为N,入堆时调用insertWithOverflow()方法。这个方法非常重要,它把想要入堆的元素和堆顶元素做比较,决定是否该入堆,从而使堆空间复杂度降低。堆空间复杂度降低,会使堆的深度降低,对时间性能上也有提升。为获得TopN,最后还需要先把堆中所有元素弹出。需要注意的是:最先弹出的是topN里的最小值,最后弹出的才是最大值。

    大顶堆:如果按从大到小取TopN的话,初始化的对大小必须为元素的总个数,只能add。堆占用的数组空间比小顶堆要大,建堆性能劣于小顶堆。出堆时,最先出的就是最大值。可见,取TopN,小顶堆优于大顶堆。但如果取lastN(按从大到小,排在最后面的N个元素),则大顶堆更有优势,这时大顶堆可以实现一个insertWithOverflow()方法。

    PriorityQueue有两种模式:

    1. pre-populate=false,不用重写getSentinelObject()方法,堆的size()方法可以直接用。初始化堆的大小应该根据实际需求来设置,使用时需注意区分add()和insertWithOverflow()方法,如果错误使用add()有数组下标越界的可能。

    2. pre-populate=true。该类的源码注释很清楚:关于入堆、出堆、size都有特殊用法,size需要自己来维护,使用者应自己知道堆里有多少元素,知道每一步操作到底使堆发生哪些变化。相比之下,pre-populate=true的性能应该更优,它会在初始化堆数组的时候,一次性全部初始化数组里的所有元素,集中创建对象。所以看lucene的默认实现,基本都采用了pre-populate=true的用法。这种模式下,必须重写protected T getSentinelObject()方法,以便初始化堆数组。Add一个元素,主要包括以下步骤:

   * MyObject pqTop = pq.top();

   * pqTop.change().

   * pqTop = pq.updateTop();

    PriorityQueue类的其它方法:

1.

  protected final Object[] getHeapArray() {

    return (Object[]) heap;

  }

个人感觉这个方法价值不大,返回的数组很难满足业务需要,因为pop时把数组后面元素置为null了,所以取topN还需使用者自己再创建一个数组,有些浪费。

2.

  public final T pop() {

    ……

      heap[size] = null; // permit GC of objects

      size--;

……

  }

这个方法会把堆size--。在pre-populate=true的用法中,size()方法返回的值是没有实际意义的,堆初始化时即设置size为maxSize,top()、updateTop()不影响size,pop()影响size。

(二org.apache.lucene.search.HitQueue

从源码看,HitQueue 是一个final类,继承了PriorityQueue, 小顶堆, prePopulate=true,使用时需关注size的含义。

(三)Collector的子类

1.  建堆过程

TopScoreDocCollector使用了HitQueue,参见OutOfOrderTopScoreDocCollector的部分源码,在collect(doc)过程中完成建堆过程:

OutOfOrderTopScoreDocCollector.collect(int doc):

      if (score == pqTop.score && doc > pqTop.doc) {

        // Break tie in score by doc ID:

        return;

      }

      pqTop.doc = doc;

      pqTop.score = score;

      pqTop = pq.updateTop();

 

这种用法堆数组变化过程如下:

初始化之后对象状态:[ O1,O2,O3,……On ]

top更改:O1-->A1

updateTop()之后:      [ On,O2,O3,……A1 ]

再更改top,再updateTop,……直至堆数组所有元素完成建堆。

2. 出堆过程。

 org.apache.lucene.search.TopDocsCollector类中,topDocs(int start, int howMany)方法完成出堆排序的全过程,代码如下:

  public TopDocs topDocs(int start, int howMany) {
    
     int size = topDocsSize();

    if (start < 0 || start >= size || howMany <= 0) {
      return newTopDocs(null, start);
    }

    // We know that start < pqsize, so just fix howMany. 
    howMany = Math.min(size - start, howMany);
    ScoreDoc[] results = new ScoreDoc[howMany];

    for (int i = pq.size() - start - howMany; i > 0; i--) { pq.pop(); }
     populateResults(results, howMany);
    
    return newTopDocs(results, start);
  }

其中,populateResults(results, howMany)会把topN放入一个新的数组返回。

  protected void populateResults(ScoreDoc[] results, int howMany) {
    for (int i = howMany - 1; i >= 0; i--) { 
      results[i] = pq.pop();
    }

  }


Lucene里使用比较多的一种集合就是这个

PriorityQueue

比如取前10条相关结果。

jdk本身也有一个优先级队列,为什么lucene要实现自己的呢。。

后面看了jdk的 PriorityQueue ,它是使用最大堆来实现的,而且它的长度是什么可以变长的,就是如果我要一个top k的数据,但它会将所有数据都存起来,当然小数据无所谓,但如果达到几十万,几百万的时候,可想多浪费空间,而且每次调整更是要漫长多了。

lucene实现的PriorityQueue(以最小堆来实现top k)

它是一个不变长的优先级队列.当队列达到指定最大size的时候,并不会扩充,top k外的数据都会抛掉,这样不仅省空间,也因为轻便调整这个堆 时可以更快。一般来说我要一个top k的数据,直觉想到的是使用最大堆,但这里它不是使用最大堆 ,而是使用最小堆来维护top k的数据。最后在输出的时候,先吐出来的时候倒一下序就可以了。

先看一下数据加入队列的时候的代码:

 public T insertWithOverflow(T element) {
    //堆未满的时候
    if (size < maxSize) {
      add(element);
      return null;
    }
    //新元素跟堆顶值比较,如果比堆顶值大,则进行调整
    else if (size > 0 && !lessThan(element, heap[1])) {
      T ret = heap[1];
      heap[1] = element;
      updateTop();
      return ret;
    } 
    //否则不需要调整原堆
    else {
      return element;
    }
  }
这样当堆满的时候,每次新来一个数据只需要跟堆顶比较就可以了,比堆顶小的数据都可以抛掉,比堆顶大的数就将堆顶抛掉,将新数据加到这个最小堆,然后再调整最小堆 .之后以此类推,最后这个堆里的数据就是我们要的top k的数据。。

当加入新元素时,此时堆未满,则从下往上调整

  private final void upHeap() {
    int i = size;
    T node = heap[i];			  // save bottom node
    int j = i >>> 1;
    while (j > 0 && lessThan(node, heap[j])) {
      heap[i] = heap[j];			  // shift parents down
      i = j;
      j = j >>> 1;
    }
    heap[i] = node;				  // install saved node
  }
当堆满 的时候,从下往上调整,从(n/2)的位置开始,保持最小堆的定义,heap[1]保存最小堆的元素

 private final void downHeap() {
    int i = 1;
    T node = heap[i];			  // save top node
    int j = i << 1;				  // find smaller child
    int k = j + 1;
    if (k <= size && lessThan(heap[k], heap[j])) {
      j = k;
    }
    while (j <= size && lessThan(heap[j], node)) {
      heap[i] = heap[j];			  // shift up child
      i = j;
      j = i << 1;
      k = j + 1;
      if (k <= size && lessThan(heap[k], heap[j])) {
        j = k;
      }
    }
    heap[i] = node;				  // install saved node
  }
最后最小堆推出数据,每次吐出这个最小堆里最小的数据,

  public final T pop() {
    if (size > 0) {
      T result = heap[1];			  // save first value
      heap[1] = heap[size];			  // move last to first
      heap[size] = null;			  // permit GC of objects
      size--;
      downHeap();				  // adjust heap
      return result;
    } else
      return null;
  }
所以一般是用一个数组从后往前填写,这样就得到一个top k的列表

 for (int i = howMany - 1; i >= 0; i--) { 
      results[i] = pq.pop();
    }

猜你喜欢

转载自blog.csdn.net/asdfsadfasdfsa/article/details/80643545