【编程珠玑】【第二章】问题A
A题
给定一个最多包含40亿个随机排列的32位整数的顺序文件,找出一个不在文件中一32位整数。有三个问题:(1)在文件中至少存在这样一个数?(2)如果有足够的内存,如何处理?(3)如果内存不足,仅可以用文件来进行处理,如何处理?
答案:
(1)32位整数,包括-2146473648~~2146473647,约42亿个整数,32bit可用表示的最大无符号整数约为43亿。可见,一定存在至少一个这样的整数不被包含在40亿个整数中。
(2)如果采用位向量思想,通过建立一个大小为 2^32 的bool数组,用来表示相应的整数是否出现,进而,这种数组其实可以用第一章中学过的位图bitmap来实现,先对各位全部初始化为0。遍历输入文件,若某数出现,将该位置为1。最后,遍历一遍这个位图,所有为0的位就表示该位对应的整数没有出现过。时间复杂度为 O(n)+ 2*L (L为定值,表示 2^32,乘以2表示一次初始化和一次遍历),空间上32位整数最多需要占用43亿(2的32次方)个位,大约(2^32)/(8*10^6) = 512 MB的内存空间。
使用位向量方法,通常是逐位进行判断,但是这个例子中绝大多数的位都被置为1,只有极少的位为0,所以为了加速比较可以直接粗粒度的对int进行比较而不是对bit进行test(i)比较。为-1的二进制表示是全1的,判断每个int是否等于-1,若等于意味着该Int的32位都为1,否则说明其中某一位为0,该位对应的整数缺失。
这个方法的最大缺点,就是耗费内存。这时采用多趟算法,假如我们只有 500 B内存,就是说我们一次只能申请到 0~3999 个可用位(实际上加上其他开销,可能申请不到这么多),可以先遍历一趟文件,只测试在 0~3999 之间的整数,第二趟,测试 4000~7999 ,依次类推……。(如果题目要求只找到一个数就停止,那这种方法应该很不错。。)
(3.1)内存不足(假如只有1M内存),可以采用散列分桶的思想:输入的40亿个数
2的32次方个数(近43亿)用位向量表示需要512MB的内存,可是实际上只有1MB内存,这样不能够直接使用位向量记录输入文件中的40亿个整数(相应位置为0)并进而判断没有被记录的那些整数(相应位为0)。我们可以使用分桶的策略,把2的32次方分成一个个能够被装入内存中的小桶,每个小桶进行独立的处理,这样处理完所有的桶之后便能够输出所有的不在文件中的整数。此策略的缺点是需要重复的遍历输入文件和重复的进行每个桶的处理,优点是以时间代价来满足空间限制、且思路简单易懂。1MB内存空间有2^23个bits,可以表示2^23个整数,而整个整数范围为2^32,因此需要划分为2^(32-23)=2^9个桶,即512个桶。每个桶内仍使用位向量方法进行判断。
(3.2)题目中并没有说输入的40亿个数字中有没有重复,只说明了是32位的整数,这意味着40亿的输入数据一定不能够覆盖全部的2^32的范围,无论是否有重复,如果有重复意味着40亿的输入数据所缺失的数字更多,所以也没有影响。任意将40亿个数字分为两个集合,能确定至少有一个集合(数据量少的那个)会有缺失的项。
因此这里考虑二分法(这里要摒弃固定思维,二分法并不是什么情况下都需要排序后才能使用)。二分法思想是这样的:我们读取输入文件并从中取出整数,根据高位为1及高位为0将这些整数分为两类放到不同文件里,这个过程不需要多少工作内存,几十个byte足够,设高位为1的放入文件A中,为0的放入B中。
(3.2.1)当A中数据量和B一样多时,说明两组中都有遗漏的数据(遗漏一个或者多个,遗漏的数目相同),因为数据范围为43亿,实际数据只有40亿(没有重复的情况下,有重复的话则更少),最多每组只有20亿,不可能都包含43亿数据中的一半数据。此时任取其中一组进行接下来的二分即可。
(3.2.2)如果A数据量和B数据量不同时,可以肯定的是数据量少的那组一定缺少某一个或者多个数,而数据量多的那组遗漏与否是不一定的。在没有重复的情况下可以通过比较数据量多的那组数据量和2^32/2值的大小来判断它是否包含完整的一半数据,若有重复的情况下难以直接判断数据量多的那组是否缺项。此时我们取数据量小的(可以使得接下来的二分需要处理的数据更少,数据量多的那个则有可能没有不存在的数)那组进行二分——然后递归前面的步骤,最终会找到某个不在40亿数中的数。
但是较本题目更为复杂的一个问题是,如果输入数据量大于整数范围的最大值,且输入的数据中有重复数据,此时问题比较难以处理,若仍要使用二分法可能要进行精密的改动。下面将要讲的是来自网上的一个例子,例子能够很好地解释前述的二分法过程,但是并不能正确的应用于该例子假设的所有情况,具体请详细参阅:
假设一个文件里头有20个4bit的整数,需要找出其中遗漏的数字。我们一次从中取出一个数字,如果是最高位为1,那么放到一个文件A中,否则放到另外一个文件B中。理论上最高位为1的4bit数字不重复的共有2^3=8个,最高位为0的4bit数字同样为8个。若统计的个数少于8个肯定是这堆数中有遗漏的数。
设输入数据文件中包含的20个整数中有大量重复数字,而且导致最高位为1的数字个数可能大于8,但这不表示它其中没有缺少的数字。那到底如何去分辨哪个堆里头有缺少数字呢?在分拣到文件的过程中,程序有两个计数器,分别记录放入哪个文件的数字的个数,缺少的数字肯定在较小个数的那个文件里头。
对{1,3,4,5,3,6,3,7,9,4,9,1,2,2,11,12,15,11,14,15}共20个整数进行二分法,最高位为1的数字共有{9,11,12,15,11,14,15}七个,而最高位为0的数字共有{1,3,4,5,3,6,3,7,4,1,2,2}十二个,我们应该选择高位为1的那组数据继续二分法。同时我们还要设一个“标兵”,当前1 000和0 000是二分法的两个边界,我们取1000为此轮二分法的分界点,值为8。
对{9,11,12,15,11,14,15}继续二分法,第二高位为1的数字共有{12,15,15},第二高位为0的数字共有{9,11,11,14}。取较小数量的{12,15,15},又因边界为1100和1000,故“标兵”为8+4=12。
对{12,15,15}继续二分法,第三高位为1的数字共有{15,15}两个,第三高位为0的共有{12}一个。选择较小数量的{12},又因边界为1110和1100,这时“标兵”为12。
对{12}继续二分法,第四高位为1的数字个数零个,第四高位为0的数字为{12}一个。选择第四高位为1的空集合{},边界为1101和1100,因此这时候“标兵”为12+1=13。
四个位判断完毕后,我们求出上例中没有的数字为13。
#include <stdlib.h> int getLost(unsigned char *a, unsigned char *b, unsigned char *c, int alen, int bit) { unsigned char *t; int re = 0, v = 0, biter = 0, citer, i = 0; if (!a || !b || alen >=(unsigned long)( (1<< bit))) return -1; //这规定了输入数据量不能大过bits所能表示的最大数值。 while (bit--) { //从最高位开始逐位进行二分,直到所有的bits位处理完为止。 v = (1 << bit); //定位到当前最高位 for (biter = citer = i = 0; i < alen; ++i) { //遍历a[len]数组 if (a[i] & (1 << bit)) //将当前最高位为1的元素存储到b数组中。 b[biter++] = a[i]; else //将当前最高位为0的元素存储到c数组中。 c[citer++] = a[i]; } if (biter <= citer) { //b,c数组中选择数据量较小的数组 re += v; //如果b组数据量小,意味着当前高位为1的数组被选中, t = a; a = b; b = t; //需要更新“标兵”re,否则不需要更新re。 alen = biter; //把a指向b地址,并更新alen的长度值,以便进行下次二分 } else { t = a; a = c; c = t; alen = citer; } } return re; //所有位二分结束后,返回“标兵”的值,即为缺失的值。 } int main() { unsigned char b[20] ={0}; //b数组相当于文件a unsigned char c[20] ={0}; //c数组相当于文件b unsigned char a[] = {1,3,4,5,3,6,3,7,9,4,9,1,2,2,11,12,15,11,14,15}; //相当于输入数据 printf("%d\n",getLost(a,b,c,20,4)); system("pause"); return 1; }
已知4bits二进制的整数值最大为15,按理说20个输入数据中有可能没有遗漏的数,也就是说包含所有的数,所以上述的思路实际上是错误的,它只能够处理输入数据量小于整数最大值范围的情况,不能够处理超过最大范围的情况,因此代码中用红色注释标红了注意事项,这个条件限制了输入的情况,保证了算法的适用性,足以满足编程珠玑中的题目要求。
注意输入的数据默认是无序的,这样我们在将所有输入数据根据最高位划分成两组时,需要依次遍历所有的数据并将其添加到对应的数组b或者c中存储起来。下一次二分时根据b,c的长度选择较小的数组进行类似的操作。但是若输入的数据是有序的,我们在使用二分法查找遗漏数据时,并不需要开辟新的数组来存储两组数据,只需要存储几个输入数组的下标便可,这是因为通过下标变换就能够在有序数组上随意的定位某一段范围内的元素。代码如下:
#include <stdio.h> #include <stdlib.h> int getLost(unsigned char *a, int length, int bitlen){ unsigned char result = 0; int start = 0; int i,j; for( i = 1; i <= bitlen; i++){ int bit0 = 0; int bit1 = 0; int mod = 1 << (bitlen - i); int len = length/(1<<(i-1)); for( j = start; j < start + len; j++){ if((a[j] & mod) == 0) bit0++; else bit1++; } if(bit0 < bit1) result |= 0<<(bitlen - i); else{ result |= 1<<(bitlen - i); start+=len/2; } } return result; } int main(){ unsigned char b[] = {1,3,4,5,5,6,7,8,9,10,11,12,13,14,15}; printf("%d\n",getLost(b,15,4)); system("pause"); return 1; }
(4)算法分析:书中明确指出了可以利用外部临时文件,但是内存较小,这意味着我们可以使用较小的内存和充足的外存,这给了我们什么提示呢?
这通常意味着我们不能够把大量数据读入内存中处理,只能够通过遍历输入文件,读取少量数据到内存中,进行处理后存储到临时文件里去,而后可能通过对临时文件进行处理得到最终结果。
正如本例中,输入数据量是巨大的,只能通过一条一条记录读取,根据当前数据记录分到两个临时组文件A和B中存储,而后转而处理其中一个临时组文件,其他的文件被交替使用。如此重复,最终求得结果。
复杂度分析:每次需处理的数据量都是原来的一半:n+n/2+n/4+n/8+n/2^log(2)n=2n-1;的确是O(n)的。所以不要误以为时间复杂度正比于log2(n),但要注意一共划分的次数是正比于 log2(n)的。为了便于理解,举个常见的二分查找的例子,二叉树查找目标元素,需要log2(n)次比较,每次比较的复杂度是O(1),因此二分查找的复杂度即为O(log2(n))。但是在这里,不仅需要执行log2(n)次划分,每次划分的复杂度不是1而是n/2^log2(n),所以本例中的复杂度是正比于O(n)的。
(5)上面介绍了乱序输入和排序后输入两种情况下的getlost算法,不过这两种算法只是模拟算法,仅仅用于介绍二分思想,因为他们的输入数据都是由数组保存的,在内存有限的情况下是不可能开辟出这么大的内存数组来保存输入数据的。因此,这里介绍真正的利用文件实现的算法,整体思路上与之前的算法是一致的:
/* * 1.定义相关变量 * 2.如果当前探测位为最高位,意味着是第一次二分,需要读原始数据文件。 * 如果当前探测位不是最高位,意味着需要读上次二分后所含数据元素少的临时文件。 * 3.如果当前探测位是最低位,意味着这次二分要写的文件即为最终输出文件。 * 如果当前探测位不是最低位,意味着本次二分要写的文件为临时输出文件。 * 4.其他思路同上 * 5.值得注意的是curbit和totalbit之间的关系以及mask = 1<<(curbit-1)中的curbit-1的意义。 *6.参数totalbits是指需要进行二分的总位数,原则上要求输入数据不能超过这些位所能表示的最大值,但是代码中并没有进行错误检查等,因此该代码并不具备较高的容错性;而且每次二分都是新建临时文件比较浪费磁盘空间,可以每次读取完毕后及时删除或者重复利用固定数目的几个文件,等等的问题值得后续进行改进。 */ #include <stdio.h> #include <assert.h> void GetLost(int totalbits) { FILE *input,*output0,*output1; char filename[30] = ""; int mask,value,num0 = 0,num1 = 0,missing =0,curbit=totalbits; while(curbit>0) { if(curbit==totalbits){ input = fopen("source_input.txt","r"); }else if(num0<=num1){ sprintf(filename,"tmp_bit%d_0.txt",curbit+1); input = fopen(filename,"r"); }else { sprintf(filename,"tmp_bit%d_1.txt",curbit+1); input = fopen(filename,"r"); } if(curbit==1) { sprintf(filename,"final_output_0.txt"); output0 = fopen(filename,"w"); sprintf(filename,"final_output_1.txt"); output1 = fopen(filename,"w"); }else { sprintf(filename,"tmp_bit%d_0.txt",curbit); output0 = fopen(filename,"w"); sprintf(filename,"tmp_bit%d_1.txt",curbit); output1 = fopen(filename,"w"); } assert(input!=NULL && output0!=NULL&&output1!=NULL); num1=num0=0; mask = 1<<(curbit-1); while(!feof(input)) { fscanf(input,"%d\n",&value); if(value&mask) { fprintf(output1,"%d\n",value); num1++; }else { fprintf(output0,"%d\n",value); num0++; } } if(num1<=num0){ missing |= (1<<(curbit-1)); } fflush(output0); fflush(output1); fclose(output0); fclose(output1); fclose(input); curbit--; } printf("missing number:%d\n",missing); } int main() { GetLost(4); return 0; }