一个C语言的基本教程—位运算篇

13.从底层操纵数据——位运算篇

  在前面的章节中,我们应该提到过数据是以一串串二进制数字的形式存储的。这一章中,我会先回顾一下各种数据的存储方式,然后介绍一下针对于整型的位运算操作

(1). 各种数据的存储方式

#1.无符号整型

  无符号整型还是比较好说的,就是将一个数字转换成它的二进制表示,例如: ( 45 , 341 , 234 ) 10 = ( 0010 , 1011 , 0011 , 1101 , 1010 , 0011 , 0010 ) 2 (45,341,234)_{10} = (0010,1011,0011,1101,1010,0011,0010)_2 (45,341,234)10=(0010,1011,0011,1101,1010,0011,0010)2
  然后再根据存储的数据类型在高位补充0写入内存即可, n n n位无符号整型可以表示的数字范围为 [ 0 , 2 n − 1 ] [0, 2^n-1] [0,2n1]。假设把负数赋值给无符号整型,那么最后用%d输出时会根据负数的二进制表示直接转换为对应的十进制数输出。

#2.有符号整型

  有符号整型会将最高位作为符号位,当最高位为1时,当前表示的数字就为负数,负数和正数的相互转化可以通过补码的方式完成,一般的步骤为:

  • 先把正数的二进制数按位取反
  • 将取反后的二进制数加一,即为对应负数的二进制补码

  更具体的可以参考第一章中的数据的存储相关内容。

#3.字符型

  字符型(char)其实就是一个1字节的整型,按字符打印时会依照ASCII码表将这个数字映射为不同的字符再打印出来。

#4.浮点型

  浮点型算是一个重点,我们需要介绍一下IEEE754标准
  首先是科学计数法,用初中一年级的知识,我们可以知道一个十进制数123.456789可以用科学计数法表示为 1.23456789 × 1 0 2 1.23456789\times 10^2 1.23456789×102,那么我们对一个二进制小数11011.10111也可以用对应的科学计数法表示为 1.101110111 × 2 0100 1.101110111\times 2^{0100} 1.101110111×20100,这里指数部分也用二进制表示

  对于一个32位浮点数(C中的float类型),IEEE754标准将这32位划分为3个部分:符号位(1位)、指数位(8位)以及小数位(23位)

  • 符号位:0表示正数、1表示负数
  • 指数位:如果直接按照无符号整型来表示,那么8位的指数位只能表示 [ 0 , 255 ] [0, 255] [0,255],大是挺大的,但是不能表示小于1的数字了,那怎么办?
      标准中有一个移码的操作,即编码值是实际值偏移了一个固定值,这个固定值在IEEE754中被定为 2 e − 1 − 1 2^{e-1}-1 2e11,其中 e e e指数位的位数,所以在32位浮点数中这个偏移量就是127,我们可以表示的指数范围即为 [ − 127 , 128 ] [-127, 128] [127,128],这种移码表示指数部分,也叫作阶码
  • 小数位:由于小数部分科学计数法的特征——小数点前一定是1,所以在后面的23位中,我们不再存储这个1,只存储后面的小数位数,例如前面提到的 1.101110111 1.101110111 1.101110111,我们只要存储 101110111 101110111 101110111即可,不过大部分小数转换为二进制小数都是无限小数,存储的过程中只能存23位,后面的位数都被舍弃了,这样一来就会有精度问题,特别是做乘除法的时候,这个精度问题还会被继续放大

  一个64位浮点数(C中的double类型),IEEE754标准中的符号位、指数位和小数位分别为1、11、52,这个小数位数多了这么多,看起来的确是要精确不少呢!

溢出:和整型一样,受限于机器,浮点数也会发生溢出,浮点数一般有上溢出下溢出两种,上溢出一般指超过了一个浮点数能表示的最大数字,例如用一个32位浮点数表示 8.5 × 1 0 39 8.5\times 10^{39} 8.5×1039,这就超过了最大范围,显然会有问题,这种时候就会发生上溢出,你在C中可能会看到"inf"这个表示,代表无穷大。同样的,下溢出就是小于了浮点数能表示的最小范围,这时可能就会被直接被表示为0,我们从之前所说的浮点数能看出,浮点数的表示范围中是不包括0的,利用浮点数并不能精确地表示出0来。

(2). 什么是位运算

  既然我们说了,不管是什么数据都是以二进制数据的形式存储的,各种语言中都提供了一套运算符,可以针对二进制数直接操作,操作的单位是,所以这一套运算符称为位运算。
  不过由于只有整型是直接将数字转为二进制的,所以我们之后提到的各种位运算符的意义只对整型是有效的。

(3). 移位运算

  移位运算分为左移(<<)右移(>>) 两种,这俩符号还挺直观的,往右就是右移,往左就是左移,接下来看看他们具体的用法:

