解密——二进制的奥秘

目录

1、二进制和进制转换

1.1二进制与十进制

1.2二进制与八、十六进制

2、数据在内存中的存储

2.1整数在内存中的存储

2.2大小端字节序和字节序判断

什么是大小端

常见练习

2.3浮点数在内存中的存储

浮点数存的过程

浮点数取的过程

3、移位操作符

3.1左移操作符

3.2右移操作符

4、位操作符及其妙用

空间复杂度O(1)交换变量

统计整数存储在内存中的二进制中1的个数

二进制比特位 置0或者置1

位运算技巧

只出现一次的数字


1、二进制和进制转换

我们经常能听到 2进制、8进制、10进制、16进制 这样的讲法,那是什么意思呢?其实2进制、8 进制、10进制、16进制是数值的不同表示形式。

比如:数值15的各种进制的表示形式:

15的2进制:1111

15的8进制:17

15的10进制:15

15的16进制:F

//16进制的数值之前写:0x

//8进制的数值之前写:0

//2进制的数值前写:0b

1.1二进制与十进制

其实10进制的123表示的值是⼀百二十三,为什么是这个值呢?其实10进制的每⼀位是有权重的,10 进制的数字从右向左是个位、⼗位、百位....,分别每⼀位的权重是 10^0,10^1,10^2等

如下图:

2进制和10进制是类似的,只不过2进制的每⼀位的权重,从右向左是:2^0,2^1,2^2等

如果是2进制的1101,该怎么理解呢?

进制转换规则:

对于整数,十进制整数转为二进制整数采用  除2取余,逆序排列


原理分析

假设十进制整数A化得的二进制整数为edcba形式,按权展开有等式A=2^0*a+2^1*b+2^2*c+2^3*d+2^4*e

同时除以2,那么a被余下来,其余的可以整除。依次余下所有数值,后来的余数数位高,所以要逆序排列,正好是 edcba

对于小数,十进制小数转为二进制小数采用 乘2取整,顺序排列

原理分析

假设十进制小数B化得到二进制小数0.ab形式,按权展开有等式

B=b*2^-2+a*2^-1

同时乘以2,a就成了整数部分,取整之后依次类推,值得一提的是小数部分的数位顺序正好和整数部分相反,所以不必反向取整

正好是 ab

总结一下可以看到位数权重比较大的是不管十进制还是二进制都是排在前面

1.2二进制与八、十六进制

8进制的数字每⼀位是0~7的,0~7的数字,各自写成2进制,最多有3个2进制位就足够了,比如7的2进制是111,所以在2进制转8进制数的时候,从2进制序列中右边低位开始向左每3个2进制位会换算一个8进制位,剩余不够3个2进制位的直接换算。

如:2进制的01101011,换成8进制:0153,0开头的数字,会被当做8进制。

16进制的数字每⼀位是0~9,a ~f 的,0~9,a ~f 的数字,各自写成2进制,最多有4个2进制位就足够了, 比如 f 的⼆进制是1111,所以在2进制转16进制数的时候,从2进制序列中右边低位开始向左每4个2进制位会换算⼀个16进制位,剩余不够4个2进制位的直接换算。

 如:2进制的01101011,换成16进制:0x6b,16进制表示的时候前面加0x

2、数据在内存中的存储

2.1整数在内存中的存储

整数的2进制表示方法有三种:原码、反码和补码

有符号整数的三种表示方法均有符号位数值位两部分,2进制序列中,最高位的1位是被当做符号 位,剩余的都是数值位。 符号位都是用0表示“正”,用1表示“负”。

正整数的原、反、补码都相同。 负整数的三种表示方法各不相同。

原码:直接将数值按照正负数的形式翻译成二进制得到的就是原码。

反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。

补码:反码+1就得到补码。

补码得到原码也是可以使用:取反,+1的操作。

对于整形来说:数据存放内存中其实存放的是补码。

为什么呢?

在计算机系统中,数值⼀律用补码表示和存储。原因在于,使用补码,可以将符号位和数值位统一处理;同时,加法和减法也可以统⼀处理(CPU只有加法器)此外,补码与原码相互转换,其运算过程是相同的(取反+1),不需要额外的硬件电路。

2.2大小端字节序和字节序判断

当我们了解了整数在内存中存储后,我们调试看⼀个细节:

//vs2022环境
int main()
{
	int n = 0x11223344;
	return 0;
}

调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的。这是为什么呢?

  • 什么是大小端

其实超过⼀个字节的数据在内存中存储的时候,就有存储顺序的问题,按照不同的存储顺序,我们分为大端字节序存储和小端字节序存储,下面是具体的概念:

大端(存储)模式:

是指数据的低位字节内容保存在内存的高地址处,而数据的高位字节内容,保存在内存的低地址处。

小端(存储)模式:

是指数据的低位字节内容保存在内存的低地址处,而数据的高位字节内容,保存在内存的高地址处。

为什么要有大小端呢??

这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8  bit 位,但是在C语言中除了8 bit  的 char 之外,还有16 bit 的 short 型,32 bit 的 long 型(要看 具体的编译器),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于⼀个字节,那么必然存在着⼀个如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。

  • 常见练习

<1>设计一个小程序判断当前机器的字节序

int main()
{
	int n = 1;
	// 0x 00 00 00 01 大端字节序
	// 0x 01 00 00 00 小端字节序
	
	//如何访问到 n的第一个字节呢??
	if (*(char*)&n == 1) //取到n的地址 强转为char* 一个字节 
		printf("小端\n");
	else
		printf("大端\n");
	return 0;
}

<2>画图分析以下数据输出原理

#include <stdio.h>

int main()
{
	char a = -1;
	signed char b = -1;
	unsigned char c = -1;
	printf("a=%d b=%d c=%d", a, b, c);
	return 0;
}

那么 无符号的char类型取值范围就是0~255了

#include <stdio.h>

int main()
{
	// char是有符号还是无符号是未定义的取决于编译器 vs2022 char==signed char
	char a = -1;
	//10000000000000000000000000000001 原码
	//11111111111111111111111111111111 补码
	// 给了char类型只占一个字节 截断了 11111111 -a
	
	// 打印的是 %d 整形 需要提升类型之后才可以打印!!
	//11111111111111111111111111111111 提升后的补码
	signed char b = -1; //vs2022 char==signed char

	unsigned char c = -1;
	// 11111111 -c 发生截断 但是是无符号char 最高位仍是数值位

	//00000000000000000000000000111111 提升后的补码 
	printf("a=%d b=%d c=%d", a, b, c);
	//      -1    -1   255
	return 0;
}

<3>分析以下程序输出内容

int main()
{
	char a = -128;
	char b = 128;
	printf("%u %u %d %d", a, b, a,b);
	return 0;
}

这里我们需要注意的是 以什么形式打印是我们看数据在内存当中二进制序列的不同方式 但序列本身是不变的。

由上图可知定义变量的数据类型决定着如何整形提升,有符号补1,无符号补0。

int main()
{
	char a = -128;
	//10000000000000000000000010000000 原码
	//11111111111111111111111110000000 补码
	// 整形截断 10000000 -a
	
	char b = 128;
	//00000000000000000000000010000000 原码
	//11111111111111111111111110000000 补码
	// 整形截断 10000000 -b 可以看到与 -a一样

	printf("%u %u %d %d", a, b, a,b);
	// 注意 内存存放的是补码 是不变的 但是以什么形式打印是我们看待内存补码的方式
	// a是char类型 首先整形提升才可以打印
	//11111111111111111111111110000000 提升后
	// 因为是无符号打印 我们看待这串二进制序列是无符号形式 所以反码补码原码是一个 很大的数字

	//对于-b 11111111111111111111111110000000 提升后 打印数字 同-a

	//对于 %d 形式的打印 -a 11111111111111111111111110000000 提升后 翻译成原码 10000000000000000000000010000000 即-128
	//对于 %d 形似的打印 -b 11111111111111111111111110000000 提升后 翻译成原码 10000000000000000000000010000000 即-128
	return 0;
}

<3>画图分析输出值

#include <stdio.h>
int main()
{
	char a[1000]; // char 8个比特位
	int i;
	for (i = 0; i < 1000; i++)
	{
		a[i] = -1 - i;
	}
	// -1 -2 -3 .....-127 -128 127 126 ..... 2 1 0 -1 -2 ....
	printf("%d", strlen(a));// 求的是字符串长度 统计的是\0之前的字符个数
	//255
	return 0;
}

调试监视数组a的内容

<4>分析程序

#include <stdio.h>
unsigned char i = 0;
// unsigned char 取值范围 0~255 再+1呢? 
int main()
{
	for (i = 0; i <= 255; i++)
	{
		printf("hello world\n");
	}
	return 0;
}

可以看到255+1 变为0了 所以陷入死循环

<5>代码输出的结果??

#include <stdio.h>

//X86环境 ⼩端字节序 

