进制与位运算

进制

12 个物品被称为 1 打,12 打被称为 1 罗,12 罗被称为 1 格。请问:

  1. 15 个是几打几个?
  2. 6775 个是几格几罗几打?
  3. 2 打 3 个是多少个?
  4. 1 格 9 罗 8 打 10 个是多少个?

以上的“几”均是不小于 0 且小于 12 的整数

分析
  1. 根据 15÷12=1···3,15 个除以每打是几个,就是几打,剩下的就是几个。因此 15 个是 1 打余 3 个。如果把这两个数字写到一起,写成 \((13)_{12}\),则这种计数方式被称为十二进制,因为逢 12 进一位。在这种计数方式下,\((13)_{12}\) 代表 1 打 3 个,等于十进制的 15
  2. 根据 6775÷12=564···7,说明 6775 个等于 564 打余 7 个;564÷12=47···0,说明 564 打等于 47 罗余 0 打;47÷12=3···11,说明 47 罗等于 3 格余 11 罗。因此 6775 个等于 3 格 11 罗 0 打 7 个,若写成 \((31107)_{12}\),则会产生歧义,因为 11 占了两位,而最好每个余数都能表示成一位数。可以另字母 A 为 10,令 B 为 11,这样就可以表示成 \((3B07)_{12}\),因此在十二进制下的 3B07 等于十进制的 6775
  3. 显然是 \(2 \times 12 + 3 = 27\),这说明 \((23)_{12}=(27)_{10}\)
  4. \(1 \times 12^3 + 9 \times 12^2 + 8 \times 12^1 + 10 \times 12^0 = 3130\),这说明 \((198A)_{12}=(3130)_{10}\)

在十六进制下的 ABCD 等于十进制的多少?

分析

十六进制下的一位中,A 代表 10,B 代表 11,C 代表 12,D 代表 13,E 代表 14,F 代表 15,那么 ABCD 转换为十进制就是:\(10 \times 16^3 + 11 \times 16^2 + 12 \times 16^1 + 13 \times 16^0 = 43981\)

任何除了 0 之外的自然数的 0 次方都是 1,对于十六进制来说,每个“个位数”都代表 1 个,每个“十位数”权重是 16,每个“百位数”权重是 \(16^2\),每个“千位数”权重是 \(16^3\),所以可以写成 \((ABCD)_{16} = (43981)_{10}\)

对比十进制很好理解,在十进制下,个位数代表 1 个,十位数权重是 10,百位数权重是 \(10^2\),千位数权重是 \(10^3\),万位数权重是 \(10^4\),在十进制下,可以得到一个显然的结论:\(43981 = 4 \times 10^4 + 3 \times 10^3 + 9 \times 10^2 + 8 \times 10^1 + 1 \times 10^0\)

image

不同进制下同样的数量表示出来的数字看起来差别很大,但是它们表示的是相同的数量,不同进制下“基底”不同——十六进制的基底是 16,而十进制的基底是 10

十进制下的 114514 在十六进制中表示为什么?

分析

每次除以 16 然后取余数,记录所有得到的余数

image

将原来的十进制数字每次除以基底(16),然后分别记录下商和余数,然后继续将商除以 16,以此反复,直到商为 0 为止。从下往上记录每一个得到的余数,就是对应的十六进制数。即 \((114514)_{10} = (1BF52)_{16}\),注意两位数的余数在十六进制下要用字母表示

二进制和其他进制的原理并没有什么不同,但是二进制的特殊之处在于能使用最少的符号数量(0 和 1)表示出所有的整数

二进制下的 10101101 是十进制的多少?

分析

二进制数 10101101 转换为十进制数,最右边一位代表 1,右边第二位代表 2,第三位代表 \(4=2^2\)……最左边一位(其实是从右边数的第 8 位)代表 \(128=2^7\),因此将各位代表的数字加起来,答案就是 \(2^0+2^2+2^3+2^5+2^7=173\)

十进制下的 89 在二进制下如何表示?

分析

image

由于二进制中只使用 0 和 1 两种符号,非常适合使用电子方式实现运算过程

image


选择题:二进制数 \(00101010_2\)\(00010110_2\) 的和为?

  • A. \(00111100_2\)
  • B. \(01000000_2\)
  • C. \(00111100_2\)
  • D. \(01000010_2\)
答案

B


选择题:选出以下最大的数?

  • A. \((550)_{10}\)
  • B. \((777)_8\)
  • C. \(2^{10}\)
  • D. \((22F)_{16}\)
答案

C

为了比较不同进制的数的大小,最直接的方法是把它们全部转换成最熟悉的十进制(基数为 10)数,然后进行比较。

A 选项已经是十进制表示,就是 550。

B 选项是一个八进制数,转换成十进制的方法是按权展开:\(7 \times 8^2 + 7 \times 8^1 + 7 \times 8^0 = 511\)

C 选项就是 1024。

D 选项是一个十六进制数,其中字母 F 代表十进制的 15,转换方法同样是按权展开:\(2 \times 16^2 + 2 \times 16^1 + 15 \times 16^0 = 559\)


选择题:下列四个不同进制的数中,与其它三项数值上不相等的是?

  • A. \((269)_{16}\)
  • B. \((617)_{10}\)
  • C. \((1151)_8\)
  • D. \((1001101011)_2\)
答案

D

为了比较不同进制的数的大小,最常用的方法是把它们全部转换成最熟悉的十进制数,然后进行比较。

\((269)_{16} = 2 \times 16^2 + 6 \times 16^1 + 9 \times 16^0 = 617\)

\((1151)_8 = 1 \times 8^3 + 1 \times 8^2 + 5 \times 8^1 + 1 \times 8^0 = 617\)

\((1001101011)_2 = 1 \times 2^9 + 1 \times 2^6 + 1 \times 2^5 + 1 \times 2^3 + 1 \times 2^1 + 1 \times 2^0 = 619\)


例题:P1143 进制转换

