leetcode實戰—位運算(兩數相除、只出現一次的數字、重複的DNA序列等)

前言

對0和1的操做是計算機最底層的操做,全部的程序無論用什麼語言寫的,都要轉化成機器可以讀懂的語言也就是二進制進行基本的運算,而這些基本的運算就是咱們今天要講到的位運算。由於硬件的支持,計算機在進行二進制計算的時候要比普通的十進制計算快的多,把普通的運算用位運算的方法實現可以極大提升程序性能,是一個重要的技能。html

簡介

在計算機中有超過七種位運算操做,在這裏就以Java爲例來說解Java中須要用到的七種位運算符。java

符號 描述 規則 舉例
& 按位與 左右兩側同時爲1獲得1,不然爲0 1&1=1
1&0=0
| 按位或 左右兩側只要一個是1就返回1,都是0才返回0 1|0=1
0|0=0
^ 按位異或 左右兩側相同時爲0,不一樣爲1 1^1=0
0^0=0
1^0=1
~ 按位取反 1變成0,0變成1 ~1=0
~0=1
<< 有符號左移 保持最高位的符號,其餘的位向左移動,右側用0補齊 1<<2=4
>> 有符號右移 保持最高位符號,其餘位向右移動,除最高位其餘用0補齊 5>>1=2
-10>>1=-5
>>> 無符號右移 全部位都向左移動,高位用0補齊 5>>>1=2
-10>>>1=2147483643

既然說到了位運算就不得不提一下在Java中int類型數字的表示,Java的int一共是32位,其中第32位符號標誌位,若是是0就表明是正數,1表明是負數,好比1的表示方法是0000 0000 0000 0000 0000 0000 0000 0001,最大隻能是前31位都置1的結果,也就是$2^{31}-1$;對於負數的表示須要注意,並非直接把對應的正數最高位0變成1,而是所有位取反而後加一,這樣作的目的是爲了讓兩個二進制數字直接相加的和爲0,好比-1的表示方法是1111 1111 1111 1111 1111 1111 1111 1111,這樣兩個數字二進制相加獲得的最終結果是0(最高位溢出捨棄掉)。負數的最小值是$2^{31}$,絕對值比正數最大值大一位,由於這個負數比較特殊,二進制表示爲1000 0000 0000 0000 0000 0000 0000 0000,在取反加一以後仍是它自己。算法

這裏順帶介紹一下位操做的小技巧,其中的原理能夠本身揣摩數組

序號 公式 解釋(n0開始)
1 num |= 1<<n numn位設置爲1
2 num &= ~(1<<n) numn位設置爲0
3 num ^= 1<<n numn位取反
4 num & (1<<n) == (1<<n) 檢查numn位是否爲1
5 num = num & (num-1) num最低位的10
6 num & -num 得到num最低位的1
7 num &= ~((1<<n+1)-1) numn位右側置0
8 num &= (1<<n)-1 numn位左側置0

更多位操做相關技巧能夠看這裏這裏ide


這裏捎帶着說一下浮點類型float在Java中的表示,若是已經比較清楚的同窗能夠直接跳過這個部分。浮點數總共4個字節,表示能夠用下面的公式:函數

$$ (-1)^s \times m \times 2^{e-127} $$性能

其中學習

  • s,第31位,若是是1表明是負數,0表明正數
  • e,第30~23位,是指數位,表示一個無符號的整數,最大是255
  • m,第22~0位,尾數,包含了隱藏的1,表示小數位,須要去掉後面的無效的0

好比在浮點數0.15625在二進制中的表示方法是0011 1110 0010 0000 0000 0000 0000 0000(使用Java代碼Integer.toBinaryString(Float.floatToIntBits(0.15625F))能夠獲得浮點數的二進制表示,可是省去了最高位的0),其中測試

  • 第31位0,表示正數
  • 第30~23位,011 1110 0指數位,十進制是124,減去127以後獲得-3
  • 第22~0位,010 0000 0000 0000 0000 0000尾數數,捨去小數後面的0以後獲得01,加上默認1後獲得1.01(二進制表示的小數)
  • 綜上根據公式獲得0.15625的二進制公式爲$(-1)^0\times(1.01)\times2^{-3}=0.00101$,而後把二進制轉成十進制就變成了$2^{-3}+2^{-5}=0.125+0.03125=0.15625$

double類型和float的計算方法同樣,只不過double的指數位有11位,尾數位52位,計算指數的時候須要減去1023。相比於float,double的精度要高不少,若是不在乎空間消耗的狀況下,最好用double。網站


leetcode相關題目

如今基本的計算機的操做咱們知道了,數字的二進制表示咱們也清楚了,那麼如何經過二進制的位運算來完成十進制數字的計算呢?下面來一一揭曉。

371 兩整數之和

Leetcode第371題兩整數之和

不使用運算符+-​​​​​​​,計算兩整數​​​​​ab​​​​​​之和。

示例1:

輸入: a = 1, b = 2
輸出: 3

示例2:

輸入: a = -2, b = 3
輸出: 1

這道題在leetcode的美國網站上面有1900多個dislike,看來你們對這個題目意見很大,不過就趁這個機會回顧一下計算機是怎樣進行加法操做的。

回顧一下咱們小學就開始學習的十進制的加法,好比15+7,最低位5+7獲得12,對10取模獲得2,進位爲1,再高位相加1+0再加上進位1就獲得高位結果2,組合起來就是22。這裏面涉及到了兩個數字,一個是相加獲得的低位,也就是5+7獲得的結果2,第二個是進位1。在二進制的計算中就是要經過位操做來獲得結果的低位和進位,對於不一樣的狀況,用表格來表示一下,兩個數字分別爲ab

a b 低位 進位
0 0 0 0
1 0 1 0
0 1 1 0
1 1 0 1

從上面的表格就能夠發現,低位 = a^b進位 = a & b。這樣的計算可能要持續屢次,回想一下在十進制的計算中,若是進位一直大於0,就得日後面進行計算,在這裏也是同樣,只要進位不是0,咱們就得一直重複計算低位和進位的操做(須要在下一次計算以前要把進位向左移動一位,這樣進位才能和更高位進行運算)。這個時候的ab就是剛纔計算的低位和進位,用簡單的加法迭代的代碼表示:

public int getSum(int a, int b) {
    if (a==0) return b;
    if (b==0) return a;
    int lower;
    int carrier;
    while (true) {
        lower = a^b;    // 計算低位
        carrier = a&b;  // 計算進位
        if (carrier==0) break;
        a = lower;
        b = carrier<<1;
    }
    return lower;
}

29 兩數相除

這是leetcode第19題兩數相除

給定兩個整數,被除數dividend和除數divisor。將兩數相除,要求不使用乘法、除法和mod運算符。

返回被除數dividend除以除數divisor獲得的商。

示例1:

輸入: dividend = 10, divisor = 3
輸出: 3

示例2:

輸入: dividend = 7, divisor = -3
輸出: -2

說明:

  • 被除數和除數均爲 32 位有符號整數。
  • 除數不爲 0。
  • 假設咱們的環境只能存儲 32 位有符號整數,其數值範圍是$[−2^{31},  2^{31} − 1]$。本題中,若是除法結果溢出,則返回$2^{31} − 1$。

這道題其實能夠很容易聯想到咱們常常用到的十進制的除法,這裏有一個例子來講明如何把十進制除法的思想套用在二進制上面。

binary division

圖片是用33除以6,對應二進制100001除以110,有三步:

  1. 將被除數從110開始向左移位右側補0,直到找到最大的比100001小的數字11000(圖中右側的0已經被省略掉了),這個時候向左移動了兩位也就是乘以100(二進制),餘數是1000
  2. 再將110向左移動到最大的比1000小數字,這個時候就是自己,至關於乘以1(向左移動了0位),餘數是11
  3. 由於餘數已經比被除數110要小了,這個時候能夠直接中止運算,將上面兩個步驟計算獲得的乘數1001)加起來就是咱們最後的結果了(101)。

這裏咱們就用java代碼來實現一下這個邏輯:

public int divide(int dividendInt, int divisorInt) {
    int shiftedDivisor;                   // 移位後的除數
    int quotient = 0;                     // 記錄除法獲得的商
    int remainder = dividendInt;

    while (remainder>=divisorInt) {
        int tempQuotient = 1;             // 臨時的商
        dividendInt = remainder;          // 處理上一輪的餘數
        shiftedDivisor = divisorInt;      // 重置被除數
        while (dividendInt>=shiftedDivisor) {
            shiftedDivisor <<=1;
            tempQuotient <<= 1;
        }
        quotient += tempQuotient >> 1;    // 累加計算獲得的商
        remainder = dividendInt - (shiftedDivisor >> 1); // 位移優先級比減號低,要用括號
    }
    return quotient;
}

經過循環的方法獲得了咱們要的除法的實現邏輯,可是這個方法只是對除數和被除數都是正數才起做用,好比除數是-100被除數是10或者-10的時候,返回的結果是0!對於-100除以-10這個例子,在比較餘數和移位的除數的大小的時候,若是都是正數,餘數比除數小的時候中止循環是正常的,可是在都是負數的時候,這樣作就有問題了,餘數比除數大,商纔是0,因此上面兩個循環的終止條件的不等號方向換一下就能夠了。

