程序员面试题狂想曲:第三章、寻找最小的k个数

程序员面试题狂想曲:第三章、寻找最小的k个数(原称号外)

作者:July。
时间:二零一一年四月二十八日。
致谢:litaoye, strugglever,yansha,luuillu,Sorehead,及狂想曲创作组。
微博:http://weibo.com/julyweibo
出处:http://blog.csdn.net/v_JULY_v
----------------------------------


前奏
    @July_____:1、当年明月:“我写文章有个习惯,由于早年读了太多学究书,所以很痛恨那些故作高深的文章,其实历史本身很精彩,所有的历史都可以写得很好看,...。”2、IT技术文章,亦是如此,可以写得很通俗,很有趣,而非故作高深。希望,我可以做到。

    本文原被称为“号外 ”,但写着写着,越写越多,便不称之为号外了,特此独立成章,为此狂想曲系列第三章。(我先告诉你什么叫做号外,号外的意思是:比如我现在出了第二章,在出第三章之前,特意临时写一篇文章,补充阐述相关的问题,而不列入原有的章节之中,特此称为号外 )。

    下面,我试图用最清晰易懂,最易令人理解的思维或方式阐述有关寻找最小的k个数这个问题(这几天一直在想,除了计数排序外,这题到底还有没有其它的O(n)的算法? )。希望,有任何问题,欢迎不吝指正。谢谢。


寻找最小的k个数
题目描述:5.查找最小的k个元素
题目:输入n个整数,输出其中最小的k个。
例如输入1,2,3,4,5,6,7和8这8个数字,则最小的4个数字为1,2,3和4。


第一节、各种思路,各种选择

0、   咱们先简单的理解,要求一个序列中最小的k个数,按照惯有的思维方式,很简单,先对这个序列从小到大排序,然后输出前面的最小的k个数即可。
1、   至于选取什么的排序方法,我想你可能会第一时间想到快速排序,我们知道,快速排序平均所费时间为n*logn,然后再遍历序列中前k个元素输出,即可,总的时间复杂度为O(n*logn+k)=O(n*logn)。
2、   咱们再进一步想想,题目并没有要求要查找的k个数,甚至后n-k个数是有序的,既然如此,咱们又何必对所有的n个数都进行排序列?
       这时,咱们想到了用选择或插入排序,即遍历n个数,先把最先遍历到得k个数存入大小为k的数组之中,对这k个数,进行排序,找到k个数中的最大数kmax,k1<k2,K3<…<kmax(kmax设为k个元素的数组中最大元素),用时O(k),后再继续遍历后n-k个数,x与kmax比较,如果x<kmax,则x代替kmax,并再次排序k个元素的数组。如果x>kmax,则不更新数组。这样,每次更新或不更新数组的所用的时间为O(k)或O(0),整趟下来,总的时间复杂度平均下来为:n*O(k)=O(n*k)。
3、   当然,更好的办法是维护k个元素的最大堆,原理与上述第3个方案一致,即用容量为k的最大堆存储最小的k个数,此时,k1<k2<...<kmax(kmax设为大顶堆中最大元素)。遍历一次数列,n,每次遍历一个元素x,与堆顶元素比较,x<kmax,更新堆(用时logk),否则不更新堆。这样下来,总费时O(n*logk)。此方法得益于在堆中,查找等各项操作时间复杂度均为logk(不然,就如上述思路2所述:直接用数组也可以找出前k个小的元素,用时O(n*k))。
4、 按编程之美第141页上的所述,类似快速排序的划分方法,N个数存储在数组S中,再从数组中随机选取一个数X,把数组划分为Sa和Sb俩部分,Sa<=X<=Sb,如果要查找的k个元素小于Sa的元素个数,则返回Sa中较小的k个元素,否则返回Sa中k个小的元素+Sb中小的k-|Sa|个元素。书上提到,此题的复杂度为logk*O(n),每次操作O(n),深度为logk(但我目前手头里,暂缺一个证明 ),即需要logk这样的操作,所以,总的时间复杂度为:O(n*logk)。
5、   随机快排 ,每次都是随机选取数列中的一个元素作为主元,在0(n)的时间内找到第k小的元素,然后遍历输出前面的k个小的元素。 如果能的话,那么总的时间复杂度为O(n+k)=O(n)(当k比较小时)。
         Ok,稍后,我会具体给出RANDOMIZED-SELECT(A, p, r, i)的整体完整伪码。在此之前,要明确一个问题,即我们通常所熟知的快速排序是以固定的最后一个元素作为主元,每次递归划分都是不均等的,最后的平均时间复杂度为:O(n*logn),但随机快速排序与普通的快速排序不同的是,每次递归都是随机选择序列从第一个到最后一个中任一一个元素作为主元。