分析:可以先将输入的 n 进制数转换为十进制数,然后再将这个十进制数转换为 m 进制数

参考代码
#include <cstdio>
#include <cstring>
const int N = 40;
char num[N];
int ans[N];
int char_to_int(char ch) { // 单个数位符号转成数字
    return ch >= '0' && ch <= '9' ? ch - '0' : ch - 'A' + 10;
}
char int_to_char(int x) { // 数字转成单个数位符号
    return x < 10 ? '0' + x : x - 10 + 'A';
}
int main()
{
    int n, m;
    scanf("%d%s%d", &n, num, &m);
    int dec = 0, len = strlen(num);
    // 原数转换为十进制
    for (int i = 0; i < len; i++) dec = dec * n + char_to_int(num[i]);
    len = 0;
    // 转换为m进制
    while (dec != 0) {
        ans[len] = dec % m; dec /= m; len++;
    }
    // 输出转换好的数字
    for (int i = len - 1; i >= 0; i--) printf("%c", int_to_char(ans[i]));
    printf("\n");
    return 0;
}

本程序中定义了两个函数可以将 char 类型的一位字符转换为 int(例如 '5' 变成 5,'C' 变成 12),也可以将一个 int 类型的数字转换为 char(例如 8 变成 '8',15 变成 'F')

转换为十进制时,不需要每次计算 n 的幂,可以使用迭代的方式(秦九韶算法)提升效率

转换为 m 进制时,由于最先计算得到的余数是最低位,然后是次低位……所以要将这些余数存入数组中,全部计算完毕后反着输出对应的字符

补充:一位十六进制数码对应 4 位数的二进制数码,所以将十六进制和二进制之间相互转换时可以不用十进制为中间跳板,直接进行翻译即可(二进制需要四位四位分组,必须从右向左分组)。例如,二进制数 1010110111 经过分组可以变为 0010 1011 0111,直接口算得到 2B7,反之亦然


选择题:C++ 语言中,以 0b 开头的数是什么进制的数?

A. 二进制
B. 八进制
C. 十进制
D. 十六进制

答案

A。0b 开头的是二进制,0o 开头的是八进制,0x 开头的是十六进制。


选择题:若 \(n = \sum_{i=0}^k 16^i \cdot x_i\),定义 \(f(n) = \sum_{i=0}^k x_i\),其中 \(x_i \in \{ 0, 1, \dots, 15 \}\)。对于给定的自然数 $n_0,存在序列 \(n_0, n_1, n_2, \dots, n_m\),其中对于 \(1 \le i \le m\) 都有 \(n_i = f(n_{i-1})\),且 \(n_m = n_{m-1}\),称 \(n_m\)\(n_0\) 关于 \(f\) 的不动点。问在 \(100_{16}\)\(1A0_{16}\) 中,关于 \(f\) 的不动点为 \(9\) 的自然数个数为?

  • A. 10
  • B. 11
  • C. 12
  • D. 13
答案

\(f(n)\) 的意思实际上就是计算一个十六进制数各位数字相加之和,例如,如果 \(n = 1A_{16}\),它的数位是 1 和 A(即 10),那么 \(f(1A_{16}) = 1 + 10 = 11 = B_{16}\)

显然 \(f(9_{16}) = 9_{16}\),可以先考虑各个数位相加等于 9 的数字,在题目范围内的有(以下皆为十六进制形式):108、117、126、135、144、153、162、171、180。

\(f(18_{16}) = f(9_{16})\),可以考虑各个数位相加等于 24 的数字,在题目范围内的有(以下皆为十六进制形式):18F、19E。

各个数位相加总和更大的情况不可能落在题目范围内了。

所以答案选 B


完善程序题:

优美的进制。问题:给出整数 \(n\),认为 \(k\) 进制是优美的,当且仅当 \(n\)\(k\) 进制下至少有两位,且每一位的数值都不同。求对于给定的 \(n\),有哪些进制是优美的,不存在则输出 \(-1\)。试补全程序。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100000;
int n;
int vis[MAXN], a[MAXN];
vector<int> ans;
int check(int k) {
    int x = n, top = 0;
    for (int i = 0; i <= k; i++) vis[i] = 0;
    while (①) {
        a[++top] = ②;
        x = ③;
    }
    if (top < 2) 
        return 0;
    for (int i = 1; i <= top; i++) {
        if (④)
            return 0;
        vis[a[i]] = 1;
    }
    return 1;
}
int main() {
    cin >> n;
    for (int i = ⑤; i <= n; i++) {
        if (check(i))
            ans.push_back(i);
    }
    if (ans.empty()) {
        cout << -1;
    }
    for (int i = 0; i < ans.size(); i++)
        cout << ans[i] << " ";
    return 0;
}

①处应填?
A. x>0 / B. x>1 / C. x/k>0 / D. x/k>1

②处应填?
A. x/k / B. x%k / C. (x-1)/k+1 / D. (x-1)%k+1

③处应填?
A. x/k / B. x%k / C. (x-1)/k+1 / D. (x-1)%k+1

④处应填?
A. vis[i]==1 / B. vis[a[i]]==0 / C. vis[i]==0 / D. vis[a[i]]==1

⑤处应填?
A. 1 / B. n-1 / C. 2 / D. 0

答案

程序的整体思路是枚举每一种进制,将 \(n\) 转换成对应进制下的结果,并检查“是否每一位数值都不同”,将符合条件的进制记录下来,最终输出。

image

答案:ABADC。

二进制与数据存储

计算机内存只能一位一位地存储 0 和 1,那么内存中如何存储各种数据类型?
假设在 C++ 中定义了这些变量:

int a = 233;
int b = -233;
float c = 3.14;
char d[4] = "Ha!";

image

上图表现了计算机内存中变量的存储方式,“0x”是十六进制数字前的前缀

内存非常大,如果希望定位到某个变量,就需要知道这个变量所在的地址。假如在一台 32 位计算机中,地址是 32 位二进制数,可以缩写为 8 位十六进制。一个 0 或 1 的数码被称为一,8 位被称为一字节,也就是 1B(Byte)

