BFPRT 算法(TOP-K 問題)

原文連接: https://subetter.com/algorith...

一:背景介紹

在一堆數中求其前k大或前k小的問題,簡稱TOP-K問題。而目前解決TOP-K問題最有效的算法便是BFPRT算法,又稱爲中位數的中位數算法,該算法由Blum、Floyd、Pratt、Rivest、Tarjan提出,最壞時間複雜度爲$O(n)$。html

在首次接觸TOP-K問題時,咱們的第一反應就是能夠先對全部數據進行一次排序,而後取其前k便可,可是這麼作有兩個問題:ios

  1. 快速排序的平均複雜度爲$O(nlogn)$,但最壞時間複雜度爲$O(n^2)$,不能始終保證較好的複雜度;
  2. 咱們只須要前k大的,而對其他不須要的數也進行了排序,浪費了大量排序時間。

除這種方法以外,堆排序也是一個比較好的選擇,能夠維護一個大小爲k的堆,時間複雜度爲$O(nlogk)$。c++

那是否還存在更有效的方法呢?咱們來看下BFPRT算法的作法。算法

在快速排序的基礎上,首先經過判斷主元位置與k的大小使遞歸的規模變小,其次經過修改快速排序中主元的選取方法來下降快速排序在最壞狀況下的時間複雜度數組

下面先來簡單回顧下快速排序的過程,以升序爲例:函數

  1. 選取主元;
  2. 以選取的主元爲分界點,把小於主元的放在左邊,大於主元的放在右邊;
  3. 分別對左邊和右邊進行遞歸,重複上述過程。

二:算法過程及代碼

BFPRT算法步驟以下:spa

  1. 選取主元;
    1.1. 將n個元素按順序分爲$⌊\frac n5⌋$個組,每組5個元素,如有剩餘,捨去;
    1.2. 對於這$⌊\frac n5⌋$個組中的每一組使用插入排序找到它們各自的中位數;
    1.3. 對於 1.2 中找到的全部中位數,調用BFPRT算法求出它們的中位數,做爲主元;
  2. 以 1.3 選取的主元爲分界點,把小於主元的放在左邊,大於主元的放在右邊;
  3. 判斷主元的位置與k的大小,有選擇的對左邊或右邊遞歸。

上面的描述可能並不易理解,先看下面這幅圖:3d

BFPRT()調用GetPivotIndex()和Partition()來求解第k小,在這過程當中,GetPivotIndex()也調用了BFPRT(),即GetPivotIndex()和BFPRT()爲互遞歸的關係。code

下面爲代碼實現,其所求爲前k小的數htm

#include <iostream>
#include <algorithm>

using namespace std;

int InsertSort(int array[], int left, int right);
int GetPivotIndex(int array[], int left, int right);
int Partition(int array[], int left, int right, int pivot_index);
int BFPRT(int array[], int left, int right, int k);

int main()
{
    int k = 8; // 1 <= k <= array.size
    int array[20] = { 11,9,10,1,13,8,15,0,16,2,17,5,14,3,6,18,12,7,19,4 };

    cout << "原數組:";
    for (int i = 0; i < 20; i++)
        cout << array[i] << " ";
    cout << endl;

    // 由於是以 k 爲劃分,因此還能夠求出第 k 小值
    cout << "第 " << k << " 小值爲:" << array[BFPRT(array, 0, 19, k)] << endl;

    cout << "變換後的數組:";
    for (int i = 0; i < 20; i++)
        cout << array[i] << " ";
    cout << endl;

    return 0;
}

/**
 * 對數組 array[left, right] 進行插入排序,並返回 [left, right]
 * 的中位數。
 */
int InsertSort(int array[], int left, int right)
{
    int temp;
    int j;

    for (int i = left + 1; i <= right; i++)
    {
        temp = array[i];
        j = i - 1;
        while (j >= left && array[j] > temp)
        {
            array[j + 1] = array[j];
            j--;
        }
        array[j + 1] = temp;
    }

    return ((right - left) >> 1) + left;
}

/**
 * 數組 array[left, right] 每五個元素做爲一組,並計算每組的中位數,
 * 最後返回這些中位數的中位數下標(即主元下標)。
 *
 * @attention 末尾返回語句最後一個參數多加一個 1 的做用其實就是向上取整的意思,
 * 這樣能夠始終保持 k 大於 0。
 */
