位運算實現加減乘除運算

我們知道,計算機最基本的操作單元是字節(byte),一個字節由8個位(bit)組成,一個位只能存儲一個0或1,其實也就是高低電平。無論多麼複雜的邏輯、龐大的數據、酷炫的界面,最終體現在計算機最底層都只是對0101的存儲和運算。因此,瞭解位運算有助於提升我們對計算機底層操作原理的理解。

今天就來看看怎麼不使用顯式“ + - * /”運算符來實現加減乘除運算。

下面我們一個一個來看。

1. 加法運算

先來個我們最熟悉的十進制的加法運算:

13 + 9 = 22

我們像這樣來拆分這個運算過程:

不考慮進位,分別對各位數進行相加,結果爲sum:
個位數3加上9爲2;十位數1加上0爲1; 最終結果爲12;

只考慮進位,結果爲carry:
3 + 9 有進位,進位的值爲10;

如果步驟2所得進位結果carry不爲0,對步驟1所得sum,步驟2所得carry重複步驟1、 2、3;如果carry爲0則結束,最終結果爲步驟1所得sum:
這裏即是對sum = 12 和carry = 10重複以上三個步驟,(a) 不考慮進位,分別對各位數進行相加:sum = 22; (b) 只考慮進位: 上一步沒有進位,所以carry = 0; (c) 步驟2carry = 0,結束,結果爲sum = 22.
我們發現這三板斧行得通!

那我們現在還使用上面的三板斧把十進制運算放在二進制中看看是不是也行的通。

13的二進制爲0000 1101,9的二進制爲0000 1001:

不考慮進位,分別對各位數進行相加:
sum = 0000 1101 + 0000 1001 = 0000 0100

考慮進位:
有兩處進位,第0位和第3位,只考慮進位的結果爲:
carry = 0001 0010

步驟2carry == 0 ?,不爲0,重複步驟1 、2 、3;爲0則結束,結果爲sum:
本例中,
(a)不考慮進位sum = 0001 0110;
(b)只考慮進位carry = 0;
(c)carry == 0,結束,結果爲sum = 0001 0110
轉換成十進制剛好是22.

我們發現,適用於十進制的三板斧同樣適用於二進制!仔細觀察者三板斧,大家能不能發現其實第一步不考慮進位的加法其實就是異或運算;而第二部只考慮進位就是與運算並左移一位–;第三步就是重複前面兩步操作直到第二步進位結果爲0。

這裏關於第三步多說一點。爲什麼要循環步驟1、 2、 3直到步驟2所得進位carry等於0?其實這是因爲有的數做加法時會出現連續進位的情況,舉例:3 + 9,我們來走一遍上述邏輯:

    a = 0011, b = 1001;
    start;

    first loop;
    1.1 sum = 1010
    1.2 carry = 0010
    1.3 carry != 0 , go on;

    second loop;
    2.1 sum = 1000;
    2.2 carry = 0100;
    2.3 carry != 0, go on;

    third loop;
    3.1 sum = 1100;
    3.2 carry = 0000;
    3.3 carry == 0, stop; result = sum;

end

如上面的例子,有的加法操作是有連續進位的情況的,所以這裏要在第三步檢測carry是不是爲0,如果爲0則表示沒有進位了,第一步的sum即爲最終的結果。

有了上面的分析,我們不難寫出如下代碼:

// 遞歸寫法
int add(int num1, int num2){
    if(num2 == 0)
        return num1;
    int sum = num1 ^ num2;
    int carry = (num1 & num2) << 1;
    return add(sum, carry);
}

// 迭代寫法
int add(int num1, int num2){
    int sum = num1 ^ num2;
    int carry = (num1 & num2) << 1;  
    while(carry != 0){
        int a = sum;
        int b = carry;
        sum = a ^ b;
        carry = (a & b) << 1;  
    }
    return sum;
}

我們的計算機其實就是通過上述的位運算實現加法運算的(通過加法器,加法器就是使用上述的方法實現加法的),而程序語言中的+ - * /運算符只不過是呈現給程序員的操作工具,計算機底層實際操作的永遠是形如0101的位,所以說位運算真的很重要!

2. 減法運算