6、 线性时间的排序,即计数排序。这个大家都很熟知,不做具体阐述。
7、 updated: huaye502在本文的评论下指出:“可以用最小堆(刚开始还以为他说错了)初始化数组,然后取这个优先队列前k个值。复杂度O(n)+k*O(log n)”。ok,刚开始以为他弄错了,后经朋友litaoye提醒,是对的。huaye502的意思是针对整个数组序列建最小堆,建堆所用时间为O(n)(算法导论一书上第6章第6.3节已经论证,在线性时间内,能将一个无序的数组建成一个最小堆),然后取堆中的前k个数,总的时间复杂度即为:O(n)+k*O(logk)。
    至于O(n)+k*O(logn)是否小于与上述思路四的O(n*logk),即O(n)+k*O(logn)?<O(n*logk)。我们只能这样说,视n、和k的大小情况而定。

第二节、Randomized-Select,线性时间
   下面是RANDOMIZED-SELECT(A, p, r)完整伪码(来自算法导论),我给了注释,或许能给你点启示。在下结论之前,我还需要很多的时间去思量,以确保结论之完整与正确。

PARTITION(A, p, r)         //partition过程 p为第一个数,r为最后一个数
1  x ← A[r]               //以最后一个元素作为主元
2  i ← p - 1
3  for j ← p to r - 1
4       do if A[j] ≤ x
5             then i ← i + 1
6                  exchange A[i] <-> A[j]
7  exchange A[i + 1] <-> A[r]
8  return i + 1

RANDOMIZED-PARTITION(A, p, r)      //随机快排的partition过程
1  i ← RANDOM(p, r)                                 //i  随机取p到r中个一个值
2  exchange A[r] <-> A[i]                          //以随机的 i作为主元
3  return PARTITION(A, p, r)            //调用上述原来的partition过程

RANDOMIZED-SELECT(A, p, r, i)       //随机选择排序
1  if p = r          //p=r,序列中只有一个元素
2      then return A[p]
3  q ← RANDOMIZED-PARTITION(A, p, r)   //随机选取的元素q作为主元
4  k ← q - p + 1                     // A[p…q]内的元素个数k,处于划分低区的元素个数加上一个主元元素
5  if i = k                        //检查得到的i即为要查找的第k小的元素,
6      then return A[q]        //找到了,则直接返回A[q]
7  else if i < k      
8      then return RANDOMIZED-SELECT(A, p, q - 1, i)  
          //得到的i小于要查找的k的大小,则递归到低区间A[p,q-1]中去查找
9  else return RANDOMIZED-SELECT(A, q + 1, r, i - k)
          //得到的i 大于要查找的k的大小,则递归到高区间A[q+1,r]中去查找。 

    写此文的目的,在于起一个抛砖引玉的作用。希望,能引起你的重视及好的思路,直到有个彻底明白的结果。

     updated:算法导论(中文第二版)上第111页有关于RANDOMIZED-SELECT(A, p, r)为O(n)的证明。在我们彻底弄清原理之前,我们还需要更多的思量+验证。

 

第三节、各执己见,百家争鸣

updated :本文昨晚发布后,现在朋友们之间,主要有以下几种观点(在彻底弄清之前,最好不要下结论):