一个 int 类型或 float 类型的变量占用 32 位空间。十进制数字 233 转换为二进制数字为 11101001,所以 233 存储在内存中,在低位(右边)填入 11101001,而左边(高位)用 0 填充。十进制的 -233 是一个负数,在内存中就会表示为 1···100010111,高位是用 1 填充的。而浮点数比较复杂,需要将十进制的浮点数转换为二进制的浮点数,然后在内存中分别记录符号、指数和有效数字

一个 char 类型的变量占用 8 位,大小为 4 的 char 类型数组占用 32 位。将这个数组中的每个原数的 ASCII 码转换为二进制后直接存入内存中。例如,'H' 的 ASCII 码值为 72,'a' 的 ASCII 码值为 97,'!' 的 ASCII 码值为 33;字符串最后还有一个 '\0',对应的值是 0

定义一个变量,就会为这个变量准备一块内存空间,并记录这个空间的起始地址,当访问到这个变量的时候,就会根据地址在内存中找到这个变量的值

有些变量类型也有无符号数,例如 unsigned int 类型,这个类型和 int 类型一样占用 32 位,但是以放弃存储正负符号为代价,可以存储 \(0\)\(2^{32}-1\)

计算机中还有其他表示数据大小的单位,比如 1KB 是 \(2^{10}=1024\) 字节,1MB 是 \(2^{20}\) 字节,1GB 是 \(2^{30}\) 字节


选择题:计算机系统用小端(Little Endian)和大端(Big Endian)来描述多字节数据的存储地址顺序模式,其中小端表示将低位字节数据存储在低地址的模式、大端表示将高位字节数据存储在低地址的模式。在小端模式的系统和大端模式的系统分别编译和运行以下 C++ 代码段表示的程序,将分别输出说明结果?

unsigned x = 0xDEADBEEF;
unsigned char *p = (unsigned char *)&x;
print("%X", *p);
  • A. EF、EF
  • B. EF、DE
  • C. DE、EF
  • D. DE、DE
答案

正确答案是 B

代码分析

  1. unsigned x = 0xDEADBEEF;:定义一个 4 字节的无符号整数 x
    • 高位字节是 DE
    • 低位字节是 EF
  2. unsigned char *p = (unsigned char *)&x;:定义一个指向 unsigned char 的指针 p,并让它指向变量 x 的起始(最低)内存地址。因为 pchar 指针,所以它一次只会访问 1 个字节。
  3. print("%X", *p);:解引用指针 p,也就是读取 x 存储位置的第一个字节,并以十六进制 %X 格式打印出来。

在小端模式的系统上

低位字节存储在低地址,*p 读取的是低地址的第一个字节,即 EF

image


在大端模式的系统上

高位字节存储在低地址,*p 读取的是低地址的第一个字节,即 DE

image


负数转二进制

考虑到 int 占用 32 位太长,这里使用只占 8 位的 signed char 类型举例,57 用二进制表示为 00111001(补足 8 位)。要表示一个负数,那就要占用最高位的一位来表示正负,0 表示非负,1 表示负数

  • 用除了第一位的数字表示这个负数的绝对值,第一位变成 1,这样 -57 表示为 10111001,这种表示方式称为原码。一般计算机不使用这种方式来表示负数

  • 将负数的绝对值对应的数全部取反,由 1 变为 0,由 0 变为 1,这样 -57 表示为 11000110,这种表示方式称为反码。使用反码有一个问题:0 有两种表示方式(全 0 和全 1),所以也不常用

  • 先计算负数的反码,然后加 1,这样 -57 表示为 11000111,这是计算机使用的表示负数的方法,被称为补码。这种表达方式下,0 只有 1 个,全 1 代表 -1

有了补码这种表示负数的方式,计算机就可以很方便地计算二进制减法了。例如要计算 66-57 时,可以认为是 66+(-57)。66 的二进制是 01000010,-57 的二进制是 11000111,列竖式累加

  ..    ..  (进位记号)
  0100 0010
+ 1100 0111
-----------
 10000 1001

由于这个数字溢出了 8 位,所以只取低位数的 8 位,得到的答案是 00001001,也就是十进制下的 9。补码这种非常巧妙的设计使得计算机可以化减为加。但是谈论到补码时必须要确定总位数,例如 8 位下的有符号整数实质上是在 -128~127 之间形成了一个环。

使用补码,不论是正数加正数、正数加负数还是负数加负数,都可以直接使用同一套加法逻辑。硬件不需要进行任何“条件判断”,只需要将两个补码数直接相加即可。运算结果(包括符号位)自然就是正确的补码表示。减法 \(A-B\) 就变成了计算 \(A\) 的补码加上 \(B\) 的负数补码。

为什么加法逻辑统一如此重要?

  1. 简化硬件设计:CPU 的核心 —— ALU 只需要一套加法器电路。这大大降低了芯片的复杂度、尺寸和制造成本。
  2. 提高运算速度:一套电路意味着更短的信号路径。不需要额外的逻辑门来判断符号或处理特殊情况,每个时钟周期可以完成更多的工作,从而提升了 CPU 的整体性能。
  3. 降低功耗:更少的晶体管和更简单的逻辑意味着更低的能耗。

总的来说,计算机的底层是物理电路,电路的设计追求极致的简洁和高效。补码通过巧妙的数学转换为代价高昂的“逻辑判断”和“多套电路”问题,变成了一个简单的、统一的“加法”物理过程。这正是计算机科学中优雅与效率的完美体现。