我們知道了位運算實現加法運算,那減法運算就相對簡單一些了。我們實現了加法運算,自然的,我們會想到把減法運算11 - 6變形爲加法運算11 + (-6),即一個正數加上一個負數。是的,很聰明,其實我們的計算機也是這樣操作的,那有的人會說爲什麼計算機不也像加法器一樣實現一個減法器呢?對的,這樣想當然是合理的,但是考慮到減法比加法來的複雜,實現起來比較困難。爲什麼呢?我們知道加法運算其實只有兩個操作,加、 進位,而減法呢,減法會有借位操作,如果當前位不夠減那就從高位借位來做減法,這裏就會問題了,借位怎麼表示呢?加法運算中,進位通過與運算並左移一位實現,而借位就真的不好表示了。所以我們自然的想到將減法運算轉變成加法運算。

怎麼實現呢?

剛剛我們說了減法運算可轉變成一個正數加上一個負數,那首先就要來看看負數在計算機中是怎麼表示的。

+8在計算機中表示爲二進制的1000,那-8怎麼表示呢?

很容易想到,可以將一個二進制位(bit)專門規定爲符號位,它等於0時就表示正數,等於1時就表示負數。比如,在8位機中,規定每個字節的最高位爲符號位。那麼,+8就是00001000,而-8則是10001000。這只是直觀的表示方法,其實計算機是通過2的補碼來表示負數的,那什麼是2的補碼(同補碼,英文是2’s complement,其實應該翻譯爲2的補碼)呢?它是一種用二進制表示有號數的方法,也是一種將數字的正負號變號的方式,求取步驟:

  • 第一步,每一個二進制位都取相反值,0變成1,1變成0(即反碼)。

  • 第二步,將上一步得到的值(反碼)加1。

簡單來說就是取反加一!

關於補碼更詳細的內容可參維基百科-補碼,這裏不再贅述。

其實我們利用的恰巧是補碼的可以將數字的正負號變號的功能,這樣我們就可以把減法運算轉變成加法運算了,因爲負數可以通過其對應正數求補碼得到。計算機也是通過增加一個補碼器配合加法器來做減法運算的,而不是再重新設計一個減法器。

以上,我們很容易寫出了位運算做減法運算的代碼:

/*
* num1: 減數
* num2: 被減數
*/
int substract(int num1, int num2){
    int subtractor = add(~num2, 1);// 先求減數的補碼(取反加一)
    int result = add(num1, subtractor); // add()即上述加法運算  
    return result ;
}

3. 乘法運算

我們知道了加法運算的位運算實現,那很容易想到乘法運算可以轉換成加法運算,被乘數加上乘數倍的自己不就行了麼。這裏還有一個問題,就是乘數和被乘數的正負號問題,我們這樣處理,先處理乘數和被乘數的絕對值的乘積,然後根據它們的符號確定最終結果的符號即可。步驟如下:

(1) 計算絕對值得乘積
(2) 確定乘積符號(同號爲證,異號爲負)

有了這個思路,代碼就不難寫了:

/*
* a: 被乘數
* b: 乘數
*/
int multiply(int a, int b){ 
    // 取絕對值      
    int multiplicand = a < 0 ? add(~a, 1) : a;    
    int multiplier = b < 0 ? add(~b , 1) : b;// 如果爲負則取反加一得其補碼,即正數      
    // 計算絕對值的乘積      
    int product = 0;    
    int count = 0;    
    while(count < multiplier) {        
        product = add(product, multiplicand);        
        count = add(count, 1);// 這裏可別用count++,都說了這裏是位運算實現加法      
    }    
    // 確定乘積的符號      
    if((a ^ b) < 0) {// 只考慮最高位,如果a,b異號,則異或後最高位爲1;如果同號,則異或後最高位爲0;            
        product = add(~product, 1);    
    }    
    return product;
}

上面的思路在步驟上沒有問題,但是第一步對絕對值作乘積運算我們是通過不斷累加的方式來求乘積的,這在乘數比較小的情況下還是可以接受的,但在乘數比較大的時候,累加的次數也會增多,這樣的效率不是最高的。我們可以思考,如何優化求絕對值的乘積這一步。

考慮我們現實生活中手動求乘積的過程,這種方式同樣適用於二進制,下面我以13*14爲例,向大家演示如何用手動計算的方式求乘數和被乘數絕對值的乘積。

這裏寫圖片描述
從上圖的計算過程可以看出,如果乘數當前位爲1,則取被乘數左移一位的結果加到最終結果中;如果當前位爲0,則取0加到乘積中(加0也就是什麼也不做);

整理成算法步驟:

(1) 判斷乘數是否爲0,爲0跳轉至步驟(4)
(2) 將乘數與1作與運算,確定末尾位爲1還是爲0,如果爲1,則相加數爲當前被乘數;如果爲0,則相加數爲0;將相加數加到最終結果中;
(3) 被乘數左移一位,乘數右移一位;回到步驟(1)
(4) 確定符號位,輸出結果;

代碼如下:

int multiply(int a, int b) {  
    //將乘數和被乘數都取絕對值 
    int multiplicand = a < 0 ? add(~a, 1) : a;   
    int multiplier = b < 0 ? add(~b , 1) : b;  
     
    //計算絕對值的乘積  
    int product = 0;  
    while(multiplier > 0) {    
        if((multiplier & 0x1) > 0) {// 每次考察乘數的最後一位    
            product = add(product, multiplicand);    
        }     
        multiplicand = multiplicand << 1;// 每運算一次,被乘數要左移一位    
        multiplier = multiplier >> 1;// 每運算一次,乘數要右移一位(可對照上圖理解)  
    }   
    //計算乘積的符號  
    if((a ^ b) < 0) {    
        product = add(~product, 1);  
    }   
    return product;
}

顯而易見,第二種求乘積的方式明顯要優於第一種。

4. 除法運算

除法運算很容易想到可以轉換成減法運算,即不停的用除數去減被除數,直到被除數小於除數時,此時所減的次數就是我們需要的商,而此時的被除數就是餘數。這裏需要注意的是符號的確定,商的符號和乘法運算中乘積的符號確定一樣,即取決於除數和被除數,同號爲證,異號爲負;餘數的符號和被除數一樣。
代碼如下:

/*
* a : 被除數
* b : 除數
*/
int divide(int a, int b){    
    // 先取被除數和除數的絕對值    
    int dividend = a > 0 ? a : add(~a, 1);    
    int divisor = b > 0 ? a : add(~b, 1);    

    int quotient = 0;// 商    
    int remainder = 0;// 餘數    
    // 不斷用除數去減被除數,直到被除數小於被除數(即除不盡了)    
    while(dividend >= divisor){// 直到商小於被除數        
        quotient = add(quotient, 1);        
        dividend = substract(dividend, divisor);    
    }    
    // 確定商的符號    
    if((a ^ b) < 0){// 如果除數和被除數異號,則商爲負數  
        quotient = add(~quotient, 1);    
    }    
    // 確定餘數符號    
    remainder = b > 0 ? dividend : add(~dividend, 1);    
    return quotient;// 返回商
}

這裏有和簡單版乘法運算一樣的問題,如果被除數非常大,除數非常小,那就要進行很多次減法運算,有沒有更簡便的方法呢?

上面的代碼之所以比較慢是因爲步長太小,每次只能用1倍的除數去減被除數,所以速度比較慢。那能不能增大步長呢?如果能,應該怎麼增大步長呢?

計算機是一個二元的世界,所有的int型數據都可以用[2^0, 2^1,…,2^31]這樣一組基來表示(int型最高31位)。不難想到用除數的2^31,2^30,…,2^2,2^1,2^0倍嘗試去減被除數,如果減得動,則把相應的倍數加到商中;如果減不動,則依次嘗試更小的倍數。這樣就可以快速逼近最終的結果。

2的i次方其實就相當於左移i位,爲什麼從31位開始呢?因爲int型數據最大值就是2^31啊。

代碼如下:

int divide_v2(int a,int b) {   
    // 先取被除數和除數的絕對值    
    int dividend = a > 0 ? a : add(~a, 1);    
    int divisor = b > 0 ? a : add(~b, 1);    
    int quotient = 0;// 商    
    int remainder = 0;// 餘數    
    for(int i = 31; i >= 0; i--) {
        //比較dividend是否大於divisor的(1<<i)次方,不要將dividend與(divisor<<i)比較,而是用(dividend>>i)與divisor比較,
        //效果一樣,但是可以避免因(divisor<<i)操作可能導致的溢出,如果溢出則會可能dividend本身小於divisor,但是溢出導致dividend大於divisor       
        if((dividend >> i) >= divisor) {            
            quotient = add(quotient, 1 << i);            
            dividend = substract(dividend, divisor << i);        
        }    
    }    
    // 確定商的符號    
    if((a ^ b) < 0){
        // 如果除數和被除數異號,則商爲負數        
        quotient = add(~quotient, 1);    
    }    
    // 確定餘數符號    
    remainder = b > 0 ? dividend : add(~dividend, 1);    
    return quotient;// 返回商
}

好了,以上。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章