代码改变世界

一道面试题与Java位操作 和 BitSet 库的使用

2014-04-05 22:41  yellowb  阅读(2870)  评论(1编辑  收藏  举报

  前一段时间在网上看到这样一道面试题:

有个老的手机短信程序,由于当时的手机CPU,内存都很烂。所以这个短信程序只能记住256条短信,多了就删了。

每个短信有个唯一的ID,在0到255之间。当然用户可能自己删短信.

现在要求设计实现一个功能: 当收到了一个新短信啥,如果手机短信容量还没"用完"(用完即已经存储256条),请分配给它一个可用的ID。

由于手机很破,我要求你的程序尽量快,并少用内存.

1.审题

  通读一遍题目,可以大概知道题目并不需要我们实现手机短信内容的存储,也就是不用管短信内容以什么形式存、存在哪里等问题。需要关心的地方应该是如何快速找到还没被占用的ID(0 ~ 255),整理一下需求,如下:

  1. 手机最多存储256条短信,短信ID范围是[0,255];
  2. 用户可以手动删除短信,删除哪些短信是由用户决定的;
  3. 当收到一条新短信时,只需要分配一个还没被占用的ID即可,不需要是可用ID中最小的ID;
  4. 题目没说明在手机短信容量已满的情况下,也就是无法找到可用ID时需要怎么办,这里约定在这种情况下程序返回一个错误码即可;

 理清需求之后,其实需要做的事情就很清楚了:

  1. 设计一个数据结构来存储已被占用的或没被占用的短信ID;
  2. 实现一个函数,返回一个可用的ID,当无法找到可用ID时,返回-1;
  3. 在实现以上两点的前提下,尽量在程序执行速度和内存占用量上做优化。

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 }