public int divide(int dividendInt, int divisorInt) {
    int shiftedDivisor;
    int quotient = 0;
    int remainder = dividendInt;

    while (remainder<=divisorInt) {           // 注意 變成了小於等於
        int tempQuotient = 1;
        dividendInt = remainder;
        shiftedDivisor = divisorInt;
        while (dividendInt<=shiftedDivisor) {  // 注意 變成了小於等於
            shiftedDivisor <<=1;
            tempQuotient <<= 1;
        }
        quotient += tempQuotient >> 1;
        remainder = dividendInt - (shiftedDivisor >> 1);
    }
    return quotient;
}

如今咱們都是正數和都是負數的狀況都有了,一個正數一個負數咋辦呢?那就把符號變一下,變成同樣的,最後在返回結果的時候變回來。這裏題目善意的提醒了一下要考慮邊界問題,在考慮轉換符號的時候須要考慮到-2147483648這個值的特殊性,也就是負數變正數要當心,可是正數變成負數能夠隨意,那就不如直接負數運算得了(參考這篇文章)。在累加每一次除法獲得的商的時候,也是使用負數-1的移位來避免溢出問題(好比-2147483648除以1帶來的溢出問題)。

在對除數進行移位的時候,還須要注意一下移位以後除數不能溢出,可以使用的最好方法就是在移位以前判斷一下除數是不是小於最小數字的一半(若是是用正數移位的話,就應該是判斷是不是大於最大數字的一半),若是小於,下一次移位必定就會溢出,這個時候就只能直接終止循環。

在判斷結果的符號的時候用一下位運算的小技巧,在上面講到異或操做的時候,若是是相同的就返回0,不然是1,因此對兩個數字最高位進行異或操做,若是是0就表明結果是正號,反之就是負號。除此以外還有一個極端狀況,當被除數取最小值-2147483648而且除數取-1的時候,獲得的結果是2147483648會溢出,由於只有這樣的一個特例,能夠直接排除,對於剩下的其餘的數字無論怎樣都不會溢出,整理以後的代碼以下:

public int divide(int dividendInt, int divisorInt) {
    if (dividendInt == Integer.MIN_VALUE && divisorInt == -1) {
        return Integer.MAX_VALUE;         // 對於極端狀況,直接排除,返回int最大值
    }
    boolean negSig =
            ((dividendInt ^ divisorInt) & Integer.MIN_VALUE) == Integer.MIN_VALUE;  // 判斷結果是不是負數

    dividendInt = dividendInt > 0 ? -dividendInt : dividendInt;                     // 被除數取負值
    divisorInt = divisorInt > 0 ? -divisorInt : divisorInt;                         // 除數取負值

    int shiftedDivisor;                   // 移位後的除數
    int quotient = 0;                     // 記錄除法獲得的商
    int remainder = dividendInt;
    int minShiftDivisor = Integer.MIN_VALUE >> 1; // 防止溢出,大於這個值以後不能再向左移位了

    while (remainder<=divisorInt) {
        int tempQuotient = -1;             // 臨時的商,所有用負數處理
        dividendInt = remainder;           // 處理上一輪的餘數
        shiftedDivisor = divisorInt;       // 重置被除數
        while (dividendInt<=(shiftedDivisor<<1)          // 比被除數小才進行接下來的計算
                && shiftedDivisor >= minShiftDivisor) {  // 判斷是否移位後會溢出
            shiftedDivisor <<=1;
            tempQuotient <<= 1;
        }
        quotient += tempQuotient;                        // 累加計算獲得的商
        remainder = dividendInt - shiftedDivisor;        // 獲得餘數,下一輪做爲新的被除數
    }

    return negSig?quotient:-quotient;     // 若是是負數就直接返回結果,若是不是就變換成正數
}

191. 位1的個數

leetcode第191題位1的個數

編寫一個函數,輸入是一個無符號整數,返回其二進制表達式中數字位數爲1的個數(也被稱爲漢明重量)。

示例1:

輸入: 00000000000000000000000000001011
輸出: 3
解釋:輸入的二進制串 00000000000000000000000000001011中,共有三位爲'1'。

示例2:

輸入: 00000000000000000000000010000000
輸出: 1
解釋:輸入的二進制串 00000000000000000000000010000000中,共有一位爲 '1'。

這道題任何人最簡單暴力的作法就是直接移位,在Java裏面直接用無符號的右移,每次判斷最低位是否是1,把判斷爲1的全部的次數記錄起來就能夠了。

public int hammingWeight(int n) {
    int count = 0;
    for (int i = 0; i < 32; i++) {
        count += n&1;  // 累加1
        n>>>=1;        // 循環右移1位
    }
    return count;
}