使用 memset 给 int 数组初始化时,如果想要精准的初始化,只能初始化为 0 或 -1(而给一个其他数字,则不会将数组初始化为这个数字),因为 memset 只能将一片数组区域的每一个字节初始化为这个数字(小于 255),而一个 int 是由 4 字节组成的,所以只能填充成全 0(最后的值还是 0)或者全 1(最后的值是 -1),所以通常只使用 memset(a, 0, sizeof(a)) 或者 memset(a, -1, sizeof(a)) 这样的写法将整个数组初始化为 0 或 -1。而如果写出 memset(a, 3, sizeof(a)) 这样的代码时,实际上是把每个元素初始化成了 0x03030303

小数转二进制

将实数从十进制转换为二进制,可以将整数部分和实数部分分别处理。如 3.14,整数部分的 3 是二进制的 11;而小数部分 0.14 如下图所示处理

image

将原来的小数数字,每次都乘 2,如果得到的整数部分是 1,则答案记录一个 1,并去掉这个整数部分,然后继续运算;如果得到的结果中的整数部分还是 0,那么答案记录一个 0,继续计算。因此 3.14 表示为二进制数是 11.00100011···,在二进制下是一个无限小数。因此,这就是计算机浮点数类型无法精确表示很多实数的原因

那么,如何将一个二进制小数转换成十进制呢?例如 101.101,同样将整数部分和小数部分分开,整数部分十进制是 5,小数部分的计算方式和整数转换方式差不多:\(1 \times 2^{-1} + 0 \times 2^{-2} + 1 \times 2^{-3} = 0.625\),所以整个数的十进制就是 5.625

计算题:十进制数 10.375 转换为八进制数的结果是?

答案

12.3

逻辑命题

在逻辑学中,命题指的是判断一件事情的陈述句,且有明确的真伪。一般用 1 表示真命题,用 0 表示伪命题

多个命题可以进行复合,进行与、或、非、异或等操作

  1. 或:\(A \vee B\),两个命题中至少有一个真命题时,其复合命题为真

  2. 与:\(A \wedge B\),两个命题必须全为真命题,其复合命题才是真命题

  3. 非:\(\lnot A\),将原命题取反

  4. 异或:\(A \oplus B\),两个命题一真一假时复合命题为真,等价于 \((A \wedge \lnot B) \vee (\lnot A \wedge B)\)

有时为了简化逻辑表达式,可以将或运算变成加号,与运算变成乘点(甚至可以省略),而非运算变成上划线。例如,\(\lnot ((A \wedge \lnot B) \vee (\lnot A \wedge B))\) 可以表示为 \(\overline{A \overline{B} + \overline{A} B}\)

之所以能将或运算变成加号、与运算变为乘号,是因为逻辑运算有和普通代数运算有类似的性质,而且与运算的优先级高于或运算

  1. 交换律:AB=BA, A+B=B+A

  2. 结合律:(AB)C=A(BC), (A+B)+C=A+(B+C)

  3. 分配律:\(A(B+C)=AB+AC\)

除此之外,还有一些显然的性质:

  1. A+1=1, 0A=0

  2. \(AA=A, A+A=A, A + \overline{A} = 1\)

还有一个非常重要的德·摩根定律,使得与运算和或运算可以在一定条件下互相转化:

  1. \(\overline{A} + \overline{B} = \overline{AB}, \overline{A} \cdot \overline{B} = \overline{A+B}\)

以上逻辑运算性质可以化简一个复杂的逻辑表达式,便于求出逻辑表达式的值

化简逻辑表达式为最简与或式。由“与运算”连接的一组变量(或者带非运算的变量)叫“与项”,将一些“与项”用“或运算”连接的表达式为与或式。最简与或式是指与项数量最少,同时每项数量也最少的与或式

编程时有时会出现一些比较复杂的条件判断语句,可以使用化简逻辑运算的方法与技巧来化简判断语句,或者明确有些判断语句是等效的。例如 !((x <= 0 || x > 5) && (y <= 0 || y > 10)) 等价于 x > 0 && x <= 5 || y > 0 && y <= 10

image

答案

A

位运算

#include <cstdio>
int main()
{
    int a = 85, b = 51;
    int p = a & b;
    int q = a | b;
    int r = a ^ b;
    int s = ~a;
    int u = a << 2;
    int v = a >> 3;
    printf("%d %d %d %d %d %d\n", p, q, r, s, u, v);
    return 0;
}

运行程序,得到的结果是

17 119 102 -86 340 10

这里涉及到了 C++ 中的位运算,也就是直接对整数在内存中的二进制位进行按位操作

& 运算是按位与,注意只有一个符号,&& 是逻辑与。该符号将前后两个操作数按位对齐,然后每一位上都进行与运算,最后得到位运算的结果。例如,85 的二进制数为 1010101,51 的二进制数为 110011,计算过程是这样的(int 类型是 32 位二进制数):

   a 0000 0000 0000 0000 0000 0000 0101 0101
&  b 0000 0000 0000 0000 0000 0000 0011 0011
--------------------------------------------
   p 0000 0000 0000 0000 0000 0000 0001 0001 

可以发现,每一位都进行了与运算,最后得到的结果是 10001,也就是十进制的 17

| 符号是按位或;^ 符号是按位异或。异或运算符的优先级高于按位或运算,但是低于按位与运算

而 ~ 符号是取反;<< 符号是按位左移;>> 符号是按位右移,它们运行的机理是这样的:

   a: 00000000000000000000000001010101
  ~a: 11111111111111111111111110101010
a<<2: 00000000000000000000000101010100
a>>3: 00000000000000000000000000001010

可见,取反就是将这个数字的二进制数 0 变 1、1 变 0,然后根据前面介绍的补码,就可以知道转换后的数字。对于带符号整数来说,~a 的值和 -a-1 的值是一样的。

而左移是将这个二进制数的所有位数往左移动指定的位数,右边用 0 补齐,左边截掉。而右移则是将这个二进制数的所有位数往右移动指定的位数,右边截掉。右移时,如果原数是非负数,则左边补 0,否则左边补 1,因此 a<<n 等于 a 乘 2 的 n 次方,a>>n 等于 a 整除 2 的 n 次方(负数右移运算是下取整的整除,如 (-3) >> 1 是 -2)。在使用左移和右移运算符时,左移/右移的位数非负且注意值不要溢出。