luuillu :我不认为随机快排比直接快排的时间复杂度小。使用快排处理数据前,我们是不知道数据的排列规律的,因此一般情况下,被处理的数据本来就是一组随机数据,对于随机数据再多进行一次随机化处理,数据仍然保持随机性,对排序没有更好的效果。   对一组数据采用随选主元的方法,在极端的情况下,也可能出现每次选出的主元恰好是从大到小排列的,此时时间复杂度为O(n^2).当然这个概率极低。随机选主元的好处在于,由于在现实中常常需要把一些数据保存为有序数据,因此,快速排序碰到有序数据的概率就会高一些,使用随机快排可以提高对这些数据的处理效率。这个概率虽然高一些,但仍属于特殊情况,不影响一般情况的时间复杂度。我觉得楼主博客中提到的的4和5的时间复杂度是一样的。
571楼 得分:0 Sorehead 回复于:2011-03-09 16:29:58
关于第五题:
Sorehead: 这两天我总结了一下,有以下方法可以实现:
      1、第一次遍历取出最小的元素,第二次遍历取出第二小的元素,依次直到第k次遍历取出第k小的元素。这种方法最简单,时间复杂度是O(k*n)。看上去效率很差,但当k很小的时候可能是最快的。
      2、对这n个元素进行排序,然后取出前k个数据即可,可以采用比较普遍的堆排序或者快速排序,时间复杂度是O(n*logn)。这种方法有着很大的弊端,题目并没有要求这最小的k个数是排好序的,更没有要求对其它数据进行排序,对这些数据进行排序某种程度上来讲完全是一种浪费。而且当k=1时,时间复杂度依然是O(n*logn)。
      3、可以把快速排序改进一下,应该和楼主的kth_elem一样,这样的好处是不用对所有数据都进行排序。平均时间复杂度应该是O(n*logk)。
      4、使用我开始讲到的平衡二叉树或红黑树,树只用来保存k个数据即可,这样遍历所有数据只需要一次。时间复杂度为O(n*logk)。后来我发现这个思路其实可以再改进,使用堆排序中的堆,堆中元素数量为k,这样堆中最大元素就是头节点,遍历所有数据时比较次数更少,当然时间复杂度并没有变化。
      5、使用计数排序的方法,创建一个数组,以元素值为该数组下标,数组的值为该元素在数组中出现的次数。这样遍历一次就可以得到这个数组,然后查询这个数组就可以得到答案了。时间复杂度为O(n)。如果元素值没有重复的,还可以使用位图方式。这种方式有一定局限性,元素必须是正整数,并且取值范围不能太大,否则就造成极大的空间浪费,同时时间复杂度也未必就是O(n)了。当然可以再次改进,使用一种比较合适的哈希算法来代替元素值直接作为数组下标。
litaoye :按照算法导论上所说的,最坏情况下线性时间找第k大的数。证明一下:把数组中的元素,5个分为1组排序,排序需要进行7次比较(2^7 > 5!),这样需要1.4 * n次比较,可以完成所有组的排序。取所有组的中位数,形成一个新的数组,有n/5个元素,5个分为1组排序,重复上面的操作,直到只剩下小于5个元素,找出中位数。根据等比数列求和公式,求出整个过程的比较次数:7/5 + 7/25 + 7/125 +...... = 7/4,用7/4 * n次比较可以找出中位数的中位数M。能够证明,整个数组中>=M的数超过3*n / 10 - 6,<=M的数超过3*n / 10 - 6。以M为基准,执行上面的PARTITION,每次至少可以淘汰3*n / 10 - 6,约等于3/10 * n个数,也就是说是用(7/4 + 1) * n次比较之后,最坏情况下可以让数据量变为原来的7/10,同样根据等比数列求和公式,可以算出最坏情况下找出第k大的数需要的比较次数,1 + 7/10 + 49/100 + .... = 10/3, 10/3 * 11/4 * n = 110/12 * n,也就是说整个过程是O(n)的,尽管隐含的常数比较大 。
 总结:

      关于RANDOMIZED-SELECT(A, q + 1, r, i - k),期望运行时间为O(n)已经没有疑问了,更严格的论证可参考算法导论一书上第9章,第9.2节。

         ok,现在,咱们剩下的问题是,除了此RANDOMIZED-SELECT(A, q + 1, r, i - k)方法(实用价值并不大)和计数排序,都可以做到O(n)之外,还有类似快速排序的partition过程,是否也能做到O(n)?

 

