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

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

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

这个算法的介绍更详细的可以参照算法导论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(n2)O(n^2)O(n2)

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

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

选择枢纽

  1. 将数组五个五个划分为┌n/5┐\ulcorner n/5 \urcornern/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(n5)+Θ(n)+T(7n10)+Θ(n)T(n)=T(\frac{n}{5})+\Theta(n)+T(\frac{7n}{10})+\Theta(n)T(n)=T(5n)+Θ(n)+T(107n)+Θ(n)

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

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

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

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

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

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

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

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

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

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

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

为什么是5?

首先,我们肯定是要划分成奇数块的,偶数块求中位数不好求。
其次,最小可以选择的奇数是3,但是如果是3的话可以证明其复杂度就不是线性的了T(n)=T(n3)+Θ(n)+T(7n10)+Θ(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)的,但是我将按自己思路实现的代码和按网上思路实现的代码都对规模为1e81e81e8规模的数据进行了测试,发现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;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/383666.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

随机化快速排序+快速选择 复杂度证明+运行测试

对于快速排序和快速选择我之前的文章已经有详细的说明&#xff0c;需要了解的同学可以移步 传送门&#xff1a;快速排序&#xff5c;快速选择(BFPTR) 所谓随机化其实就是选择枢纽的时候使用随机数选择而已&#xff0c;实现起来很简单。但是我们使用随机数如何保证复杂度呢&am…

【Linux基础】Linux的5种IO模型详解

引入 为了更好的理解5种IO模型的区别&#xff0c;在介绍IO模型之前&#xff0c;我先介绍几个概念 1.进程的切换 &#xff08;1&#xff09;定义 为了控制进程的执行&#xff0c;内核必须有能力挂起正在CPU上运行的进程&#xff0c;并恢复以前挂起的某个进程的执行。即从用户…

计算机网络【五】广播通信+以太网

局域网的拓扑 广域网使用点到点通信 局域网使用广播通信 可以随意向网络中添加设备。 总线网星形网&#xff0c;使用集线器。现在多使用星形网络。环状网树形网 其中匹配电阻用来吸收总线上传播的信号。 共享通信媒体 静态划分信道 频分复用、时分复用、波分复用、码分复用…

聊聊Linux 五种IO模型

一篇《聊聊同步、异步、阻塞与非阻塞》已经通俗的讲解了&#xff0c;要理解同步、异步、阻塞与非阻塞重要的两个概念点了&#xff0c;没有看过的&#xff0c;建议先看这篇博文理解这两个概念点。在认知上&#xff0c;建立统一的模型。这样&#xff0c;大家在继续看本篇时&#…

操作系统【四】分页存储管理

连续分配方式的缺点&#xff1a; 固定分区分配&#xff1a;缺乏灵活性&#xff0c;产生大量的内部碎片&#xff0c;内存的利用率较低 动态分区分配&#xff1a;会产生许多外部碎片&#xff0c;虽然可以用紧凑技术处理&#xff0c;但是紧凑技术的时间代价较高 基本分页存储管理…

操作系统【五】分段内存管理+段页式内存管理

基本分段存储管理 与分页最大的区别&#xff1a;离散分配时所分配地址空间的基本单位不同 进程的地址空间&#xff1a;按照程序自身的逻辑关系划分为若干个段&#xff0c;每个段都有一个段名&#xff0c;每段从0开始编址 内存分配规则&#xff1a;以段位单位进行分配&#xff…

计算机网络【六】网络层协议

网络层负责在不同网络之间尽力转发数据包&#xff08;基于数据包的IP地址转发&#xff09;。不负责丢失重传&#xff0c;也不负责顺序&#xff08;每一个数据包都是单独选择路径&#xff09;。 可靠传输是由传输层实现。 网络设备和OSI参考模型 通过分层&#xff0c;屏蔽了…

计算机网络【3】网络层

主要任务时把分组从源端发送到目的端&#xff0c;为分组交换网上的不同主机提供服务。网络层传输单位是数据报 功能&#xff1a; 路由选择与分组转发&#xff08;最佳路径 &#xff09;异构网络互联拥塞控制 数据交换方式 电路交换&#xff1a;通信时延小、有序传输、没有冲…

Linux探秘之用户态与内核态