如果对位运算的优先级不熟悉,建议编程时多打括号。


选择题:二进制数 11101110010111 和 01011011101011 进行按位或运算的结果是?

  • A. 11111111011111
  • B. 11111111111101
  • C. 10111111111111
  • D. 11111111111111
答案

D


已知一个正整数变量 a,对这个数的二进制数列进行以下操作,尝试用位运算符号写出操作方式:

1) 将最后一位的右边加上一个 1,例如 101 变为 1011

分析

首先将 101 左移 1 位变为 1010,然后再加上 1,表达式为 (a<<1)+1,注意左移右移的优先级低于加减乘除,但是高于除了取反以外的逻辑运算符(与、或、异或)

2)将最后一位变为 0,例如 1010 或者 1011 处理后都变成 1010

分析

第一种方法是将它和 1 相或,使其最后一位变成 1 后减 1(不能直接和 1 相与,否则除最后一位外都没了),表达式是 (a|1)-1;第二种方法是和 11···110(十进制的 -2)相与,保留左边的所有位数,而最右边变为 0,表达式是 a&-2

3)取末 5 位序列,例如 11011010 处理后得到 11010

分析

可以知道与运算有“割草机”的作用,如果原数和 0 相与,则会被“割掉”(无论原数是 0 还是 1,都会变成 0),否则就保留原数不变。因此可以构造一个右边是 5 个 1 的剃刀(也就是 0···011111),这个数字刚好就是 0···0100000 减去 1 得到的,所以表达式是 a&((1<<5)-1)


选择题:奇偶校验编码是常见的校验编码方式。对于二进制编码 \(A_nA_{n-1} \dots A_2A_1\),奇偶校验编码在编码的最后增加一位校验位 \(G\),并将原编码与校验位作为整体发送。校验位分为奇校验位与偶校验位,奇校验位保证 \(A_n \oplus A_{n-1} \oplus \dots \oplus A_2 \oplus A_1 \oplus G = 1\),偶校验位保证 \(A_n \oplus A_{n-1} \dots \oplus A_2 \oplus A_1 \oplus G = 0\)。下列编码与校验位对应正确的是?

  • A. 编码 \(11100111\),奇校验位 \(0\)
  • B. 编码 \(01100010\),偶校验位 \(0\)
  • C. 编码 \(00010010\),奇校验位 \(1\)
  • D. 编码 \(11100010\),偶校验位 \(1\)
答案

C


选择题:假设有以下的 C++ 代码:

int a = 5, b = 3, c = 4;
bool res = a & b || c ^ b && a | c;

请问,res 的值是什么?提示:在 C++ 中,逻辑运算的优先级从高到低依次为:逻辑非(!)、逻辑与(&&)、逻辑或(||)。位运算的优先级从高到低依次为:位非(~)、位与(&)、位异或(^)、位或(|)。同时,双目位运算的优先级高于双目逻辑运算;逻辑非与位非优先级相同,且高于所有双目运算符。

  • A. true
  • B. false
  • C. 1
  • D. 0
答案

a & b 的结果为 101 & 011 等于 001 也就是 1,c ^ b 的结果为 100 ^ 011 等于 111 也就是 7,a | c 的结果为 101 | 100 等于 101 也就是 5。

在 C++ 的逻辑运算中,任何非零整数都被视为 true,只有 0 被视为 false。所以,表达式等价于 true || true && true,而 res 是一个 bool 类型的变量,它将被赋予 true 这个值。

因此,正确答案是 A


位掩码

什么是位掩码?

位掩码(bitmask)是一种利用单个整数值的各个二进制位(bit)来独立存储多个布尔状态(是/否、开/关)的技术,计算机中的所有数据最终都以二进制(0 和 1)形式存储,一个整数(如 int 等类型)通常由 32 位或 64 位组成。可以将这些位看做是一排小开关,每个开关都有两种状态。

位掩码就是精心设计的一个整数,当把它与另一个数进行“位运算”时,可以精确地操作那个数特定位置上的位,而不会影响到其他位。

核心思想

核心思想是为系统中的每个独立选项或状态分配一个唯一的二进制位,一个很好的现实世界例子是 Linux/Unix 系统中的文件权限。

文件权限分为三组:所有者(User)、所属组(Group)和其他人(Others)。每组都有三种基本权限:读取(Read)、写入(Write)和执行(Execute)。

可以用一个整数中的不同位来表示这些权限:

  • Read (读): 值为 4 (二进制 100)
  • Write (写): 值为 2 (二进制 010)
  • Execute (执行): 值为 1 (二进制 001)

这些值(4, 2, 1)就是掩码。通过将它们组合,可以为每个组(所有者、组、其他)设置权限。例如,rwx 权限就是 4 | 2 | 1 = 7

chmod 是一个用于改变文件或目录权限的 Shell 命令,当看到 chmod 750 myfile.txt 这样的命令时,它的含义是:

  • 7 (rwx): 4 | 2 | 1,所有者拥有读、写、执行权限。
  • 5 (r-x): 4 | 0 | 1,所属组拥有读、执行权限。
  • 0 (---): 0 | 0 | 0,其他人没有任何权限。

这很好地展示了位掩码思想在实际工具中的应用:一个简单的数字 750 紧凑地编码了 9 个独立的权限状态。

优点

高效:位运算是处理器级别的操作,速度很快。

节省空间:一个整数可以存储多个状态,非常紧凑。例如,一个 32 位整数可以管理 32 个不同的开关。

常见位运算操作

判断某一位是否为 1:(x >> i) & 1 或者 x & (1 << i)

判断是否有相邻的 1:x & (x >> 1)

x 是 y 的子集:(x & y) == x 或者 (x | y) == y

若 x 是 y 的子集,取 x 以 y 为全集的补集:y ^ x

将某一位置 1:x | (1 << i)