可是這道題有一個更加有意思的解法,首先對於一個二進制數字好比10100,在減一操做以後獲得10011,比較這兩個數字,發現原數字最低位的1的右邊的數字沒有變,而右邊的0都變成了1,而這個1變成了0,若是對原來的數字和減一的數字進行按位與,就會把最低位的1置零。

因此就有了一個神奇的想法,若是咱們想把數字a最低位的1變成0而不改變其餘位,直接經過a&(a-1)就能夠獲得了,而把全部的1都變成了0的次數不就是位1的個數了嗎?

好比對於數字101,在通過a&(a-1) = 101&100 = 100,再操做一次就是100&011 = 0,一共兩次操做,最後的結果就變成了0

簡單代碼以下:

public int hammingWeight(int n) {
    int count = 0;    // 統計置0的次數
    while (n!=0) {
        count ++;
        n = n&(n-1);  // 最低位的1置0
    }
    return count;
}

看起來上面兩個算法均可以達到咱們想要的目的,可是不知道效率怎麼樣,那就來測試比較一下(添加的java的內置計算1個數的方法)

private void test() {
    int t = 10000000;                        // 比較一千萬次
    long s = System.currentTimeMillis();
    for (int i = 0; i < t; i++) {
        hammingWeight1(-3);
    }
    System.out.println("Java builtin:\t" + (System.currentTimeMillis()-s));
    s = System.currentTimeMillis();
    for (int i = 0; i < t; i++) {
        hammingWeight2(-3);
    }
    System.out.println("最低位1置0:   \t" + (System.currentTimeMillis()-s));
    s = System.currentTimeMillis();
    for (int i = 0; i < t; i++) {
        hammingWeight3(-3);
    }
    System.out.println("向右移位比較:\t" + (System.currentTimeMillis()-s));
}

// java內置方法
public int hammingWeight1(int n) {
    return Integer.bitCount(n);
}

// 最低位1置0
public int hammingWeight2(int n) {
    int count = 0;
    while (n!=0) {
        count ++;
        n = n&(n-1);
    }
    return count;
}

// 向右移位比較
public int hammingWeight3(int n) {
    int count = 0;
    for (int i = 0; i < 32; i++) {
        count += n&1;  // 累加1
        n>>>=1;        // 循環右移1位
    }
    return count;
}

輸出的結果以下:

Java builtin:   10
最低位1置0:     150
向右移位比較:    10

雖然上面只是一次測試的結果(每次運行測試的時候結果都不同),可是能夠看出最低位置0這個方法效率要比普通的移位操做或者內置的方法慢一個數量級,猜想緣由應該是n=n&(n-1)這個操做耗時比較高(n=n&(n-1)實際上是兩步,第一步int a = n-1,第二步n=n&a),而n&1或者n>>>=1這兩個操做都要快不少。

回到測試,多測幾回發現java內置的方法有的時候要比向右移位快不少,這就有意思了,得看看它究竟是怎麼實現的:

public static int bitCount(int i) {
    // HD, Figure 5-2
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}

看完以後一句臥槽涌上心頭,這寫的是什麼玩意兒?!這是什麼神仙算法!那就來仔細看看這個算法,對每一步進行分解,假設咱們的輸入是-1

public static int bitCount(int i) {                  // i = 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11  -1的二進制
    // HD, Figure 5-2                                //     01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01  
    i = i - ((i >>> 1) & 0x55555555);                // i = 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10  (1)每2位的計算
                                                     //     00 11 00 11 00 11 00 11 00 11 00 11 00 11 00 11  
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); // i = 0100 0100 0100 0100 0100 0100 0100 0100          (2)每4位求和
                                                     //     0000 1111 0000 1111 0000 1111 0000 1111
    i = (i + (i >>> 4)) & 0x0f0f0f0f;                // i = 00001000 00001000 00001000 00001000          (3)每8位求和

    i = i + (i >>> 8);                               // i = 0000100000010000 0001000000010000            (4)每16位求和

    i = i + (i >>> 16);                              // i = 00001000000100000001100000100000             (5)每32位求和

    return i & 0x3f;                                 // i = 00000000000000000000000000 100000            (6)取最低的6位
}

