位向量/位元影像是一個很有用的資料結構,在充分利用小空間儲存大量資料方面非常具有優勢,Linux核心中很多地方都是用了位元影像。同時,它不但基礎,而且用到了很多程式設計語言的知識,以及對細節的把握,常常作為面試題出現。這裡將要介紹它的實現、操作、應用。
與位元影像(bitmap)比,我更傾向於用位向量(bit vector),前者比較容易與圖形學裡的名詞混淆,其實提到位元影像,多指的是“是使用像素陣列來表示的映像”(維基百科),為了避免這一點,下文中使用位向量。先來看看產生位向量的需求:
一般地,對於多個對象和一個性質,這些對象可能滿足(true)也可能不滿足(false)這條性質。那麼,為了表示所有對象對這個性質的滿足情況,最容易想到的方式是分配一個int型變數(這裡不討論布爾型,C++沒有對布爾型佔用空間有明確規定;本文主要討論C)作為標誌,用1表示滿足條件,用0表示不滿足條件。同時為了方便查詢,把這些對象的標誌整合成一個數組。顯然,使用int來表示是0還是1有些太浪費空間了,即使把int改為char,浪費的情況也只是減輕了一部分,仍有很大的空間被浪費。考慮到電腦中最小的資料單位是非0即1的二進位位,對於一個對象,使用一個二進位位就足夠了。很多語言都具有位元運算,利用位元運算是可以完成本段開始處提出的要求的。當然,因為不能用一個變數名直接表示一個位,那麼可以將多個位組合成一個基礎資料型別 (Elementary Data Type),通過對這個基礎資料型別 (Elementary Data Type)進行操作,達到使用位的方法。同時,為了方便,延續使用int數組的做法,把這些由位組合成的基礎資料型別 (Elementary Data Type)也組成數組。
經過這樣分析,位向量的實現方法大體是:多個位組成一個基礎資料型別 (Elementary Data Type),基礎資料型別 (Elementary Data Type)組合成數組。根據這個思路就可以寫出位向量的表示了。在閱讀下面代碼前,建議讀者嘗試自己獨立完成,這是一些提示:簡單起見,使用int作為位組成的基礎資料型別 (Elementary Data Type),且int使用32位表示;int數組中元素的個數如何計算?
#define N 10000000 //number of elements#define BITPERWORD 32 //bits of int depends on machineint a[(N-1)/BITPERWORD + 1]; //allocate space for bitmap //《編程珠璣》原書中是N/BITPERWORD + 1//如果N恰為BITPERWORD的倍數,那麼又要浪費一個int的空間了//N取0時,仍會浪費一個int的空間//N非0非BITPERWORD的倍數時,最多是最後一個int不完全利用而已
位向量的表示
寫完了表示,就需要為這個資料結構增加對應的操作了。對於每個位的操作,有三種:設定為0、設定為1、讀取當前值。根據上文位向量的表示,實現這三種操作。同樣建議讀者先嘗試獨立完成。以下代碼參考自《編程珠璣》習題1.2。
#define SHIFT 5 //32 = 2^5#define MASK 0x1F // vaule 11111 in binary//i代表需要進行操作的第i個對象//i>>SHIFT相當於i/32,將i定位到具體是哪個int中,即a[i>>SHIFT]//i&MASK相當於i%32 只保留i的0至4位,即i在int中的第幾位,然後把1左移這麼多位//將a[i>>SHIFT]和(1<<(i&MASK))視需要進行操作//同1做或運算或即為位設定//同1取反再做與運算即為位清除//同1做與,結果為0則原位為0,為1則原位為1void set(int i) { a[i>>SHIFT] |= (1<<(i&MASK));}void clr(int i) { a[i>>SHIFT] &= ~(1<<(i&MASK));}void test(int i) { return a[i>>SHIFT] & (1<<(i&MASK));}
位向量操作
使用位向量前不要忘記對所有位進行初始化:
for(i=0;i<N;i++) clr(i);
當然,你也可以用這個或許更快的初始化方式:
int temp = N/BITPERWORD+1;for (i=0; i< temp;i++) a[i] = 0;
補充討論:
Q:為什麼要用看上去並不那麼直接的位元運算而不是i/32和i%32?
A:為了速度而不是易讀性。你所寫下的最初版本如果使用的是/和%,當然沒有錯;當你著手於提高效率的時候,把它們改成移位元運算就勢在必行了。雖然編譯器可能會對/和%在除數為2的冪時進行最佳化,寫成移位元運算,你當然可以自己來完成這件事來保證確實最佳化了。
Q:是否可以將存放位向量的數組作為參數?
A:當然可以,上文代碼只是為了敘述方便。
Q:是否可以把這三個函數寫成宏?
A:其實我曾經被面試過一道題,就是要求寫一個宏,完成上面clr(i)的任務。寫成宏當然沒問題,注意加好括弧就行,具體的用法讓調用的人去擔心吧(笑)。
Q:位向量和位欄位/位域有什麼區別和聯絡?可以用位欄位來實現位向量嗎?
A:C語言允許將結構的整數成員存入比編譯器通常允許的更小的空間裡。然而它有三個風險:不同電腦中對齊限制不同;位欄位寬度限制不同;將位封裝成字的位元組順序不同。在位向量裡,雖然也有依賴於具體電腦特性的限制(需要預Crowdsourced Security Testing道int的位元),但是位的封裝是由程式員來控制的,也不必考慮對其限制。移植性略好於將成員聲明為1位整數位欄位的結構。
應用:
1.Linux中分配唯一pid的演算法、記憶體管理的夥伴分配系統等,詳細可以google,關鍵詞:linux+位元影像。
2.一個最多包含n個正整數的檔案,每個數都小於n,其中n=107,並且沒有重複。最多有1MB記憶體可用。要求用最快方式將它們排序並按升序輸出。(《編程珠璣》第一章本文)方法是一次讀入檔案,把出現過的數字對應位置1;讀取完畢後從低位到高位輸出位向量為1的位所代表的數。
3.如果有用時間換空間的必要,可以將尋找1至某個數之間所有質數的埃氏篩法用位向量實現。原始版本的代碼見於《編程珠璣(續)》習題1.2,演算法簡介:一個n位元的數組,對應1至n-1,初值均為真。從2開始,每發現一個質數,就把這個數組中所有這個數的倍數設為假。下一個質數就是數組中下一個為真的位元。迴圈至所有數都被遍曆,即可得到該範圍內所有的質數。
引申和擴充:
1.(習題1.6)每個整數最多出現10次而不是1次;如果記憶體限制不變,又應該怎麼做?提示:多趟排序
(更一般的擴充:用幾個位表示一個對象的多個性質,也即一個對象不僅僅只佔用一位而是多位,但每個對象佔用的位元是相同的。重寫位元影像。)
2.(習題1.9)對於稀疏的位向量,初始化很浪費時間。一般地,對於一個向量(不僅限於位向量),怎樣利用輔助資料結構,使得第一次訪問位向量的某一位時才將其初始化為0?
解法:
對於向量int data[N],分配輔助資料結構int from[N]和to[N],以及一個整數top,初始化top=0。
插入data[i]時,from[i]填入top,to[top] = i,top++;
這樣維持了一個性質:top代表下一個被插入的值的次序;from[i]代表了data[i]是第幾個被插入的;to[]中小於top的元素to[j]儲存了第j個被插入對應在data[]的下標。
若data[i]未初始化,則from[i]也未初始化,此時即使from[i]中未初始化的垃圾值<top,to[from[i]]!= i,仍能判斷出data[i]未初始化。
“珠璣之櫝”系列簡介與索引