第 3 讲 · 数据表示、位运算与内存
屏幕上的 200、0.3 和 'A' 看起来完全不同,进入计算机后却都要变成有限长度的 0 和 1。类型的作用,就是约定怎样解释这些比特。
1. 位、字节与内存
- bit(位)是一个二进制位置,只能取 0 或 1;
- byte(字节)通常由 8 位组成,是 C 中
sizeof的计量单位; - 内存按字节编号,每个编号就是一个地址;
- 一个多字节变量占据一段连续地址。
char c;
int n;
double x;
printf("%zu %zu %zu\n", sizeof c, sizeof n, sizeof x);
sizeof(char) 按定义永远是 1;这个“1”指一个 C 字节。其他类型的大小由实现决定。
2. 进制转换
2.1 任意进制转十进制
把每一位乘上对应的基数幂再求和。例如:
2.2 十进制整数转其他进制
反复“除以基数取余”,余数倒序排列:
19 ÷ 2 = 9 余 1
9 ÷ 2 = 4 余 1
4 ÷ 2 = 2 余 0
2 ÷ 2 = 1 余 0
1 ÷ 2 = 0 余 1
所以 19 = (10011)₂
2.3 二进制与八、十六进制
从右向左每 3 个二进制位对应 1 个八进制位,每 4 位对应 1 个十六进制位:
010 011₂ = 23₈
0001 0011₂ = 13₁₆
它们适合人阅读二进制模式。任意七进制等其他进制仍按“位权”和“除基取余”处理。
3. 有符号整数为什么用补码
设一个整数占 位。无符号整数表示范围为:
常见补码有符号整数的范围为:
3.1 原码、反码、补码
对正数,三者相同。对负数:
- 先写绝对值的二进制;
- 所有位取反得到反码;
- 反码加 1 得到补码。
以 8 位的 为例:
7 的原码:0000 0111
逐位取反:1111 1000
加一:1111 1001 ← -7 的补码
补码把加减法统一成同一套二进制加法,并且只有一个 0。8 位补码中 1000 0000 表示 ,因此负数一侧比正数多一个值。
3.2 截断与溢出
课件用下面的现象引出补码:
signed char sum = 100 + 100;
printf("%d\n", sum);
在 8 位 signed char 的常见实现中,200 的低 8 位为 1100 1000,按补码解释得到 -56。这里有两层风险:结果超出目标类型范围;从较宽整数转换为较窄有符号类型的结果还可能依实现而定。正确做法是选够宽的类型,而不是利用回绕猜结果。
4. 位运算
位运算直接作用于整数的每一位:
| 运算 | 含义 | 某一位的规则 |
|---|---|---|
a & b | 按位与 | 两位都为 1 才为 1 |
a | b | 按位或 | 至少一位为 1 就为 1 |
a ^ b | 按位异或 | 两位不同为 1 |
~a | 按位取反 | 0、1 互换 |
a << k | 左移 | 整体向高位移动 |
a >> k | 右移 | 整体向低位移动 |
逻辑运算 &&、|| 只关心整个操作数真假;位运算 &、| 会逐位计算,不能混用。
4.1 掩码的三个基本动作
假设从 0 开始编号第 k 位:
unsigned mask = 1u << k;
x |= mask; /* 第 k 位置 1 */
x &= ~mask; /* 第 k 位清 0 */
x ^= mask; /* 第 k 位翻转 */
判断奇偶性就是检查最低位:
if ((x & 1u) != 0) {
/* x 为奇数 */
}
取一段连续位可先构造低位全 1 的掩码:
unsigned low_11 = (1u << 11) - 1;
unsigned field = x & low_11;
4.2 左移和右移的边界
对无符号数,在结果可表示时左移 位相当于乘 ,右移相当于整除 。但需要注意:
- 移动位数不能为负,也不能大于或等于类型位宽;
- 无符号右移左侧补 0;
- 负的有符号数右移结果由实现定义;
- 有符号数左移溢出会产生未定义行为。
处理位模式时优先使用 unsigned 类型。
异或交换在课件中作为位运算例子出现:
a ^= b;
b ^= a;
a ^= b;
它要求两者不是同一对象,可读性也不如临时变量。工程代码应写普通交换,不必为了“少一个变量”牺牲清晰性。
5. 浮点数:范围和精度不能同时无限
十进制小数 可以有限写成二进制:
而 的二进制展开无限循环,只能截取一个近似值。因此:
float y = 0.3f;
printf("%d\n", y == 0.3); /* 常见结果为 0 */
右侧 0.3 是 double,y 保存的是先舍入到 float 的近似值,再提升回 double,二者不一定相等。
5.1 IEEE 754 的直觉
常见的 float 和 double 使用 IEEE 754,把有限位宽分成:
符号位 | 指数 | 尾数
普通规格化数可理解为:
指数控制范围,尾数控制有效数字。数值绝对值越大,相邻可表示浮点数之间的绝对间隔也越大,这就是“小数点会浮动”的含义。
全 1 指数等特殊编码还能表示无穷和 NaN。NaN 与任何值比较都不相等,包括它自己;不能用普通相等判断识别它,应使用 <math.h> 中的分类函数。
5.2 误差比较
固定绝对误差适合数值量级已知的题:
#include <math.h>
const double eps = 1e-9;
if (fabs(a - b) < eps) {
/* 近似相等 */
}
量级跨度很大时,结合相对误差更稳妥:
double scale = fmax(1.0, fmax(fabs(a), fabs(b)));
if (fabs(a - b) <= 1e-9 * scale) {
/* 近似相等 */
}
machine epsilon 描述 1 与大于 1 的下一个可表示数之间的距离;它不是所有计算都应直接使用的统一容差。
5.3 求根时按情况分支
解 时,浮点版程序应先判断 是否接近 0,再判断判别式:
double delta = b * b - 4 * a * c;
if (fabs(a) < eps) {
/* 退化为一次方程 */
} else if (delta > eps) {
/* 两个不同实根 */
} else if (fabs(delta) <= eps) {
/* 重根 */
} else {
/* 无实根 */
}
这比直接写 delta == 0 更符合浮点数实际表示方式。
6. 变量、地址与字节序
变量可以看成一段内存空间的名字,具有名称、类型、大小、值和地址:
int value = 0x12345678;
printf("value=%d, size=%zu, address=%p\n",
value, sizeof value, (void *)&value);
多字节数据的字节排列有两种常见方式:
- little-endian:低有效字节放低地址;
- big-endian:高有效字节放低地址。
Intel/AMD 桌面处理器通常使用小端;网络协议常约定大端字节序。字节序只影响多字节对象在内存或传输中的排列,不改变 int 运算看到的数值。
7. 数组、sizeof 与存储位置
int data[5] = {1, 3, 5};
数组元素类型相同且连续存放,后两个元素初始化为 0。
size_t count = sizeof data / sizeof data[0];
这个写法只在 data 仍是数组的作用域内有效。把数组传给函数后,参数会按指针处理,sizeof 得不到原数组长度,所以长度应作为额外参数传入。
大型局部数组会占用有限的调用栈,可能导致栈溢出。全局/静态数组位于静态存储区,动态数组可从堆中申请。选择放在哪里应由容量和生命周期决定,而不是机械地“数组大就全局”。
课件还讨论了 C99 变长数组(VLA):数组长度可以在运行时决定,但支持情况和栈容量有限,C++ 也没有标准 VLA。需要可控的大空间时,动态内存通常更通用。
8. 标准输入输出重定向
程序里的 stdin 和 stdout 默认连接键盘与控制台,也可以被重定向到文件。
8.1 在命令行重定向
./program < input.txt > output.txt
程序本身不需要修改,仍然从 stdin 读、向 stdout 写。
8.2 在程序中重定向
freopen("input.txt", "r", stdin);
freopen("output.txt", "w", stdout);
这适合本地调试,但提交 OJ 前通常应删除或用条件编译隔离,否则评测程序会找不到本地文件。
9. 本讲最重要的因果链
类型
├─ 决定占多少字节
├─ 决定同一串比特怎样解释
├─ 决定数值范围和精度
└─ 决定表达式、转换和输入输出格式
当程序出现整数变负、浮点比较失败、格式化输入异常、位运算结果奇怪时,不要只盯着某一行语法;先沿这条因果链检查“数据到底以什么类型保存和计算”。