詳解快速選擇算法(Lucene實現源碼分析)

前言

什麼是選擇算法?java

在計算機科學中,選擇算法是一種在列表或數組中找到第k個最小數字的算法;git

計算集合中第k大(小)的元素. 就是topK相關係列的問題,可是選擇算法只須要找到第k個就好.github

在lucene的源碼中, 對於選擇算法定義了一個接口:算法

/** An implementation of a selection algorithm, ie. computing the k-th greatest * value from a collection. */
// 選擇算法,topK問題
public abstract class Selector {

  /** Reorder elements so that the element at position {@code k} is the same * as if all elements were sorted and all other elements are partitioned * around it: {@code [from, k)} only contains elements that are less than * or equal to {@code k} and {@code (k, to)} only contains elements that * are greater than or equal to {@code k}. */
  // 重排序元素,以使k位置的元素做爲分割點. from->k 的都是小於等於k的. k -> to 的都是大於k的
  public abstract void select(int from, int to, int k);

  void checkArgs(int from, int to, int k) {
    if (k < from) {
      throw new IllegalArgumentException("k must be >= from");
    }
    if (k >= to) {
      throw new IllegalArgumentException("k must be < to");
    }
  }

  /** Swap values at slots <code>i</code> and <code>j</code>. */
  // 交換兩個槽的內容
  protected abstract void swap(int i, int j);
}
複製代碼

定義的接口除了選擇還有交換.apache

Lucene對於選擇算法有兩個實現,快速選擇算法及基數選擇算法.本文將詳細分析快速選擇算法的源碼. 該類的路徑是: org.apache.lucene.util.IntroSelector.後端

完整版本的帶有註釋的源碼在github上. IntroSelector源碼數組

原理介紹

在計算機科學中,快速選擇(英語:Quickselect)是一種從無序列表找到第k小元素的選擇算法。它從原理上來講與快速排序有關。與快速排序同樣都由託尼·霍爾提出的,於是也被稱爲霍爾選擇算法。[1] 一樣地,它在實際應用是一種高效的算法,具備很好的平均時間複雜度,然而最壞時間複雜度則不理想。快速選擇及其變種是實際應用中最常使用的高效選擇算法。微信

快速選擇的整體思路與快速排序一致,選擇一個元素做爲基準來對元素進行分區,將小於和大於基準的元素分在基準左邊和右邊的兩個區域。不一樣的是,快速選擇並不遞歸訪問雙邊,而是隻遞歸進入一邊的元素中繼續尋找。這下降了平均時間複雜度,從O(n log n)至O(n),不過最壞狀況仍然是O(n2)。markdown

對於快速排序,想必你們對其原理都很清楚,這裏不贅述了.less

衆所周知,快速排序最壞的時間複雜度是O(n2). 快速選擇也是.

最壞狀況一般出如今每次選擇分割點時,都選擇了最錯誤的那個. 好比在已排序數組中每次都取第一個,那麼根本起不到分割的做用.

所以,對快速選擇的優化,主要集中在分割點的選取上.

最左/最右做爲分割點

這種就是咱們一般隨手實現的那種,性能幾乎就是線性的, 也就是 O(n). 可是他解決不了已排序數組的問題,會退化到 O(n2).

隨機選擇分割點

因爲咱們的數組是未排序的,整個數組其實就是隨機. 所以這種方案與上面的方案本質上沒什麼區別,仍是看運氣。

三者中位數法選擇分割點

取第一個,最後一個,中間位置,三個元素的中位數做爲分割點. 這樣對已部分排序的數據依然可以達到線性複雜度. 可是在人爲構造的特殊數組上,仍是會退化成O(n2).

我猜測的算法思路: 之因此隨機選擇法,會出現最壞的狀況,是由於每次都選擇到了最差也就是最大的數字. 加入三個數字的中位數,能夠保證選擇到的分割點既不是最大,也不是最小,刻意避免了最壞的狀況出現.

中位數的中位數法(又叫作BFPRT法,根據5個做者的名字首字母命名)

一次分割點的選擇方法:

  1. 將全部元素分紅5個一堆的組. 得到了(n/5)個5元組.
  2. 每一個5元組,經過插入排序的辦法,求到中位數.
  3. 對於(n/5)箇中位數,遞歸調用本方法,求到中位數.

時間複雜度分析

2021-03-25-21-19-49

爲何是5??

在BFPRT算法中,爲何是選5個做爲分組?

首先,偶數排除,由於對於奇數來講,中位數更容易計算。

若是選用3,帶入上面的公式,會發現和自己沒有什麼區別.

若是選取7,9或者更大,在插入排序時耗時增長,常數 [公式] 會很大,有些得不償失。
複製代碼

實際應用

根據上面的原理,大概能得出的結論:

  • 三者中位數法,能提供不錯的線性複雜度,可是有極小的機率遇到極端狀況,致使O(n2)
  • 中位數的中位數法,能提供絕對的線性時間複雜度保證. 可是他的常數比較大,有時候有些浪費.

那麼實際應用中固然是取長補短了.

因此實際應用中的最佳快速選擇實現,應該是使用三者中位數法選取分割點,設置閾值,若是遇到了極端狀況,切換到中位數的中位數(BFPTR)來保證最壞狀況下的時間複雜度

真巧呢,Lucene就是這麼實現的.(否則我爲啥會寫呢?)

Lucene源碼org.apache.lucene.util.IntroSelector.

版本8.7.0

定義