#include <stdio.h>
int main()
{
    int a = 12;
    printf("a << 1 = %d\n a >> 1 = %d\n", a << 1, a >> 1);
    return 0;
}

p131

  就这么简单,左右都写着数字,然后会返回移位后的对应值,移位操作简单来说就是把一个二进制数的每一位都向着某个方向移动n位,例如: ( 0011 , 0100 , 1001 , 1101 ) 2 < < 2 = ( 1101 , 0010 , 0111 , 0100 ) 2 (0011,0100,1001,1101)_2 << 2 = (1101,0010,0111,0100)_2 (0011,0100,1001,1101)2<<2=(1101,0010,0111,0100)2,例如这是左移2位,就是每一位往左移动2位,然后右边缺少的位数就自动补0,那么左移有没有什么实际意义呢?
  当然是有的,例如上面的例子,12的二进制表示为 ( 0000 , 1100 ) 2 (0000,1100)_2 (0000,1100)2,然后左移一位之后可以得到 ( 0011 , 0000 ) 2 (0011,0000)_2 (0011,0000)2,它对应的就是24,其实逻辑上也很好理解,二进制数的是2,那么每向左移动一位就会让每一位都变成原来的2倍,因此总体表示的数字就可以变成之前的2倍,由此,在不溢出的前提下,左移一位相当于将这个数字乘2,不过这个对于负数可能不成立哦,你可以想想在什么时候会出问题。

  然后是右移,右移一位可以把所有数位变成原来的1/2,这样相当于把整个数字整除2,不过这就涉及到一个问题了,对于正数来说没问题,那对一个负数(以-2147483648为例,它的二进制表示中仅最高位为1),假设和正数一样,最高位补0,那这个数字右移一位之后就会变成+1073741824,虽然绝对值上看是一半,但变成正数了。

#include <stdio.h>
int main()
{
    int a = -2147483648;
    printf("a >> 1 = %d\n", a >> 1);
    return 0;
}

p132

  它还是变成了整除2的结果,那这是为什么呢?这就涉及到了逻辑右移算术右移两种右移方式,逻辑右移n位就是在最高位补n个0
算术右移n位则是在最高位补充n个原最高位的值,也就是正数补0,负数补1,算术右移可以保证得到的结果都是原来的数整除 2 n 2^n 2n的值
  在C中,右移操作默认使用的是算术右移,如果我们要使用逻辑右移,可以把数字先转为对应的unsigned值,再进行右移

  在CPU的机器指令中就有左移和两种右移的指令,这样可以更快地完成乘2和除以2的操作。

(4). 位与、位或、按位取反与按位异或

  对二进制位的运算符还有位与&, 位或|, 按位取反~和按位异或^四种,之所以每个都有一个位,是因为他们都是一位一位对应操作的。让我们来看看吧。
  位与、位或和按位异或都是双目运算符,而按位取反是单目运算符,下面是三个双目运算符的真值表:

&01|01^01
000001001
101111110

  一定要记住,这些操作符是按照每一位对应进行操作的,例如二进制的 0010 , 1101 0010,1101 0010,1101 1011 , 0000 1011,0000 1011,0000做按位异或操作,结果就是 1001 , 1101 1001,1101 1001,1101

(5). 一些位运算的妙用

#1.利用异或取反

  根据上面异或的真值表我们可以发现,0对0异或得0,0对1异或得1,所以假设用0与一个任意的整型值做异或操作,那么得到的结果就应该是它本身。
  同时1对0异或得1,1对1异或的0,所以用1和一个数的某一位做异或,那么就可以把这一位取反,假设有一个数每一位都是1,那就可以把另外一个数每一位都取反了。
  那1的这个性质怎么利用呢?怎么把1送到某一个特定位数上去做异或呢?我们可以这么做:

(1 << p) ^ n

  那假设我需要构造一系列1把其中某几位全部取反怎么办?我们需要构造一个这样的数字:有连续k位为1,且最低位1在第p位,我们可以这么做:

~(~0 << k) << p

  我来稍微解释一下这串代码:首先取反0,这样可以得到一个全部为1的数字,然后向左移k位,那么低位就有k个连续的0,之后再对这个数字取反,这k个连续的0变为1,再右移p次,就可以满足最低位的1在第p位了,还是相当有意思的对吧?

#2.利用异或交换数据

  异或运算的性质也是比较良好的,它有结合律交换律,即a^b = b^a,a^b^c = a^(b^c) = (a^b)^c…并且异或还有一个很有意思的性质:a^b^b = a,利用结合律就是a^(b^b)=a^0=a,所以我们可以利用这个性质完成交换数字的操作:

#include <stdio.h>
int main()
{
    int a = 10, b = 20;
    printf("a = %d, b = %d\n", a, b);
    a ^= b;
    b ^= a;
    a ^= b;
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

p133

  最后我们一个临时变量都没用,就完成了变量a和b值的交换,那看样子这个异或交换数字的操作只能对整数用了呗?浮点数还不能用位运算的,诶,还真不是,在了解了《雷神之锤3》的平方根倒数速算法后,我发现了这么一个有意思的写法:

float c = 1.23;
long i = * (long *)&c;

  这是在做什么呢?很简单,它先取c的地址,然后把这个指针转为long类型的指针,再取值。首先long和float的字节数是相同的,那么取出来的二进制数也是相同的,讲道理用异或交换数字我只需要把二进制表示交换一下就行了,我也不必管是不是浮点数了。
这里再提一嘴,对于二进制的事情我们要用二进制的思维考虑,不要考虑什么数据类型,也不要转换成十进制来理解,这样不但不方便你理解,反而可能还会让你越来越想不通。
  那这么一来,我们交换两个浮点数也可以这么做了:

#include <stdio.h>
int main()
{
    double c = 1.234, d = 99.81;
    printf("c = %.3f, d = %.3f\n", c, d);
    *(long long*)&c ^= *(long long*)&d;
    *(long long*)&d ^= *(long long*)&c;
    *(long long*)&c ^= *(long long*)&d;
    printf("c = %.3f, d = %.3f\n", c, d);
    return 0;
}

p134

  很神奇,对吧?你也可以尝试一下探索一下位运算还有没有什么其他的神奇操作。

#3.利用位与取特定位数

  一个新的问题:我想从某个二进制位数的第p位起连续取出n位数要怎么做呢?首先我们知道,位与的操作中1和0、1做位与得到的就是原来的数据,而0和0、1做位与得到的都是0,这样我们只要构造出对应的数字就行了,我们像这么构造:

~(~0 << n) << p

  眼熟吧?没错,它跟把第p位起的k位取反是完全一样的,然后之后再跟原来的数字做位与就可以得到我们需要的数位了。

#4.数一个整数二进制表示中1的个数

  怎么数一个整数的二进制表示中1的个数呢?首先思考一下,一个二进制数中决定奇偶的位数就是第0位,因为后续位数都是2的倍数,如果第0位为1,那么这个数字就是奇数,反之则是偶数。当然,这样可以写出我们的第一种写法:

#include <stdio.h>
int main()
{
    int n = 0;
    scanf("%d", &n);
    int cnt = 0;
    while (n) {
        cnt += n % 2;
        n /= 2;
    }
    printf("cnt = %d\n", cnt);
    return 0;
}

  不过这又是除以2,又是对2取余的,我们了解了位运算之后显然可以换个写法(之前提过位运算的速度应当更快,当然这个可能会被编译器优化掉),所以我们可以写出第二种写法:

#include <stdio.h>
int main()
{
    int n = 0;
    scanf("%d", &n);
    unsigned int nu = (unsigned int)n;
    int cnt = 0;
    do {
        cnt += nu & 1;
        nu >>= 1;
    } while (nu);
    printf("cnt = %d\n", cnt);
    return 0;
}

  这样就好了,不过记得要转成unsigned类型,这样才能防止在负数的情况下出现问题

(6). 还有一些很神奇的东西

  假设要求一个数字开方的倒数你会怎么写,我猜你会这么写:

#include <math.h>
double y = 1/sqrt(x);

  简单。由于现在的处理器运算速度变得很快,这样的计算时间可以几乎忽略不计,不过假如20多年前的程序需要短时间内频繁计算这样的数据,那这样的计算方式势必会导致卡顿,那怎么办?

  Id Software在1999年推出的《Quake 3》中有这样一种实现:

float Q_rsqrt( float number )
{

  long i;
  float x2, y;
  const float threehalfs = 1.5F;

  x2 = number * 0.5F;
  y = number;
  i = * ( long * ) &y;                       // evil floating point bit level hacking
  i = 0x5f3759df - (i >> 1);                 // what the fuck?
  y = * ( float * ) &i;
  y = y * (threehalfs - ( x2 * y * y ) );    // 1st iteration
  y = y * (threehalfs - ( x2 * y * y ) );    // 2nd iteration, this can be removed

  return y;
}

  先不说那么多,我们测试一下这个函数:
p135

  结果还是相当可以的,虽然有一定的误差,但只要精度要求没那么高,为了运算速度做出这一点牺牲还是值得的。而且这个求取的过程中除了后续的牛顿迭代,完全没有涉及到乘除法,这就显著提升了效率。
  其实这段代码最神秘的地方就在于这个常数0x5f3759df,为什么会有这么一个常数,而且这个常数为什么是这个数字?
以下过程参考来自于:Magic Number - 《雷神之锤3》平方根倒数速算法

  用E表示浮点数的阶码,M表示小数部分,L表示正浮点数的二进制表示,那么:
L = 2 23 × E + M L = 2^{23} \times E + M L=223×E+M
  其实就是把阶码移到23位到31位的位置上去,然后将小数部分放在后面32位。
  之后我们再用F表示正浮点数的十进制值,那么:
F = ( 1 + M 2 23 ) × 2 E − 127 F = (1 + \frac{M}{2^{23}})\times 2^{E-127} F=(1+223M)×2E127
  前面的1是因为小数部分的二进制表示中去掉了最前面的1。

  考虑到F的表达式中有乘方,我们对两边同时取以2为底的对数:
l o g 2 F = l o g 2 ( 1 + M 2 23 ) + E − 127 log_2F = log_2(1+\frac{M}{2^{23}}) + E - 127 log2F=log2(1+223M)+E127
  在 [ 0 , 1 ] [0, 1] [0,1]的范围内, l o g 2 ( 1 + x ) ≈ x log_2(1+x)\approx x log2(1+x)x,如下图中红线为 l o g 2 ( 1 + x ) log_2(1+x) log2(1+x),蓝线为 x x x
p136

  那么就有(其中的 μ \mu μ为校正系数,使得 l o g 2 ( 1 + x ) log_2(1+x) log2(1+x)更加接近 x x x):
l o g 2 F ≈ M 2 23 + E − 127 + μ = 1 2 23 ( 2 23 × E + M ) − 127 + μ = L 2 23 − 127 + μ log_2F \approx \frac{M}{2^{23}} + E - 127 + \mu = \frac{1}{2^{23}}(2^{23}\times E + M) - 127 + \mu = \frac{L}{2^{23}} - 127 + \mu log2F223M+E127+μ=2231(223×E+M)127+μ=223L127+μ

  接下来我们设 Γ = 1 y \Gamma = \frac{1}{\sqrt{y}} Γ=y 1,那么
l o g 2 Γ = − 1 2 l o g 2 y log_2\Gamma = -\frac{1}{2}log_2y log2Γ=21log2y
  之后又可以根据定义写出以下表达式:
Γ = 2 23 × E Γ + M Γ , y = 2 23 × E y + M y \Gamma = 2^{23}\times E_{\Gamma} + M_{\Gamma}, y = 2^{23}\times E_y + M_y Γ=223×EΓ+MΓ,y=223×Ey+My
  综合可以得到:
2 23 × E Γ + M Γ = 3 2 2 23 ( 127 − μ ) − 1 2 ( 2 23 × E y + M y ) 2^{23}\times E_{\Gamma} + M_{\Gamma} = \frac{3}{2}2^{23}(127-\mu)-\frac{1}{2}(2^{23}\times E_y + M_y) 223×EΓ+MΓ=23223(127μ)21(223×Ey+My)

  前面的 3 2 2 23 ( 127 − μ ) \frac{3}{2}2^{23}(127-\mu) 23223(127μ)经过计算得到的就是 1597463007 1597463007 1597463007,即十六进制的 0 x 5 f 3759 d f 0x5f3759df 0x5f3759df

  这里的步骤还是比较简单的哈,如果要看更加具体的解释还是应当参考原文Magic Number - 《雷神之锤3》平方根倒数速算法

小结

  这一章讲了一些基础的位运算知识,还介绍了一些位运算的妙用,当然,更多的内容还是需要你自己去发掘,毕竟我也没有办法面面俱到对吧?
  其实位运算真的有很多很骚的用法,毕竟和人用的进制不一样,人很有可能会受到十进制思维的禁锢。
  位运算在我们现在的编程中已经没有那么常见了,即使是你写的很多位运算操作最后可能都会被编译器给优化掉。现在位运算应该在单片机/嵌入式开发中比较多见,毕竟单片机附带的内存与处理器等都有机能限制,开发的时候还是要比较小心谨慎的。

尾声

  我的这一部C语言教程写到这里就要结束了,耗时大约三个来月,想想三个月来经历了不少事情,能够坚持下来,把教程写完,感觉也算是完成了一个成就了呢!
  因为也是第一次写教程,可能很多地方会有疏漏或者语言不够严谨的地方,还请大家见谅,如果有什么问题可以直接在评论中指出,我一定会尽快改正。当然,如果你愿意将这篇教程分享给别人那就更好了,这是对我的鼓励,在这里谢谢大家了!

  我的下一步计划是写一篇C++基本教程,想想其实还有点不安,毕竟C++这门语言的特性太多,我的教程可能连其中的10%都覆盖不到,不过我认为没有关系,我也不是像语言标准一样把所有内容全部都搬进教程里来,那样对我来说不好写,对各位来说也可能会比较乏味。
  那么,就暂时说再见了!这篇教程中没有提到的一些内容之后可能会以补充篇的形式发到博客当中,敬请期待!

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值