我們知道,計算機最基本的操作單元是字節(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, 21,...,231]這樣一組基來表示(int型最高31位)。不難想到用除數的231,230,...,22,21,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;// 返回商
}
好了,以上。