神奇的比特存储
两个算法题
号码去查重
现在有两批QQ号,每批大约40亿个,每批可能存在重复的数据,但记录着不同的数据,我们有两个iterator,都可以通过iterator.nextuint获得下一个QQ号,数据类型为unsigned int,即每个QQ好最大不超过2^32-1[当然目前我所见过的QQ号,连30亿都没超过],现在设计算法,使用内存不超过1G,找出两批QQ号公共QQ号的个数?这个问题有许多衍生,但我们选取了最简单的一部分。
我也不卖关子,直接说算法了,这题的关键在于QQ号是有序的,同时我们只关心是否有这个QQ号,虽然可以使用bool数组,但在C里它占用一个字节,不能充分利用空间,我们需要的是用bit(一个二进制位)来存储bool,这样才能最大限度的利用空间。同时QQ号是有序的,于是我们可以通过内存地址上的排序来映射相应QQ号的位置。我们举一个简单例子来说明,比如一个char总共有8个二进制位,比如00001010则表示2和4存在,其它不存在。进一步char[2]则有16个二进制位,01000000 00100010则表示2,6,15存在,其它不存在。然后我们需要判断的QQ号的最大值为2^32-1,计算一下可得,需要大概512M内存,因为数组只能传有符号的int,所以我们不推荐使用char数组,而使用unsigned int的数组。我们来写一个函数,用来判断传入的unsigned int是否存在于传入的unsigned int里。
1 | int isInBit(unsigned int* nums, unsigned int num) { |
然后我们再写一个存入的函数
1 | int storeInBit(unsigned int* nums, unsigned int num) { |
当然,我们发现了与之前重复的部分,本应分离出来,还有重复存入的问题,但我们只讲算法,细节就不关注了。最后再写一个删除函数,也使用类似的方法。
1 | int removeInBit(unsigned int* nums, unsigned int num) { |
到此我们的基本内容都完成了,接下来我们来写核心函数吧。
1 | int main() { |
兔子试毒
有1000瓶药水,其中有一瓶是毒药,只要喝了一滴一天后必死,现在要用最合适的兔子和时间试出哪瓶是毒药?这里有两个隐性条件要看出来,就是一只兔子可以同时喝多瓶毒药和一瓶毒药可以被多只兔子尝试。当然我这里给了合适一词,因为最少的兔子是1只兔子试1000天,最少的天数是使用1000只兔子1天内试出结果,使用一一对应的做法,大家都懂。
2^10>1000,所以我们只要10只兔子就足够了。我们将药水从0-999编号,并将其转化为二进制,哪个位置上有1就让哪一只兔子喝一滴,如0不会被任何兔子喝,101会被3号和1号兔子喝。一天以后,我们将死去兔子的编号组合成一个二进制数,即是毒药对应的编号。原理其实就是简单的排除法,我们来使用代码实现这个过程。
1 | //生成毒药 |
整体不算复杂,我们不过多说明,重在学会其中的思想。
大整数存储
最后我们再来讲讲大整数的存储问题,通过之前的研究我们可以很容易地知道,我们可以通过bit来存储序数。比如每个int可以存储上限为2^32-1的整数,int[2]存储2^32的方式就可以是int[1]=1,int[0]=0。
在java里的BigInteger采用的就是上述方式,它使用int[]来存储大整数,数组的上限是private static final int MAX_MAG_LENGTH = Integer.MAX_VALUE / Integer.SIZE + 1; // (1 << 26)
总共才64M,虽然看起来不是很大,但实际上,我们一般通过String来初始化BigInteger,如果每秒输入一个字符,也要输2年多才能输满,这是你可能会告诉我使用pow函数来产生大的BigInteger。其实为什么设这个上限我也不太明白,可能因为它是运行在java虚拟机上,需要对内存稍做管理吧,其实数组可以传入的最大值应该是int即2^31-1,不过也可以通过链表进行实现,这样就可以无限申请内存了,这实际上是python内部大整数的实现方式。这是你可能会稍微疑惑为什么不使用long数组,这大概是java的倔强,它的加减乘除运算都是在java虚拟机层实现的,为了保证加法的进位不丢失,每次运算前它会使用final static long LONG_MASK = 0xffffffffL;
先对一块int进行数据类型转化,再通过long的运算得到结果,最后分别取出进位继续运算,取一段加法稍微看看。
1 | while (yIndex > 0) { |
sum是一个long用来缓存中间结果,x和y是两个加数,result用来存储最后的结果。
说到java,我们还要提一下android的java api,谷歌是我十分佩服的公司,安卓的javaapi虽然与java的许多部分都基本一致,但实现的方式却大有不同,比如这个BigInteger,首先它并不是在类似java虚拟机的delvik虚拟机上运行,而是通过jni来运行在native层的相关代码。从安卓在java层源码,我们可以看到BigInteger还有两个相关类,分别是BigInt和NativeBN,NativeBN用来申明Native方法,加减乘除幂等都在这申明,实现的话是在native层,BigInt则是真正处理BigInteger相关事务的类,比如构造函数初始化数据等,BigInteger则是为了保留java接口,本质是使用BigInt实现相同于java的功能,当然安卓SDK只保留了BigInteger的使用,所有用起来与java基本没有区别就是了。
最后或许要讲讲python,但实际上已经没什么好说的了,python3之前有对大整数和小整数进行分类存储,可能一定程度有提高效率,但python3则一律存储为大整数的方式,底层实现类似于bit存储的思想,但是因为python的原始解释器是C系列的语言,就可以直接操作内存,因此可以不受数组申明长度的限制,实现真正意义上的无限大整数,不过物理空间的限制和时间的限制已经超出软件的范畴了,不是我们应该考虑的。
这次我对大整数的实现,并没有带读者直接去读源码,有了之前lua的经验,我发现这样的意义,实际是不大的。其实,一旦我们可以理解其中的思想的话,读源码实际上就是水到渠成的,读源码的最大障碍就是不能理解,为什么要什么这个变量?为什么要如此回调?等。“授人以鱼不如授人以渔”,所以这次我先从某种思想的使用先讲清它的基本内容,再稍微提了一下,其它源码是如何使用这种思想又有哪些不同的地方。这实际是一种引导式的阅读,理解思想的读者其实读不读源码已经不重要了,读源码的目的则从学习方法变成了验证方法。
就到这里吧,明天也该开始把lua的坑给填了。