演算法之美 : 位運算

玉剛說發表於2019-03-04

上一小節我們用三道題了解一下面試過程中棧和佇列的常見面試題。本小節筆者將通過幾個 位運算 的題目來帶大家熟悉下常用的位運算知識。

相比於棧和佇列來講,筆者自身認為位運算需要掌握的知識就要多一些,包括對於數字的二進位制表示,二進位制的反碼,補碼。以及二進位制的常見運算都需要了解。當然如果系統的去學,可能沒有經歷,也可能即使學完了,仍舊不會做題。所以筆者認為通過直接去刷一些相應的題目,則是一個比較便捷的途徑。

給定一個整數,請寫一個函式判斷該整數的奇偶性(✭✩✩✩✩)

該題目作為後續題目的鋪墊,看上去還是沒有任何難度的。主要考察了面試能否想到用二進位制的位運算方法去解決。

首先整數可以分為正數,負數,0。也可以分為奇數和偶數。偶數的定義是:如果一個數是2的整數倍數,那麼這個數便是偶數。如果不使用位運算的方法,我們完全可以使用下面的方式解決:

public boolean isOdd(int num){//odd 奇數
    return num % 2 != 0;
}
複製程式碼

可是面試題不可能去簡單就考察這麼簡單的解法,進而我們想到了二進位制中如果 一個數是偶數那麼最後一個一定是 0 如果一個數是奇數那麼最後一位一定是 1;而十進位制 1 在 8 位二進位制中表示為 0000 0001,我們只需將一個數個 1相與(&) 得到的結果如果是 1 則表示該數為奇數,否知為偶數。所以這道題的最佳解法如下:

public boolean isOdd(int num){
    return num & 1 != 0;
}
複製程式碼
#include "iostream"  
using namespace std;  
//宣告
bool IsOdd(int num);

bool IsOdd(int num)
{
    int res = (num & 1);
    return res != 0;
}
複製程式碼

測試:

int main(int argc, const char * argv[]) {
  std::cout << "是否是奇數 : " << IsOdd(1) <<endl;
  std::cout << "是否是奇數 : " << IsOdd(4) <<endl;
  return 0;
}

//結果
是否是奇數 : 1//是 true
是否是奇數 : 0//不是 false
複製程式碼

同樣給定一個整數,請寫一個函式判斷該整數是不是2的整數次冪(✭✩✩✩✩)

這道題仍舊考察面試者對於一個數的二進位制的表示特點,一個整數如果是2的整數次冪,那麼他用二進位制表示完肯定有唯一一位為1其餘各位都為 0,形如 0..0100...0。比如 8 是 2的3次冪,那麼這個數表示為二進位制位 0000 1000 。

除此之外我們還應該想到,一個二進位制如果表示為 0..0100...0,那麼它減去1得到的數二進位制表示肯定是 0..0011..1 的形式。那麼這個數與自自己減一後的數相與得到結果肯定為0。

如:

演算法之美 : 位運算

所以該題最佳解法為:

public boolean log2(int num){
   return (num & (num - 1)) == 0;
}
複製程式碼
#include "iostream"  
using namespace std;  
//宣告
bool IsLog2(int num);
//定義
bool IsLog2(int num)
{
    return (num & (num -1)) == 0;
}
複製程式碼

測試:

int main(int argc, const char * argv[]) {
    std::cout << "是否是2的整數次冪 : " << IsLog2(1) <<endl;
    std::cout << "是否是2的整數次冪 : " << IsLog2(3) <<endl;
    return 0;
}

//結果
是否是2的整數次冪 : 1 //是 true
是否是2的整數次冪 : 0 //不是 false
複製程式碼

給定一個整數,請寫一個函式判斷該整數的二進位制表示中1的個數(✭✭✩✩✩)

此題較之上一題又再進一步,判斷一個整數二進位制表示中1的個數,假設這個整數用32位表示,可正可負可0,那麼這個數中有多少個1,就需要考慮到符號位的問題了。