将某一位反转:x ^ (1 << i)

取 x 拥有的最低位的 1(lowbit):x & -x 或者 x & (~x + 1)


选择题:为了统计一个非负整数的二进制形式中 1 的个数,代码如下:

int CountBit(int x)
{
    int ret = 0;
    while (x)
    {
        ret++;
        ___________;
    }
    return ret;
}

则空格内要填入的语句是?

  • A. x >>= 1
  • B. x &= x - 1
  • C. x |= x >> 1
  • D. x <<= 1
答案

B

从代码结构可以看出,循环的条件是 x 不为 0,每次循环,计数器 ret 都会加 1。这意味着,循环执行的次数就等于最终返回的 ret 值。因此,要让 ret 正确地统计 1 的个数,空格处的操作必须能够在每次循环中,不多不少,正好消除掉 x 的二进制表示中的一个 1。当所有的 1 都被消除后,x 变为 0,循环终止。

B 选项是一个非常巧妙的位运算技巧。它的作用是:x 的二进制表示中最右边的那个 1 变成 0

x - 1 的操作会使 x 最右边的 1 变为 0,并且该位之后的所有 0 都变为 1。然后,xx - 1 进行按位与操作。由于 x - 1 在那个关键位置上是 0,所以与操作的结果在该位上必然是 0。同时,由于 x - 1 在关键位之后都是 1,而 x 在这些位置上都是 0,所以与操作的结果在这些位上也都是 0。关键位之前的部分保持不变。

例如,设 x = 12,二进制为 1100。x - 1 = 11,二进制为 1001。x & (x - 1) 的计算结果为二进制 1000,也就是 8。可以看到,1100 最右边的 1 被消除了。


阅读程序题:

#include <bits/stdc++.h>
using namespace std;
int x, y;
unsigned int n;
int main() {
	cin >> n >> x >> y;
	unsigned int mask = 0xff;
	int x8 = x << 3;
	int y8 = y << 3;
	unsigned int nx = (n >> x8) & mask, ny = (n >> y8) & mask;
	n &= (~(mask << x8));
	n &= (~(mask << y8));
	n |= (nx << y8);
	n |= (ny << x8);
	cout << "0x";
	cout << std::hex << n << endl;
	return 0;
}

假设输入的 n 是 32 位无符号整数范围内的整数,x,y 是不超过 3 的自然数。

判断题:

代码中 mask 变量的值转化为二进制的低 16 位结果是 0000 0000 1111 1111

答案

正确。0xff 在 unsigned int 下就是前 24 位二进制为 0,后 8 位二进制为 1 的数,因此低 16 位是 0000 0000 1111 1111

当输入 x=0 的时候,nx 表示 n 中最低八位对应的字节的数据。

答案

正确。当 x=0 时,x8=x<<3=0,因此 nx=(n>>x8)&mask 相当于 n 直接与 mask 位与,所以就是取了 n 二进制下的低八位。

去掉程序第 11 行至第 12 行中 (~(mask << x8))(~(mask << y8)) 两处中的最内层括号不会改变程序的结果。

答案

错误。去掉括号后,~mask 会先计算,与原来的计算顺序不同。

单选题:

当输入为 15078 0 1 时,变量 nxny 的值分别为多少?(提示:十进制数 15078 与十六进制数 3AE6 相同)
A. 0xE6, 0x3A / B. 0x6, 0xE0 / C. 0x6, 0xE / D. 0x6, 0xA

答案

A

image

当输入为 23270 0 1 时,输出为?
A. 0x5A6E / B. 0x5E6A / C. 0xA56E / D. 0xE65A

答案

D

image

以下哪一个变量的类型修改可能影响程序的输出?
A. 将 x,y 修改为 unsigned int 类型。
B. 将 x8,y8 修改为 short 类型。
C. 将 mask 修改为 int 类型。
D. 将 nx,ny 修改为 unsigned long long 类型。

答案

C。考点:有符号、无符号整数类型,表达式计算中的隐式类型转换。

A选项:题目保证输入的 x,y 是不超过 3 的自然数,因此只能是 0/1/2/3,而这两个变量的作用是在第 8~9 行进行左移运算(对应结果为 0/8/16/24),而 x8,y8 最终都是 int 类型,因此不管 x,yint 类型还是 unsigned int 类型对 x8,y8 的计算结果没有影响。

B选项:如上分析,x8,y8 需要存的值是 0/8/16/24 中的某个,因此在这两行时 short 类型依然足够。而 10~14 行中用到 x8,y8 时另一侧的运算数 n,nx,ny,mask 都是 unsigned int 类型,所以不管 x8,y8int 类型还是 short 类型在参与运算时都会被隐式转换为 unsigned int 类型,因此对输出没有影响。

C选项:分析第 11~12 行,因为 x8,y8int 类型,所以 mask<<x8mask<<y8 这个表达式中如果 maskint 类型,则整体计算也基于 int 类型,如果 maskunsigned int 类型,则整体计算基于 unsigned int 类型。考虑 x8,y8 取到 24 的场景,mask 原本的值相当于一个最后八位全为 1 的二进制数,这个数左移 24 位如果是 unsigned int 类型,则正好还没有溢出。而如果是 int 类型,左移 24 位之后正好溢出到符号位,触发未定义行为(指 C/C++ 语言标准对此时的程序行为没有明确限定的语义,由编译器自行决定实现效果,编程中应尽可能避免触发未定义行为),因此这里的类型改变可能影响程序的行为。

D选项:首先第 10 行的计算结果显然不会造成影响。而第 13~14 行中,改成 unsigned long long 也不过是让左移 x8y8 的计算结果前面多补几个 0 而已,最终这个结果要和 unsigned int 类型的 n 做按位或的运算,因此那些高位的 0 在最终接收的变量 n 中全被截掉了,对数值大小没有影响。


例题:P1469 找筷子