int main()
{
	int a[4] = { 1, 2, 3, 4 };
	int* ptr1 = (int*)(&a + 1);
	int* ptr2 = (int*)((int)a + 1);
	printf("%x,%x", ptr1[-1], *ptr2);
	return 0;
}

考察大小端字节序结合指针地址偏移的理解

2.3浮点数在内存中的存储

#include <stdio.h>

int main()
{
 int n = 9;
 float *pFloat = (float *)&n;
 printf("n的值为:%d\n",n);
 printf("*pFloat的值为:%f\n",*pFloat);
 *pFloat = 9.0;
 printf("num的值为:%d\n",n);
 printf("*pFloat的值为:%f\n",*pFloat);
 return 0;
}

输出结果是什么呢?

上面的代码中, num 和 *pFloat 在内存中明明是同⼀个数,为什么浮点数和整数的解读结果会差别这么大? 要理解这个结果,⼀定要搞懂浮点数在计算机内部的表示方法。

根据国际标准IEEE(电气和电子工程协会) 754,任意⼀个二进制浮点数V可以表示成下面的形式:

V   =  (−1) ^ S* M *2^E

  • (−1) ^S 表示符号位,当S=0,V为正数;当S=1,V为负数
  • M 表示有效数字,M是大于等于1,小于2的
  • 2 ^E 表示指数位

举例来说: 十进制的5.0,写成二进制是 101.0 ,相当于 1.01×2^2 。

那么,按上面V的格式,可以得出S=0,M=1.01,E=2。 十进制的 -5.0,写成二进制是 -101.0 相当于 -1.01×2^2 。那么,S=1,M=1.01,E=2。

所以我们可以知道,浮点数的存储,其实存储的就是S、M、E相关的值。

IEEE 754规定:

  • 对于32位的浮点数,最高的1位存储符号位S,接着的8位存储指数E,剩下的23位存储有效数字M

  • 对于64位的浮点数,最高的1位存储符号位S,接着的11位存储指数E,剩下的52位存储有效数字M

浮点数存的过程

IEEE 754 对有效数字M和指数E,还有⼀些特别规定。

前面说过, 1≤M<2,也就是说,M可以写成 1.xxxxxx 的形式,其中 xxxxxx表示小数部分。

IEEE 754 规定,在计算机内部保存M时,默认这个数的第⼀位总是1,因此可以被舍去,只保存后面的 xxxxxx部分。比如保存1.01的时候,只保存01,等到读取的时候,再把第⼀位的1加上去。这样做的目的,是节省1位有效数字。以32位浮点数为例,留给M只有23位,将第⼀位的1舍去以后,等于可以保存24位有效数字。

至于指数E,情况比较复杂  

首先,E为⼀个无符号整数(unsigned int)

这意味着,如果E为8位,它的取值范围为0~255;如果E为11位,它的取值范围为0~2047。但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。比如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。

int main()
{
	float f = 5.5f;
	//二进制 101.1
	//  1.011*2^2
	// (-1)^0*1.011*2^2  S=0,M=1.011,E=2
	//存入内存当中的 E=2+127=129 (加中间值)M舍去整数位 
	//0 10000001 011 00000000000000000000 不够补0
	//0x 40 B0 00 00
	return 0;
}

Tip:浮点数是二进制是无法精准存储的,会出现误差

浮点数取的过程

指数E从内存中取出还可以再分成三种情况:

  • E不全为0或不全为1

这时,浮点数就采用下面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第⼀位的1。 

  • 比如:0.5 的二进制形式为0.1,由于规定正数部分必须为1,即将小数点右移1位,则为1.0*2^(-1),其阶码为-1+127(中间值)=126,表示为01111110,而尾数1.0去掉整数部分为0,补齐0到23位 00000000000000000000000,则其二进制表示形式为:
0 01111110 00000000000000000000000
  • E全为0

这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第⼀位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。

0 00000000 00100000000000000000000
  • E全为1

这时,如果有效数字M全为0,表⽰±⽆穷大(正负取决于符号位S);

0 11111111 00010000000000000000000

回到最初的问题

为什么 *pFloat 值为0呢???

因为以浮点数的视角读取整数存储的n读到的就是0

*pFloat =9.0 后,按照%d形式观察浮点数的视角存储9.0,所以是一个很大的数字

%f形式观察则是正常的。

3、移位操作符

<<左移操作符

>>右移操作符

注:移位操作符的操作数只能是整数。

3.1左移操作符

移位规则:左边抛弃、右边补0

#include <stdio.h>

int main()
{
 int num = 10;
 int n = num<<1;
 printf("n= %d\n", n);
 printf("num= %d\n", num);
 return 0;
}

