BFPTR算法详解+实现+复杂度证明

BFPTR算法是由Blum、Floyed、Pratt、Tarjan、Rivest这五位牛人一起提出来的,其特点在于可以以最坏复杂度为 O ( n ) O(n) O(n)地求解 t o p − k top-k topk问题。所谓 t o p − k top-k topk问题就是从一个序列中求解其第k大的问题。

t o p − k top-k topk问题有许多解决方法,其他解决方法总结可以看我之前的一篇博客:传送门

这个算法的介绍更详细的可以参照算法导论9.3节 Selection in worst-case linear time,这里记录一下我对这个算法的理解、实现以及复杂度的证明。

下面详细的介绍一下BFPTR算法:

算法思想

宏观来看,BFPTR算法是快速排序思想的一种应用:

  1. 每次我们找到枢纽元素,然后按照枢纽元素将数组划分为两部分,就可以得到左边x个元素都是比枢纽元素小的,右边y个元素都是比枢纽元素大的

  2. 然后根据我们想要查找的k决定到哪边进行递归查找

    假设我们想要查找第k小,如果x==y,则枢纽元素就是需要查找的元素,如果x>k,则到左边继续查找,如果x<k,则去右边的区间查找第k-x个元素

典型的分治思想。问题的关键在于如何查找枢纽以及如何划分,如果这两步做的不够好那么将会导致子问题的规模不能够很快的缩小,从而使复杂度退化为 O ( n 2 ) O(n^2) O(n2)

关于如何划分,主要就是快速排序的划分思想,我在另一篇博客中已经详细介绍了各种划分方法的优劣,如果有兴趣可以移步:传送门

而BFPTR算法的精髓就在于如何选择枢纽以保证复杂度不会退化。

选择枢纽

  1. 将数组五个五个划分为 ┌ n / 5 ┐ \ulcorner n/5 \urcorner n/5个小块,求出每个块的中位数
  2. 递归求出这些中位数的中位数,将这个数当做枢纽,如何做到这点呢?我们BFPTR不就是求第k大吗?我们递归地调用BFPTR就可以了。

我在网上仔细研究了其他人实现BFPTR的代码,个人觉得他们的代码并没有按照算法原本的思想操作(如果我理解的有问题欢迎大家批评指正),他们的做法都是将第一步划分的中位数再递归地划分为五个块,求取中位数,直到最后不足五个元素直接返回中位数作为枢纽。代码如下:

void GetPovit(T* a,int l,int r)
{
    int x;    //将区间分割为[x,x+5)
    int cnt=0;  //有多少个中位数
    for(x=l; x+5<r; x+=5)
    {
        InsertSort(a,x,x+5);
        swap(a[l+cnt],a[x+2]);  //将当前区间的中位数放在最前面
        ++cnt;
    }
    if(x<r)
    {
        InsertSort(a,x,r);
        swap(a[l+cnt],a[(x+r)>>1]);
        ++cnt;
    }
    if(1 == cnt) return;
    GetPovit(a,l,l+cnt);
}

虽然这样仍然可以解决问题,但是我觉得这样并不是BFPTR算法的思想,我们可以参见算法导论中对这一步骤的描述:
在这里插入图片描述
其中第三步中的递归地选择这些中位数中的中位数,这显然不是再将其划分为五个五个的小块求取中位数。因为我们再将其划分为五个五个的小块是不能保证最后选出来的是中位数的,如果这样可行的话就会有一种求中位数的方法是这样分块递归了,但是现实是我们现在做的正是这个算法。所以我认为他们的做法是不对的,可行的原因也只是这是类似于一种随机选取枢纽的操作。同样佐证这一点的是后面对于复杂度的分析,如果使用上面网上的做法那么是无法进行复杂度分析的。

我自己的做法是对于选出来的中位数调用BFPTR算法得到中位数

实现代码

首先是主体部分:

int BFPTR(T* a,int l,int r,int k)
{
    if(r-l == 1) return l;   //返回找到的数字
    //五个一组的中位数的中位数的位置
    int idx = GetPovit(a,l,r);
    T povit = a[idx];
    swap(a[l],a[idx]);
    int i=l,j=r;
    while(i<j)
    {
        do ++i; while(i+1 < r && a[i]<povit);
        do --j; while(a[j]>povit);
        if(i<j) swap(a[i],a[j]);
    }
    swap(a[l],a[j]);
    int num=j-l+1;  //povit在当前序列中排第几
    if(k == num) return j;
    else if(num > k) return BFPTR(a,l,j,k);
    else return BFPTR(a,j+1,r,k-num);
}

