位向量/位图是一个很有用的数据结构,在充分利用小空间存储大量数据方面非常具有优势,Linux内核中很多地方都是用了位图。同时,它不但基础,而且用到了很多编程语言的知识,以及对细节的把握,常常作为面试题出现。这里将要介绍它的实现、操作、应用。
与位图(bitmap)比,我更倾向于用位向量(bit vector),前者比较容易与图形学里的名词混淆,其实提到位图,多指的是“是使用像素阵列来表示的图像”(维基百科),为了避免这一点,下文中使用位向量。先来看看产生位向量的需求:
一般地,对于多个对象和一个性质,这些对象可能满足(true)也可能不满足(false)这条性质。那么,为了表示所有对象对这个性质的满足情况,最容易想到的方式是分配一个int型变量(这里不讨论布尔型,C++没有对布尔型占用空间有明确规定;本文主要讨论C)作为标志,用1表示满足条件,用0表示不满足条件。同时为了方便查询,把这些对象的标志整合成一个数组。显然,使用int来表示是0还是1有些太浪费空间了,即使把int改为char,浪费的情况也只是减轻了一部分,仍有很大的空间被浪费。考虑到计算机中最小的数据单位是非0即1的二进制位,对于一个对象,使用一个二进制位就足够了。很多语言都具有位运算,利用位运算是可以完成本段开始处提出的要求的。当然,因为不能用一个变量名直接表示一个位,那么可以将多个位组合成一个基本数据类型,通过对这个基本数据类型进行操作,达到使用位的方法。同时,为了方便,延续使用int数组的做法,把这些由位组合成的基本数据类型也组成数组。
经过这样分析,位向量的实现方法大体是:多个位组成一个基本数据类型,基本数据类型组合成数组。根据这个思路就可以写出位向量的表示了。在阅读下面代码前,建议读者尝试自己独立完成,这是一些提示:简单起见,使用int作为位组成的基本数据类型,且int使用32位表示;int数组中元素的个数如何计算?
#define N 10000000 //number of elements #define BITPERWORD 32 //bits of int depends on machine int a[(N-1)/BITPERWORD + 1]; //allocate space for bitmap //《编程珠玑》原书中是N/BITPERWORD + 1 //如果N恰为BITPERWORD的倍数,那么又要浪费一个int的空间了 //N取0时,仍会浪费一个int的空间 //N非0非BITPERWORD的倍数时,最多是最后一个int不完全利用而已
写完了表示,就需要为这个数据结构增加对应的操作了。对于每个位的操作,有三种:设置为0、设置为1、读取当前值。根据上文位向量的表示,实现这三种操作。同样建议读者先尝试独立完成。以下代码参考自《编程珠玑》习题1.2。
#define SHIFT 5 //32 = 2^5 #define MASK 0x1F // vaule 11111 in binary //i代表需要进行操作的第i个对象 //i>>SHIFT相当于i/32,将i定位到具体是哪个int中,即a[i>>SHIFT] //i&MASK相当于i%32 只保留i的0至4位,即i在int中的第几位,然后把1左移这么多位 //将a[i>>SHIFT]和(1<<(i&MASK))视需要进行操作 //同1做或运算或即为位设置 //同1取反再做与运算即为位清除 //同1做与,结果为0则原位为0,为1则原位为1 void set(int i) { a[i>>SHIFT] |= (1<<(i&MASK));} void clr(int i) { a[i>>SHIFT] &= ~(1<<(i&MASK));} int test(int i) { return a[i>>SHIFT] & (1<<(i&MASK));}
使用位向量前不要忘记对所有位进行初始化:
for(i=0;i<N;i++) clr(i);
当然,你也可以用这个或许更快的初始化方式:
int temp = N/BITPERWORD+1; for (i=0; i< temp;i++) a[i] = 0;
补充讨论:
Q:为什么要用看上去并不那么直接的位运算而不是i/32和i%32?
A:为了速度而不是易读性。你所写下的最初版本如果使用的是/和%,当然没有错;当你着手于提高效率的时候,把它们改成移位运算就势在必行了。虽然编译器可能会对/和%在除数为2的幂时进行优化,写成移位运算,你当然可以自己来完成这件事来保证确实优化了。
Q:是否可以将存放位向量的数组作为参数?
A:当然可以,上文代码只是为了叙述方便。
Q:是否可以把这三个函数写成宏?
A:其实我曾经被面试过一道题,就是要求写一个宏,完成上面clr(i)的任务。写成宏当然没问题,注意加好括号就行,具体的用法让调用的人去担心吧(笑)。
Q:位向量和位字段/位域有什么区别和联系?可以用位字段来实现位向量吗?
A:C语言允许将结构的整数成员存入比编译器通常允许的更小的空间里。然而它有三个风险:不同计算机中对齐限制不同;位字段宽度限制不同;将位包装成字的字节顺序不同。在位向量里,虽然也有依赖于具体计算机特性的限制(需要预先知道int的位数),但是位的包装是由程序员来控制的,也不必考虑对其限制。移植性略好于将成员声明为1位整数位字段的结构。
应用:
1.Linux中分配唯一pid的算法、内存管理的伙伴分配系统等,详细可以google,关键词:linux+位图。
2.一个最多包含n个正整数的文件,每个数都小于n,其中n=107,并且没有重复。最多有1MB内存可用。要求用最快方式将它们排序并按升序输出。(《编程珠玑》第一章正文)方法是一次读入文件,把出现过的数字对应位置1;读取完毕后从低位到高位输出位向量为1的位所代表的数。
3.如果有用时间换空间的必要,可以将寻找1至某个数之间所有质数的埃氏筛法用位向量实现。原始版本的代码见于《编程珠玑(续)》习题1.2,算法简介:一个n比特的数组,对应1至n-1,初值均为真。从2开始,每发现一个质数,就把这个数组中所有这个数的倍数设为假。下一个质数就是数组中下一个为真的比特。循环至所有数都被遍历,即可得到该范围内所有的质数。
引申和扩展:
1.(习题1.6)每个整数最多出现10次而不是1次;如果内存限制不变,又应该怎么做?提示:多趟排序
(更一般的扩展:用几个位表示一个对象的多个性质,也即一个对象不仅仅只占用一位而是多位,但每个对象占用的位数是相同的。重写位图。)
2.(习题1.9)对于稀疏的位向量,初始化很浪费时间。一般地,对于一个向量(不仅限于位向量),怎样利用辅助数据结构,使得第一次访问位向量的某一位时才将其初始化为0?
解法:
对于向量int data[N],分配辅助数据结构int from[N]和to[N],以及一个整数top,初始化top=0。
插入data[i]时,from[i]填入top,to[top] = i,top++;
这样维持了一个性质:top代表下一个被插入的值的次序;from[i]代表了data[i]是第几个被插入的;to[]中小于top的元素to[j]保存了第j个被插入对应在data[]的下标。
若data[i]未初始化,则from[i]也未初始化,此时即使from[i]中未初始化的垃圾值<top,to[from[i]]!= i,仍能判断出data[i]未初始化。