第四节、类似partition过程,做到O(n)?

   我想,经过上面的各路好汉的思路轰炸,您的头脑和思维肯定有所混乱了。ok,下面,我尽量以通俗易懂的方式来继续阐述咱们的问题。上面第三节的总结提出了一个问题,即类似快速排序的partition过程,是否也能做到O(n)?

    我们说对n个数进行排序,快速排序的平均时间复杂度为O(n*logn),这个n*logn的时间复杂度是如何得来的列?

   经过之前我的有关快速排序的三篇文章,相信您已经明了了以下过程:快速排序每次选取一个主元X,依据这个主元X,每次把整个序列划分为A,B俩个部分,且有Ax<X<Bx。

   假如我们每次划分总是产生9:1 的划分,那么,快速排序运行时间的递归式为:T(n)=T(9n/10)+T(n/10)+cn。形成的递归树,(注:最后同样能推出T(n)=n*logn,即如下图中,每一层的代价为cn,共有logn层(深度),所以,最后的时间复杂度为O(n)*logn)如下:

 

    而我们知道,如果我们每次划分都是平衡的,即每次都划分为均等的两部分元素(对应上图,第一层1/2,1/2,,第二层1/4,1/4.....),那么,此时快速排序的运行时间的递归式为:

    T (n) ≤ 2T (n/2) + Θ(n) ,同样,可推导出:T (n) = O(n lg n).

    这就是快速排序的平均时间复杂度的由来。

    那么,咱们要面对的问题是什么,要寻找n个数的序列中前k个元素。如何找列?假设咱们首先第一次对n个数运用快速排序的partition过程划分,主元为m,此刻找到的主元元素m肯定为序列中第m小的元素,此后,分为三种情况:
    1、如果m=k,即返回的主元即为我们要找的第k小的元素,那么直接返回主元m即可,然后直接输出m前面的m-1个元素,这m个元素,即为所求的前k个最小的元素。
    2、如果m>k,那么接下来要到低区间A[0....m-1]中寻找,丢掉高区间(对应上图中总是只看一边,即总是1,1/2,1/4,1/8,.... );
    3、如果m<k,那么接下来要到高区间A[m+1...n-1]中寻找,丢掉低区间。

    当m一直>k的时候,好说,区间总是被不断的均分为俩个区间(理想情况),那么最后的时间复杂度如luuillu所说,T(n)=n + T(n/2) = n + n/2 + n/4 + n/8 + ...+1 . 式中一共logn项。可得出:T(n)为O(n)。
    但当m<k的时候,上述情况,就不好说了。正如luuillu所述:当m<k,那么接下来要到高区间A[m+1...n-1]中寻找,新区间的长度为m/2, 需要寻找 k-m个数。此时可令:k=k-m, m=m/2, 递归调用原算法处理,本次执行次数为 m,当m减到1算法停止(当m<k 时 ,k=m-k.这个判断过程实际上相当于对m取模运算,即:k=k%m;)。
    最终在高区间找到的k-m个数,加上在低区间的k个数,即可找到最小的k个数,是否也能得出T(n)=O(n),则还有待验证。

    所以,在诸多的理想情况下,上述类似快速排序的partition过程,期望值或许的确能做到O(n),但如此理想的情况,在现实中几乎不可能出现。因此,咱们不认为类似快速排序的partition过程每次划分,丢弃一半,能做到O(n)。

    ( 类似快速排序的partition过程,个人认为比较靠谱的结论是本文第一节中,思路4的结论,即O(n*logk)。)

 这是我目前为止,得到的最靠谱的结论,如果有任何问题,欢迎随时不吝指正。

 Ok,如果在评论里回复,有诸多不便,欢迎到此帖子上回复:微软100题维护地址 ,我会随时追踪这个帖子。谢谢。

