1. 程式人生 > >Java計算二進位制數中1的個數

Java計算二進位制數中1的個數

前言

逐位法

查表法

Brian Kernighan法

分治法

Hamming Weight法

Hamming Weight優化法

Hamming Weight乘法優化法

MIT HAKMEM 169演算法

江峰求一演算法

分治法總結

效率測試

後記

前言

        昨天學習全組合時,突然涉及到如何計算二進位制中1的問題,我就直接使用的Integer.bitCount的方法進行計算,但開啟原始碼後,突然忘記它的原理了,因此我就在網上搜索了相關資料,但發現它們大多數都是C或者C++實現,就算是Java實現的也只是隻言片語,不成系統,所有我就有了總結了他們的想法。當時天真的認為這些演算法可能就涉及點位操作,隨著深入學習後,有些演算法充分利用了數論的相關理論,數學真的無所不在呀!!!

逐位法

        老慣例,首先說說最簡單的演算法,核心思想就是逐位去判斷是否為1,然後對其數量進行統計。程式碼如下:

public static int bitCount(int n){
    int count = 0;
    while(n != 0){
        count += n & 1;
        n >>>= 1;
    }
    return count;
}

        這個演算法需要注意的就是無符號右移運算子的問題,如果使用的是右移運算子,處理負數時,會出現死迴圈。雖然C或者C++沒有無符號右移這個操作符,但它們可以直接使用unsigned int實現此操作。我們在單元測試的時候一定注意正負數都要測試到,否則很容易出現問題。

查表法

        在逐位法中,我們是逐位進行計算,效率不是很高,如果我們能夠一次計算多位中1的數量,那效率將成倍提升,如何實現呢?拋開二進位制轉換為十進位制時利用了1這個資訊,一般而言二進位制的值和1的數量並沒有直接關係,因此難點就在於如何建立二進位制值和1的數量之間的關係。在數學中,想要建立兩個不相干變數a和b的關係,一般我們都會找一個和兩者都有關係的變數c,來間接確定a和b的關係。首先二進位制的值和十進位制值有關係,但是1的數量好像找不到它和其他的關係,因此該問題轉換為十進位制值和1的數量關係,即十進位制值如何對映1的數量。在我們學過的資料結構中,涉及到對映的有Map(鍵值對映)和Array(下標和值對映)。具體選哪一個呢,其實針對這個問題,兩者都可以,但是陣列查詢效率以及空間利用率都大於Map,因此我們選擇使用陣列來對映十進位制值和1數量的關係,即二進位制值和1數量的關係,其中二進位制的值作為陣列下標,1的數量作為陣列中的值,這種處理方式也叫查表法。

        如果大家對Integer原始碼比較熟悉的話,裡面就有一些查表法的操作,如DigitOnes和DigitTens。查表法是用空間換時間的典型代表,陣列開得太大會佔用記憶體空間,開的太小對效率的提升又太小,因此需要根據程式的實際需求來制定合適的方案。為了兼顧空間和效率,我選擇開闢長度為256的陣列來儲存8位二進位制中1的數量值。Talk is cheap,程式碼如下:

private static final int[] oneNumberTable ={
    0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
    3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,4,5,5,6,5,6,6,7,5,6,6,7,6,7,7,8
};
public static int bitCount6(int n){
    int count = 0;
    for (int i = 0; i < 32; i += 8){
        count += oneNumberTable[(n >> i) & 0xFF];
    }
    return count;
}

        雖然這方法看起來笨笨的,但如果使用得當,就能極大提升程式執行效率。

Brian Kernighan法

        在逐位法中,我們是逐位判斷1,比如二進位制為0b1000,就需要判斷三次0一次1,如何一步到位直接判斷第四位的1呢?我們知道2^{n} = \sum_{m = 0}^{n - 1}2^{m} + 1,這個1剛好就是可以當做n位中1的數量,再利用x & (x - 1)將n位及以下的位全置0,這樣我們每次的操作都能獲取含1的位,提升了逐位法的效率。具體程式碼如下:

public static int bitCount(int n){
    int count = 0;
    while(n != 0){
        n = n & (n - 1);
        count++;
    }
    return count;
}

        如果n & (n - 1) = 0,則說明此時的n值並無含1的位,因此我們也可以用n & (n - 1) == 0來判斷一個數是否是2的冪次方。

分治法

        前面的三種方法雖然看起來不同,但都是逐位法的變種,其思想都是逐個計算。對於歸併排序肯定大家都不陌生,它就是將待排陣列分為多塊,然後排序多塊,最後進行合併操作。那麼對於32位的二進位制,我們能不能也把它分成多塊,然後計算每一塊他們所含1的數量,最後將多塊的1數量累加起來,進而得到二進位制數中1的個數呢?要實現這種操作,就有二個問題需要解決,一是如何計算每一塊中所含1的數量,二是如何合併已被計算的塊。我在Brian Kernighan法提到過2^{n} = \sum_{m = 0}^{n - 1}2^{m} + 1這個式子,如果我們要計算第n+1位二進位制值的十進位制值,其結果為a * 2^{n} = a * \sum_{m = 0}^{n - 1}2^{m} + a,其中a就是第n位二進位制值,即0或1。對於0b1101,我們記其四位的二進位制值分別為a、b、c、d。

        0b1101中第四位的二進位制值:a = a * 2^{3} - a * 2^{2} - a * 2^{1} - a * 2^{0}

        0b1101中第三位的二進位制值:b = b * 2^{2} - b * 2^{1} - b * 2^{0}

        0b1101中第二位的二進位制值:c = c * 2^{1} - c * 2^{0}

        0b1101中第一位的二進位制值:d = d * 2^{0}

        則:a + b + c + d = (a*2^{3}+b*2^{2}+c*2^{1}+d*2^{0})-(a*2^{2}+b*2^{1}+c*2^{0})-(a*2^{1}+b*2^{0} )-a*2^{0}

               a + b + c + d =  0b1101 - 0b0110 - 0b0011 - 0b0001。

        轉換為位操作,即:a + b + c + d =  0b1101 -  ((0b1101 >>> 1) & 0b0111)

                                                                              -  ((0b1101 >>> 2) & 0b0011)

                                                                              -  ((0b1101 >>> 3) & 0b0001)。

        其中紅體數字是掩碼,避免高數位對結果產生影響。掩碼的選取必須和塊數量對應,如果資料是32位,塊長度為4,則掩碼0b0111也必須變為擴大八倍,即0xFFFFFFFF

        有了上面的理論基礎,則對於二位的情況,a + b = x - ((x >>> 1) & 0b01)

                                               對於三位的情況,a + b = x - ((x >>> 1)  & 0b011) - ((x >>> 2) & 0b001)

以此類推,我們可以僅僅通過減、移位和掩碼操作,就能計算任意位數塊中所含1的數量。這也就解決了第一個問題。

        前面我們已經計算了指定位數塊中1的數量了,如何累加相鄰兩位數塊,使其合併成兩倍的位數塊呢?其實只需要右移相應位數,再相加,利用兩倍的位數掩碼消除髒資料即可。比如對於二位數塊0b1001,(0b1001 + (0b1001 >>> 2) ) & 0b1111 = 0b0011,其結果就是兩位數塊累加的結果。此時,第二個問題也被解決了。

        綜合前面所述,我們完全可以使用分治法來計算二進位制數中1的個數,其中還包含一些優化操作,也將在下面的具體演算法中得以說明。

Hamming Weight法

        該方法的初始位數塊長度為2,然後分治為4位數塊,接著8位數塊,以此類推,最終擴大到32位數塊,其結果就是1的數量。如果大家完全理解我前面的分治法原理,其實可以直接寫出相應程式碼。在學習該演算法時,我還是看看了關於它的維基百科,放個傳送門:Hamming weight,解釋還是比較詳細的。這些東西說起來比較抽象,還是程式碼中理解吧。

public static int bitCount(int n){
    n = (n & 0x55555555) + ((n >>> 1) & 0x55555555);
    n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
    n = (n & 0x0F0F0F0F) + ((n >>> 4) & 0x0F0F0F0F);
    n = (n & 0x00FF00FF) + ((n >>> 8) & 0x00FF00FF);
    n = (n & 0x0000FFFF) + ((n >>> 16) & 0x0000FFFF);
    return n;
}

上面的程式碼真的美得讓人不知所措。我把上面幾個十六進位制的值轉換成二進位制字串,如下所示

0x55555555 = 01010101010101010101010101010101
0x33333333 = 00110011001100110011001100110011
0x0F0F0F0F = 00001111000011110000111100001111
0x00FF00FF = 00000000111111110000000011111111
0x0000FFFF = 00000000000000001111111111111111

        看到這些數值的二進位制值,就比較清晰了,因為它初始位數塊長度為2,所以第一步就是解決a + b的問題,這也是第一行程式碼的作用,第二行程式碼分治為4位,第三行程式碼分治為8位,一直分治到32位即可。理解了分治法思想,上面的程式碼看起來是非常之簡單的。

Hamming Weight優化法

        Hamming Weight法的第一步操作,雖然是實現a + b,但我在介紹分治法時是使用的減法,實際操作能節約一次與操作,其實後續還有很多地方需要優化,這裡我們先給出優化後的程式碼,再詳細說明優化的思路。

public static int bitCount(int n){
    n = n - ((n >>> 1) & 0x55555555);
    n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
    n = (n + (n >>> 4)) & 0x0F0F0F0F;
    n = n + (n >>> 8);
    n = n + (n >>> 16);
    return n & 0x3F;
}

        第一步優化(少了一次與操作)我們在前面已經解釋了,此處就不在贅言。第二步沒變化,因為現在的n = 8a + 4b + 2c + d,單純的減操作不能把n轉換為a + b + c + d,因此,這裡就照舊。第三步,直接把兩者相加,然後掩碼操作,我們知道四位裡面最多有4個1,記作0100,就算兩個塊裡面都是4個1,加起來也就是1000,結果沒有溢位塊限制,所以就可以直接加,但前面兩步相加後的結果超出了塊範圍,比如11 + 11 = 110,就超過了二位的限制範圍,所以必須分開加,再將結果放到分治後的塊裡面,這樣又少了一次與操作。第四步,結果沒有進行掩碼處理,我們知道掩碼處理是防止塊裡面有“髒”資料,因為32位整型資料最多就32個1,即最大值為00100000,所以絕對要保證後六位的絕對正確。打個比方,相鄰的四個位桶裡面分別裝著0100和0010的資料,相加以後為0110,此時兩個桶共同的位數為00100110,如果此時不用掩碼0x0F0F0F0F處理,那麼八個位桶的數值就是00100110= 38,這個資料弄髒了第六位,因此需要用掩碼剔除它,使計算結果為0110= 6。第四步是八位數塊,其最大值就是00001000 = 8,就算兩個一樣數值的塊相加,其結果是00001000 00010000,分治後的16位數塊也能保證最後六位資料的絕對正確,所以就無需利用掩碼剔除髒資料,節約了兩次與操作。第五步也如第四步一般,又節約了兩次與操作。最後再用六位掩碼獲取最後六位的數值即可。因此該演算法相比上面的演算法,少了5次與操作。Integer的bitCount方法就是採用的該演算法。

Hamming Weight乘法優化法

        小學我們都學過乘法,也知道乘法交換律,即c * (a + b) = a * c + b * c,這裡面的加號就隱含了能計算c各位之和的可能性,比如12 * (10 + 1) = 132,其中第二位的3就是12的個位和十位之和;121 * (100 + 10 + 1) = 13431,其中第三位的3就是121的個位、十位和百位之和,此時我們大膽推測,假設一個數字x有n位,則x * \begin{matrix} \underbrace{ 111\cdots111 } \\ n\end{matrix}的結果的第n位等於數字x所有位數值的累加和,但是這個推測有一些不足,比如198 * 111 = 21978,第三位的9 != 1 + 9 + 8,這是因為1 + 9 + 8 = 18 > 9,需要進位,且1 + 9 = 10 > 9也需要進位,它的進位值1也加到位數累加值上,這兩種情況都會“汙染”累加值,因此上面的推測若想合理,則還需要加一個限制條件:所有位數值的累加和不能大於9,即不能出現進位情況。

        既然十進位制乘法能通過乘法來累加所有位數值,那麼我們也能將其推廣到二進位制。首先保證所有位數累加值不能出現進位,32位的累加值最大為32 = 0b100000,因此我們必須用大於或等於6位的的位數塊來儲存所有位的累加值,為了計算的方便,位數塊的大小M最好是2的冪次方,這樣能保證計算的累加值在32位的最左邊,然後通過無符號右移(32 - M)位就可以直接獲取累加值。假設我們使用八位的位數塊來計算所有位的累加值,則32位資料可以分為4個位數塊,四個位數塊的十六進位制的值分別記作0x0a、0x0b、0x0c和0x0d,則相應的乘法操作如下所示:

                                                                     0a  0b  0c  0d

                                                                     01  01  01  01

                                                                                                             

                                                                     0a  0b  0c  0d

                                                               0a  0b  0c  0d

                                                         0a  0b  0c  0d

                                                   0a  0b  0c  0d

        紅色部分是溢位的資料,藍色部分相加正是四個位數塊的累加值,其值通過無符號右移32 - 8 = 24位即可得到。該演算法的詳細思路也可以參考維基百科的Hamming weight。具體程式碼如下:

public static int bitCount(int n){
    n = n - ((n >>> 1) & 0x55555555);
    n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
    n = (n + (n >>> 4)) & 0x0F0F0F0F;
    return (n * 0x01010101) >>> 24; 
}

        該方法的優化需要CPU中基本運算部件乘法器的給力。如果CPU執行乘法操作指令比較慢的話,這樣優化可能會適得其反。但一般來說不會這樣,比如說AMD在兩個時鐘週期裡就可以完成乘法運算。至於Java的bitCount為什麼沒有選這種,可能也有這方面的考慮吧。

MIT HAKMEM 169演算法

        在前面累加所有位數值時,我們採用了直接累加法和利用乘法交換律的性質來實現累加,那麼還有沒有其他方法來實現數值累加呢?我們對於餘數都不陌生,5 % 9 = 5, 12 % 9 = 3, 123 % 9 = 6,是不是發現了點啥了,此時我們大膽假設,任意十進位制數值x對9求餘都等於x各位數值之和。對不對呢?很可惜不對,因為789 % 9 = 6,但是這個6 = (7 + 8 + 9) % 6,突然感覺柳暗花明又一村,此時我們將假設改為:任意十進位制數值x對9求餘都等於x各位數值之和對9求餘。如果x各位數值之和小於9,則初始的解設也對。此時我們幾乎找不到反例,但這並不代表假設一定成立,要想假設成立仍需數學來證明其正確性。其中9 = 10 - 1,這裡的10就是十進位制的權,將假設轉換為數學模型,其核心就是證明(a + m^{p}b) ≡ (a + b) mod (m - 1),其中(a + b) < m - 1,m > 2,m為進位制的權,a、b、m、p均為正整數。

        在證明之前,我們要用到數論中的兩個餘數定理,如下所示:

(a + b) % c = ((a % c) + (b % c)) % c
(a * b) % c = ((a % c) * b) % c

         證明:假設(a + m^{p}b) ≡ (a + b) mod (m - 1)成立,記 r = m - 1,則(a + (r+1)^{p}b) ≡ (a + b) mod r成立。

                    此時 (a + (r+1)^{p}b) % r = ((a % r) + ((r+1)^{p}b) % r) % r

                    \because a + b < m - 1 \therefore a < r , b < r

                   \therefore 上式 = (a + ((r+1)^{p}b) % r) % r

                  又 \because m > 2,則 r = m - 1 > 1

                  \therefore  (r + 1)^{p} % r = (((r + 1) % r ) * (r + 1)^{p - 1})  % r = (r + 1) ^{p - 1} % r = 1

                  \therefore  ((r+1)^{p}b) % r = (((r + 1)^{p} % r) * b ) % r = b % r = b

                 \therefore  (a + ((r+1)^{p}b) % r) % r = (a + b) % r = a + b

                 \therefore  (a + m^{p}b) ≡ (a + b) mod (m - 1),假設成立,證畢。

        利用上面的結論(這結論是我無意間發現的,我也不知道數論書中的餘數定理是否包含該結論,不管有沒有,這結論都為後續的演算法研究奠定了基礎。),我們也就能輕鬆證明(a{_0} + \sum_{m = 1}^{n}a_{m} * p^{m}) ≡ (\sum_{m = 0}^{n}a_{m}) mod (m - 1),其中(a + b) < m - 1,m > 2,m為進位制的權,a{_0}a_{1}、...、a_{m}、b、m、p均為正整數。因此我們推廣前面的假設為,任意n進位制的數值x對(n - 1)求餘都等於x各位數值之和對(n - 1)求餘。

        32位數值中各位值之和最大為32,因此,我們可以選擇大於等於64(大於32的最小2的冪次方)的2的冪次方作為進位制的權,根據前面的結論來實現計算二進位制數中1的個數的操作。

        假設選取64位作為進位制的權,則我們只需分治到6位數塊,就可對(64 - 1) = 63求餘來計算二進位制數中1的個數。程式碼如下:

public static int bitCount(int n){
    n = n - (((n >>> 1) & 0xdb6db6db) + ((n >>> 2) & 0x49249249));
    n = (n + (n >>> 3)) & 0xc71c71c7;
    return n < 0 ? ((n >>> 30) + ((n << 2) >>> 2) % 63) : n % 63;
}

       程式碼中的三個掩碼轉換為二進位制形式,如下所示:

0xdb6db6db = 11011011011011011011011011011011
0x49249249 = 01001001001001001001001001001001
0xc71c71c7 = 11000111000111000111000111000111

        第一行程式碼就是分治法中a + b + c的操作,第二行就是把三位數塊分治成六位數塊,因為負數求餘也等於負數,且32 % 6 = 2,因此對於最終結果為負數的情況,先將前兩位中1的數量直接通過移位計算出來,再把後面的30位中1的數量通過求餘計算出來,兩者相加即可得32位負數中1的數量。該演算法也就是傳說中的MIT HAKMEM 169演算法。

江峰求一演算法

        既然學透了MIT HAKMEM 169演算法,那麼我也來按照它的思路寫個自己的版本,就叫做江峰求一演算法,也證明了MIT HAKMEM 169演算法並不是該思路的唯一演算法。這裡我選取256作為進位制權數,分治到16位數塊,再對255求餘。具體程式碼如下:

public static int bitCount(int n){
    n = n - ((n >>> 1) & 0x55555555); 
    n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); 
    n = (n + (n >>> 4)) & 0x0f0f0f0f; 
    n = (n + (n >>> 8)) & 0x00FF00FF;  
    return n % 255;
}

        這上面的幾個十六進位制值在原始Hamming Weight法裡已經介紹過了,這裡就不贅述了。前面四步來自於Hamming Weight優化法,主要是將位數塊分治到16位數塊。因為32 % 16 = 0,且數量32只儲存在後六位中,所以不會出現負數情況,因此可以直接對(256 - 1) = 255求餘,進而得到二進位制數中1的個數。

        我在學習MIT HAKMEM 169演算法時,網上很多博文都只是誇它一番,然後給出維基百科的程式碼,原理什麼的並無涉及,連最基本的為什麼要對63求餘都沒談到,對一個自己一無所知的東西推崇備至,真的不是一個程式設計師應該做的事,所以為了調侃這行為,我就把該方法修改了一番,並用自己的名字進行命名。不管怎樣,都希望自己能永遠保持對未知的好奇,不人云亦云。

分治法總結

        根據前面的分析,分治法可以根據優化方式的不同,分化出非常多不同版本。接下來,我就簡要總結一下這些優化方式的適用條件。

  1. 按照分治法的理論推導,初始位數塊的長度範圍為2~32,理論上我們可以設計31種不同版本的分治法,但是隨著長度的逐漸提高,構建a + b + c + ... + n所需要運算元也將成倍提高,具體運算元為1次減法、2 * n次與和2 * n次無符號右移。因此初始化位數塊的長度不能太大,一般是選擇2,又因為32只需要6位儲存,所以也可以選3。
  2. 利用乘法交換律能累加所有位數值,32只需要6位儲存且為了保證計算的累加值在32位的最左邊,一般初始塊長度為2的冪次方。如果分治後位數塊的長度大於等於6,就可以利用乘法加移位來計算二進位制數中1的個數。
  3. 利用餘數性質也能累加所有位數值,我們只要保證分治後位數塊長度大於6,計算結果在指定區間不溢位,即可使用餘數定理進行優化。

效率測試

        因為逐位法和Brian Kernighan法效率較低,因此測試資料為-1000000到1000000,其餘都是Integer.MIN_VALUE到Integer.MAX_VALUE。測試結果如下:

$ java -version
java version "1.8.0_111"  
Java(TM) SE Runtime Environment (build 1.8.0_111-b14)  
Java HotSpot(TM) 64-Bit Server VM (build 25.111-b14, mixed mode) 
$ The data range is from -1000000 to 1000000 .)
逐位法:42ms
Brian Kernighan法:27ms
$ The data range is from -2147483648 to 2147483647.)
查表法:6ms
Hamming Weight法:5ms
Hamming Weight優化法:4ms
Hamming Weight乘法優化法:4ms
MIT HAKMEM 169演算法:1686ms
江峰求一演算法:4ms

        執行結果和我預期差不多,分治法的效率非常之高,查表法也是隱藏的大黑馬,MIT HAKMEM 169演算法在負數處理上稍遜一籌,但處理正數的效率,和其它幾個分治法幾乎沒區別,效率槓槓的。

後記

        總算寫完了,剛開始學這部分時,還覺得好麻煩,怎麼這麼多稀奇古怪的操作,最後學完了,覺得也還好,只要知道演算法原理,程式碼的編寫也就自然而然了。不過能獨立想到這些方法其實還是很難的,必須要對數學的累加求和有足夠的認識,這次的演算法學習也讓我再次體會到數學的強大!

        差點忘了,這些方法的總結主要參考了這篇博文,放個傳送門:求二進位制數中1的個數 (上)求二進位制數中1的個數 (下)。這兩篇2010年的部落格,現在看來依舊具有極高的參考價值,值得我去學習,希望我也能如他般優秀。