int GetPivotIndex(int array[], int left, int right)
{
    if (right - left < 5)
        return InsertSort(array, left, right);

    int sub_right = left - 1;

    // 每五個做爲一組,求出中位數,並把這些中位數所有依次移動到數組左邊
    for (int i = left; i + 4 <= right; i += 5)
    {
        int index = InsertSort(array, i, i + 4);
        swap(array[++sub_right], array[index]);
    }

    // 利用 BFPRT 獲得這些中位數的中位數下標(即主元下標)
    return BFPRT(array, left, sub_right, ((sub_right - left + 1) >> 1) + 1);
}

/**
 * 利用主元下標 pivot_index 進行對數組 array[left, right] 劃分,並返回
 * 劃分後的分界線下標。
 */
int Partition(int array[], int left, int right, int pivot_index)
{
    swap(array[pivot_index], array[right]); // 把主元放置於末尾

    int partition_index = left; // 跟蹤劃分的分界線
    for (int i = left; i < right; i++)
    {
        if (array[i] < array[right])
        {
            swap(array[partition_index++], array[i]); // 比主元小的都放在左側
        }
    }

    swap(array[partition_index], array[right]); // 最後把主元換回來

    return partition_index;
}

/**
 * 返回數組 array[left, right] 的第 k 小數的下標
 */
int BFPRT(int array[], int left, int right, int k)
{
    int pivot_index = GetPivotIndex(array, left, right); // 獲得中位數的中位數下標(即主元下標)
    int partition_index = Partition(array, left, right, pivot_index); // 進行劃分,返回劃分邊界
    int num = partition_index - left + 1;

    if (num == k)
        return partition_index;
    else if (num > k)
        return BFPRT(array, left, partition_index - 1, k);
    else
        return BFPRT(array, partition_index + 1, right, k - num);
}

運行以下:

原數組:11 9 10 1 13 8 15 0 16 2 17 5 14 3 6 18 12 7 19 4
第 8 小值爲:7
變換後的數組:4 0 1 3 2 5 6 7 8 9 10 12 13 14 17 15 16 11 18 19

三:時間複雜度分析

BFPRT算法在最壞狀況下的時間複雜度是$O(n)$,下面予以證實。令$T(n)$爲所求的時間複雜度,則有:

$$ T(n)≤T(\frac n 5)+T(\frac {7n}{10})+c⋅n\tag{c爲一個正常數} $$

其中:

  • $T(\frac n 5)$來自GetPivotIndex(),n個元素,5個一組,共有$⌊\frac n5⌋$箇中位數;
  • $T(\frac {7n}{10})$來自BFPRT(),在$⌊\frac n5⌋$箇中位數中,主元x大於其中 $\frac 12⋅\frac n5=\frac n{10}$的中位數,而每一箇中位數在其原本的5個數的小組中又大於或等於其中的3個數,因此主元x至少大於全部數中的$\frac n{10}⋅3=\frac {3n}{10}$個。即劃分以後,任意一邊的長度至少爲$\frac 3{10}$,在最壞狀況下,每次選擇都選到了$\frac 7{10}$的那一部分。
  • $c⋅n$來自其它操做,好比InsertSort(),以及GetPivotIndex()和Partition()裏所需的一些額外操做。

設$T(n)=t⋅n$,其中t爲未知,它能夠是一個正常數,也能夠是一個關於n的函數,代入上式:

$$ \begin{align} t⋅n&≤\frac {t⋅n}5+\frac{7t⋅n}{10}+c⋅n \tag{兩邊消去n}\\ t&≤\frac t 5+\frac {7t}{10}+c \tag{再化簡}\\ t&≤10c \tag{c爲一個正常數} \end{align} $$

其中c爲一個正常數,故t也是一個正常數,即$T(n)≤10c⋅n$,所以$T(n)=O(n)$,至此證實結束。

接下來咱們再來探討下BFPRT算法爲什麼選5做爲分組主元,而不是2, 3, 7, 9呢?

首先排除偶數,對於偶數咱們很難取捨其中位數,而奇數很容易。再者對於3而言,會有$T(n)≤T(\frac n 3)+T(\frac {2n}3)+c⋅n$,它自己仍是操做了n個元素,與以5爲主元的$\frac {9n}{10}$相比,其複雜度並無減小。對於7,9,...而言,上式中的10c,其總體都會增長,因此與5相比,5更適合。

四:參考文獻

相關文章
相關標籤/搜索