https://www.cnblogs.com/bakari/p/5520860.html 一、 Unix/Linux的体系架构 如上图所示&#xff0c;从宏观上来看&#xff0c;Linux操作系统的体系架构分为用户态和内核态&#xff08;或者用户空间和内核&#xff09;。内核从本质上看是一种软件——控制计算机的硬件资源&…

哈夫曼算法证明+哈夫曼编码译码程序实现

哈夫曼算法证明 哈夫曼算法是一种贪心算法&#xff0c;我们考虑证明其最优子结构和贪心选择性质&#xff1a; 最优子结构&#xff1a;假设一个树是哈夫曼树&#xff0c;则以其任意节点为根节点的最大子树也是哈夫曼树。 证明&#xff1a;子树的根节点的值是其所有叶子节点出现…

Python3小知识

对于迭代器对象&#xff0c;Python默认赋值是将引用赋值&#xff0c;即指向同一片内存空间。为了实现对内存空间的赋值&#xff0c;我们可以使用分片进行深复制。例如&#xff1a; 当定义元组的时候&#xff0c;我们一般使用小括号将元素包围起来&#xff0c;也可以不使用括号…

汇编:实现日历星期数查询工具

编制一个简单日历查询工具&#xff0c;输入年、月、日&#xff0c;能够判断当日的星期数&#xff0c;并进行输出&#xff0c;数据的输入和结果的输出要有必要的提示&#xff0c;且提示独占一行。 查阅资料 ​ 经过查阅资料&#xff0c;发现有两个相关的算法可以解决这个问题&…

Linux C 实现一个简单的线程池

https://www.cnblogs.com/GyForever1004/p/9185240.html 线程池的定义 线程池是一种多线程处理形式&#xff0c;处理过程中将任务添加到队列&#xff0c;然后在创建线程后自动启动这些任务。线程池线程都是后台线程。每个线程都使用默认的堆栈大小&#xff0c;以默认的优先级…

斐波那契数列求解+尾递归

1.普通递归 这里观察f[4]的递归树代替f[10]的递归树&#xff08;后者比较大&#xff0c;画不下&#xff09;。 使用递归求解的时候复杂度为T(n)T(n−1)T(n−2)T(n)T(n-1)T(n-2)T(n)T(n−1)T(n−2)&#xff0c;观察递归树&#xff0c;发现降速最快的是最右边每次减2&#xff0c…

LCS最长公共子串

问题介绍 LCS问题(longest common subsequence problem)指的是求解两个字符串最长公共子序列问题。这里的子序列是可以不连续的。LCS问题广泛地出现在计算生物学中&#xff08;DNA序列、系统生成树等等&#xff09;。这里介绍如何解决LCS问题&#xff0c;以及算法的正确性证明…

操作系统【六】虚拟内存

传统存储管理方式的不足 一次性&#xff1a;作业必须一次性全部装入内存后才能开始运行。这会造成&#xff1a;当作也很大时不能全部装入内存&#xff1b;当大量作业要求运行时&#xff0c;由于内存无法容纳所有作业&#xff0c;因此只有少量作业能够运行&#xff0c;导致多道…

SQL Server【一】简介和基本概念和命令

数据结构和数据库的区别 数据库是应用软件级别研究数据的存储和操作&#xff08;主要针对磁盘上的数据&#xff09; 数据结构是在系统软件级别研究数据的存储和操作&#xff08;主要是针对内存中的数据&#xff09; 对硬盘数操作是数据库的强项&#xff0c;是数据库研究的核心…

Linux下网络socket编程——实现服务器(select)与多个客户端通信

一、关于socket通信 服务器端工作流程&#xff1a; 调用 socket() 函数创建套接字 用 bind() 函数将创建的套接字与服务端IP地址绑定调用listen()函数监听socket() 函数创建的套接字&#xff0c;等待客户端连接 当客户端请求到来之后调用 accept()函数接受连接请求&#xff0c…

SQL Server【四】

identity 主键自动增长&#xff0c;用户不需要为identity修饰的主键赋值 create table student (std_id int primary key identity(10,5),--(10,5)可以省略&#xff0c;默认为(1,1)std_name nvarchar(200) not null ) select * from student insert into student values (张三…

计算机网络【4】传输层

概述 传输层是只有主机才有的层次 传输层的功能&#xff1a; 传输层提供进程和进程之间的逻辑通信&#xff08;网络层提供主机与主机之间的逻辑通信&#xff09;复用和分用传输层对收到的报文进行差错检测 传输层有两个协议&#xff1a; 面向连接的传输层控制协议TCP&…