求最小的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).