关于这个问题,经过这么久的讨论,两篇文章及大家的回复,已经比较很清楚了。这里就来完整的整理一下解答。其实本来已经整理得差不多了,不过很不幸,电脑忽然罢工,怎么也启动不了,然后又感冒了,所以一直到现在才开始做这个解答。
好了,不说这个了。下面进入正题。
这个题目来源于某公司的面试题,是absolute同学在我的“面试题收集贴”中提出的,之后CMGS同学在回复中提到,腾讯今年的面试题中有类似题目,问题规模扩大了10倍,但是本质相同。下面我们来看一下题目:
10亿个正整数,只有其中1个数重复出现过,要在O(n)的时间里面找出这个数,内存要尽可能少(小于100M)。
这个问题,下面有同学提出题目不严谨,我看了一下,的确是和我希望表达的意思有些模糊,我这里将题目从我自己的角度澄清一下:
对于正整数,其范围为1-10亿,然后从中随机的选择出k个数(可以重复,也可以不重复,从具体题目的要求来看),选择完之后开始做题目,无论是对其排序,还是找重复数。
不知道这样表达是不是清楚。
我们首先不急着来解决这个问题,而是从其来源来慢慢看。这里提到的题目与面试题有一些不同(在面试题中是要求找重复的数,而这里的题目是对其进行排序),所以不要感觉疑惑。
[来源]
这种类型的题目,其来源为《编程珠玑》这本书,这里推荐这本书一下,但是由于这本书比较薄,所以里面涉及到的知识只能简单提到,但是并不能够完全覆盖,所以读这本书还是要经历将其读厚的过程,对涉及到的知识点,通过其它参考书来得到关于其的全部知识。另外,尽管这本书的翻译还不错,但是还是推荐下载或得到其英文版进行参考,对于中文版中感觉了解不是很清楚的地方,再来对照英文版看看。
该题的原型是在《编程珠玑》的开头,题目在“面试题之10亿正整数问题”中我已经详细介绍过了,而且园子中应该大部分兄弟都有这本书吧,我就不详细将书中内容重新打一遍了。
大概介绍一下,作者通过和程序员交谈,了解到程序员需要在一个大系统中实现一个电话号码的文本数据库,读入电话号码,输出排序好的以800开头的文件。
通过作者的整理,得到精确的问题陈述如下
输入:
所输入的是一个文件,至多包含n个正整数,每个正整数都要小于n,这里的n为10^7。如果输入时某一个整数出现了两次,就会产生一个致命的错误。这些整数与其他任何数据都不关联。
输出:
以增序形式输出经过排序的整数列表。(这里应该补充一下是文件形式吗?)
约束:
至多(大概)只有1MB的可用主存,但是可用磁盘空间非常充足。运行时间至多只允许几分钟,最适宜的时间大概为10秒钟。
[解答]
从上面的这些描述来看一下,题1为10亿正整数的问题,题2为《编程珠玑》中的问题。
题1中问题规模为10亿,也就是10^9,内存要求为小于100M,问题要求是找出重复的数;
题2中问题规模为10^7,而内存要求为1M左右,问题要求是对这些数进行排序。
同时题1中要求算法的时间复杂度为O(n)。
这里我们就来看一下《编程珠玑》中是怎样来解决这个问题的。
《编程珠玑》中使用了三种方法来对其进行解决。一种为Merge Sort,一种为Multi-pass Sort,还有一种为作者起名的Wonder Sort。这里我们会一个个分析一下这些算法,包括时间复杂度和空间复杂度,同时在后面还会有实例的说明。
Merge Sort
Merge Sort大家应该很熟悉,是惯常使用的一种外排序方法,其主要思想是采用了分而治之的思想,该思想被应用于多个算法领域。其中文称为归并排序,在大部分的算法书中都会提到,同时也是外排序中经常使用到的排序方法。
归并排序可以通过迭代和递归两种方式来实现。
这些实现在殷人昆的那本黄书中可以找到(大家应该知道我说的是哪本书吧),但是他的那本书的实现有些不太好,使用了datalist和staticlinklist这两个与归并排序本身无关的数据结构,而这里我们用来示例的话只需要int的数组即可。(另外加上文件读写操作,针对题目的话),所以这里我们仅仅参考一部分黄书中的实现,来自己实现归并排序方法。
归并排序可以使用多种方法来进行实现,这里只谈常规方式下的归并排序,在这种情况下,我们需要和被排序数组同样大小的一个额外空间来辅助进行排序。
2 using namespace std;
3
4 void merge( int initlist[], int mergedlist[], int l, int m, int n)
5 {
6 int i = l,j = m + 1 ,k = l;
7 while (i <= m && j <= n)
8 {
9 if (initlist[i] <= initlist[j])
10 {
11 mergedlist[k] = initlist[i];
12 i ++ ;
13 k ++ ;
14 }
15 else
16 {
17 mergedlist[k] = initlist[j];
18 j ++ ;
19 k ++ ;
20 }
21 }
22 if (i <= m)
23 {
24 for ( int n1 = k,n2 = i;n1 <= n && n2 <= m;n1 ++ ,n2 ++ )
25 {
26 mergedlist[n1] = initlist[n2];
27 }
28 }
29 else
30 {
31 for ( int n1 = k,n2 = j;n1 <= n && n2 <= n;n1 ++ ,n2 ++ )
32 {
33 mergedlist[n1] = initlist[n2];
34 }
35 }
36 }
37
38 void mergepass( int initlist[], int mergedlist[], const int len, const int listlen)
39 {
40 int i = 0 ;
41 while (i + 2 * len <= listlen - 1 )
42 {
43 merge(initlist,mergedlist,i,i + len - 1 ,i + 2 * len - 1 );
44 i += 2 * len;
45 }
46 if (i + len <= listlen - 1 )
47 {
48 merge(initlist,mergedlist,i,i + len - 1 ,listlen - 1 );
49 }
50 else
51 {
52 for ( int j = i;j <= listlen - 1 ;j ++ )
53 {
54 mergedlist[j] = initlist[j];
55 }
56 }
57 }
58
59 void mergesort( int list[], int listlen)
60 {
61 int * templist = new int [listlen];
62 int len = 1 ;
63 while (len < listlen - 1 )
64 {
65 mergepass(list,templist,len,listlen);
66 len *= 2 ;
67 mergepass(templist,list,len,listlen);
68 len *= 2 ;
69 }
70 delete []templist;
71 }
72
73 int main()
74 {
75 int initlist[] = { 21 , 25 , 49 , 25 , 93 , 62 , 72 , 8 , 37 , 16 , 54 };
76
77 int i;
78 for (i = 0 ;i < sizeof (initlist) / sizeof ( int );i ++ )
79 cout << initlist[i] << " " ;
80 cout << endl;
81
82 mergesort(initlist, sizeof (initlist) / sizeof ( int ));
83 for (i = 0 ;i < sizeof (initlist) / sizeof ( int );i ++ )
84 cout << initlist[i] << " " ;
85 cout << endl;
86
87
88 return 0 ;
89 }
归并排序的时间复杂度和空间复杂度:
时间复杂度为O(nlgn),空间复杂度为O(n)。实例说明见后。
Multi-pass Sort
在中文翻译中,称其为多通道排序。但是从其描述来看,似乎是多趟排序更为确切。
补充:eaglet提到所谓多通道排序的原型就是B+树。对于B+树没有具体研究过,不过multipass在算法书中似乎没有查找到,倒是B+树会出现,而且看描述似乎的确是一样的。
伪码(using tmp file):
2 read one record from input file (I / O Operation)
3 if (record in the region)
4 put the record into memory
5 else
6 write the record into the file(s). (I / O Operation)
7 end if
8 end while
9
10 sort the records in the memory using some inner sort method.
11 write the sorted records into the output file. (I / O Operation)
12 while (there are tmp files left)
13 dump one tmp file (begin from the smallest) to memory
14 sort the records in the memory using some inner sort method.
15 write the sorted records into the output file. (I / O Operation)
16 end while
17 DONE.
伪码(not using tmp file):
2 while (not the endof file)
3 read one record from input file (I / O Operation)
4 if (record in the region)
5 put the record into memory
6 end if
7 end while
8 sort the records in the memory using some inner sort method.
9 write the sorted records into the output file. (I / O Operation)
10 end for
11 DONE
多趟排序的时间复杂度和空间复杂度
可以看到,通过存储到硬盘文件,我们可以控制使用空间的大小,但是这样同样也就加大了I/O读写的次数,而大家都很清楚,I/O操作的效率远远低于内存操作,这样在整个耗时方面,I/O读写次数越多的,其实际运行时间就越长。要提高算法的效率,就需要减少I/O操作的次数。
实例说明见后。
Wonder Sort
OK,现在就进入最精彩的部分,也是这道题目最希望的解法。还记得张柏芝在有一部电影中叫wonderful,很喜欢这个名字,看来作者也很喜欢这个名字哈~
在上面读入文件记录的时候,我们可以使用string, int等类型来表示我们所读入的这一条记录的具体值。而对于32位的机器,其int值为32位,也就是4个byte。而1M空间则有1024*1024=1048576个字节,一共能够表达的int值为262144。而对于10的7次方也就是1千万这样的数,我们除下来得到38.14697265625也就是差不多要做40次。
而Wonder Sort中使用位图来做,以每一位是0还是1表示数是否存在,这样1M空间共有8388608个位,也就能表示大概800万个数。这里我们暂时考虑不存在一个对内存的严格限制。所以如果要表示1000万,我们需要的内存空间大概是1.25M。
伪码:
2 while (not end of the input file)
3 read one record from input file
4 if (correspond bit in the bit - map is 0 )
5 change the correspond bit in the bit - map to 1 . (bit - map[record] = 1 )
6 else
7 // if we want to sort, just do nothing.
8 // if we want to find the num that came twice, just print it out and break.
9 end if
10 end while
11
12 write the records into the output file. (I / O Operation)
13
精彩排序的时间复杂度和空间复杂度
空间复杂度肯定是最低的,而时间复杂度为O(n)。实例见后。
【在该问题上面的扩展】
上面三个解法中,当然是Wonder Sort最好。但是作者也提到了,使用Wonder Sort时,我们需要1.25M的空间(加上其他一些操作,还要更多些,不过最大的需求肯定是1.25M的用来进行位图法的区域),但是如果严格要求使用1M空间的话,我们怎么做?
从上面解法的描述来看,我们可以将第二种解法与第三种解法结合起来,这样就可以解决。
伪码:
2 while (not the endof file)
3 read one record from input file (I / O Operation)
4 if (record in the region, which means 0 – 800 , 0000 )
5 if (correspond bit in the bit - map is 0 )
6 change the correspond bit in the bit - map to 1 . (bit - map[record] = 1 )
7 else
8 // if we want to sort, just do nothing.
9 // if we want to find the num that came twice, just print it out and break.
10 end if
11 else
12 write the record into the file(s). (I / O Operation)
13 end if
14 end while
15
16 write the records into the output file. (I / O Operation)
17
18 // dump the tmp file (800,0000 – 1000,0000) to memory
19 while (not the endof file)
20 if (correspond bit in the bit - map is 0 )
21 change the correspond bit in the bit - map to 1 . (bit - map[record] = 1 )
22 else
23 // if we want to sort, just do nothing.
24 // if we want to find the num that came twice, just print it out and break.
25 end if
26 end while
27 write the records into the output file. (I / O Operation)
28
29 DONE.
30
下面就是实例了,在做实例之前,我们有一个问题,就是如何生成题目中要求的测试数据?
测试数据的生成,必须比较随机,我们需要生成范围从0-10000000的数,这里我生成为8000000个数。同时这些数要求不重复。
[回答]
在《编程珠玑》的第12章中给出了回答,这里我直接写出代码。我使用这个生成了一个random.txt文件,一共67.8M。
测试文件生成代码:
2 #include < fstream >
3 using namespace std;
4
5 #include < ctime >
6 // total use 126 seconds
7
8 ofstream ofs( " random.txt " );
9
10 void myswap( int & a, int & b)
11 {
12 int tmp = a;
13 a = b;
14 b = tmp;
15 }
16
17 int bigrand()
18 {
19 return RAND_MAX * rand() + rand();
20 }
21
22 int randint( int l, int u)
23 {
24 return l + bigrand() % (u - l + 1 );
25 }
26
27 void generate( int x[], int k, int n)
28 {
29 int i = 0 ;
30 for (i = 0 ;i < n;i ++ )
31 {
32 x[i] = i;
33 }
34 for (i = 0 ;i < k;i ++ )
35 {
36 // myswap(i,randint(i,n-1));
37 int j = randint(i,n - 1 );
38 int t = x[i];
39 x[i] = x[j];
40 x[j] = t;
41 ofs << x[i] << endl;
42 }
43 }
44
45 int main()
46 {
47 clock_t Start, Finish;
48 Start = clock();
49 int * x = new int [ 10000000 ];
50 generate(x, 8000000 , 10000000 );
51 // generate(x,100000,1000000);
52 delete []x;
53 Finish = clock();
54 int second = double (Finish - Start) / CLOCKS_PER_SEC;
55 cout << " total use " << second << " seconds " << endl;
56 return 0 ;
57 }
该代码生成八百万的测试数据共用时126秒,机器配置为1G内存,P4处理器。这里,能否更快生成测试数据,当然,要保证随机性和正确性。
问题:
这里我们得到程序的时间是使用clock函数,那如何得到程序占用的内存呢?
还有一个问题就是,我们如何像linux中那样得到用户时间和系统调用时间?
这个还不清楚,这里提出来,大家有知道的可以共享一下。
在测试文件生成好之后,我们就可以来实际的看一下这3种方法所具体使用的时间了。
因为是使用debug版来测试,同时还有其他程序运行,所以可能不是十分精准。但是都是在同一台机器上面测试,所以数量级上面还是可以参考的。
结果:
使用merge sort来排序800万的测试数据,我们共用了337.7秒
使用multipass sort来排序800万的测试数据,我们共用了581.67秒,其实大家仔细看一下,这里我做得不太公平,我这里一趟是使用了一半的数,也就是400万,这样来达到两趟的目的,所以这里来比较是有些问题的。
使用wonder sort时,居然也用了很长时间,共为334.171秒。稍微换了一下,将函数调用去掉一层,最后还是得到total time is 333.328 seconds。
这和我们的预期相差还是比较大的。到底是什么原因?
是否是I/O操作历时比较长的原因?
单纯I/O操作total time is 354.343seconds。
下面列出我使用的代码
1. merge sort
2
3 #include < iostream >
4 #include < fstream >
5 using namespace std;
6
7 #include < ctime >
8
9 ifstream randfile( " random.txt " );
10 ofstream sortedfile( " sorted.txt " );
11
12 const int NUMS = 8000000 ;
13
14 void merge( int initlist[], int mergedlist[], int l, int m, int n)
15 {
16 int i = l,j = m + 1 ,k = l;
17 while (i <= m && j <= n)
18 {
19 if (initlist[i] <= initlist[j])
20 {
21 mergedlist[k] = initlist[i];
22 i ++ ;
23 k ++ ;
24 }
25 else
26 {
27 mergedlist[k] = initlist[j];
28 j ++ ;
29 k ++ ;
30 }
31 }
32 if (i <= m)
33 {
34 for ( int n1 = k,n2 = i;n1 <= n && n2 <= m;n1 ++ ,n2 ++ )
35 {
36 mergedlist[n1] = initlist[n2];
37 }
38 }
39 else
40 {
41 for ( int n1 = k,n2 = j;n1 <= n && n2 <= n;n1 ++ ,n2 ++ )
42 {
43 mergedlist[n1] = initlist[n2];
44 }
45 }
46 }
47
48 void mergepass( int initlist[], int mergedlist[], const int len, const int listlen)
49 {
50 int i = 0 ;
51 while (i + 2 * len <= listlen - 1 )
52 {
53 merge(initlist,mergedlist,i,i + len - 1 ,i + 2 * len - 1 );
54 i += 2 * len;
55 }
56 if (i + len <= listlen - 1 )
57 {
58 merge(initlist,mergedlist,i,i + len - 1 ,listlen - 1 );
59 }
60 else
61 {
62 for ( int j = i;j <= listlen - 1 ;j ++ )
63 {
64 mergedlist[j] = initlist[j];
65 }
66 }
67 }
68
69 void mergesort( int list[], int listlen)
70 {
71 int * templist = new int [listlen];
72 int len = 1 ;
73 while (len < listlen - 1 )
74 {
75 mergepass(list,templist,len,listlen);
76 len *= 2 ;
77 mergepass(templist,list,len,listlen);
78 len *= 2 ;
79 }
80 delete []templist;
81 }
82
83 int main()
84 {
85 clock_t start,finish;
86 start = clock();
87 int * initlist = new int [NUMS];
88
89 int i;
90 for (i = 0 ;i < NUMS;i ++ )
91 randfile >> initlist[i];
92
93 mergesort(initlist,NUMS);
94 for (i = 0 ;i < NUMS;i ++ )
95 sortedfile << initlist[i] << endl;
96
97 delete []initlist;
98
99 finish = clock();
100 double seconds = ( double )(finish - start) / CLOCKS_PER_SEC;
101 cout << " total running time is " << seconds << " seconds " << endl;
102 return 0 ;
103 }
2. multipass Sort
2
3 #include < iostream >
4 #include < fstream >
5 using namespace std;
6
7 #include < ctime >
8
9 ifstream randfile( " random.txt " );
10 ofstream sortedfile( " sorted.txt " );
11 ifstream helpfileout;
12 ofstream helpfile( " tmp.txt " );
13
14 int cmp( const void * a, const void * b)
15 {
16 return * ( int * )a - * ( int * )b;
17 }
18
19 void multipassSort( int x[])
20 {
21 int record;
22 int i = 0 ;
23 int j;
24 while (randfile >> record)
25 {
26 if (record < 4000000 )
27 x[i ++ ] = record;
28 else
29 helpfile << record << endl;
30 }
31 qsort(x,i, sizeof ( int ),cmp);
32 for (j = 0 ;j < i;j ++ )
33 sortedfile << x[j] << endl;
34 i = 0 ;
35 helpfile.close();
36 helpfileout.open( " tmp.txt " );
37 while (helpfileout >> record)
38 {
39 x[i ++ ] = record;
40 }
41 qsort(x,i, sizeof ( int ),cmp);
42 for (j = 0 ;j < i;j ++ )
43 sortedfile << x[j] << endl;
44 }
45
46 int main()
47 {
48 clock_t start,finish;
49 start = clock();
50 int * x = new int [ 8000000 ];
51 multipassSort(x);
52 delete []x;
53
54 finish = clock();
55 double secs = ( double )(finish - start) / CLOCKS_PER_SEC;
56 cout << " total time is " << secs << " seconds " << endl;
57 return 0 ;
58 }
59
3. wonder Sort
2 #include < fstream >
3 using namespace std;
4
5 #include < ctime >
6
7 ifstream randfile( " random.txt " );
8 ofstream sortedfile( " sorted.txt " );
9
10 const int NUMS = 8000000 ;
11
12 const int BITSPERINT = 32 ;
13 const int SHIFT = 5 ;
14 const int MASK = 0x1f ;
15
16 void seti( int x[], int i)
17 {
18 x[i >> SHIFT] |= ( 1 << (i & MASK));
19 }
20
21 void clri( int x[], int i)
22 {
23 x[i >> SHIFT] &= ~ ( 1 << (i & MASK));
24 }
25
26 int test( int x[], int i)
27 {
28 return x[i >> SHIFT] & ( 1 << (i & MASK));
29 }
30
31 void wonderSort( int x[])
32 {
33 int tmp;
34 int i = 0 ;
35 for (i = 0 ;i < NUMS;i ++ )
36 {
37 randfile >> tmp;
38 if (test(x,tmp) == 0 )
39 {
40 seti(x,tmp);
41 }
42 }
43 for (i = 0 ;i < 10000000 ;i ++ )
44 {
45 if (test(x,i) != 0 )
46 {
47 sortedfile << i << endl;
48 }
49 }
50 }
51
52 int main()
53 {
54 clock_t start,finish;
55 start = clock();
56 int * x = new int [ 10000000 / BITSPERINT];
57 memset(x, 0 , 10000000 / BITSPERINT * sizeof ( int ));
58
59 wonderSort(x);
60 delete[] x;
61 finish = clock();
62 double seconds = ( double )(finish - start) / CLOCKS_PER_SEC;
63 cout << " total use time is " << seconds << " seconds " << endl;
64
65 return 0 ;
66 }
4. 单纯读写文件
2 #include < fstream >
3 using namespace std;
4
5 #include < ctime >
6
7 ifstream randfile( " random.txt " );
8 ofstream outfile( " out.txt " );
9
10 #define NUMS 8000000
11
12 int main()
13 {
14 clock_t start,finish;
15 start = clock();
16 int i;
17 int tmp;
18 for (i = 0 ;i < NUMS;i ++ )
19 {
20 randfile >> tmp;
21 outfile << tmp;
22 }
23 finish = clock();
24 double secs = ( double )(finish - start) / CLOCKS_PER_SEC;
25 cout << " total time is " << secs << " seconds " << endl;
26
27 return 0 ;
28 }
如果不使用文件读写,就可以看出算法的效率来了,但是如何做到呢?
排序部分结束,然后回到面试题,如果只是来查找是否有重复数的话,是否有其他解法,如何做?
在上次的回复中,winter-cn提到了字典树和哈希表的解决方案。
字典树的确是一个好办法,而且仅仅是对于查找,如果是排序,字典树就不合适了。
而哈希还没有想到好的哈希函数,具体也没有细想,但是应该也是可以的。
【在其基础上衍生的面试问题】
其实上面的内容都解决了的话,那一开始的面试题也就解决了,因为其尽管问题规模变大了,但是其能够使用的内存也一样变大了,其基本方法还是一样的。
现在很多公司,因为其筛选人员的目的,同时也由于其工作是处理海量数据,所以在面试的时候,会出一些这样的关于海量数据处理的问题,但是我们很多人,包括我,都一般不会接触到这样海量的数据,所以,适量的减小问题的规模,但是同时将其内存限制变得更加严格,其实其本质还是一样的。
【TODO – 海量数据处理的话题】
这里的话题可以归入“海量数据处理”,关于海量数据处理,现在有很多公司的面试题会提相关的问题,如果没有对这个话题思考过的话,是不太可能会有很好的答案的,关于这个话题,以后还可以找到其他的问题来进行讨论,当问题规模不大的时候,体现不出算法的优势,但是当问题的规模到达一定数量级的时候,O(n)或者O(lgn)的算法的优势就能够体现出来了。
因为内容比较多,整理也花了一些时间,还有编写代码,如果其中有错误,欢迎大家指出,本来想分为几篇来写的,最后还是一下子写成一篇写完算了,篇幅就比较长,而且代码也较多,多谢大家能够看到最后~