相信讀者應該都能想到最近基本的解法即通過右移運算後與 1 相與得到的結果來計算結果,如果採用這種解法,那麼這個題的陷阱就在於存在負數的情況,如果負數的話標誌位應該算一個1。所以右移的時候一定要採用無符號右移才能得到正確的解法。

ps 對於正數右移和無符號右移得到結果一樣,如果是負數,右移操作將在二進位制補碼左邊新增追加1,而無符號右移則是補 0 。

所以此題一種解法如下:

public int count1(int n) {
   int res = 0;
   while (n != 0) {
       res += n & 1;
       n >>>= 1;
   }
   return res;
}
複製程式碼
#include "iostream"  
using namespace std;
  
//注意C++中沒有無符號右移操作,所以這裡傳入一個 unsigned 數作為 params
int count1(unsigned int n){
    int res = 0;
    while(n != 0){
        res += n & 1;
        n >>= 1;
    }
    return res;
}
複製程式碼

測試結果:

int main(int argc, const char * argv[]) {
    std::cout << "二進位制中1的個數 : " <<  count1(-1) <<endl;
    std::cout << "二進位制中1的個數 : " <<  count1(1) <<endl;
    return 0;
}

//結果
二進位制中1的個數 : 32
二進位制中1的個數 : 1
複製程式碼

能回答出上邊的答案你的面試肯定是及格了,但是作為練習來說,是否有額外的解法呢?首先上述結果最壞的情況可能需要迴圈32次。上面我們算過一道如何判斷一個數是否是2的整數倍,我們用過了 n&(n-1)==0 的方法。其實該題的第二個解法也可以用這個方法。為什麼呢?我們開看一次上邊的圖:

演算法之美 : 位運算

我們是否能發現,每次與比自己小1的數與那麼該數的二進位制表示最後一個為1位上的1將將會被抹去。其實這是一個知道有這種原理才能想到的方法,所以大家也不用哀嘆說我怎麼想不到,通過這次記住有這個規律下次就多一個思路也不是很麼壞事。

下面我們來看下判斷一個數中有多少個1的完整圖解:

演算法之美 : 位運算

所以我們可以通過如下方法來得到題解,這樣我們可以減少移動次數

public int countA(int n){
   int res = 0;
   while(n != 0){
       n &= (n - 1);
       res++;
   }
   return res;
}
複製程式碼
#include "iostream"  
using namespace std;  
// 同上傳入無符號整數 
int countA(unsigned int n){
    int res = 0;
    while(n != 0){
        n &= (n - 1);
        res++;
    }
    return res;
}
複製程式碼

測試結果:

int main(int argc, const char * argv[]) {
    std::cout << "二進位制中1的個數 : " <<  countA(-1) <<endl;
    std::cout << "二進位制中1的個數 : " <<  countA(1) <<endl;
    return 0;
}

//結果
二進位制中1的個數 : 32
二進位制中1的個數 : 1
複製程式碼

在其他數都出現兩次的陣列中找到只出現一次的那個數(✭✭✩✩✩)

這道題同樣是考察為位運算的一道題,但是如果對於不熟悉位運算的朋友可能壓根都不會往這方面想,也許當場直接就下邊寫下了遍歷陣列記每個數出現次數的程式碼了。其實這道題要求在時間複雜度在O(n) 空間複雜度為O(1)的條件下,那種解法是不符合要求的。我們來看下為位運算的解題思路。

首先我們應該知道二進位制異或操作,異或結果是二進位制中兩個位相同為0,相異為1。因此可以有個規律:

任何整數 n 與 0 異或總等於其本身 n,一個數與其本身異或那麼結果肯定是 0。

還需要知道一個規律:

多個數異或操作,遵循交換律和結合律。

對於第一條朋友們肯定都很好理解,然而第二條規律才是這道題的解題關鍵。如果我們有一個變數 eO = 0 那麼在遍歷陣列過程中,使每個數與 eO 異或得到的值在賦值給額 eO 即 eO=eO ^ num 那麼遍歷結束後eO的值一定是那個出現一次的數的值。這是為什麼呢?我們可以舉個例子:

假設有這麼一個序列: C B D A A B C 其中只有 D 出現一次,那麼因為異或滿足交換律和結合律,所以我們遍歷異或此序列的過程等價於

