求n个数中的最小的k个数的BFPRT算法

最近在学习算法,平常没有写博客的习惯,慢慢发现,有些东西写了自己的印象才会更加深刻,每次看着那些牛人写的博客,总是心生感叹,琢磨着自己也写几篇博客。

                                                                                            ————写在自己的第一篇CSDN博客前言 

原创文章:转载请注明出处http://blog.csdn.net/adong76/article/details/10071297

一 算法由来

       BFPRT 算法:1973 年, Blum 、 Floyd 、 Pratt 、 Rivest 、 Tarjan 集体出动,合写了一篇题为 “Time bounds for selection” 的论文,给出了一种在数组中选出第 k 大元素的算法,俗称"中位数之中位数算法"。依靠一种精心设计的 pivot 选取方法,该算法从理论上保证了最坏情形下的线性时间复杂度,打败了平均线性、最坏 O(n^2) 复杂度的传统算法。一群大牛把递归算法的复杂度分析玩弄于股掌之间,构造出了一个当之无愧的来自圣经的算法。

                       -------摘引自大神的博客http://www.matrix67.com/blog/archives/3748

二 算法主要思想

      快速排序算法,相信很多人都明白,通过选择枢纽元,将原始数据划分为两个部分,在分别对两部分数据按同样的方法分治递归,通过不断的递归将大的问题划分为两个小的子问题,达到快速的目的,算法的平均时间复杂度为O(n*logn), 这里的枢纽元的选择很关键,如果选择的是最大值或是最小值,那么划分的连个子问题将会严重不平衡,算法的最坏复杂度将会达到O(n*n)。关于枢纽元的选择,在算法导论或是Mark Allen Weiss的数据结构中都有详细的讨论,主要有随机选择,三元素取中值,五划分中项的中项法。

      五划分中项的中项法就是BFPRT算法。Mark Allen Weiss的数据结构中P282中介绍了此算法,并给出了相关证明:

      1.此算法可以保证每个递归的子问题的大小最多是原问题的的大约70%

      2.对于整个选择算法,枢纽元可以足够算法算出,保证O(n)的运行时间

      这里的算法复杂度的分析,可以参看相关书籍和本文最后列出的大神博客,本文不做叙述。

       这里有一个福州大学的教学视频, http://ds.fzu.edu.cn/fine/resources/FlashContent.asp?id=82,感谢这个视频的创作者,关于算法的讲解用视频的方式最能够让人理解了。

     下面是算法主要步骤:

     其中有些细节的处理,主要是边界问题还是比较关键,后面会给出这些问题。

     数据有n个,取出最小的k个数字

     终止条件:n=1时,返回的即是i小元素。

     算法步骤:

     step1:将n个元素每5个一组,分成n/5(上界)组,最后的一个组的元素个数为n%5,有效的组数为n/5。

     step2:取出每一组的中位数,最后一个组的不用计算中位数,任意排序方法,这里的数据比较少只有5个,

                  可以用简单的冒泡排序或是插入排序。

     setp3 :  将各组的中位数与数组开头的数据在组的顺序依次交换,这样各个组的中位数都排在了数据的左边。

                  递归的调用中位数选择算法查找上一步中所有组的中位数的中位数,设为x,偶数个中位数的情况下设定为选取中间小的一个。

     setp4:   按照x划分,大于或者等于x的在右边,小于x的在左边,关于setp4数据的划分,中位数放在左边或是右边会有些影响。

                  后面的代码调试将会看到。

     step5:setp4中划分后数据后返回一个下表i,i左边的元素均是小于x,i右边的元素包括i都是大于或是等于x的。

                  若i==k,返回x;

                  若i<k,在小于x的元素中递归查找第i小的元素;

                  若i>k,在大于等于x的元素中递归查找第i-k小的元素。

三 网上的算法代码剖析

      我在网上找了一下,发现很多介绍BFPRT算法,要么只介绍了算法思想,没有给出代码实现,给出代码实现的基本上是下面的代码

     代码来源

      http://zhangzhibiao02005.blog.163.com/blog/static/3736782020117114643670/

      http://hi.baidu.com/hj11yc/item/9b9e11d941b4a1f6cb0c39db

