zoukankan      html  css  js  c++  java
  • 一道面试题与Java位操作 和 BitSet 库的使用

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

    有个老的手机短信程序,由于当时的手机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 }
  • 相关阅读:
    【Splay树】
    毕业设计每日总结2020/2/8
    毕业设计每日总结2020/2/7
    毕业设计每日总结2020/2/6
    毕业设计每日总结2020/2/5
    毕业设计每日总结2020/2/4
    毕业设计每日总结2020/2/3
    毕业设计每日总结2020/2/2
    毕业设计每日总结2020/2/1
    毕业设计第一周计划
  • 原文地址:https://www.cnblogs.com/yellowb/p/3647442.html
Copyright © 2011-2022 走看看