一道面试题与Java位操作 和 BitSet 库的使用
2014-04-05 22:41 yellowb 阅读(2870) 评论(1) 编辑 收藏 举报前一段时间在网上看到这样一道面试题:
有个老的手机短信程序,由于当时的手机CPU,内存都很烂。所以这个短信程序只能记住256条短信,多了就删了。
每个短信有个唯一的ID,在0到255之间。当然用户可能自己删短信.
现在要求设计实现一个功能: 当收到了一个新短信啥,如果手机短信容量还没"用完"(用完即已经存储256条),请分配给它一个可用的ID。
由于手机很破,我要求你的程序尽量快,并少用内存.
1.审题
通读一遍题目,可以大概知道题目并不需要我们实现手机短信内容的存储,也就是不用管短信内容以什么形式存、存在哪里等问题。需要关心的地方应该是如何快速找到还没被占用的ID(0 ~ 255),整理一下需求,如下:
- 手机最多存储256条短信,短信ID范围是[0,255];
- 用户可以手动删除短信,删除哪些短信是由用户决定的;
- 当收到一条新短信时,只需要分配一个还没被占用的ID即可,不需要是可用ID中最小的ID;
- 题目没说明在手机短信容量已满的情况下,也就是无法找到可用ID时需要怎么办,这里约定在这种情况下程序返回一个错误码即可;
理清需求之后,其实需要做的事情就很清楚了:
- 设计一个数据结构来存储已被占用的或没被占用的短信ID;
- 实现一个函数,返回一个可用的ID,当无法找到可用ID时,返回-1;
- 在实现以上两点的前提下,尽量在程序执行速度和内存占用量上做优化。
2.解题
(由于作者对Java最熟悉,下面的代码都是采用Java书写)
2.1 线性查找
这应该是最简(无)单(脑)一个办法。如果想用一个数据结构保存已占用的ID,由于这是一个变长无序的集合,而数组(Array)这种结构是定长的,并且原生并未提供删除数组元素的功能,所以应该很容想到用Java类库提供的List作为容器。那么寻找一个可用ID的方法就很简单:只要多次遍历这个List,第一次遍历时查找0是否在这个List中,如果没找到,着返回0,否则进行下一趟遍历查找1,直到255,这个过程可以用一个2重循环来实现:
1 /** 2 * 线性查找 3 * 时间复杂度: O(n^2) 4 * @param busyIDs 被占用的ID 5 * @return 6 */ 7 public int search(List<Integer> busyIDs) { 8 for(int i = 0; i < 255; i++) { 9 if(busyIDs.indexOf(i) == -1) return i; 10 } 11 return -1; 12 }
但是这种实现方式的问题不少,其中最严重的就是时间复杂度问题。由于List.indexOf(Object)函数的实现方式是顺序遍历整个数据结构(无论是ArrayList还是LinkedList都是如此,ArrayList由于底层用数组实现,遍历操作在连续的内存空间上进行,比LinkedList要快一些),再套上外层的循环,导致时间复杂度为O(2^n)。
另外一个问题是空间复杂度。先不论List这个类内部包含的各种元数据(ArrayList或LinkedList类的一些私有属性),由于List中存储的元素必须为Java Object,所以上面的代码的List中实际上存放的事Integer类。我们知道这种封装类型要比对应的基本数据类型(Primitive Types)占用更多的内存空间,以Integer为例,在64bit JVM(关闭压缩指针)下,一个Integer对象占用的内存空间为24Byte = 8Byte mark_header + 8Byte Klass 指针 + 4Byte int(用于存储数值)+ 4Byte(Padding,Java对象必须以8Byte为界对齐)。 而一个int变量只需要4Byte!另外即使把Integer替换成Short,情况也是一样。也就是说,当手机保存了256条短信时,存储被占用ID总共需要的空间为:256 × 24Byte = 6KB! 而且还不包括List本身的元数据!
最后还有个问题就是List在删除元素时的效率问题。ArrayList由于底层用数组实现,所以当删除一个元素后,被删除元素后面的所有元素都要往前移动一个位置(用System.arraycopy()实现);而LinkedList由于用双向链表存储数据,所以删除元素比较简单,但正是由于其采用双向链表,所以每个元素要额外多占用2个指针的空间(指向前一个和后一个元素)。
2.2 Hash表
由于2.1中内层循环采用顺序查找的方式导致时间复杂度为O(2^n),一个很容易想到的改进就是把已经被占用的ID存放在一个Hash表中,由于Hash表对查找操作的时间复杂度为O(C)(实际上并不一定,对于用链表法解决冲突的Hash表,查找一个元素的时间跟链表的平均长度有关,也就是O(n)。但这里简单认为时间复杂度就是常数),所以查找一个可用ID的时间复杂度为O(n)。代码如下:
1 /** 2 * Hash表查找 3 * 时间复杂度: O(n) 4 * @param busyIDs 被占用的ID 5 * @return 6 */ 7 public int search(HashSet<Integer> busyIDs) { 8 for(int i = 0; i < 255; i++) { 9 if(!busyIDs.contains(i)) return i; 10 } 11 return -1; 12 }
这种实现方式相对2.1在时间上有了改进,但是空间占用问题却更严重了:Java类库中的HashSet其实是用HashMap来实现的,这里不考虑任何元数据,只考虑HashMap本身,用于HashMap本身有一个load factor(默认是0.75,即是HashMap中保存的元素个数不能超过HashMap容量的75%,否则要Re-hash);另外对于HashMap中的每一个元素Entry<K,V>,即是我们用的是HashSet,只占用<K,V>中的K,但是V也要占用一个指针的位置(其值为null)。
2.3 boolean数组
这种实现方式与上面2种比较一个根本的不同是:不存储具体被占用的ID的值,而是存储所有ID的状态(就2种状态,可用与被占用)。由于对于一个ID来说,总共只有2种状态,所以可以用boolean代表一个ID的状态,然后用一个长度为256的boolean数组表示所有ID的状态(假定false=可用,true=被占用)。
当需要查找可用ID时,只需要遍历这个数组,找到第一个值为false的boolean,返回其索引即可。用于现代CPU每次读内存时都可以一次性读取1个Cache Line(一般是64Byte)的内容,而一个boolean只占1Byte,所以达到很高的遍历速度。
另外做删除操作时,只需要把数组中ID对应索引的那个boolean设为false即可。
不过这种方案只适用与定长数据(比如题中注明最多256条短信)。代码如下:
1 /** 2 * boolean数组 3 * 时间复杂度: O(n) 4 * @param busyIDs 被占用的ID 5 * @return 6 */ 7 public int search(boolean[] busyIDs) { 8 for(int i = 0, len = busyIDs.length; i < len; i++) { 9 if(busyIDs[i] == false) return i; 10 } 11 return -1; 12 }
这种方案对比前面2种,在空间复杂度上有非常大的优化:只占用256Byte内存。并且在查找上也可以达到不错的速度。
2.4位图(Bit Map)
这种方案是对2.3的一个优化。由于一个boolean值在JVM中占用1Byte,而1Byte=8bit,8个bit可以表示的状态为2^8 = 256种(0000 0000 ~ 1111 1111),而我们的短信ID状态只有2种!所以用一个boolean表示1个状态是非常大的浪费,实际上1个bit就足够,其余7个bit都浪费了。这就给我们提供了一个思路:能不能用一个bit表示一个短信ID?如果可以的话,空间复杂度相对2.3有可以下降7/8!
这里可以用一种叫位图(Bit map)的数据结构,其实这东西在Linux内核源码中被大量使用,但是似乎Java并没提供原生的操作bit的方式。所以我们需要自己包装,可以把64个bit包装到一个long值里面(因为long = 8Byte = 64bit),然后我们只需要4个long(总共32Byte)就可以完全表示256个ID的状态了!
但是还有个问题,如何寻找一个可用ID呢(其实就是找值=0的bit)?这需要用到Java的位操作符:& (“与”)。假设我们有一个长度为8的bit串,要判断它的从左起第2位是否为0,可以这样做:
1100 1010
& 0100 0000
-----------------
= 0100 0000
上面红色的0100 0000为掩码(mask),常用于检测一个bit串中某些位是否为1,比如上面,如果只需要检测第2位,着需要一个第2位=1,其余位=0的掩码,把这个掩码跟被比较的bit串做&操作,如果结果!=0,则表示被比较的bit串的第2位为1 。
通过上面的例子可知,我们一个long有64bit,所以需要64个掩码(分别都是只有1个位=1).
当需要查找可用ID时,只需要依次遍历4个long,判断long的值是否为0xFFFFFFFFFFFFFFFFL(其实就是所有bit都为1,换算成有符号整数是 -1)。如果是则表示这个long中的所有64个bit都被占用了,则判断下一个long;否则表示这个long中还有空闲的bit,然后依次用64个掩码去跟它做&操作,既可以知道到底哪一个bit是0,这个bit就是我们要找的。下面给出代码:
1 package bit; 2 3 public class B256Phone { 4 // 最大短信数量 5 private final static int MSG_NUM = 256; 6 // long占多少bit 7 private final static int LONG_SIZE = 64; 8 // 全1的long 9 private final static long FULL_BUSY = 0xFFFFFFFFFFFFFFFFL; 10 // 64个掩码 11 private static long[] masks; 12 // 4个long组成的位图 13 private static long[] bitMap; 14 15 static { 16 bitMap = new long[MSG_NUM/LONG_SIZE]; 17 masks = new long[LONG_SIZE]; 18 // 初始化64个掩码 19 long mask = 0x8000000000000000L; 20 for(int i = 0; i < masks.length; i++) { 21 masks[i] = mask; 22 mask = mask >>> 1; 23 } 24 } 25 26 public static int search() { 27 for(int i = 0; i < bitMap.length; i++) { 28 long val = bitMap[i]; 29 if((val & FULL_BUSY) != FULL_BUSY) { 30 int bitPos = findBitPos(val); 31 // 注意要换算一下才能得到ID的下标 32 return bitPos != -1 ? LONG_SIZE * i + bitPos : -1; 33 } 34 } 35 return -1; 36 } 37 38 public static int findBitPos(long val) { 39 for(int i = 0; i < masks.length; i++) { 40 if((val & masks[i]) == 0) { 41 return i; 42 } 43 } 44 return -1; 45 } 46 47 public static void main(String[] args) { 48 bitMap[0] = 0xFFFFFFFFEFFFFFFFL; //测试数据, 第35个bit设置为0 49 int pos = search(); 50 System.out.println(pos); 51 } 52 }
相比第1个方案, 我们把占用空间从6KB缩小到32Byte,足足减少了99.5%,满足了题目中“手机硬件很烂”的要求。另外把数据压缩到一个4个long的数组中,方便CPU在一次内存Read就把所有数据都读到Cache,减少内存访问,并且位操作也是非常快速的。
这是我想到的最优的方案了。
3 Java类库中的BitSet
后来才发现Java类库中已经提供了一个位图的实现:BitSet,使用也非常方便,看了下源码,底层也是long[]实现的,但是它具有动态扩展的功能(跟ArrayList)类似。贴下用法,以后有机会再仔细研究:
1 import java.util.BitSet; 2 3 public class Main { 4 public static void main(String[] args) { 5 // Create a BitSet object, which can store 128 Options. 6 BitSet bs = new BitSet(128); 7 bs.set(0);// equal to bs.set(0,true), set bit0 to 1. 8 bs.set(64,true); // Set bit64 9 10 // Returns the long array used in BitSet 11 long[] longs = bs.toLongArray(); 12 13 System.out.println(longs.length); // 2 14 System.out.println(longs[0]); // 1 15 System.out.println(longs[1]); // 1 16 System.out.println(longs[0] ==longs[1]); // true 17 } 18 }