一个直观的想法是先对所有数字排序,然后遍历排序的数组。成对的数字会两两相邻,不成对的那个自然就显现出来了。但对 \(10^7\) 个数字排序,\(O(n \log n)\) 的时间复杂度可能会导致“超时”(TLE),与此同时,由于 4MB 的空间限制,这个数组实际上也存不下。

这道题有一个非常经典且高效的算法,就是利用异或运算的特性。异或(以下用 ^ 表示)有几个关键性质:

  1. A ^ A = 0:任何一个数和它本身做异或运算,结果都是 0。
  2. A ^ 0 = A:任何一个数和 0 做异或运算,结果都是它本身。
  3. 交换律和结合律A ^ B = B ^ A(A ^ B) ^ C = A ^ (B ^ C),这意味着一连串的异或运算,其顺序可以任意交换。

利用上述性质,可以把所有给定的数字全部进行异或运算。根据交换律和结合律,可以把所有值相同的数字放在一起先异或。对于那些成对出现的数字(即出现偶数次的数字),它们的异或值最终会抵消成 0。因此,所有出现偶数次的数字,在总的异或运算中的贡献都是 0,最终只有那个落单的数字对结果有贡献,所以所有数字异或的结果就是落单的那个数。

参考代码
#include <cstdio>
int main()
{
    int n;
    scanf("%d", &n);
    int ans = 0;
    for (int i = 0; i < n; i++) {
        int x;
        scanf("%d", &x);
        ans ^= x;
    }
    printf("%d\n", ans);
    return 0;
}

习题:CF2082A Binary Matrix

解题思路

对于一个只包含 0 和 1 的序列,其异或和为 0 等价于这个序列中包含偶数个 1。所以,题目条件可以翻译为:每一行和每一列都必须有偶数个 1

唯一能做的操作是“修改元素”,即把 0 变为 1,或者把 1 变为 0,这个操作在二进制下等价于将该元素与 1 进行异或。

当修改(翻转)位于 \((i,j)\) 的元素时:

  • 它会改变第 \(i\) 行的奇偶性(原来有奇数个 1,现在变偶数个,反之亦然),这等价于第 \(i\) 行的异或和与 1 进行异或。
  • 它会改变第 \(j\) 列的奇偶性,这等价于第 \(j\) 列的异或和与 1 进行异或。
  • 其他所有行和列的异或和不受影响。

目标是用最少的操作次数,让所有行和所有列的异或和都变为 0。先计算出初始矩阵中,每一行的异或和 \(R_i\) 和每一列的异或和 \(C_j\)。如果 \(R_i = 1\),称第 \(i\) 行为“坏行”;如果 \(C_j = 1\),称第 \(j\) 列为“坏列”。每一次翻转 \((i,j)\) 的操作,会同时翻转第 \(i\) 行和第 \(j\) 列的“好/坏”状态。

考虑所有行 \(R_i\) 的总异或和,以及所有列 \(C_j\) 的总异或和,这两个总异或和都等于矩阵中所有元素的总异或和,因此它们必然相等。因此,坏行的数量和坏列的数量必须具有相同的奇偶性(要么都是偶数,要么都是奇数)。

假设有 \(r\) 个坏行和 \(c\) 个坏列,已知 \(r\)\(c\) 的奇偶性相同。目标是通过翻转操作,消除所有的坏行和坏列。可以采用一种贪心策略:每次翻转,都尽量同时修复一个坏行和一个坏列。比如,如果第 \(i\) 行是坏行,第 \(j\) 列是坏列,就翻转 \((i,j)\),这样一次操作就能让两个坏的变成好的。可以进行 \(\min (r,c)\) 次这样的操作,每次选择一对未被修复的坏行和坏列进行翻转。完成这 \(\min (r,c)\) 次操作后,还剩下 \(|r-c|\) 个坏行或坏列(因为 \(r\)\(c\) 奇偶性相同,所以 \(|r-c|\) 是偶数)。假设剩下 \(k = |r-c|\) 个坏列,可以将它们两两配对。对于一对坏列 \(j_1, j_2\),任选一行 \(i\)(可以是好行也可以是坏行),同时翻转 \((i,j_1)\)\((i,j_2)\)。这次操作会让 \(j_1\)\(j_2\) 列都变好,而第 \(i\) 行因为被翻转了两次,其好坏状态不变,修复这一对坏列需要 2 次翻转。同理,也可能剩的是坏行,但不管怎么样,修复这剩下的 \(k\) 个坏行或坏列总共需要 \(k\) 次翻转。所以,总的翻转次数为 \(\min (r,c) + |r-c|\)。如果 \(r \le c\),次数为 \(r + (c-r) = c\);如果 \(c \lt r\),次数为 \(c + (r-c) = r\)。因此,总次数就是 \(\max (r,c)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 105;
int r[N], c[N];
void solve() {
    int n, m; 
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) r[i] = 0;
    for (int i = 1; i <= m; i++) c[i] = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            int x; scanf("%1d", &x);
            r[i] ^= x; c[j] ^= x;
        }
    }
    int c1 = 0, c2 = 0;
    for (int i = 1; i <= n; i++) {
        if (r[i]) c1++;
    }
    for (int i = 1; i <= m; i++) {
        if (c[i]) c2++;
    }
    printf("%d\n", max(c1, c2));
}
int main()
{
    int t; scanf("%d", &t);
    for (int i = 1; i <= t; i++) {
        solve();
    }
    return 0;
}

例题:B3633 集合运算 2

本题的核心是处理集合的各种基本运算。注意到题目的关键信息:所有元素都在 \(0\)\(63\) 的范围内。这个范围非常小,恰好可以用一个 64 位无符号整数 (unsigned long long) 的二进制位来表示。

可以用一个 unsigned long long 类型的变量来表示一个集合。如果整数 \(k\) 属于这个集合,那么该变量的第 \(k\) 位就为 \(1\);否则为 \(0\)。例如,集合 \(\{1, 3, 5\}\) 可以表示为二进制数 ...00101010,即 \(2^1 + 2^3 + 2^5\)

