作者:July、上善若水、编程艺术室。
出处:http://blog.csdn.net/v_JULY_v 。
前奏
原狂想曲系列已更名为:程序员编程艺术系列。原狂想曲创作组更名为编程艺术室。编程艺术室致力于以下三点工作:1、针对一个问题,不断寻找更高效的算法,并予以编程实现。2、解决实际中会碰到的应用问题,如第十章、如何给磁盘文件排序。3、经典算法的研究与实现。总体突出一点:编程,如何高效的编程解决实际问题。欢迎有志者加入。
ok,扯远了。在上一章,我们介绍了第十章、如何给10^7个数据量的磁盘文件排序,下面介绍下本章的主题。我们知道,通常来讲,寻找给定区间内的第k小(大)的元素的问题是ACM中一类常用的数据结构的一个典型例题,即划分树/逆向归并树,通常用线段树的结构存储。
当然这里暂且不表,尚不说划分树思想的神奇,就是线段树的结构,一般没有ACM基础的人也都觉得难以理解。所以,这里提供一个时间效率尚可,空间代价还要略小的巧妙解法—伴随数组。
如果看过此前程序员编程艺术:第六章、求解500万以内的亲和数中,有关亲和数的那个题目的伴随数组的解法,也就是利用数组下标作为伴随数组,相信就会对这个方法有一定程度的理解。
第一节、寻找给定区间内的第k小(大)的元素
给定数组,给定区间,求第K小的数如何处理?求最小的k个元素用最大堆,求最大的k的元素用最小堆。OK,常规方法请查阅:程序员编程艺术:第三章、寻找最小的k个数。
1、排序,快速排序。我们知道,快速排序平均所费时间为n*logn,从小到大排序这n个数,然后再遍历序列中后k个元素输出,即可,总的时间复杂度为O(n*logn+k)=O(n*logn)。
2、排序,选择排序。用选择或交换排序,即遍历n个数,先把最先遍历到得k个数存入大小为k的数组之中,对这k个数,利用选择或交换排序,找到k个数中的最大数kmax(kmax设为k个元素的数组中最大元素),用时O(k)(你应该知道,插入或选择排序查找操作需要O(k)的时间),后再继续遍历后n-k个数,x与kmax比较:如果x<kmax,则x代替kmax,并再次重新找出k个元素的数组中最大元素kmax‘(多谢jiyeyuran 提醒修正);如果x>kmax,则不更新数组。这样,每次更新或不更新数组的所用的时间为O(k)或O(0),整趟下来,总的时间复杂度平均下来为:n*O(k)=O(n*k)。
3、维护k个元素的最大堆,原理与上述第2个方案一致,即用容量为k的最大堆存储最先遍历到的k个数,并假设它们即是最小的k个数,建堆费时O(k),有k1<k2<...kmax(kmax设为最大堆中的最小元素)。继续遍历数列,每次遍历一个元素x,与堆顶元素比较,若x<kmax,则更新堆(用时logk),否则不更新堆。这样下来,总费时O(k+(n-k)*logk)=O(N*logK)。此方法得益于在堆中,查找等各项操作时间复杂度均为logk(不然,就如上述思路2所述:直接用数组也可以找出最大的k个元素,用时O(n*k))。
4、按编程之美上解法二的所述,类似快速排序的划分方法,N个数存储在数组S中,再从数组中随机选取一个数X,把数组划分为Sa和Sb俩部分,Sa<=X<=Sb,如果要查找的k个元素小于Sa的元素个数,则返回Sa中较小的k个元素,否则返回Sa中所有的元素+Sb中较小的k-|Sa|个元素。不断递归下去,把问题分解成更小的问题,平均时间复杂度为O(N)(编程之美所述的n*logk的复杂度有误,应为O(N),特此订正。其严格证明,请参考第三章:程序员面试题狂想曲:第三章、寻找最小的k个数、updated 10次)......。
下面我们给出伴随数组解法,首先,定义一个结构体,一个是数组元素,另一个是数组原来的标号,记录每个数在数组的原顺序。
我们以下面的测试数据举例(红体部分表示下标为2~5之间的数5,2,6,3,浅色部分表示数组中的数各自对应的数组下标,淡蓝色部分为给定的下标区间,注,这里,我们让数组下标从1开始):
a[i].data 1 5 2 6 3 7 4
a[i].num 1 2 3 4 5 6 7
现在,题目给定了下标区间,如在原序列中下标2~5(即下标为2、3、4、5)区间找到第3小的数。问题亦相当于要你找原序列里给定下标区间即第2个数到第5个数之中(5 2 6 3)第3小的数(当然,答案很明显,第3小的数就是5)。
那么对原数组进行排序,然后得到的序列应该是(注:原下标始终保持不变):
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
如上,既然数据现在已经从小到大排好了,那么,我们只需要进行一次检索,从最小的数到最大的数,我们找第k(k=3)小的数,当我们发现下标a[i].num等于原给定下标区间在2~5中,即a[i].num==2 || 3 || 4 || 5的时候,k--,那么当k==0的时候,我们也就找到了第k(3)小的数了。如下(红色部分表示原给定下标区间中的数,浅色部分依然是原各数对应的下标,淡蓝色部分为原来给定的下标区间所对应的索引):
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
k 3 2 1 1 0
故下标索引为2~5之间第k(3)小的数是5。
程序的构造与解释:由于排序后,我们能保证原序列已经从小到大的排好序了,所以,当遍历或扫描到原序列给定下标区间中的数时,则k--,最终能在k==0时,找到第k小的数,且这个数是在原来给定下标区间中的某一个数。
而这个伴随数组,或者说原序列各数的索引则帮我们或者说是帮电脑记下了原来的数,已让我们后来遍历时能识别排序后序列中的数是否是给定下标区间中的某一个数。如果是原给定下标区间中的数,则k--,否则k不变。
第二节、采用伴随数组方案的实现
上述采用伴随数组的方法巧妙且简单,也很好理解和实现,关键 就是在于题目要求是在给定下标区间中找寻第k小(大)的元素,所以,基本上在排序n*logn完了之后,总能 在O(n)的时间内找到想找的数。源代码如下:
程序测试:输入的第1行数字1 5 2 6 3 7 4代表给定的数组,第二行的数字中,2 5代表给定的下标区间2~5,3表示要在给定的下标区间2~5中寻找第3小的数,第三行的5表示找到的第3小的数。程序运行结果如下:
水原来写的代码(上面我的改造,是为了达到后来扫描时O(N)的视觉效果):
- //copyright@ 水
- #include<iostream>
- #include<algorithm>
- using namespace std;
- struct node{
- int num,data;
- bool operator < (const node &p) const
- {
- return data < p.data;
- }
- };
- node p[100001];
- int main()
- {
- int n,m,i,j,a,b,c;//c:flag;
- while(scanf("%d %d",&n,&m)!=EOF)
- {
- for(i=1;i<=n;i++)
- {
- scanf("%d",&p[i].data);
- p[i].num = i;
- }
- sort(p+1,p+1+n);
- for(j=1;j<=m;j++)
- {
- scanf("%d %d %d",&a,&b,&c);
- for(i=1;i<=n;i++)
- {
- if(p[i].num>=a && p[i].num<=b)
- c--;
- if(c == 0)
- break;
- }
- printf("%d/n",p[i].data);
- }
- }
- return 0;
- }
第三节、直接排序给定下标区间的数
你可能会忽略一个重要的事实,不知读者是否意识到。题目是要求我们在数组中求给定下标区间内某一第k小的数,即我们只要找到这个第k小的数,就够了。但上述程序显示的一个弊端,就是它先对整个数组进行了排序,然后采用伴随数组的解法寻找到第k小的数。而事实是,我们不需要对整个数组进行排序,我们只需要对我们要寻找的那个数的数组中给定下标区间的数进行部分排序,即可。
对,事情就是这么简单。我们摒弃掉伴随数组的方法,只需要直接对数组中给定的那部分下标区间中的数进行排序,而不是对整个数组进行排序。如此的话,算法的时间复杂度降到了L*logK。其中,L=|b-a+1|,L为给定下标区间的长度,相对整个数组的程度n,L<=n。程序代码如下。
程序测试:我们同样采取第二节的测试用例。输入的第1行数字1 5 2 6 3 7 4代表给定的数组,第二行的数字中,2 5代表给定的下标区间2~5,3表示要在给定的下标区间2~5中的数,即从a[2]~a[5]中寻找第3小的数,第三行的5表示找到的第3小的数。程序运行结果如下。
貌似上述直接对给定区间内的数进行排序,效率上较第二节的伴随数组方案更甚一筹。既然如此,那么伴随数组是不是多此一举呢?其实不然,@水:假如,我对2-5之间进行了排序,那么数据就被摧毁了,怎么进行2次的操作?就是现在的2位置已经不是初始的2位置的数据了。也就是说,快排之后下标直接定位的方法明显只能用一次。
ok,更多请看下文第四节中的“百家争鸣”与“经典对白”。
第四节、伴随数组的优势所在
百家争鸣
经典对白
原例重现
ok,说了这么多,你可能还根本就不明白到底是怎么一回事。让我们从第一节举的那个例子说起。我们要找给定下标区间2~5的数中第3小的数,诚然,此时,我们有两种选择,1、如上第一节、第二节所述的伴随数组,2、直接对下标区间2-5的数进行排序。下面,只回顾下伴随数组的方案。
伴随数组
a[i].data 1 5 2 6 3 7 4
a[i].num 1 2 3 4 5 6 7
第一次排序后:
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
伴随数组方案查找:
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
k 3 2 1 1 0
好的,那么现在,如果题目要求你在之前数组的下标区间3~6的数中找第3小的数呢(答案很明显,为6)?
a[i].data 1 5 2 6 3 7 4
a[i].num 1 2 3 4 5 6 7
伴随数组
原第一次排序后:
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
再次扫描,直接O(N)搞定:
a [i].data 1 2 3 4 5 6 7
a [i].num 1 3 5 7 2 4 6
k 3 2 1 1 1 0
(而之前有的读者意识不到伴随数组的意义,是因为一般的人只考虑找一次,不会想到第二次或多次查找)
编程独白
给你40分钟的时间,你可以思考十分钟,然后用三十分钟的时间来写代码,最后浪费在无谓的调试上;你也可以思考半个小时,彻底弄清问题的本质与程序的脉络,然后用十分钟的时间来编写代码,体会代码如行云流水而出的感觉。