這個過程一共有6步,主要思想就是分治法,計算每二、四、八、1六、32位中1的個數:

  1. 第一步也是最重要的一步,統計每2位裏面的1的個數,好比二進制11有兩個1應該變成統計結果1011-((11>>>1)&01) = 10),10或者01應該變成0110-((10>>>1)&01) = 0101-((01>>>1)&01) = 01
  2. 統計每4位裏面的1的個數,也就是把上面計算獲得的兩位1的個數和相加,好比01 00,經過移位而且和掩碼0011按位與,變成00000001而後求和獲得0001
  3. 統計每8位二進制裏面1的個數,這一步跟上面又不同了,不是先用掩碼再相加,而是先相加再用掩碼,主要不一樣地方是在上一步兩個二位的二進制數字相加可能會溢出,使用掩碼以後就會獲得錯誤的結果,好比10 10,相加獲得01 00而後用00 11按位與獲得00 00不是咱們想要的結果。當計算8位的個數的時候就不會溢出,由於4位裏面1的個數最可能是4個,也就是0100,兩個相加最大是1000,不會進位到第二個4位,使用掩碼可以獲得正確的結果,而且能夠清除多餘的1
  4. 統計每16位二進制裏面1的個數,這個時候直接移位相加並無用掩碼,緣由很簡單,在上一步的每8位裏面的的結果的是保存在最後4位裏面,而且必定是準確的,直接相加確定不會溢出8位,結果也必定保存在最右邊的8位裏面,至於16位裏面的左邊的8位是什麼值咱們根本就不關心,因此沒有必要用掩碼,也不會影響後面的計算
  5. 這一步獲得32位的和,直接移位相加,由於左右16位的1的個數必定保存在他們各自的右側8位,相加的結果也不會溢出,必定還在最右的8位裏面,左邊的24位是什麼也沒有影響
  6. 結果必定在最後的6位裏面,由於一共最多隻有32個1,只須要6位來保存這個值。

這樣就經過一個精細的二進制的運算利用分治法獲得了最後1的個數,不得不感嘆這樣的作法實在是太妙了!

Java中還有不少精妙的整型數字的位操做方法好比下面的幾種,雖然很想介紹一下,不過由於篇幅緣由就跳過,由於後面還有更加劇要的內容

  • highestOneBit
  • lowestOneBit
  • numberOfLeadingZeros
  • numberOfTrailingZeros
  • reverse
  • rotateLeft
  • rotateRight

268 缺失數字

leetcode第268題缺失數字

給定一個包含0, 1, 2, ..., nn個數的序列,找出0 .. n中沒有出如今序列中的那個數。

示例1:

輸入: [3,0,1]
輸出: 2

這道題能夠用哈希表或者求和的方法,都是不錯的方法,不過既然這篇文章是關於位運算的,就用經典的位運算吧。

對於數字的位運算,按位異或有一個很重要的性質,對於數字ab有:

a^a=0

a^0=a

a^b^a=b

以此類推能夠發現若是對於一個數組裏面的每個元素都進行按位異或,裏面的出現頻率是偶數的元素都會變成0,若是數組裏面每一個元素的出現頻率都是偶數次,那麼整個數組的按位異或的結果就是0,若是隻有一個元素出現了奇數次,按位異或的結果就是這個元素。

回到這道題,數組中全部的數字出現的次數是1,沒有出現的數字次數是0,明顯不能直接按位異或,那咱們就再這個數組後面添加一個0..n的數組,包含全部的元素,這樣兩個數組合起來原來出現的數字的出現次數是2,而原來沒有出現的數字的出現次數是1,就能夠用按位異或了。

不過這裏並不須要顯性的添加數組,而是在循環內部再進行異或操做,代碼以下:

public int missingNumber(int[] nums) {
    int xor = 0;
    for (int i = 0; i < nums.length; i++) {
        xor ^= i^nums[i];           // 循環內部儘可能按位異或虛擬的新增數組
    }
    return xor^nums.length;         // 補齊循環中漏掉的最後一個數字n
}

136 只出現一次的數字

leetcode第136題只出現一次的數字

給定一個非空整數數組,除了某個元素只出現一次之外,其他每一個元素均出現兩次。找出那個只出現了一次的元素。

說明

你的算法應該具備線性時間複雜度。 你能夠不使用額外空間來實現嗎?

示例 1:

輸入: [2,2,1]
輸出: 1

根據上面一題的按位異或的思路,這道題能夠閉着眼睛寫出結果:

public int singleNumber(int[] nums) {
    int num = 0;
    for (int i : nums) {
        num ^= i;
    }
    return num;
}

137 只出現一次的數字 II

這是leetcode第137題只出現一次的數字 II

給定一個非空整數數組,除了某個元素只出現一次之外,其他每一個元素均出現了三次。找出那個只出現了一次的元素。

說明:

你的算法應該具備線性時間複雜度。 你能夠不使用額外空間來實現嗎?

示例 1:

輸入: [2,2,3,2]
輸出: 3