#include<iostream>
#include<stdlib.h>
#include<time.h>
using namespace std;

#define MAX_VALUE 10
#define random() rand()%MAX_VALUE
#define N 10
int a[N]; 

class Find {
public:
	void bubble(int first, int end) //冒泡排序
	{
		for (int flag = first; flag < end; flag++)
		{
			for (int i = end; i > flag; i--)
			{
				if (a[i] < a[i - 1])
				{
					int t = a[i];
					a[i] = a[i - 1];
					a[i - 1] = t;
				}
			}
		}
	}

	int partition(int p, int r, int x) //数组a中从a[p]到a[r]的元素按照x划分,大于x的在左边,小于x的在右边
	{
		int i, j;

		for (i = p, j = r; i < j; i++)
		{
			if (a[i] > x)
			{
				while (i < j && a[j] > x) {
					j--;
				}

				if (i != j) {
					int t = a[i];
					a[i] = a[j];
					a[j] = t;
					j--;
				}
			}
		}

		return i - 1;
	}

	int select(int p, int r, int k) //寻找中位数
	{
		int i;

		if (r - p < 5)
		{
			bubble(p, r);
			return a[p + k - 1];
		}
		for (i = 0; i < (r - p - 4) / 5; i++)
		{
			int s = p + 5 * i, t = s + 4;
			bubble(s, t);
			int temp = a[p + i];
			a[p + i] = a[s + 2];
			a[s + 2] = temp;
		}

		int x = select(p, p + (r - p - 4) / 5, (r - p + 6) / 10);
		i = partition(p, r, x);
		int j = i - p + 1;

		if (k <= j) {
			return select(p, i, k);
		}
		else {
			return select(i + 1, r, k - j);
		}
	}
};

int main()
{
	//  clock_t start,end;
	//  double elapsed;
	//  srand((int)time(NULL));
	     for (int k = 0; k < N; k++)
	     {
	         a[k] = random();
	         cout << a[k] << " ";
	     }

	//  start=clock();
	Find f;
	int n = 4;
	cout << "The No." << n << " is :" << f.select(0, N - 1, n) << endl;
	//  end=clock();
	//  elapsed=((double)(end-start));
	//CLOCKS_PER_SEC;
	//  cout<<"Time: "<<elapsed<<endl;
	cout << "最小的" << n << "个元素为:" << endl;

	for (int k = 0; k < n; k++)
	{
		cout << a[k] << " ";
	}

	return 0;
}

      这份代码的首创者写的还是不错的,框架很清楚,代码还是有以下问题:

1 "int partition(int p, int r, int x) //数组a中从a[p]到a[r]的元素按照x划分,大于x的在左边,小于x的在右边"

    代码的注释有问题,误导读者,实际的代码编写的应该是大于x的在右边,小于或是等于x的在左边,

    那些博客贴上去的代码,没有更改这个注释,大致是他们没有亲自测试过这些代码就从其他地方弄过来了。

2.1 对于5个分组的划分求取中位数代码:

for (i = 0; i < (r - p - 4) / 5; i++)
{
int s = p + 5 * i, t = s + 4;
bubble(s, t);
int temp = a[p + i];
a[p + i] = a[s + 2];
a[s + 2] = temp;
}

     源代码数据下表索引为[p,r],数据有r - p + 1个,分的组数应该是 (r - p + 1) / 5 , 这里却是 (r - p + 1 - 5) / 5 = (r - p - 4) / 5组,

     这样就等于是少了一组中位数,程序效率降低。后面将会看到这样的分组还有BUG出现。

2.2 下面的代码 int x = select(p, p + (r - p - 4) / 5, (r - p + 6) / 10)是求取将各组的中位数的中位数。

     从2.1中的问题可以看到中位数的个数应该是(r - p + 1) / 5,而不是(r - p - 4) / 5,这里p + (r - p + 1) / 5 - 1 = p + (r - p - 4) / 5, 

     却把最右边的那个中位数的的右边的那个数字(并非中位数)添加进来了,可以看出前后的代码是矛盾的。

     下面的推导都是按分的组数应该是 (r - p + 1) / 5来讲的。

     P是左边下标,p + (r - p - 4) / 5是右边的下标,(r - p + 6) / 10 是中位数的下标。对于(r - p + 6) / 10是如何得到的? 

     中位数的个数是(r - p + 1) / 5 , 如果是偶数,中位数应该是第 (r - p + 1) / 5 / 2; 如果是奇数,中位数应该是((r - p + 1) / 5 + 1) / 2; 

     这里的除法都是下取整的,统一起来就是((r - p + 1) / 5 + 1) / 2 = ( r - p + 6 ) / 10;

     这句代码是对的,但代码前后是矛盾的,读者可以调试运行去看一下。