updated:

    可能在没给出具体的实现之前,你对上面讲的东西,很多都不会信服,ok,下面给出利用类似快速排序中的partition过程求取无序数组中第K个数的代码,然后,你将会很明显的看到,显然,类似快速排序的partition过程,是做不到O(n)的(可能是n*logk)。//求取无序数组中第K个数
//copyright@ 飞羽 && July
//July、updated,2011.04.28。
#include <iostream>
#include <cstdio>
#include <cstdlib>
//#include "QuickSort.cpp"  //注,未包含快排的cpp文件,本代码,是不能直接运行的
using namespace std;
int kth_elem(int a[],int low, int high,int k)
{
 int pivot=a[low];
 int low_temp = low;
 int high_temp = high;
 while(low<high)
 {
  while(low<high&&a[high]>=pivot)     //多谢并不时常想起指正。
   --high;
  a[low]=a[high];
  while(low<high&&a[low]<pivot)
   ++low;
  a[high]=a[low];
 }
 a[low]=pivot;
 //上面为快排的那个分割算法

 //以下就是主要思想中所述的内容
 if(low==k-1)
  return a[low];
 else if(low>k-1)
  return kth_elem(a,low_temp,low-1,k);
 else
  return kth_elem(a,low+1,high_temp,k);
}
int main()
{
 int a[20];
 int k;
 for(int i=0;i<20;i++)
 {
  a[i] = rand()%100;    //随机地生成序列中的数
  printf("%3d  ",a[i]);
  if(i%5==4)
   cout << endl;
 }
 cin >> k;
 cout << "the" << k << "th number is:" << kth_elem(a,0,19,k)<<endl;
 //下面用快排对a进行排序,验证上面的求解结果;
 //cout << endl;  不做验证。
 getchar();
 return 0;
}

 

第五节、堆结构实现,处理海量数据

    文章,可不能这么完了,咱们还得实现一种靠谱的方案,从整个文章来看,处理这个寻找最小的k个数,最好的方案是第一节中所提到的思路3:当然,更好的办法是维护k个元素的最大堆,原理与上述第3个方案一致,即用容量为k的最大堆存储最小的k个数,此时,k1<k2<...<kmax(kmax设为大顶堆中最大元素)。遍历一次数列,n,每次遍历一个元素x,与堆顶元素比较,x<kmax,更新堆(用时logk),否则不更新堆。这样下来,总费时O(n*logk)。

    为什么?道理很简单,如果要处理的序列n比较小时,思路2(选择排序)的n*k的复杂度还能说得过去,但当n很大的时候列?同时,别忘了,如果选择思路1(快速排序),还得在数组中存储n个数。当面对海量数据处理的时候列?n还能全部存放于电脑内存中么?(或许可以,或许很难)。

    ok,相信你已经明白了我的意思,下面,给出借助堆(思路3)这个数据结构,来寻找最小的k个数的完整代码,如下://借助堆,查找最小的k个数