这里的划分方法有一点点不同于我快速排序文章中的方法,主要是这里我将枢纽放在了中间,因为这样我们有可能直接返回枢纽而不必一定要将区间划分为1才返回。我认为可以一定程度上降低复杂度。
这里我返回的是需要查找的数在数组中的位置,觉得位置包含了更多的信息,我们知道位置以后可以直接得到查询的数字,可是返回查询的数字无法直接得到位置。

下面是最重要的获取枢纽的操作:

void InsertSort(T* a,int l,int r)
{//插入排序
    int mid=(l+r)>>1;   //获得中位数就足够了
    for(int i=l+1;i<=mid;++i)
    {
        T x=a[i]; int j=i-1;
        while(j>=l && a[j]>x)
        {
            a[j+1]=a[j]; --j;
        }
        a[j+1]=x;
    }
}

int BFPTR(T* a,int l,int r,int k);

T GetPovit(T* a,int l,int r)
{
    int x;    //将区间分割为[x,x+5)
    int cnt=0;  //有多少个中位数
    for(x=l; x+5<r; x+=5)
    {
        InsertSort(a,x,x+5);
        swap(a[l+cnt],a[x+2]);  //将当前区间的中位数放在最前面
        ++cnt;
    }
    if(x<r)
    {
        InsertSort(a,x,r);
        swap(a[l+cnt],a[(x+r)>>1]);
        ++cnt;
    }
    if(1 == cnt) return l;
    return BFPTR(a,l,l+cnt,cnt/2);
}

其中上面是一个简单的插入排序,下面是将数组划分为五个五个的小段,求取中位数以后放在数组的前面,然后再调用BFPTR函数得到中位数的中位数。之所以放在数组前面是因为这样可以原地操作而不用占用其他的空间。

完整的测试代码会附在最后,也会附上网上大家的代码供大家测试。

复杂度分析

对于一个规模为n的输入,其最坏的时间复杂度为 T ( n ) = T ( n 5 ) + Θ ( n ) + T ( 7 n 10 ) + Θ ( n ) T(n)=T(\frac{n}{5})+\Theta(n)+T(\frac{7n}{10})+\Theta(n) T(n)=T(5n)+Θ(n)+T(107n)+Θ(n)

其中 T ( n 5 ) + Θ ( n ) T(\frac{n}{5})+\Theta(n) T(5n)+Θ(n)为求解其中位数的中位数的复杂度。我们首先将其划分为 ┌ n / 5 ┐ \ulcorner n/5 \urcorner n/5个小块,然后求解每个小块的中位数是常数操作,因此总共是 Θ ( n ) \Theta(n) Θ(n),前面的 T ( n 5 ) T(\frac{n}{5}) T(5n)是BFPTR递归求解中位数的复杂度

最后的 Θ ( n ) \Theta(n) Θ(n)是划分的复杂度

比较难理解的是为什么是 T ( 7 n 10 ) T(\frac{7n}{10}) T(107n)。我们可以先看一张图(算法导论上盗下来的)

在这里插入图片描述
这里的箭头表示的含义是箭屁股的元素小于箭脑袋的元素。那么白元素的含义就很明显啦,即就是每个五个元素小块里面的中位数,其中的x表示中位数中的中位数。

由箭头关系我们不难看出,右下方的灰色区域的元素全部小于x,同理左上方区域的元素全部大于x,那么我们以x为枢纽元素将数组划分以后数组的两边至少都有这些元素。这些元素最少有多少呢?

白色区域大体和灰色区域的个数相等,因此我们计算灰色区域的个数。总共有 ┌ n / 5 ┐ \ulcorner n/5 \urcorner n/5列,因为我们求取的是最小有多少,因此我们取下界 └ n / 5 ┘ \llcorner n/5 \lrcorner n/5,灰色区域占到其中的一半,每一列有三个元素,因此总共有 3 ∗ ┌ └ n / 5 ┘ / 2 ┐ 3*\ulcorner \llcorner n/5 \lrcorner/2 \urcorner 3n/5/2,即最少 3 n 10 \frac{3n}{10} 103n的元素在一边。

