一个C语言的基本教程—位运算篇
文章目录
- 13.从底层操纵数据——位运算篇
- (1). 各种数据的存储方式
- #1.无符号整型
- #2.有符号整型
- #3.字符型
- #4.浮点型
- (2). 什么是位运算
- (3). 移位运算
- (4). 位与、位或、按位取反与按位异或
- (5). 一些位运算的妙用
- #1.利用异或取反
- #2.利用异或交换数据
- #3.利用位与取特定位数
- #4.数一个整数二进制表示中1的个数
- (6). 还有一些很神奇的东西
- 小结
- 尾声
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写入内存即可,nnn位无符号整型可以表示的数字范围为[0,2n−1][0, 2^n-1][0,2n−1]。假设把负数赋值给无符号整型,那么最后用%d输出时会根据负数的二进制表示直接转换为对应的十进制数输出。
#2.有符号整型
有符号整型会将最高位作为符号位,当最高位为1时,当前表示的数字就为负数,负数和正数的相互转化可以通过补码的方式完成,一般的步骤为:
- 先把正数的二进制数按位取反
- 将取反后的二进制数加一,即为对应负数的二进制补码
更具体的可以参考第一章中的数据的存储相关内容。
#3.字符型
字符型(char)其实就是一个1字节的整型,按字符打印时会依照ASCII码表将这个数字映射为不同的字符再打印出来。
#4.浮点型
浮点型算是一个重点,我们需要介绍一下IEEE754标准。
首先是科学计数法,用初中一年级的知识,我们可以知道一个十进制数123.456789可以用科学计数法表示为1.23456789×1021.23456789\times 10^21.23456789×102,那么我们对一个二进制小数11011.10111也可以用对应的科学计数法表示为1.101110111×201001.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中被定为2e−1−12^{e-1}-12e−1−1,其中eee为指数位的位数,所以在32位浮点数中这个偏移量就是127,我们可以表示的指数范围即为[−127,128][-127, 128][−127,128],这种移码表示指数部分,也叫作阶码 - 小数位:由于小数部分科学计数法的特征——小数点前一定是1,所以在后面的23位中,我们不再存储这个1,只存储后面的小数位数,例如前面提到的1.1011101111.1011101111.101110111,我们只要存储101110111101110111101110111即可,不过大部分小数转换为二进制小数都是无限小数,存储的过程中只能存23位,后面的位数都被舍弃了,这样一来就会有精度问题,特别是做乘除法的时候,这个精度问题还会被继续放大。
一个64位浮点数(C中的double类型),IEEE754标准中的符号位、指数位和小数位分别为1、11、52,这个小数位数多了这么多,看起来的确是要精确不少呢!
溢出:和整型一样,受限于机器,浮点数也会发生溢出,浮点数一般有上溢出和下溢出两种,上溢出一般指超过了一个浮点数能表示的最大数字,例如用一个32位浮点数表示8.5×10398.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;
}
就这么简单,左右都写着数字,然后会返回移位后的对应值,移位操作简单来说就是把一个二进制数的每一位都向着某个方向移动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;
}
它还是变成了整除2的结果,那这是为什么呢?这就涉及到了逻辑右移和算术右移两种右移方式,逻辑右移n位就是在最高位补n个0。
算术右移n位则是在最高位补充n个原最高位的值,也就是正数补0,负数补1,算术右移可以保证得到的结果都是原来的数整除2n2^n2n的值。
在C中,右移操作默认使用的是算术右移,如果我们要使用逻辑右移,可以把数字先转为对应的unsigned值,再进行右移。
在CPU的机器指令中就有左移和两种右移的指令,这样可以更快地完成乘2和除以2的操作。
(4). 位与、位或、按位取反与按位异或
对二进制位的运算符还有位与&, 位或|, 按位取反~和按位异或^四种,之所以每个都有一个位,是因为他们都是一位一位对应操作的。让我们来看看吧。
位与、位或和按位异或都是双目运算符,而按位取反是单目运算符,下面是三个双目运算符的真值表:
& | 0 | 1 | | | 0 | 1 | ^ | 0 | 1 |
---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 |
1 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
一定要记住,这些操作符是按照每一位对应进行操作的,例如二进制的0010,11010010,11010010,1101和1011,00001011,00001011,0000做按位异或操作,结果就是1001,11011001,11011001,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;
}
最后我们一个临时变量都没用,就完成了变量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;
}
很神奇,对吧?你也可以尝试一下探索一下位运算还有没有什么其他的神奇操作。
#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 hackingi = 0x5f3759df - (i >> 1); // what the fuck?y = * ( float * ) &i;y = y * (threehalfs - ( x2 * y * y ) ); // 1st iterationy = y * (threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removedreturn y;
}
先不说那么多,我们测试一下这个函数:
结果还是相当可以的,虽然有一定的误差,但只要精度要求没那么高,为了运算速度做出这一点牺牲还是值得的。而且这个求取的过程中除了后续的牛顿迭代,完全没有涉及到乘除法,这就显著提升了效率。
其实这段代码最神秘的地方就在于这个常数0x5f3759df,为什么会有这么一个常数,而且这个常数为什么是这个数字?
以下过程参考来自于:Magic Number - 《雷神之锤3》平方根倒数速算法
用E表示浮点数的阶码,M表示小数部分,L表示正浮点数的二进制表示,那么:
L=223×E+ML = 2^{23} \times E + ML=223×E+M
其实就是把阶码移到23位到31位的位置上去,然后将小数部分放在后面32位。
之后我们再用F表示正浮点数的十进制值,那么:
F=(1+M223)×2E−127F = (1 + \frac{M}{2^{23}})\times 2^{E-127}F=(1+223M)×2E−127
前面的1是因为小数部分的二进制表示中去掉了最前面的1。
考虑到F的表达式中有乘方,我们对两边同时取以2为底的对数:
log2F=log2(1+M223)+E−127log_2F = log_2(1+\frac{M}{2^{23}}) + E - 127log2F=log2(1+223M)+E−127
在[0,1][0, 1][0,1]的范围内,log2(1+x)≈xlog_2(1+x)\approx xlog2(1+x)≈x,如下图中红线为log2(1+x)log_2(1+x)log2(1+x),蓝线为xxx
那么就有(其中的μ\muμ为校正系数,使得log2(1+x)log_2(1+x)log2(1+x)更加接近xxx):
log2F≈M223+E−127+μ=1223(223×E+M)−127+μ=L223−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 + \mulog2F≈223M+E−127+μ=2231(223×E+M)−127+μ=223L−127+μ
接下来我们设Γ=1y\Gamma = \frac{1}{\sqrt{y}}Γ=y1,那么
log2Γ=−12log2ylog_2\Gamma = -\frac{1}{2}log_2ylog2Γ=−21log2y
之后又可以根据定义写出以下表达式:
Γ=223×EΓ+MΓ,y=223×Ey+My\Gamma = 2^{23}\times E_{\Gamma} + M_{\Gamma}, y = 2^{23}\times E_y + M_yΓ=223×EΓ+MΓ,y=223×Ey+My
综合可以得到:
223×EΓ+MΓ=32223(127−μ)−12(223×Ey+My)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)
前面的32223(127−μ)\frac{3}{2}2^{23}(127-\mu)23223(127−μ)经过计算得到的就是159746300715974630071597463007,即十六进制的0x5f3759df0x5f3759df0x5f3759df。
这里的步骤还是比较简单的哈,如果要看更加具体的解释还是应当参考原文:Magic Number - 《雷神之锤3》平方根倒数速算法
小结
这一章讲了一些基础的位运算知识,还介绍了一些位运算的妙用,当然,更多的内容还是需要你自己去发掘,毕竟我也没有办法面面俱到对吧?
其实位运算真的有很多很骚的用法,毕竟和人用的进制不一样,人很有可能会受到十进制思维的禁锢。
位运算在我们现在的编程中已经没有那么常见了,即使是你写的很多位运算操作最后可能都会被编译器给优化掉。现在位运算应该在单片机/嵌入式开发中比较多见,毕竟单片机附带的内存与处理器等都有机能限制,开发的时候还是要比较小心谨慎的。
尾声
我的这一部C语言教程写到这里就要结束了,耗时大约三个来月,想想三个月来经历了不少事情,能够坚持下来,把教程写完,感觉也算是完成了一个成就了呢!
因为也是第一次写教程,可能很多地方会有疏漏或者语言不够严谨的地方,还请大家见谅,如果有什么问题可以直接在评论中指出,我一定会尽快改正。当然,如果你愿意将这篇教程分享给别人那就更好了,这是对我的鼓励,在这里谢谢大家了!
我的下一步计划是写一篇C++基本教程,想想其实还有点不安,毕竟C++这门语言的特性太多,我的教程可能连其中的10%都覆盖不到,不过我认为没有关系,我也不是像语言标准一样把所有内容全部都搬进教程里来,那样对我来说不好写,对各位来说也可能会比较乏味。
那么,就暂时说再见了!这篇教程中没有提到的一些内容之后可能会以补充篇的形式发到博客当中,敬请期待!
一个C语言的基本教程—位运算篇相关推荐
- c语言用位运算将一个数清零,C语言学习笔记_位运算
C语言学习笔记_位运算 知识点记录 基本位运算 按位与:全1为1,见0为0:与1相与无变化,与0相与变为0:可用于特定位清零 按位或:见1为1,全0为0:与1相或变为1,与0相或无变化:可用于特定位置 ...
- python语言中1010的二进制表示_Python语言中的按位运算
(转)位操作是程序设计中对位模式或二进制数的一元和二元操作. 在许多古老的微处理器上, 位运算比加减运算略快, 通常位运算比乘除法运算要快很多. 在现代架构中, 情况并非如此:位运算的运算速度通常与加 ...
- c语言位运算试题及解析,C语言面试题分类-位运算
1.不用临时变量交换两个整数. a = a ^ b; b = a ^ b; a = a ^ b; 2.实现一个函数,输入一个整数,输出该数二进制表示中1的个数.例如9的二进制是1001,则输出2. i ...
- 易语言 位异或c,易语言教程位运算命令(位取反、位于、位或、位异或)
比特位 比特位就是转换成二进制之后每一位数,bit中文名称是位,音译"比特",是用以描述电脑数据量的最小单位. bit 来自binary digit (二进制数字) 二进制数系统中 ...
- 一个C语言的基本教程—指针篇
文章目录 9. C语言真正的灵魂--指针 (1). 指针到底是什么? (2). 指针的基本结构 (3). 取地址(&)和解引用(*)操作符 (4). 指针有什么用呢? #1.swap函数的例子 ...
- 嵌入式linux寄存器位运算,嵌入式linux C语言(一)――位运算的使用
嵌入式linux C语言(一)――位运算的使用 ARM是内存与IO统一编址,SoC中有很多控制寄存器,通过对这些寄存器进行位运算对这些控制寄存器进行设置,进而控制外设功能.在修改寄存器某些位的过程中不 ...
- c语言集合交并补 位运算实现
#include <stdio.h> #include <stdlib.h>/*集合元素的输入*/ long long change_string(char *ch){//将元 ...
- 江哥带你玩转C语言 | 09 - C语言进制和位运算
进制基本概念 什么是进制? 进制是一种计数的方式,数值的表示形式 常见的进制 十进制.二进制.八进制.十六进制 进制书写的格式和规律 十进制 0.1.2.3.4.5.6.7.8.9 逢十进一 二进制 ...
- c语言或者cpp中位运算的技巧
简述 在知乎上看到一个题目,解答很有意思,用的是位运算. 这让我觉得位运算有更多的算法可能,但是却还没被我用过. 这种东西都是第一次看,觉得挺牛的,第二次,第三次看的时候就觉得没什么了.So,大佬们轻 ...
最新文章
- 10次机会 js 猜数_题目:(由计算机产生一个1~100的随机数,然后键入猜想数,最多猜10次跳出)请问大神们这个代码哪错了?...
- 数据结构与算法(C++)– 动态规划(Dynamic Programming)
- 头同尾合十的算法_乘法速算之首同尾合十
- yolov5搭建环境_Yolov5环境配置和训练私有数据,YOLOv5,以及,私人
- 短信hz,批量注册、模拟登陆很难吗?一个Python爬虫案例告诉你答案!
- 设计一种网络分播软件
- 4999元起!三星在中国正式发布Galaxy S22系列
- linux的文件权限分析
- 基于SSM的Java Web应用开发原理初探
- 一文解读聚类中的两种流行算法
- CSS优先级、CSS选择器、编写CSS时的注意事项
- 002.FTP配置项详解
- 第二次作业-Steam软件分析
- J-link 报错解决方法
- Java数组的扩容与缩减
- 时光飞逝,博客两周年啦
- 如何用命令行和carbite c++生成sis文件
- 浙江大学	找出直系亲属(java)
- oracle数据库安装与打开,Oracle数据库在Linux 中的安装与启动动
- SCNN--车道线检测