求最小的k个数字和求第k小的数字

求最大的和最小的原理是一样的,只不过是求最大的在应用中用的比较多。举个比较常见的例子,大家都会购物吧,购物的时候如果去京东商城,当搜索某件商品的时候,搜索后的页面会呈现很多该类型的商品,但是京东总会给我们一些推荐,那么这个推荐是依据什么呢?其实道理很简单,京东的后台会记录客户浏览或者购买的某件商品的次数,然后进行统计,把用户浏览次数或者购买次数排名靠前的几件商品放在前几的位置。还有很多其他的应用。。。例如搜索引擎。

之前零零散散的接触过一些求最小k个数,或者求第k个最小的数字,今天把这些都总结起来。在总结之前,先叙述一句比较有道理的话:我们想要得到的是最小的k个数字,没有要求这k个数字有序,同时更没有要求选取k个数字之后的其他剩余数字有序。理解这一点很重要,简而言之,就是不要去做无用功。

一,选择第k小的数字:

1,采用交换排序或者插入排序,因为我们是求第k小的,所以没必要对整个数组排序。只需要求得第k小的时候结束就好。时间复杂度为O(n*k).

2,采用堆排序或者快速排序,这次就不能像选择排序或者交换排序那样选择到第k小的时候就结束,因为我们不知道那次的选择是第k小的,只有等到整个数组有序之后。时间复杂度为O(n*logn).

3,采用randomized-select,这种方法可以在期望的线性时间内完成。这种方法主要是基于快速排序改进的。因为快速排序对pivot-key的选取依赖性很强,如果能选到好的pivot-key,那么运行时间就会很快。期望时间复杂度为O(n)。

4,采用中位数的中位数,在randomized-select过程中,中位数的选取由medianofmedians算法实现。但是因为这个算法的常数项太大,同时也过于抽象,没有在实际医用中得到广泛的发展。时间复杂度为O(n)。

二,选择最小的k个数字:

1,采用交换排序或者插入排序。时间复杂度为O(n*k)。

2,采用堆排序或者快速排序,时间复杂度为O(n*logn)。

3,采用randomized-select,期望的时间复杂度为O(n)。可能到这里需要进行解释一下,为什么找第k小的时候期望的时间复杂度为O(n),那找最小的k个的期望的时间复杂度不应该是O(n*k)吗,不要着急,很快就会解释这个问题。

4,应对大数据的情况,首先取数组中前k个数字建立大根堆,想一想为什么建立大根堆?------建立堆之后,从第k+1个元素开始,和堆顶元素进行比较,如果小于堆顶的元素,那么就替换堆顶的元素,这时候堆有可能被破坏了,所以要进行调整,假设数组中原有n个元素,那么要进行n-k躺相同的操作。总得时间复杂度为:O(k)+O((n-k)*logk).

5,同样是应对大数据的情况,如果有足够的内存,那么建立一个大小为n的小跟堆,堆顶肯定是当前最小的值,连续进k次如下操作:取堆顶,调整堆。总得时间复杂度为:

O(n)+O(k*logn)。看到网上的博客有人说,取完堆顶元素,进行调整的时候没必要进行logn次的调整,只要进行有限次就可以了,稍后也会给出这个思路的分析,同时也会求证这个思路的正确性。

 三,说明一下,为什么要区分选择第k小和最小的k个这两种情况,因为在看有些博客的时候,讲着讲着,自己都搞不懂是找哪个了,对读者的误导很大。其实也可以从上面的分析中看出,这两种情况的时间复杂度是差不多的。但是为了清晰,还是有必要区分一下,之后所有的研究情况都是基于选择最小的k个,而不是第k小。

四,在第二部分中,可以看到,求最小的k个有很多种方法,包括之后要补充的两种方法,那么什么时候选择什么样的方法是很关键的。对于应用1还是2,有必要进行区分一下,假使n*k = n*logn ,得到的结果是k=logn,根据这个n和k的关系就可以选择应用哪个方法了。对于第3种方法,我想如果给定的数组不是极端的情况,比1,2都更快。至于4,5是应对大数据的情况的。同时还要注意这几种情况的区别:1,2对数组进行了排序,而3,4,5都没有对数组进行排序。

五,每种方法的分析:

1,交换排序,引自wikipedia:

 

 1 function select(list[1..n], k)
 2      for i from 1 to k
 3          minIndex = i
 4          minValue = list[i]
 5          for j from i+1 to n
 6              if list[j] < minValue
 7                  minIndex = j
 8                  minValue = list[j]
 9          swap list[i] and list[minIndex]
10      return list[k]

2,快速排序或者堆排序:这个学过数据结构的都能给出很清晰的答案。但是为了清晰起见,个人认为算法导论上的堆排序中的堆调整算法比较好理解,故给出伪代码:

 1 void adjust_heap(int *a, int i, int len) {
 2     int left = i * 2;
 3     int right = i * 2 + 1;
 4     int min_index = 0;
 5     if(left <= len && a[left] < a[i]) {
 6         min_index = left;
 7     } else {
 8         min_index = i;
 9     }
10     if(right <= len && a[right] < a[min_index]) {
11         min_index = right;
12     }
13     if(min_index != i) {
14         swap(a[i] , a[min_index]);
15         adjust_heap(a, min_index, len);
16     }
17 }