這道題和上面的那一道題看起來彷佛是很類似的,不一樣之處僅僅是在於原來是出現2次,如今是變成了3次,原來有異或的運算操做,可讓a^a=0,若是有一個操做符號使得a?a?a=0,那這個問題就迎刃而解了。好像並不存在這樣的運算符,那就想一想其餘的方法。

首先看看異或運算符的特色,1^1=00^1=11^0=10^0=0,聯想到加法運算,01+01=10取低位爲0,因此其實就是運算符左右相加去掉進位(至關於對2取模)。若是想讓三個1相加獲得0,那就用十進制裏面的相加而後對3取模就能夠了的。

這樣對這個數組裏面的每一個數字二進制位的每一位相加而且最後對3取模,獲得的就是隻出現一次的數字的每個二進制位的結果。初步想法的代碼以下:

public int singleNumber(int[] nums) {
    int bit;
    int res = 0;
    for (int i = 0; i < 32; i++) {
        bit = 0;
        for (int num : nums) {        // 計算第i位的1的個數
            bit += (num>>i)&1;
        }
        res |= (bit%3)<<i;        // 根據1的個數取模獲得結果
    }
    return res;
}

這其實也是一個比較通用的解法,這裏出現的次數是3次,若是換成了4次、5次也能用這個方法解決。看了不少其餘人的解法,還有一個更加精妙的解法,參考這裏

在上面的解法裏面,咱們用一個bit來保存全部的數字的某一位的和,每一個元素都是int類型的,可是這道題的最多的重複次數是3,並且咱們最後須要的結果並非這個和,而是和對3取模,因此若是可以在計算的過程當中不停的取模,就能控制每一位的和小於3,最多2位的二進制數字就能夠了,並不須要32位的int來保存。雖然在java裏面沒有一個只有2位bit的二進制數字,可是能夠用兩個1位的二進制數字來表示,這樣整個數組用兩個int類型的數字表示就能夠了。上面解法的另一個問題是整個數組被遍歷了32遍,由於每計算一位都要遍歷一遍,可是若是用兩個int來表明的話,用適當的位操做,能夠把遍歷次數下降到一次!

先用lowerhigher表明這個二進制數字的低位和高位,每次遇到一個二進制數字的數字的時候的變化能夠用下圖表示(由於遇到3取模獲得0,因此不可能有lower=1 higher=1這種狀態,只能是中間存在的一種過渡狀態)。

num higher(old) lower(old) higher(過渡) lower(過渡) mask(掩碼) higher(new) lower(new)
0 0 0 0 0 1 0 0
0 0 1 0 1 1 0 1
0 1 0 1 0 1 1 0
1 0 0 0 1 1 0 1
1 0 1 1 0 1 1 0
1 1 0 1 1 0 0 0

是否是感受有點懵逼,獲得了這樣的一個表格又要怎麼把它變成公式呢?

首先看一下從老的狀態到過渡狀態,徹底就是一個二進制的加法,低位的數值根據lowernum能夠獲得的過渡狀態是lower=lower^num;而高位的數值須要獲得低位的進位,也就是lower&num,而後加上原來的高位就是higher^(lower&num),經過這兩部就能夠輕鬆計算出過渡狀態的高位和低位。

過渡狀態能夠出現higher=1 lower=1的狀態,若是是出現是4次的話,這道題就直接解決了,不用任何額外的操做就能夠獲得最後的新狀態,可是咱們這裏要求的是3次,也就是當higher=1 lower=1的時候須要把兩位同時置零,爲其餘的狀態時就保持不變。這個時候就要用到掩碼,先計算出掩碼,再經過掩碼把過渡狀態修正成最終的狀態。掩碼時根據過渡狀態的高低位決定的,若是過渡狀態的高低位組成的數字達到了咱們想要的閾值(這道題裏面是3),掩碼變成0,高低位同時進行&操做置零;沒有達到的時候就是1,使用&操做至關於維持原來的值。能夠看到這道題當higher=1 lower=1時掩碼mask=0,其餘時候mask=1,很熟悉的操做,這不就是mask=~(higher&lower)嗎!這道題已經水落石出了!

最後的新狀態直接用過渡狀態和掩碼按位與,higher=higher&masklower=lower&mask就到了新的值。

遍歷完了整個數組以後,出現了三次的數字計算獲得的higherlower必定是0,出現了一次的數字的higher必定也是0,而lower低位就是表示的出現一次的數字的二進制位的值,因此最後獲得的lower就是須要的返回結果。

public int singleNumber(int[] nums) {
    int higher = 0, lower = 0, mask = 0;
    for (int num : nums) {
        higher = higher^(lower&num);
        lower  = num^lower;
        mask = ~(higher&lower);       // 計算掩碼
        higher &=mask;
        lower &= mask;
    }
    return lower;
}