//copyright@ yansha &&July
//July、updated,2011.04.28。
#include <iostream>
#include <assert.h>
using namespace std;
void MaxHeap(int heap[], int i, int len);
/*-------------------
BUILD-MIN-HEAP(A)
1  heap-size[A] ← length[A]
2  for i ← |_length[A]/2_| downto 1
3       do MAX-HEAPIFY(A, i)
*/
// 建立大根堆
void BuildHeap(int heap[], int len)
{
 if (heap == NULL)
  return;
 
 int index = len / 2;
 for (int i = index; i >= 1; i--)
  MaxHeap(heap, i, len);
}
/*---------------------------- 
PARENT(i)
   return |_i/2_|
LEFT(i)
   return 2i
RIGHT(i)
   return 2i + 1
MIN-HEAPIFY(A, i)
1 l ← LEFT(i)
2 r ← RIGHT(i)
3 if l ≤ heap-size[A] and A[l] < A[i]
4    then smallest ← l
5    else smallest ← i
6 if r ≤ heap-size[A] and A[r] < A[smallest]
7    then smallest ← r
8 if smallest ≠ i
9    then exchange A[i] <-> A[smallest]
10         MIN-HEAPIFY(A, smallest)
*/
//调整大根堆
void MaxHeap(int heap[], int i, int len)
{
 int largeIndex = -1;
 int left = i * 2;
 int right = i * 2 + 1;
 
 if (left <= len && heap[left] > heap[i])
  largeIndex = left;
 else
  largeIndex = i;
 
 if (right <= len && heap[right] > heap[largeIndex])
  largeIndex = right;
 
 if (largeIndex != i)
 {
  swap(heap[i], heap[largeIndex]);
  MaxHeap(heap, largeIndex, len);
 }
}
int main()
{
 // 定义数组存储堆元素
 int k;
 cin >> k;
 int *heap = new int [k+1];   //注,只需申请存储k个数的数组
 FILE *fp = fopen("data.txt", "r");   //从文件导入海量数据(便于测试,只截取了9M的数据大小)
 assert(fp);
 
 for (int i = 1; i <= k; i++)
  fscanf(fp, "%d ", &heap[i]);
 
 BuildHeap(heap, k);      //建堆
 
 int newData;
 while (fscanf(fp, "%d", &newData) != EOF)
 {
  if (newData < heap[1])   //如果遇到比堆顶元素kmax更小的,则更新堆
  {
   heap[1] = newData;
   MaxHeap(heap, 1, k);   //调整堆
  }
  
 }
 
 for (int j = 1; j <= k; j++)
  cout << heap[j] << " ";
 cout << endl;
 
 fclose(fp);
 return 0;
}

    咱们用比较大量的数据文件测试一下,如这个数据文件:

 

    输入k=4,即要从这大量的数据中寻找最小的k个数,可得到运行结果,如下图所示:

至于,这4个数,到底是不是上面大量数据中最小的4个数,这个,咱们就无从验证了,非人力之所能及也。毕。

 

第六节、stl之_nth_element ,逐步实现

    以下代码摘自stl中_nth_element的实现,且逐步追踪了各项操作,其完整代码如下:

//_nth_element(...)的实现
template <class RandomAccessIterator, class T>
void __nth_element(RandomAccessIterator first, RandomAccessIterator nth,
                   RandomAccessIterator last, T*) {
  while (last - first > 3) {
    RandomAccessIterator cut = __unguarded_partition    //下面追踪__unguarded_partition
      (first, last, T(__median(*first, *(first + (last - first)/2),
                               *(last - 1))));
    if (cut <= nth)
      first = cut;
    else
      last = cut;
  }
  __insertion_sort(first, last);    //下面追踪__insertion_sort(first, last)
}

//__unguarded_partition()的实现
template <class RandomAccessIterator, class T>
RandomAccessIterator __unguarded_partition(RandomAccessIterator first,
                                           RandomAccessIterator last,
                                           T pivot) {
  while (true) {
    while (*first < pivot) ++first;
    --last;
    while (pivot < *last) --last;
    if (!(first < last)) return first;
    iter_swap(first, last);
    ++first;
  }

//__insertion_sort(first, last)的实现
template <class RandomAccessIterator>
void __insertion_sort(RandomAccessIterator first, RandomAccessIterator last) {
  if (first == last) return;
  for (RandomAccessIterator i = first + 1; i != last; ++i)
    __linear_insert(first, i, value_type(first));    //下面追踪__linear_insert
}

//_linear_insert()的实现
template <class RandomAccessIterator, class T>
inline void __linear_insert(RandomAccessIterator first,
                            RandomAccessIterator last, T*) {
  T value = *last;
  if (value < *first) {
    copy_backward(first, last, last + 1);  //这个追踪,待续
    *first = value;
  }
  else
    __unguarded_linear_insert(last, value);        //最后,再追踪__unguarded_linear_insert
}

//_unguarded_linear_insert()的实现
template <class RandomAccessIterator, class T>
void __unguarded_linear_insert(RandomAccessIterator last, T value) {
  RandomAccessIterator next = last;
  --next;
  while (value < *next) {
    *last = *next;
    last = next;
    --next;
  }
  *last = value;
}

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值