3,应用randomized-select,这种方法来源于算法导论的第九章,它是根据随机快速排序改装而来。假设我们给定的一组数据为:1,11,23,5,6,7,20,13,22,9,34,18这12个数据,找出这12个数据总最小的7个数字:随机快排运行后的结果是:1 5 6 7 9 11 13 23 22 20 34 18,第7小的数据是13,从结果中可以看出什么特点呢,13之前的数字都比它小,而13之后的数字都比它大。这个结果是选择第k小数字的算法得来的,这也就验证了,为什么选择最k小和选择第k小的时间复杂度是一样的,这就是这个算法的神奇之处。下面给出算法的核心代码:

 

 1 #include<stdio.h>
 2 #include<stdlib.h>
 3 
 4 int random(int low, int high) {
 5     int size = high - low + 1;
 6     return low + rand() % size;
 7 }
 8 
 9 void swap(int *a, int *b) {
10     int temp;
11     temp = *a;
12     *a = *b;
13     *b = temp;
14 }
15 
16 int partition(int *a, int left, int right) {
17     int key = a[left];
18     int i = left;
19     int j;
20     for(j = i + 1; j <= right; j++) {
21         if(a[j] <= key) {
22             if(i != j) {
23                 i++;
24                 swap(&a[i], &a[j]);
25             }
26         }
27     }
28     swap(&a[i], &a[left]);
29     return i;
30 }
31 
32 int random_partition(int *a, int left, int right) {
33     int index = random(left, right);
34     swap(&a[index], &a[left]);
35     return partition(a, left, right);
36 }
37 
38 int randomized_select(int *a, int left, int right, int k) {
39     if(left < 0 || (right - left + 1) < k)
40         return -1;
41     int pos = random_partition(a, left, right);
42     int m = pos - left + 1;
43     if(k == m) {
44         return pos;
45     } else if(k < m) {
46         return randomized_select(a, left, pos - 1, k);
47     } else {
48         return randomized_select(a, pos + 1, right, k - m);
49     }
50 }
51 
52 void main() {
53     int a[] = {1, 11, 23, 5, 6, 7, 20, 13, 22, 9, 34, 18};
54     int len = sizeof(a) / sizeof(int);
55     int k = 7;
56     randomized_select(a, 0, len - 1, k);
57     for(int i = 0; i < len; i++) {
58         printf("%d ", a[i]);
59     }
60     printf("\n");
61 }

4,建立大小为k的堆,要想做验证,可以应用这个链接中的代码:http://blog.csdn.net/zzran/article/details/8439367

 

 

5,建立大小为n的堆,这需要有足够的内存,假设我们要找到1000,0000数字中(有重复出现的数字),出现频数排名前10个的数字。1000,0000中假设出去重复的后有100,0000个数字,那么这些数字所需要的内存空间为:4*10^6B~4MB的内存。建立堆可以很快的建立起来,因为算法导论中曾经证明过,可以在O(n)的时间内建立堆。建立好堆之后就是对堆取堆顶,然后调整,如果n=4MB,那么logn=22,如果所需要取的k值不算大的话,即使是全堆进行调整也不会浪费很多的时间。但是如果k很大的话,那么就有人提议,对于k,每次取完堆顶元素后,只需要调整k次就足够了。先假设这个结论是正确的,这个界限是多少呢。应用等式:(k^2) = k * logn。k=logn。n=4MB,k<22的时候就有必要应用这个方法了。当k>22的时候,很多的调整都是多余的了。对于4GB的数据k的界限也只不过是32。当k小于界限值的时候,k^2,k*logn这些对于2GHZ主频的计算机来说,没有多大影响的,所以先不从、这个想法的正确性来说,但从效率上讲就没有必要进行这样的改动。这个算法的总的时间复杂度为O(n + k*logn)。但是这个方法占用了很大的内存空间,但从时间上来讲,没有比第4中方法节省多少时间。故对于大量数据的情况来说方法4是最优的。如果有人想做进一步的研究,可以看下这个链接里面的内容:http://blog.csdn.net/zzran/article/details/8443655

 

六,近期会给出另外两种方法。

 

七,总结:对于上述的几种方法,如果都能够掌握了,或者正确的编写出程序,我想就已经很厉害了。其实无论怎么变种,都逃不掉这几种方法。由于本人能力的限制,对于中位数的中位数的方法没有做出分析。原因有下面几点:第一,这个算法很复杂,第二,没有什么应用范围,如果不是科研,或者一些研究是不会涉及到此算法的。如果有人对这个有兴趣,可以看看算法导论或者MIT的算法导论公开课。

 

八,思路扩展,如果要是找第k小到第m小之间的数字呢?(k < m).考虑一下上述的第三个算法,运行完之后k左边都是比它小或者等于它的数字,它的右边都是比它大或者和它相等的数字,那么我们对于m再进行一下这样的操作,结果呢,第m位的左边都小于等于它,右边都大于等于它。除去所有等于关键字的情况,程序运行完之后就是介于第k小和第m小之间的数字。时间复杂度呢,我想不是简单的O(n) + O(n),这个值具体是多少要是分析的话没有任何现实意义,故略去 。但是总体值还是要接近期望O(n).

posted @ 2016-07-21 22:30  琴影  阅读(1608)  评论(0编辑  收藏  举报