該類是一個抽象類,它只負責提供快速選擇的分割點選擇,左右分區, 不負責具體的存儲介質,交換算法等.所以它有三個抽象方法,等待子類實現。

  • void swap(int i, int j): 交換算法,交換i,j兩個下標的值
  • void setPivot(int i): 將i下標設置爲分割點
  • int comparePivot(int j): 將j下標上的值與分割點進行比較,返回大小.

這三個方法和快速選擇的精髓毫無關係,可是爲了方便理解,這裏給出一個簡單的實現.

/** * 這是一個簡單的,基於int數組的快速選擇的實現 */
public static class TestSelector extends IntroSelector{
    Integer[] actual;
    Integer pivot;

    public TestSelector(Integer[] actual) {
      this.actual = actual;
    }

    @Override
    protected void swap(int i, int j) {
      ArrayUtil.swap(actual, i, j);
    }

    @Override
    protected void setPivot(int i) {
      pivot = actual[i];
    }

    @Override
    protected int comparePivot(int j) {
      return pivot.compareTo(actual[j]);
    }
  }
複製代碼

核心select方法

public final void select(int from, int to, int k) {
    checkArgs(from, to, k);
// 遞歸的最大深度
    final int maxDepth = 2 * MathUtil.log(to - from, 2);
    quickSelect(from, to, k, maxDepth);
    }
複製代碼

核心方法比較簡單,入參分別是:左下標,右下標,待尋找的K.

  1. 檢查參數
  2. 定義遞歸的最大深度
  3. 調用快速選擇

什麼是遞歸的最大深度

在原理部分講到,實際應用時,使用三者中位數來進行快速選擇,可是若是遞歸太屢次,會認爲遇到了極端狀況,會切換到中位數的中位數 來進行分割點的選擇. 這裏定義的閾值是:`遞歸深度 > 2*lg(n).

quickSelect

明顯能夠看出來,這裏的quick不是快速選擇的中名詞(整個類纔是真的快速選擇),而是一個形容詞,形容是比較快的選擇,那麼就是三者中位數方法的快速選擇實現了.

他的流程圖如:

2021-03-25-22-00-02

結合代碼中的註釋,應該比較好懂.

2021-03-25-22-41-37

核心邏輯能夠歸納爲:

  1. 經過三者中位數求分割點
  2. 根據分割點左右分區移動數據
  3. 左右兩邊挑選k在的一邊進行遞歸

插入一個邏輯是: 若是每次開始時發現遞歸次數達到限制了,就走slowSelect.

slowSelect方法

很明顯,做者認爲這個方法是較慢的,而上一個是較快的. 這與咱們學到的理論有點區別,咱們學到的是數學證實的時間複雜度,這裏的快慢更傾向於工業界的平均預估,對常量會比較敏感一點.

流程圖:

2021-03-25-22-29-57

代碼:

2021-03-25-22-41-03

核心邏輯:

  1. 左右相等則說明找到了,返回
  2. 用中位數的中位數法,求當前應該選擇的分割點
  3. 根據分割點進行左右分區,小的一邊,大的一邊
  4. 根據分割點與K的大小,左右兩邊選擇一邊進行遞歸查找

其中用到了分區方法, 沒什麼特別的,就是常見的快排分區方法,只是代碼又是另外一種風格,不必貼出來.

pivot方法

這個方法實現了對[left,right],求解中位數的中位數.

2021-03-25-22-34-19

這個所謂的中位數的中位數,理論上很好求解,又是一個遞歸的方法而已. 爲何變複雜了呢?

想一下:

  • 快速選擇的目的,是對一個未排序的數組,求第k大的元素.
  • 求中位數,是求數學上的中位數. 也是求未排序的數組中,求第length/2大的元素.

他們本質上講是同構的,所以Lucene的代碼中,爲了複用代碼,在求解中位數的中位數過程當中,使用了部分slowSelect的代碼,非常精巧, 可是對於剛看這份代碼的人,會感到比較困惑.(是的,說的就是我本身,我也是寫文章的時候才忽然醒悟的).

代碼以下:

2021-03-25-22-40-20

其中涉及到一個對5個之內的元素求中位數而且分區的方法,其實本質上就是直接進行了插入排序,而後取中位數. 由於控制了總數,因此插入排序的性能徹底知足,且實現簡單.

2021-03-25-22-43-49

總結

  1. 快速排序和快速選擇,都是特別有用的,快排應用於大量的工業排序, 快速選擇應用於topK問題
  2. 快速排序和選擇的核心,在於所謂主元(切割點)的選擇
  3. 切割點的選擇,有不少種優化方法,性能要求不高就隨便寫,性能要求高就按這篇文章講的寫. 儘可能使用三者中位數來求解切割點,注意防止極端狀況,設置閾值使用中位數的中位數來求切割點便可.

說完了,有一說一. Lucene的代碼,精巧且難懂. 但高效.

參考文章

zh.wikipedia.org/wiki/%E5%BF…

zhuanlan.zhihu.com/p/64627590


完。


聯繫我

最後,歡迎關注個人我的公衆號【 呼延十 】,會不按期更新不少後端工程師的學習筆記。 也歡迎直接公衆號私信或者郵箱聯繫我,必定知無不言,言無不盡。



以上皆爲我的所思所得,若有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文連接。

聯繫郵箱:huyanshi2580@gmail.com

更多學習筆記見我的博客或關注微信公衆號 <呼延十 >------>呼延十

相關文章
相關標籤/搜索