3.2右移操作符

移位规则:右移运算分两种:

  1. 逻辑右移:左边用0填充,右边丢弃
  2. 算术右移:左边用原始值的符号位填充,右边丢弃

右移动是逻辑还是算术取决于编译器

#include <stdio.h>

int main()
{
 int num = 10;
 int n = num>>1;
 printf("n= %d\n", n);
 printf("num= %d\n", num);
 return 0;
}

可以这么理解算术右移一位,相当于移动权的一位,右移就是2^-1,左移就是2^1

Tip:对于移位运算符,不要移动负数位,这个是标准未定义的。

4、位操作符及其妙用

位操作符有:

&   //按位与     每一个比特位进行与运算,两个位都为1,结果位为1

|    //按位或      每一个比特位进行或运算,只要有一个位为1,结果位为1

^   //按位异或   每一个比特位进行异或运算,两个位相同时,结果位为0,否则为1

 //按位取反  每一个比特位进行取反操作,0变为1,1变为0

注:他们的操作数必须是整数,且均以补码形式参与操作。

位操作符的妙用

  • 空间复杂度O(1)交换变量

我们可以想到算术操作

int main()
{
    int a = 2, b = 3;
    a = a + b;  //a=2+3
    b = a - b;  //b=2+3-3
    a = a - b;  //a=2+3-2

    return 0;
}

有一个问题,当数据很大时会出现溢出!

优化一下,可以采用异或运算

int main()
{
    int a = 2, b = 3;
    a = a ^ b; //a=2^3
    b = a ^ b;//b=2^3^3   3^3抵消了
    a = a ^ b;//a=2^3^2   2^2抵消了
    return 0;
}
  • 统计整数存储在内存中的二进制中1的个数

很容易想到的是依次循环取最后一位如果是1 count++

int count_of_bit(unsigned int m)// 无符号整形 为了兼容负数
{
    int count = 0;
    while (m)
    {
        if (m % 2 == 1)
            count++;
        m /= 2;// %2 /2不停的取最后一位 类比 十进制 %10 /10
    }
    return count;
}

也可以通过按位与循环移位操作

int count_of_bit(unsigned int m)// 无符号整形 为了兼容负数
{
    int count = 0;
    for (int i = 0; i < 32; i++)
    {
        if ((m >> i)&1== 1) //最低位是1才是1 
            count++;
    }
    return count;
}

4个字节要循环32次?能否优化一下呢?

int count_of_bit(unsigned int m)// 无符号整形 为了兼容负数
{
    int count = 0;
    while (m)
    {
        m = m & (m - 1);//让最右边的1消失
        count++;
    }
    return count;
}
  • 二进制比特位 置0或者置1

如何将二进制的某一位置为0或者1呢?这可以用按位与、按位或操作符来实现。

按位与:都是1则1,可以通过与数字0按位与得到0

按位或:是1则1,可以通过与数字1按位或得到1

int main()
{
	int n = 13,i; //i 取 5
	// 00000000000000000000000000001101
	// 00000000000000000000000000000001 向左移动 i-1 位
	// 00000000000000000000000000100000 按位或一下 第 i 位置为 1
	scanf_s("%d", &i); 
	n = n | (1 << (i-1));//第 i 位置为1 
	printf("%d ", n);
	// 00000000000000000000000000011101 //想要将 第 i 位 置为0 第i位必须是0 其他不改变可以是 按位与1
	// 11111111111111111111111111101111 // 如何得到这个二进制呢 观察到互补 ~(1<<(i-1)) 取反即可
	n = n & ~(1 << (i - 1));
	printf("%d ", n);
}
  • 位运算技巧

<1>判断奇偶性

x&1
// 结果1 则说明是奇数
// 结果0 则说明是偶数

<2>获取二进制数第 i 位

(x>>(i-1))&1

//向右移动 i-1位 与1对齐

<3>将二进制数第i位置为1

x|(1<<(i-1))

//将1左移 对齐第i位

<4>获取二进制中最低位的1

lowbit(x)=x&(-x) //可能数据溢出问题

//对于奇数 结果为1
//对于偶数 结果为2^k 一位是1其余位0

<5>判断数字是否为2的幂次方

x&(x-1)
// 如果x为2的幂次方 那么 x二进制仅有一位1
// x-1 将是连续个后续的1
// 按位与之后为0 那就是2的幂次方

这个操作也可以清除最右边的1
  • 只出现一次的数字

136. 只出现一次的数字 - 力扣(LeetCode)

