我们知道,计算机最基本的操作单元是字节(byte),一个字节由8个位(bit)组成,一个位只能存储一个0或1,其实也就是高低电平。无论多么复杂的逻辑、庞大的数据、酷炫的界面,最终体现在计算机最底层都只是对0101的存储和运算。因此,了解位运算有助于提升我们对计算机底层操作原理的理解。
今天就来看看怎么不使用显式“ + - * /”运算符来实现加减乘除运算。
下面我们一个一个来看。
一、加法运算
1.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.
1.2 二进制 举例
我们发现这三板斧行得通!那我们现在还使用上面的三板斧把十进制运算放在二进制中看看是不是也行的通。
13的二进制为0000 1101,9的二进制为0000 1001:
不考虑进位:分别对各位数进行相加:sum = 0000 1101 + 0000 1001 = 0000 0100
只考虑进位: 有两处进位,第0位和第3位,只考虑进位的结果为:carry = 0001 0010
步骤2 carry == 0 ?,不为0,重复步骤1 、2 、3;本例中,
a. 不考虑进位sum = 0001 0110;
b. 只考虑进位carry = 0;
c. carry == 0?为0,结束,结果为sum = 0001 0110。转换成十进制刚好是22.
1.3 结论
我们发现,适用于十进制的三板斧同样适用于二进制!仔细观察者三板斧,其实:
- 第一步不考虑进位的加法其实就是异或运算;
- 第二步只考虑进位就是与运算并左移一位;
- 第三步就是重复前面两步操作直到第二步进位结果为0。
这里关于第三步多说一点。为什么要循环步骤1、 2、 3直到步骤2所得进位carry等于0?其实这是因为有的数做加法时会出现连续进位的情况,举例:3 + 9,我们来走一遍上述逻辑:
1 | a = 0011, b = 1001; |
如上面的栗子,有的加法操作是有连续进位的情况的,所以这里要在第三步检测carry是不是为0,如果为0则表示没有进位了,第一步的sum即为最终的结果。
1.4 代码
有了上面的分析,我们不难写出如下代码:
1 | // 递归写法 |
我们的计算机其实就是通过上述的位运算实现加法运算的(通过加法器,加法器就是使用上述的方法实现加法的),而程序语言中的+ - * /运算符只不过是呈现给程序员的操作工具,计算机底层实际操作的永远是形如0101的位,所以说位运算真的很重要!
二、减法运算
我们知道了位运算实现加法运算,那减法运算就相对简单一些了。我们实现了加法运算,自然的,我们会想到把减法运算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。
简单来说就是取反加一!
关于补码更详细的内容可参维基百科-补码,这里不再赘述。
其实我们利用的恰巧是补码的可以将数字的正负号变号的功能,这样我们就可以把减法运算转变成加法运算了,因为负数可以通过其对应正数求补码得到。计算机也是通过增加一个补码器配合加法器来做减法运算的,而不是再重新设计一个减法器。
以上,我们很容易写出了位运算做减法运算的代码:
1 | /* |
三、乘法运算
我们知道了加法运算的位运算实现,那很容易想到乘法运算可以转换成加法运算,被乘数加上乘数倍的自己不就行了么。这里还有一个问题,就是乘数和被乘数的正负号问题,我们这样处理,先处理乘数和被乘数的绝对值的乘积,然后根据它们的符号确定最终结果的符号即可。步骤如下:
(1) 计算绝对值得乘积
(2) 确定乘积符号(同号为证,异号为负)
有了这个思路,代码就不难写了:
1 | /* |
上面的思路在步骤上没有问题,但是第一步对绝对值作乘积运算我们是通过不断累加的方式来求乘积的,这在乘数比较小的情况下还是可以接受的,但在乘数比较大的时候,累加的次数也会增多,这样的效率不是最高的。我们可以思考,如何优化求绝对值的乘积这一步。
考虑我们现实生活中手动求乘积的过程,这种方式同样适用于二进制,下面我以13*14为例,向大家演示如何用手动计算的方式求乘数和被乘数绝对值的乘积。
从上图的计算过程可以看出,如果乘数当前位为1,则取被乘数左移一位的结果加到最终结果中;如果当前位为0,则取0加到乘积中(加0也就是什么也不做);
整理成算法步骤:
(1) 判断乘数是否为0,为0跳转至步骤(4)
(2) 将乘数与1作与运算,确定末尾位为1还是为0,如果为1,则相加数为当前被乘数;如果为0,则相加数为0;将相加数加到最终结果中;
(3) 被乘数左移一位,乘数右移一位;回到步骤(1)
(4) 确定符号位,输出结果;
代码如下:
1 | int multiply(int a, int b) { |
显而易见,第二种求乘积的方式明显要优于第一种。
四、除法运算
除法运算很容易想到可以转换成减法运算,即不停的用除数去减被除数,直到被除数小于除数时,此时所减的次数就是我们需要的商,而此时的被除数就是余数。这里需要注意的是符号的确定,商的符号和乘法运算中乘积的符号确定一样,即取决于除数和被除数,同号为证,异号为负;余数的符号和被除数一样。
代码如下:
1 | /* |
这里有和简单版乘法运算一样的问题,如果被除数非常大,除数非常小,那就要进行很多次减法运算,有没有更简便的方法呢?
上面的代码之所以比较慢是因为步长太小,每次只能用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啊。
代码如下:
1 | int divide_v2(int a,int b) { |