上面的求解過程簡單點來將就是先用加法獲得過渡狀態,再用掩碼計算出最終的新狀態,對於任何出現了k次的數組中找到只出現了一次的數字的題目都能用,這裏只有兩位(一個高位,一個低位)。若是k=5,那麼2位就不夠了,須要3位數字來表示s1s2s3,而掩碼的計算就變成了mask = ~(s3&~s2&s1)(當過渡狀態爲101的時候掩碼爲0)。

若是把上面的掩碼的計算變成了mask=~(higher&~lower),這段代碼就能夠直接放到只出現一次的數字這道題裏面。

除了使用過渡狀態,還可使用卡諾圖來直接解決新老狀態轉換的問題,具體的解題方法能夠看這個帖子

260 只出現一次的數字 III

這是leetcode第260題只出現一次的數字 III

給定一個整數數組nums,其中剛好有兩個元素只出現一次,其他全部元素均出現兩次。 找出只出現一次的那兩個元素。

示例 :

輸入: [1,2,1,3,2,5]
輸出: [3,5]
注意:
  1. 結果輸出的順序並不重要,對於上面的例子,[5, 3]也是正確答案。
  2. 你的算法應該具備線性時間複雜度。你可否僅使用常數空間複雜度來實現?

這道題和上面的不一樣之處在於上面須要求解的元素的出現次數只是一次,獲得最後一個元素就能夠了,然而這道題須要求解兩個出現一次的元素。好比這兩個元素分別是ab,一次遍歷通過按位異或以後獲得的結果是a^b,從這個信息裏面咱們貌似什麼結論都得不到。

既然要獲得兩個數字,須要遍歷兩次,若是兩次都所有遍歷,咱們想要的信息就會混在一塊兒,惟一的方法就是把這個數組分紅兩個子數組,一個數組包含a,另外一個包含b,這樣分別遍歷就可以獲得想要的ab了。

要想拆分這個數組,須要區分ab,因爲ab必定是不一樣的,二進制表示的32位裏面必定有1位是不一樣的,找到了這一位,而後把整個數組裏面這一位爲1的和爲0數字分別列爲兩個子數組(同一個數字確定會被劃分到同一個子數組裏面),分別異或就可以獲得結果了。爲了找到ab不一樣的二進制位,上面獲得的a^b就能派上用場了,異或結果爲1的確定是兩個數字不一樣的那一位,隨便找一個就能夠區分,這裏咱們直接爲1的最低位。在文章的開頭就有獲取最低位的1的操做——num&(-num),能夠直接使用,簡化的代碼以下:

public int[] singleNumber(int[] nums) {
    if (nums == null || nums.length < 1) return null;
    int[] res = new int[2];
    int xor = 0;
    for (int num : nums) {   // 計算a^b
        xor = xor^num;
    }
    int bits = xor & (-xor); // 獲取最低位1

    for (int num : nums) {   // 獲取其中一個出現次數位1的數字a
        res[0] ^= (bits & num) == bits ? num : 0;
    }
    res[1] = res[0]^xor;     // 根據根據前面的數字獲得另外一個數字b
    return res;
}

338 比特位計數

這是leetcode第338題比特位計數

給定一個非負整數num。對於0 ≤ i ≤ num範圍中的每一個數字i,計算其二進制數中的1的數目並將它們做爲數組返回。

示例 1:

輸入: 2
輸出: [0,1,1]

示例 2:

輸入: 5
輸出: [0,1,1,2,1,2]

進階:

  • 給出時間複雜度爲O(n*sizeof(integer))的解答很是容易。但你能夠在線性時間O(n)內用一趟掃描作到嗎?
  • 要求算法的空間複雜度爲O(n)

看完這道題,看到最後的進階部分,是否是感受到很眼熟——要求線性時間內解決問題,空間複雜度位O(n)——這不就是動態規劃嗎!想一想這道題的問題的特色,找找最優子結構。

對於一個二進制數字n,若是想獲得他的從左到右第n位中的1的個數,能夠先獲得它的從左到右第n-1位中的1的個數,而後根據第n位是不是1來決定是否要加1。要獲得整個數字中的1的個數,就須要知道從左到右第31位(從1開始計數)中的1的個數,以及最低位是不是1。獲得前者並不難,由於若是把整個數字向右移一位就是一個前面已經計算過的數字(動態規劃從小到大開始計算),這就變成了最優子結構了,遞推公式變成了res[i] = res[i>>1] + (i&1);,有了這個公式問題就解決了。

好比若是想獲得10也就是二進制10101的個數,能夠先找到左邊三個二進制數字1011的個數,加上最右側的位0就能夠了。

