一道面试题与Java位操作 和 BitSet 库的使用
2014-04-05 22:41
309 查看
前一段时间在网上看到这样一道面试题:
有个老的手机短信程序,由于当时的手机CPU,内存都很烂。所以这个短信程序只能记住256条短信,多了就删了。
每个短信有个唯一的ID,在0到255之间。当然用户可能自己删短信.
现在要求设计实现一个功能: 当收到了一个新短信啥,如果手机短信容量还没"用完"(用完即已经存储256条),请分配给它一个可用的ID。
由于手机很破,我要求你的程序尽量快,并少用内存.
手机最多存储256条短信,短信ID范围是[0,255];
用户可以手动删除短信,删除哪些短信是由用户决定的;
当收到一条新短信时,只需要分配一个还没被占用的ID即可,不需要是可用ID中最小的ID;
题目没说明在手机短信容量已满的情况下,也就是无法找到可用ID时需要怎么办,这里约定在这种情况下程序返回一个错误码即可;
理清需求之后,其实需要做的事情就很清楚了:
设计一个数据结构来存储已被占用的或没被占用的短信ID;
实现一个函数,返回一个可用的ID,当无法找到可用ID时,返回-1;
在实现以上两点的前提下,尽量在程序执行速度和内存占用量上做优化。
但是这种实现方式的问题不少,其中最严重的就是时间复杂度问题。由于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.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)。
当需要查找可用ID时,只需要遍历这个数组,找到第一个值为false的boolean,返回其索引即可。用于现代CPU每次读内存时都可以一次性读取1个Cache Line(一般是64Byte)的内容,而一个boolean只占1Byte,所以达到很高的遍历速度。
另外做删除操作时,只需要把数组中ID对应索引的那个boolean设为false即可。
不过这种方案只适用与定长数据(比如题中注明最多256条短信)。代码如下:
这种方案对比前面2种,在空间复杂度上有非常大的优化:只占用256Byte内存。并且在查找上也可以达到不错的速度。
这里可以用一种叫位图(Bit map)的数据结构,其实这东西在Linux内核源码中被大量使用,但是似乎Java并没提供原生的操作bit的方式。所以我们需要自己包装,可以把64个bit包装到一个long值里面(因为long = 8Byte = 64bit),然后我们只需要4个long(总共32Byte)就可以完全表示256个ID的状态了!
但是还有个问题,如何寻找一个可用ID呢(其实就是找值=0的bit)?这需要用到Java的位操作符:& (“与”)。假设我们有一个长度为8的bit串,要判断它的从左起第2位是否为0,可以这样做:
上面红色的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个方案, 我们把占用空间从6KB缩小到32Byte,足足减少了99.5%,满足了题目中“手机硬件很烂”的要求。另外把数据压缩到一个4个long的数组中,方便CPU在一次内存Read就把所有数据都读到Cache,减少内存访问,并且位操作也是非常快速的。
这是我想到的最优的方案了。
有个老的手机短信程序,由于当时的手机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重循环来实现:/** * 线性查找 * 时间复杂度: O(n^2) * @param busyIDs 被占用的ID * @return */ public int search(List<Integer> busyIDs) { for(int i = 0; i < 255; i++) { if(busyIDs.indexOf(i) == -1) return i; } return -1; }
但是这种实现方式的问题不少,其中最严重的就是时间复杂度问题。由于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)。代码如下:/** * Hash表查找 * 时间复杂度: O(n) * @param busyIDs 被占用的ID * @return */ public int search(HashSet<Integer> busyIDs) { for(int i = 0; i < 255; i++) { if(!busyIDs.contains(i)) return i; } return -1; }
这种实现方式相对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条短信)。代码如下:
/** * boolean数组 * 时间复杂度: O(n) * @param busyIDs 被占用的ID * @return */ public int search(boolean[] busyIDs) { for(int i = 0, len = busyIDs.length; i < len; i++) { if(busyIDs[i] == false) return i; } return -1; }
这种方案对比前面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就是我们要找的。下面给出代码:
package bit; public class B256Phone { // 最大短信数量 private final static int MSG_NUM = 256; // long占多少bit private final static int LONG_SIZE = 64; // 全1的long private final static long FULL_BUSY = 0xFFFFFFFFFFFFFFFFL; // 64个掩码 private static long[] masks; // 4个long组成的位图 private static long[] bitMap; static { bitMap = new long[MSG_NUM/LONG_SIZE]; masks = new long[LONG_SIZE]; // 初始化64个掩码 long mask = 0x8000000000000000L; for(int i = 0; i < masks.length; i++) { masks[i] = mask; mask = mask >>> 1; } } public static int search() { for(int i = 0; i < bitMap.length; i++) { long val = bitMap[i]; if((val & FULL_BUSY) != FULL_BUSY) { int bitPos = findBitPos(val); // 注意要换算一下才能得到ID的下标 return bitPos != -1 ? LONG_SIZE * i + bitPos : -1; } } return -1; } public static int findBitPos(long val) { for(int i = 0; i < masks.length; i++) { if((val & masks[i]) == 0) { return i; } } return -1; } public static void main(String[] args) { bitMap[0] = 0xFFFFFFFFEFFFFFFFL; //测试数据, 第35个bit设置为0 int pos = search(); System.out.println(pos); } }
相比第1个方案, 我们把占用空间从6KB缩小到32Byte,足足减少了99.5%,满足了题目中“手机硬件很烂”的要求。另外把数据压缩到一个4个long的数组中,方便CPU在一次内存Read就把所有数据都读到Cache,减少内存访问,并且位操作也是非常快速的。
这是我想到的最优的方案了。
3 Java类库中的BitSet
后来才发现Java类库中已经提供了一个位图的实现:BitSet,使用也非常方便,看了下源码,底层也是long[]实现的,但是它具有动态扩展的功能(跟ArrayList)类似。贴下用法,以后有机会再仔细研究:import java.util.BitSet; public class Main { public static void main(String[] args) { // Create a BitSet object, which can store 128 Options. BitSet bs = new BitSet(128); bs.set(0);// equal to bs.set(0,true), set bit0 to 1. bs.set(64,true); // Set bit64 // Returns the long array used in BitSet long[] longs = bs.toLongArray(); System.out.println(longs.length); // 2 System.out.println(longs[0]); // 1 System.out.println(longs[1]); // 1 System.out.println(longs[0] ==longs[1]); // true } }
相关文章推荐
- 由字符串反转(使用递归)引申出来一道Java面试题
- 由字符串反转(使用递归)引申出来一道Java面试题
- 由字符串反转(使用递归)引申出来一道Java面试题
- Java中,匿名内部类在开发中的使用以及匿名内部类的面试题
- 面试题讲解(一) java继承、静态代码块、非静态代码块的使用
- google的一道JAVA面试题
- 由一道腾讯面试题引发的关于递归函数使用的各种情况总结
- 关于一道面试题,使用C#实现字符串反转算法
- JAVA基础再回首(二十五)——Lock锁的使用、死锁问题、多线程生产者和消费者、线程池、匿名内部类使用多线程、定时器、面试题
- 一道百度java面试题的多种解法
- 一道3G门户的面试题 (Java)
- 史上最难的一道Java面试题 (分析篇)
- 史上最难的一道Java面试题 (分析篇)
- 一道经典的Java面试题--你会吗?
- 由一道Java面试题想到的(关于类初始化以及多态)(一)
- 求解一道Java面试题。
- java工程师 一道面向对象面试题
- 一道关于Java中Integer缓冲区的面试题
- java 面试题: 使用监听来实现实时跟踪
- 一道网易Java简单集合面试题「我感觉你做不出来」