3    partition函数中对于数据的划分有BUG,大于x的数据在右边,小于或是等于x的数据在左边,

     如果测试数据为 int a[N] = {0,5,1,4,4,4,4,3,2},找出最小的4个数请看代码的划分过程:
     开始 p = 0 , r = 8, k =4,进入select函数,r - p = 8> 5 ,按照源代码将分为(r - p - 4) / 5 = 0组,(r - p + 6) / 10 = 1,

     中位数x为第一个数据0,利用x = 0对于数组来划分,结果为 i = 0 数组为0 5 1 4 4 4 4 3 2,

    接着 j = i - p + 1 = 1 ,找到1个数据,k = 4 > j 执行 select(i + 1, r, k - j),递归进入select(1,8,3),

    同样选择的中位数就为x = 5,利用x = 5来划分,读者应该已经发现,5是数据的最大值,划分函数中i将会移动到最左边,

    最后按照代码的结果,得到的数据为0 5 1 4 4 4 4 3 2,而 i = 8 - 1 = 7,下一步j = i - p + 1 = 7 - 1 + 1 = 7;

    此时 k = 3 < j,则执行select(1,7,3),现在可以看到,后面取出的数据就是在5 1 4 4 4 3中取,数据2就被抛弃了,出现bug

   下面的递归省略……最后的输出结果为0 5 1 4 ,第4大的数据为4。


4  从问题3的分析可以看到,如果中位数x是剩余数据的最大值,划分方法为大于x的在右边,小于或是等于x的在左边,

    那么i将会遍历到数据的末尾,这种划分就会比较糟糕,倘若按照边界处理i = i - 1,改为在边界时,返回i,那么,程序将陷入递归循环。 

    因此如果将划分方式改为 大于或者等于x的在右边,小于x的在左边,这也是前面说的关于setp4数据的划分中枢纽元放在左边和右边的潜在影响。

    对于数据5 1 4 4 4 4 3 2的划分就变为 2 1 4 4 4 4 3 5 最后得到的 i = 7;

    这样的划分就不会出现问题,但其实如果2.1中的分组正确,是不会出现选出的枢纽元为最大值的情况。

    因为数据小于5个时,就直接排序了,数据大于5时,至少有一组数据,数据排序取出的枢纽元应该不是最大值。

    当然如果取出的是最小值,如问题3中第一次递归的分析那样,取出的x = 0;由于这里求的最小的k个数,

    等于这次递归只取出了一个值,后面的数据将会继续取出。

   上面的分析过程均是我单步调试发现的,如果其中的分析有错误,还请各位大神指出,谢谢!

四 本文改进的算法

   有了上面的分析,下面我直接贴出我的代码,我测试了多组数据,包括上面分析中的bug数据,均没有问题。欢迎读者测试我的代码,如果发现问题,敬请告知!

  代码如下:

/*  * BFPRT.cpp
*寻找最小的K个数
*  Created on: 2013-8-19
*  Author:   */
/************************************************************************/
#include<iostream>
#include<stdlib.h>
#include<time.h>
using namespace std;

#define MAX_VALUE 20
#define random() rand()%MAX_VALUE
#define N 20
int a[N];
//测试序列
//int a[N] = {0,5,1,4,4,4,4,3,2};
//int a[N] = {5,5,5,5,5,5,5,5,5};

class Find {
public:
	void bubble(int first, int end) //冒泡排序
	{
		for (int flag = first; flag < end; flag++)
		{
			for (int i = end; i > flag; i--)
			{
				if (a[i] < a[i - 1])
				{
					int t = a[i];
					a[i] = a[i - 1];
					a[i - 1] = t;
				}
			}
		}
	}