不考虑时间复杂度和空间复杂度的题解很多种情况,在这里根据题目要求我们可使用异或运算 ⊕。异或运算有以下三个性质。

  1. 任何数和 0 做异或运算,结果仍然是原来的数,即 a⊕0=a。
  2. 任何数和其自身做异或运算,结果是 0,即 a⊕a=0。
  3. 异或运算满足交换律和结合律,即 a⊕b⊕a=b⊕a⊕a=b⊕(a⊕a)=b⊕0=b
class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ret = nums[0];
        for (int i = 1; i < nums.size(); i++) {
            ret ^= nums[i];
        }
        return ret;
    }
};

总结:
异或运算性质二和三可以知道 重复两次的数字最后抵消为0,最后任何数和0异或仍是原来的数,所以依次遍历元素即可


137. 只出现一次的数字 II - 力扣(LeetCode)

如何使用为位运算解题呢?我们可以依次确定返回值的每一位比特位

可以看到将每一位比特位求和之后模上3得到的值就是由a的比特位所决定的

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ret=0;//将每个比特位置为0
        for(int i=1;i<=32;i++)
        {
            int bitsum=0;//通过右移 获取 数组元素每一比特位的和
            for(auto arr:nums)
            {
                bitsum += ((arr>>(i-1))&1);
            }
            // 确定ret比特位0还是1
            if(bitsum%3==1)
            {
                ret|=(1<<(i-1));
            }
        }
        return ret;
    }
};

总结:

从二进制位(非零即一)的角度发现特有的规律,可以猜想到如果其他数出现n次我们只需要模上n,最后得到的值就是 ret 的比特位值

260. 只出现一次的数字 III - 力扣(LeetCode)

注意到其他数字出现两次,那么通过异或运算就可以抵消,最后异或和的结果就是两个只出现一次的数字,

我们可以使用位运算 xorsm&(-xorsum) 取出 xorsum 的二进制表示中最低位那个 1,设其为第 l 位,这样一来,我们就可以把 nums 中的所有元素分成两类,其中一类包含所有二进制表示的第 l 位为 0 的数,另一类包含所有二进制表示的第 l 位为 1 的数。

class Solution {
public:
    vector<int> singleNumber(vector<int>& nums) {
        int xorsum=0;
        for(int num:nums)
        {
            xorsum^=num; //最终两个不同值异或的结果
        }

        //取最低有效位1 将不同值分到两个场地中
         // 防止溢出
        // 因为二进制有正负0,负零用于多表示一位负数,这个负数如果取相反数,会产生溢出,所以不能用 a & (-a) 取最低有效位
        // 负0的特点是第一位是1,其余位是0,所以它的最低有效位就是自己
        int lowbit=(xorsum==INT_MIN?xorsum:xorsum&(-xorsum));
        
        int type1=0,type2=0;
        for(int num:nums)
        {
            if((num & lowbit)==0) //将数据分类到两个场地
            type1^=num;
            else
            type2^=num;
        }
        vector<int>v={type1,type2};
        return v;
    }
};

总结:

这里利用了 lowbit 分类到两个不同的场地 将两个只出现一次的数分别转换为在两个场地只出现一次的唯一数,妙不可言!!


数组中只出现一次的数(其它数出现k次)_牛客题霸_牛客网 (nowcoder.com)

此题也可以数组排序,则除了所求数,其余连续k个数必然相等,则每k个数比较前两位。如果相同则比较下一组,否则返回第一位,若比较完没有返回,则返回值是数组最后一位。

 int foundOnceNumber(vector<int>& arr, int k) {
        // write code here 排序解决
        sort(arr.begin(), arr.end());
        int  t;
        for(int i=0;i<arr.size()-1;i++){
            if(arr[i]==arr[i+1]){
                i+=k-1;
                 
            }
            else{
                return arr[i];
            }
        }
        return arr[arr.size()-1];
    }
};

在这里我们还可以使用位运算解决

  int foundOnceNumber(vector<int>& arr, int k) {

        int ret = 0; //将每个比特位置为0
        for (int i = 1; i <= 32; i++) {
            int bitsum = 0; //通过右移 获取 数组元素每一比特位的和
            for (auto v : arr) {
                bitsum += ((v >> (i - 1)) & 1);
            }
            // 确定ret比特位0还是1
            if (bitsum % k == 1) {
                ret |= (1 << (i - 1));
            }
        }
        return ret;
    }
};

总结:

与  只出现一次的数字 || 异曲同工,依次确定比特位即可.

猜你喜欢

转载自blog.csdn.net/2402_82668782/article/details/142602929