我们考虑最坏的时间复杂度,因此另一边最多 7 n 10 \frac{7n}{10} 107n个元素,而我们总落入这一边。因此我们每次划分以后都需要再进行 T ( 7 n 10 ) T(\frac{7n}{10}) T(107n)的递归。

接下来就是解递归式: T ( n ) = T ( n 5 ) + T ( 7 n 10 ) + Θ ( n ) T(n)=T(\frac{n}{5})+T(\frac{7n}{10})+\Theta(n) T(n)=T(5n)+T(107n)+Θ(n)
对于这样的递归式我们没有什么好方法,只能选择代入法进行证明。假设 T ( k ) = c k T(k)=ck T(k)=ck,那么我们需要证明
c ∗ n 5 + c ∗ 7 n 10 + Θ ( n ) < = c n c*\frac{n}{5}+c*\frac{7n}{10}+\Theta(n)<=cn c5n+c107n+Θ(n)<=cn

即就是
c ∗ n 10 > = Θ ( n ) c*\frac{n}{10}>=\Theta(n) c10n>=Θ(n)

对于足够大的 c c c,上式成立。
当处于基本情况时,对于足够大的 c c c,上式也成立。

因此,BFPTR的复杂度为 O ( n ) O(n) O(n),证毕。

为什么是5?

首先,我们肯定是要划分成奇数块的,偶数块求中位数不好求。
其次,最小可以选择的奇数是3,但是如果是3的话可以证明其复杂度就不是线性的了 T ( n ) = T ( n 3 ) + Θ ( n ) + T ( 7 n 10 ) + Θ ( n ) T(n)=T(\frac{n}{3})+\Theta(n)+T(\frac{7n}{10})+\Theta(n) T(n)=T(3n)+Θ(n)+T(107n)+Θ(n)
也可以选择7,但是选择7的话常数会更大,因此我们选择将数组划分为长度为5的小区间

测试与分析

虽然这个算法的复杂度为 O ( n ) O(n) O(n)的,但是我将按自己思路实现的代码和按网上思路实现的代码都对规模为 1 e 8 1e8 1e8规模的数据进行了测试,发现BFPTR算法不是很快。原因可能是BFPTR算法的常数比较大,而网上的思路作为一种模拟的随机性快速选择算法常数比较小。但是我认为如果要使用随机性算法的话直接使用随机数常数会更小。关于随机性快速选择算法和模拟的随机性算法以及BFPTR到底哪一个更快,可以看我的另一篇博客:传送门

BFPTR算法代码

#include <iostream>
#include <fstream>
#include <ctime>

using namespace std;

typedef double T;

T* CreatList(int &n)
{
    ifstream in("TestFile");
    in >> n;
    T* ret = new T[n];
    for(int i=0;i<n;++i)
    {
        in>>ret[i];
    }
    in.close();
    return ret;
}


void Show(T* a,int n)
{
    for(int i=0;i<n;++i)
    {
        cout<<a[i]<<" ";
    }
    cout<<endl;
}

void InsertSort(T* a,int l,int r)
{//插入排序
    int mid=(l+r)>>1;   //获得中位数就足够了
    for(int i=l+1;i<=mid;++i)
    {
        T x=a[i]; int j=i-1;
        while(j>=l && a[j]>x)
        {
            a[j+1]=a[j]; --j;
        }
        a[j+1]=x;
    }
}

int BFPTR(T* a,int l,int r,int k);

T GetPovit(T* a,int l,int r)
{
    int x;    //将区间分割为[x,x+5)
    int cnt=0;  //有多少个中位数
    for(x=l; x+5<r; x+=5)
    {
        InsertSort(a,x,x+5);
        swap(a[l+cnt],a[x+2]);  //将当前区间的中位数放在最前面
        ++cnt;
    }
    if(x<r)
    {
        InsertSort(a,x,r);
        swap(a[l+cnt],a[(x+r)>>1]);
        ++cnt;
    }
    if(1 == cnt) return l;
    return BFPTR(a,l,l+cnt,cnt/2);
}