	//求取最小的k个数
	//数组a中从a[p]到a[r]的元素按照x划分,大于或者等于x的在右边,小于x的在左边
	int partitionModify(int p, int r, int x) 
	{
		int i, j;

		for (i = p, j = r; i < j; i++)
		{
			if (a[i] >= x)
			{
				while (i < j && a[j] >= x) {
					j--;
				}

				if (i != j) {
					int t = a[i];
					a[i] = a[j];
					a[j] = t;
					j--;
				}
				else
				{
					break;
				}
			}
		}
		
/*上面的循环结束分为几种情况
	1 i > j 此时必定有 a[i] >= x,否则 a[j+1] = a[i] < x 与 a[j+1] >= x 矛盾 ,如果不是边界,进入if语句
	2 i = j 此时如果 a[i] < x 则a[i+1] = a[j+1] >x 返回 i
	3 当i==p,此时直接返回边界元素下标
	4 当i == r,此时为右边界,此时a[i]肯定为x,返回i - 1,也即r - 1
	*/
		if (a[i] >= x && i > p)
		{
			return i - 1;
		}
		return i;
	}

	//将r-p+1个数据按五个元素分为一组,分别找出各组的中位数,
	//再将各组的中位数与数组开头的数据在组的顺序依次交换,对这些各组的中位数
	//按同样的方法继续求出中位数,最后得出的整个数组的中位数,利用中位数就可以将数据按照与中位数的比较
	//来划分
	int selectModify(int p, int r, int k) //寻找中位数
	{
		int i;

		if (r - p + 1 <= 5)
		{
			bubble(p, r);
			return a[p + k - 1];
		}

		//将r-p+1个数据按五个元素分为一组,可以分为(r - p + 1) / 5组
		//分别找出各组的中位数,再将各组的中位数与数组开头的数据按组的顺序依次交换
		for (i = 0; i < (r - p + 1) / 5; i++)
		{
			int s = p + 5 * i, t = s + 4;
			bubble(s, t);
			int temp = a[p + i];
			a[p + i] = a[s + 2];
			a[s + 2] = temp;
		}

		//对这些各组的中位数
		//按同样的方法继续求出中位数,最后得出的整个数组的中位数 x
		int x = selectModify(p, p + (r - p + 1) / 5 - 1, (r - p + 6) / 10);

		i = partitionModify(p, r, x);
		int j = i - p + 1;

		if (k <= j) {
			return selectModify(p, i, k);
		}
		else {
			return selectModify(i + 1, r, k - j);
		}
	}
};

int main()
{
    clock_t start,end;
    double elapsed;
     srand((int)time(NULL));
     for (int k = 0; k < N; k++)
     {
	      a[k] = random();
          cout << a[k] << " ";
    }
 	cout << endl;
    start=clock();
	Find f;
	int n = 4;
	cout << "The No." << n << " is :" << f.selectModify(0, N - 1, n) << endl;
	end=clock();
	elapsed=((double)(end-start));
	//CLOCKS_PER_SEC;
	cout<<"Time: "<<elapsed<<endl;
	cout << "最小的" << n << "个元素为:" << endl;

	for (int k = 0; k < n; k++)
	{
		cout << a[k] << " ";
	}

	return 0;
}


五 总结

     关于TOPk的问题,其实是排序中的一个比较经典的问题,网上也有很多大神在研究,也写了不少精彩的博客,下面列出部分大牛的相关博客:

  大神v_july_v的博客 http://blog.csdn.net/v_JULY_v/article/details/6370650

  这个总结的也不错 http://blog.csdn.net/titer1/article/details/7674947

  这位大神的也不错 http://blog.csdn.net/feixiaoxing/article/details/6882510

    写的过程才发现有些问题还是考虑的不够清楚,通过写的过程逐渐清晰了,花了一下午的时间写了第一篇博客,感觉写作的过程还是比较耗时的,CSDN的排版对我来说还真是菜鸟,亲手写博客就越发能体会到那些大神的内功之强大,因为你只有明白了才能写出!

猜你喜欢

转载自blog.csdn.net/adong76/article/details/10071297