public int[] countBits(int num) {
    int[] res = new int[num+1];      // 不須要初始化,默認爲0
    for (int i = 0; i < res.length; i++) {
        res[i] = res[i>>1] + (i&1);
    }
    return res;
}

187 重複的DNA序列

這是leetcode第187題重複的DNA序列

全部 DNA 都由一系列縮寫爲 A,C,G 和 T 的核苷酸組成,例如:「ACGAATTCCG」。在研究 DNA 時,識別 DNA 中的重複序列有時會對研究很是有幫助。

編寫一個函數來查找 DNA 分子中全部出現超過一次的 10 個字母長的序列(子串)。

示例:

輸入:s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
輸出:["AAAAACCCCC", "CCCCCAAAAA"]

看到了前面的那麼多的明擺着用位操做的題目,再跳到這個題目感受是否是有點矇蔽,彷佛跟位操做一點關係都沒有。可是注意讀題,裏面有兩個關鍵的限定條件——10個字母長的序列只有「ACGT」四個字符,看到這兩個條件就聯想到一個int數字有32位,因此一個int數字就可以正好表明這樣的一個10個字符長的序列。

在實現中咱們用

  • 00表明A
  • 01表明C
  • 10表明G
  • 11表明T

好比序列AAAAACCCCC就能夠用00 00 00 00 00 01 01 01 01 01來表示,這只是從右到左的20位,更高位都是0省略掉了。

一串DNA正好須要20位,高位直接用掩碼去掉。每個惟一的DNA串都能用一個惟一的int數字表示,用一個數組來表示每一串出現的次數,數組最大長度是1<<20

public List<String> findRepeatedDnaSequences(String s) {
    if (s == null || s.length() <= 10) return new ArrayList<>();
    char[] chars = s.toCharArray();              // 轉化成數組提升效率
    int[] freq = new int[1<<20];                 // 出現頻率數組
    int num = 0;                                 // 計算過程當中的int值
    int mask = (1<<20)-1;                        // 掩碼,只保留最低的20位
    for (int i = 0; i < 10; i++) {               // 初始化第一個DNA串
        num <<= 2;
        if (chars[i] == 'C') {
            num |= 1;
        } else if (chars[i] == 'G') {
            num |= 2;
        } else if (chars[i] == 'T') {
            num |= 3;
        }
    }
    freq[num]++;
    List<Integer> repeated = new ArrayList<>();
    for (int i = 10; i < chars.length; i++) {    // 遍歷全部的長度爲10的DNA串
        num <<= 2;                               // 清楚最高的兩位,也就是移除滑出的字符串
        if (chars[i] == 'C') {
            num |= 1;
        } else if (chars[i] == 'G') {
            num |= 2;
        } else if (chars[i] == 'T') {
            num |= 3;
        }
        num &= mask;                             // 掩碼 保留最低的20位
        freq[num]++;                             // 統計出現頻率
        if (freq[num] == 2) repeated.add(num);   // 只有出現次數是2的時候才計入,避免重複
    }

    List<String> res = new ArrayList<>(repeated.size());
    for (Integer integer : repeated) {           // 將int數字轉化成DNA串
        char[] seq = new char[10];
        for (int i = 9; i >= 0; i--) {
            switch (integer&3) {
                case 0:seq[i]='A';break;
                case 1:seq[i]='C';break;
                case 2:seq[i]='G';break;
                case 3:seq[i]='T';break;
            }
            integer >>=2;
        }
        res.add(new String(seq));
    }
    return res;
}

總結

這篇文章主要主要講解了二進制位操做的幾個基本的使用方法和小技巧,順帶提了一下浮點型數字的表示方法,但願可以增長對計算機底層的數據的理解。

後面講到了幾個leetcode上面的比較經典的題目好比兩數相除只出現一次的數字,都是使用位運算解決實際的問題,而重複的DNA序列是更高層次的經過位運算解決問題的案例。

洋洋灑灑寫了幾千字,但願幫助你們加深對位運算的理解,並可以熟練運用在工做學習中。

參考

A summary: how to use bit manipulation to solve problems easily and efficiently
Bit Manipulation 4% of LeetCode Problems
Bit Twiddling Hacks
Bitwise operators in Java
9.1 Floating Point
java浮點數的二進制格式分析
How is a floating point number represented in Java?
執行時間1ms,擊敗100%
詳細通俗的思路分析,多解法
Detailed explanation and generalization of the bitwise operation method for single numbers
Bitwise Hacks for Competitive Programming
Bit Tricks for Competitive Programming

更多內容請看個人我的博客

相關文章
相關標籤/搜索