一道面試題與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 }

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM