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

有个老的手机短信程序,由于当时的手机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. (转)Maven实战(七)settings.xml相关配置

    一.简介 settings.xml对于maven来说相当于全局性的配置,用于所有的项目,当Maven运行过程中的各种配置,例如pom.xml,不想绑定到一个固定的project或者要分配给用户时,我们 ...

  2. JavaScript新手学习笔记1——数组

    今天,我复习了一下JavaScript的数组相关的知识,总结一下数组的API: 总共有11个API:按照学习的先后顺序来吧,分别是: ① toString()  语法:arr.toString(); ...

  3. Android 物理按键

    import android.app.Activity; import android.os.Bundle; import android.util.Log; import android.view. ...

  4. JDK动态代理实现简单AOP--转

    JDK 动态代理是 java 反射的一个重要特性.它在某种方面为 java 提供了动态性的特性,给应用带来了无限的空间.大名鼎鼎的 Hessian . Spring AOP 基于动态代理实现.本文将简 ...

  5. Android(java)学习笔记255:JNI之JNI概念

    1. JNI是什么? java native interface (java本机接口) 比如方法声明: public final native Class<?>  getClass(): ...

  6. Android(java)学习笔记250:ContentProvider使用之获得系统联系人信息02(掌握)

    1.重要: 系统删除一个联系人,默认情况下并不是把这个联系人直接删除掉了,只是做了一个标记,标记为被删除. 2.前面一讲说过了如何获取系统联系人信息(通过ContentProvider),获取联系人信 ...

  7. windows下配置两个或多个Tomcat启动的方法

    确保window的环境变量中找不到CATALINA_HOME和CATALINA_BASE 修改server.xml,用解压版的tomcat,不要用安装版的. 1.修改http访问端口 conf下的se ...

  8. 30款jQuery常用网页焦点图banner图片切换 下载 (转)

    1.jquery 图片滚动特效制作 slide 图片类似窗帘式图片滚动 查看演示 2.jquery幻灯片插件带滚动条的圆形立体图片旋转滚动 查看演示 3.jQuery图片层叠旋转类似洗牌翻转图片幻灯片 ...

  9. java图片缩放

    package com.rubekid.springmvc.utils; import java.awt.AlphaComposite; import java.awt.Graphics2D; imp ...

  10. Java HttpClient

    public class WebClient { public static final String POST_TYPE_JSON = "json"; public static ...