eO ^ (A ^ A ^ B ^ B ^ C ^ C ) ^ D = eO ^ 0 ^ D = D
複製程式碼

所以對於任何排列的陣列,如果只有一個數只出現了奇數次,其他的數都出現了歐數次,那麼最終異或的結果肯定為出現奇數次的那個數。

所以此題可以有下面的這種解法:

java 解法

public int oddTimesNum(int[] arr) {
   int eO = 0;
   for (int cur : arr) {
       eO = eO ^ cur;
   }
   
   return eO;
}
複製程式碼

C++ 解法

int oddTimesNum(vector<int> arr) {
    int eO = 0;
    for (int cur : arr) {
        eO = eO ^ cur;
    }
    return eO;
}
複製程式碼

測試:

int main(int argc, const char * argv[]) {
  vector<int>  arr = {2,1,3,3,2,1,4,5,4};
  std::cout << "出現奇數次的那個數: " << oddTimesNum(arr) <<endl;
  return 0;
}

//結果
出現奇數次的那個數: 5
複製程式碼

關於這道題還有個延伸版本,就是如果陣列中出現1次的數有兩個,那麼該如何得到這兩個數。

在其他數都出現兩次的陣列中找到只出現一次的那兩個數(✭✭✭✩✩)

我們順著上題的思路來思考,如果有兩個數獲得的結果 eO 肯定是 eO = a^b,此題的關鍵就在於如何分別得到 a,b 這兩個數。我們應該想到,任何不相同的兩個除了跟自己異或外,不可能每一個位都相同,也就是說不相同的兩個數 a b 異或得到結果二進位制表示上肯定有一位為 1。 這是關鍵。

我們可以假設第 k 位不為 0 ,那麼就說明 a 與 b 在這位上數值不相同。我們要做只是設定一個數第 k 位 為 1,其餘位為 0 記為 rightOne

這時需要拿 eOhasOne = 0 再異或遍歷一次陣列,但是需要忽略與 rightOne 相與等於 0 的數。因為相與等於 0 則代表了這個數肯定是兩個數中第 k 位不為 1的那個。最終得到的 eOhasOne 就是 a b 中第 k 為為 1 的那個。

那麼接下來就剩下一個問題要解決了,如何找到 rightOne ,這裡採用與本身補碼相與的方法得到即 int rightOne = eO & (~eO + 1)

可以參照下圖來理解下整個過程:

演算法之美 : 位運算

我們來看下最終的程式碼:

java 寫法

public void printOddTimesNum(int[] arr) {
   int eO = 0;
   int eOhasOne = 0;

   for (int cur : arr) {
       eO = eO ^ cur;
   }

   int rightOne = eO & (~eO + 1);
   for (int cur : arr) {
       if ((rightOne & cur) != 0) {
           eOhasOne = eOhasOne ^ cur;
       }
   }

   System.out.println("eOhasOne = " + eOhasOne + "  " + (eOhasOne ^ eO));
}
複製程式碼

C++ 寫法

void printOddTimesNum(vector<int> arr) {
    int eO = 0;
    int eOhasOne = 0;
    
    for (int cur : arr) {
        eO = eO ^ cur;
    }
    
    int rightOne = eO & (~eO + 1);
    
    for (int cur : arr) {
        if ((cur & rightOne) != 0) {
            eOhasOne = eOhasOne ^ cur;
        }
    }
    
    std::cout<<"一個出現1次的數 " << eOhasOne << endl;
    std::cout<<"二個出現1次的數 " << (eO ^ eOhasOne) <<endl;
}
複製程式碼

測試:

int main(int argc, const char * argv[]) {
    vector<int>  arr1 = {2,1,3,3,2,1,4,5};
    printOddTimesNum(arr1);
    return 0;
} 

//結果:
一個出現1次的數 5
二個出現1次的數 4
複製程式碼

參考

《劍指 offer 第二版》 《程式設計師程式碼面試指南 - 左程雲》

演算法之美 : 位運算
歡迎關注我的微信公眾號,接收第一手技術乾貨

相關文章