在这种表示方法下,集合运算可以被直接转换为高效的位运算:

  • 表示集合:用 unsigned long long num_A 表示集合 \(A\)
  • 添加元素 \(k\)num_A |= (1ull << k)
  • 集合大小 \(|A|\):计算 num_A 中二进制位为 1 的个数(即 population count)。
  • 交集 \(A \cap B\)num_A & num_B (按位与)。
  • 并集 \(A \cup B\)num_A | num_B (按位或)。
  • 补集 \(\complement_UA\)~num_A (按位取反)。因为全集是 \([0, 63]\),正好对应 64 位整数的所有位。
  • 相等 \(A = B\)num_A == num_B
  • 子集 \(A \subseteq B\)\(A\)\(B\) 的子集意味着 \(A\) 中的所有元素都在 \(B\) 中。这等价于 (num_A & num_B) == num_A
  • 元素存在 \(k \in A\):检查第 \(k\) 位是否为 1,即 (num_A >> k) & 1

要按升序输出集合中的元素,只需从 \(0\) 遍历到 \(63\),检查对应位是否为 \(1\),如果是就输出当前遍历到的数字。

参考代码
#include <cstdio>
using ull = unsigned long long; // 使用 unsigned long long (64位无符号整数) 来表示集合

// 计算一个64位整数中 '1' 的个数 (population count)
// 这是 Brian Kernighan 算法,效率很高
int popcnt(ull x) {
    int cnt = 0;
    while (x > 0) {
        cnt++;
        x &= (x - 1); // 这个操作会把最低位的 1 变成 0
    }
    return cnt;
}

int main()
{
    int x; scanf("%d", &x);
    ull num_x = 0; // 用于表示集合 A 的位掩码
    // 读入集合 A 的元素,并在位掩码中设置相应的位
    for (int i = 1; i <= x; i++) {
        int a; scanf("%d", &a);
        num_x |= (1ull << a); // 将第 a 位置为 1
    }

    int y; scanf("%d", &y);
    ull num_y = 0; // 用于表示集合 B 的位掩码
    // 读入集合 B 的元素,并在位掩码中设置相应的位
    for (int i = 1; i <= y; i++) {
        int b; scanf("%d", &b);
        num_y |= (1ull << b); // 将第 b 位置为 1
    }

    // 1. 输出 |A|
    printf("%d\n", popcnt(num_x));

    // 2. 输出 A 交 B
    ull inter = num_x & num_y; // 交集对应按位与
    for (int i = 0; i <= 63; i++) {
        if ((inter >> i) & 1) { // 检查第 i 位是否为 1
            printf("%d ", i);
        }
    }
    printf("\n");

    // 3. 输出 A 并 B
    ull uni = num_x | num_y; // 并集对应按位或
    for (int i = 0; i <= 63; i++) {
        if ((uni >> i) & 1) { // 检查第 i 位是否为 1
            printf("%d ", i);
        }
    }
    printf("\n");

    // 4. 输出 A 在 U 中的补集
    // 全集 U 是 [0, 63],正好对应一个 64 位整数的所有位
    // 补集对应按位取反
    for (int i = 0; i <= 63; i++) {
        if ((~num_x >> i) & 1) { // 检查取反后第 i 位是否为 1
            printf("%d ", i);
        }
    }
    printf("\n");

    // 5. A == B 是否成立
    // 两个集合相等,当且仅当它们的位掩码完全相同
    // 6. A 是 B 的子集 是否成立
    // A 是 B 的子集,当且仅当 A 中所有为 1 的位,在 B 中也为 1。
    // 这等价于 (A & B) == A
    // 7. 0 是否在 A 中
    // 0 在 A 中,当且仅当第 0 位为 1。
    printf("%d\n%d\n%d\n", num_x == num_y, (num_x & num_y) == num_x, (int)(num_x & 1));
    return 0;
}

例题:P7076 [CSP-S2020] 动物园

解题思路

对于 a 中的每一个数,看在哪些二进制位上是 1,用 f1 标记;再看 m 条要求里对哪些位有要求,用 f2 标记

求出有多少位是被 f1 标记的或者没被 f2 标记的,若满足这样条件的位数有 x 个,则答案为 \(2^x - n\),结果需要使用 unsigned long long(用 printf 输出时对应的格式化占位符为 %llu

\(2^x\) 在不溢出的情况下可以写成 1 << x,如果需要得到 long long 类型的结果可以写成 1ll << x,要得到 unsigned long long 类型的结果则是 1ull << x

当 x 是 64 时,可以将 \(2^{64}\) 拆成两个 \(2^{63}\),计算 (1ull << 63) - n + (1ull << 63),防止溢出

注意特判当 x 是 64 而 n 是 0 的情况,此时答案为 \(2^{64}\),超出了 unsigned long long 的表示范围,没法用整数类型的变量输出,应直接算出具体数字后以字符串形式输出结果

参考代码
#include <cstdio>
using namespace std;
typedef unsigned long long ULL;
int main()
{
    int n, m, c, k;
    scanf("%d%d%d%d", &n, &m, &c, &k);
    ULL f1 = 0, f2 = 0;
    for (int i = 1; i <= n; i++) {
        ULL x; scanf("%llu", &x);
        f1 |= x;
    }
    for (int i = 1; i <= m; i++) {
        int p, q; scanf("%d%d", &p, &q);
        f2 |= 1ull << p;
    }
    int cnt = 0;
    for (int i = 0; i < k; i++) {
        if (((f1 >> i) & 1) || !((f2 >> i) & 1)) cnt++;
    }
    if (cnt == 64 && n == 0) printf("18446744073709551616\n");
    else if (cnt == 64) printf("%llu\n", (1ull << 63) - n + (1ull << 63));
    else printf("%llu\n", (1ull << cnt) - n);
    return 0;
}
posted @ 2023-12-02 11:50  RonChen  阅读(583)  评论(0)    收藏  举报