int BFPTR(T* a,int l,int r,int k)
{
    if(r-l == 1) return l;   //返回找到的数字
    //五个一组递归求取中位数
    int idx = GetPovit(a,l,r);
    T povit = a[idx];
    swap(a[l],a[idx]);
    int i=l,j=r;
    while(i<j)
    {
        do ++i; while(i+1 < r && a[i]<povit);
        do --j; while(a[j]>povit);
        if(i<j) swap(a[i],a[j]);
    }
    swap(a[l],a[j]);
    int num=j-l+1;  //povit在当前序列中排第几
    if(k == num) return j;
    else if(num > k) return BFPTR(a,l,j,k);
    else return BFPTR(a,j+1,r,k-num);
}

void Query(T* a,int n)
{
    int k;
    while(true)
    {
        cout<<"请输入k:";
        cin>>k;
        if(cin.fail() == true)
            break;
        if(k>n || k<1)
        {//检查输入的有效性
            cout<<"请输入1~"<<n<<"之间的数字"<<endl;
            continue;
        }
        clock_t S,E;
        S=clock();
        int idx=BFPTR(a,0,n,k);
        E=clock();
        cout<<"区间第"<<k<<"大的数字为:"<<a[idx]<<endl;
        cout<<"这次查询花费时间"<<(double)(E-S)/CLOCKS_PER_SEC<<"s"<<endl;
    }
    cin.clear();
    cin.ignore();
}

int main()
{
    int n;
    T* a=CreatList(n);

    Query(a,n);

    delete[] a;
    return 0;
}

模拟随机化选择的代码:

#include <iostream>
#include <fstream>
#include <ctime>

using namespace std;

typedef double T;

T* CreatList(int &n)
{
    ifstream in("TestFile");
    in >> n;
    T* ret = new T[n];
    for(int i=0;i<n;++i)
    {
        in>>ret[i];
    }
    in.close();
    return ret;
}


void Show(T* a,int n)
{
    for(int i=0;i<n;++i)
    {
        cout<<a[i]<<" ";
    }
    cout<<endl;
}

void InsertSort(T* a,int l,int r)
{//插入排序
    for(int i=l+1;i<r;++i)
    {
        T x=a[i]; int j=i-1;
        while(j>=l && a[j]>x)
        {
            a[j+1]=a[j]; --j;
        }
        a[j+1]=x;
    }
}

void GetPovit(T* a,int l,int r)
{
    int x;    //将区间分割为[x,x+5)
    int cnt=0;  //有多少个中位数
    for(x=l; x+5<r; x+=5)
    {
        InsertSort(a,x,x+5);
        swap(a[l+cnt],a[x+2]);  //将当前区间的中位数放在最前面
        ++cnt;
    }
    if(x<r)
    {
        InsertSort(a,x,r);
        swap(a[l+cnt],a[(x+r)>>1]);
        ++cnt;
    }
    if(1 == cnt) return;
    GetPovit(a,l,l+cnt);
}

int BFPTR(T* a,int l,int r,int k)
{
    if(r-l == 1) return l;   //返回找到的数字
    GetPovit(a,l,r);            //五个一组递归求取中位数
    T povit=a[l];
    int i=l-1,j=r;
    while(i<j)
    {
        do ++i; while(a[i]<povit);
        do --j; while(a[j]>povit);
        if(i<j) swap(a[i],a[j]);
    }
    if(j-l+1>=k) return BFPTR(a,l,j+1,k);
    else return BFPTR(a,j+1,r,k-j+l-1);
}

void Query(T* a,int n)
{
    int k;
    while(true)
    {
        cout<<"请输入k:";
        cin>>k;
        if(cin.fail() == true)
            break;
        if(k>n || k<1)
        {//检查输入的有效性
            cout<<"请输入1~"<<n<<"之间的数字"<<endl;
            continue;
        }
        clock_t S,E;
        S=clock();
        int idx=BFPTR(a,0,n,k);
        E=clock();
        cout<<"区间第"<<k<<"大的数字为:"<<a[idx]<<endl;
        cout<<"这次查询花费时间"<<(double)(E-S)/CLOCKS_PER_SEC<<"s"<<endl;
    }
    cin.clear();
    cin.ignore();
}

int main()
{
    int n;
    T* a=CreatList(n);

    Query(a,n);

    delete[] a;
    return 0;
}

  • 12
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值