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

有个老的手机短信程序,由于当时的手机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重循环来实现:

 /**
* 线性查找
* 时间复杂度: 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); //
System.out.println(longs[0]); //
System.out.println(longs[1]); //
System.out.println(longs[0] ==longs[1]); // true
}
}

一道面试题与Java位操作 和 BitSet 库的使用的更多相关文章

  1. 由阿里巴巴一道笔试题看Java静态代码块、静态函数、动态代码块、构造函数等的执行顺序

    一.阿里巴巴笔试题: public class Test { public static int k = 0; public static Test t1 = new Test("t1&qu ...

  2. 「每天一道面试题」Java类的生命周期包括哪几个阶段?

    一个Java类被加载到虚拟机中,它的生命周期才算开始,直到被从内存中卸载,它的生命周期才算结束.从开始到结束,它的整个生命周期包括加载.验证.准备.解析.初始化.使用和卸载7个阶段,其中验证.准备和解 ...

  3. 关于Java类加载双亲委派机制的思考(附一道面试题)

    预定义类加载器和双亲委派机制 JVM预定义的三种类型类加载器: 启动(Bootstrap)类加载器:是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib下面 ...

  4. Java中有关构造函数的一道笔试题解析

    Java中有关构造函数的一道笔试题解析 1.详细题目例如以下 下列说法正确的有() A. class中的constructor不可省略 B. constructor必须与class同名,但方法不能与c ...

  5. 一道笔试题来理顺Java中的值传递和引用传递

      题目如下: private static void change(StringBuffer str11, StringBuffer str12) { str12 = str11; str11 = ...

  6. 一道面试题:C++相比C#或者java的优势到底在哪里

    被问到了这样一道面试题,当时就懵了,内心一直觉得C++肯定在很多方面要比C#或者java要牛b的. 但是真的不知道怎么回答. 问题是:你以前一直做得是.NET相关项目,现在为什么找C++开发相关工作呢 ...

  7. 一道非常棘手的 Java 面试题:i++ 是线程安全的吗

    转载自  一道非常棘手的 Java 面试题:i++ 是线程安全的吗 i++ 是线程安全的吗? 相信很多中高级的 Java 面试者都遇到过这个问题,很多对这个不是很清楚的肯定是一脸蒙逼.内心肯定还在质疑 ...

  8. 一道面试题引发的对 Java 内存模型的一点疑问

    一道面试题引发的对Java内存模型的一点疑问 问题描述 如上图所示程序,按道理,子线程会通过 num++ 操作破坏 while 循环的条件,从而终止循环,执行最后的输出操作.但在我的多次运行中,偶尔会 ...

  9. 一道面试题:按照其描述要求用java语言实现快速排序

    回来想了想,写出了如下的程序: /** * 一道面试题,按照其描述要求进行快速排序(英文的,希望理解是对的..) * 要求:和一般的快速排序算法不同的是,它不是依次交换pivot和左右元素节点(交换2 ...

随机推荐

  1. AFNetworking (3.1.0) 源码解析 <一>

    首先说一下AFNetworking的github地址:GitHub - AFNetworking/AFNetworking: A delightful networking framework for ...

  2. Mysql事务及行级锁的理解

    在最近的开发中,碰到一个需求签到,每个用户每天只能签到一次,那么怎么去判断某个用户当天是否签到呢?因为当属表设计的时候,每个用户签到一次,即向表中插入一条记录,根据记录的数量和时间来判断用户当天是否签 ...

  3. Sorting File Contents and Output with sort

     Sorting File Contents and Output with sort   Another very useful command to use on text file is  so ...

  4. 在公网上布署Web Api的时候,不能调用,返回404

    在internet上布署web API做的站点时,发现不能调用web api的任何action, 返回404. 经过很多的努力,也找不到原因,环境是win server 2008, IIS 75. n ...

  5. ManagedPipelineHandler IIS

    IIS上部署MVC网站,打开后500错误:处理程序“ExtensionlessUrlHandler-Integrated-4.0”在其模块列表中有一个错误模块“ManagedPipelineHandl ...

  6. java socket报文通信(三)java对象和xml格式文件的相互转换

    前两节讲了socket服务端,客户端的建立以及报文的封装.今天就来讲一下java对象和xml格式文件的相互转换. 上一节中我们列举了一个报文格式,其实我们可以理解为其实就是一个字符串.但是我们不可能每 ...

  7. E10后,导致VS2010调试时报错“未能将脚本调试器附加到计算机..."

    以管理员身份打开CMD,运行:regsvr32.exe "%ProgramFiles(x86)%\Common Files\Microsoft Shared\VS7Debug\msdbg2. ...

  8. 关于——NSThread

    创建.启动线程 NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil] ...

  9. C# 导出word文档及批量导出word文档(3)

    在初始化WordHelper时,要获取模板的相对路径.获取文档的相对路径多个地方要用到,比如批量导出时要先保存文件到指定路径下,再压缩打包下载,所以专门写了个关于获取文档的相对路径的类. #regio ...

  10. js中apply和call的用法 以及apply的妙用 (来自网络)

    apply:方法能劫持另外一个对象的方法,继承另外一个对象的属性. Function.apply(obj,args)方法能接收两个参数obj:这个对象将代替Function类里this对象args:数 ...