进制转换看着简单,但背后藏着 CPU 是怎么真正存数字的——浮点为什么不准、负数怎么编码、IEEE 754 标准做了什么取舍。理解这些既能解释程序里的”诡异结果”,也是面试常问。
二进制为什么存不下 0.1
十进制 0.1 在二进制是无限循环小数:
0.1 (十进制) = 0.0001100110011001100110011… (二进制,循环节 0011)
类比:十进制写不完 1/3 = 0.3333…,因为分母 3 不是 10 的因子。二进制写不完 0.1,因为分母 10 不是 2 的因子。
能在二进制精确表示的小数只有”分母是 2 的幂”的那些:
| 十进制 | 二进制 | 精确? |
|---|---|---|
| 0.5 | 0.1 | ✓ |
| 0.25 | 0.01 | ✓ |
| 0.125 | 0.001 | ✓ |
| 0.75 | 0.11 | ✓(0.5 + 0.25) |
| 0.1 | 0.0001100110011… | ✗(无限循环) |
| 0.2 | 0.0011001100110… | ✗ |
| 0.3 | 0.0100110011001… | ✗ |
实战影响:
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.005 * 100; // 100.49999999999999
Math.round(1.005 * 100) / 100; // 1(不是 1.01)
修复方法:
| 场景 | 方案 |
|---|---|
| 显示用 | (0.1 + 0.2).toFixed(2) → “0.30” |
| 比较用 | Math.abs(a - b) < Number.EPSILON |
| 金额计算 | 转整数分(150 分而非 1.5 元) |
| 高精度 | BigDecimal(Java)、decimal.js(JS)、Decimal(Python) |
IEEE 754 双精度浮点的内部结构
64 位双精度浮点的存储布局:
[1 位符号 S][11 位指数 E][52 位尾数 M]
└─ 0=正 └─ -1023 偏移 └─ 隐含 1.xxx
值的计算公式:
value = (-1)^S × (1 + M/2^52) × 2^(E - 1023)
举例:1.5 的存储
1.5 = 1.1₂ × 2⁰
S = 0
E = 0 + 1023 = 1023 = 01111111111
M = .1000…0(去掉隐含的 1,只存小数部分)
存储:0 01111111111 1000000000000000000000000000000000000000000000000000
特殊值:
| 值 | E | M | 用途 |
|---|---|---|---|
| ±0 | 全 0 | 全 0 | 零的两种表示 |
| ±∞ | 全 1 | 全 0 | 除以 0、溢出 |
| NaN | 全 1 | 非全 0 | 无效计算 |
| 次正规数 | 全 0 | 非全 0 | 接近 0 的极小数 |
精度极限:
- 双精度可精确表示的最大整数 = 2⁵³ = 9007199254740992(JS 的
Number.MAX_SAFE_INTEGER) - 超过后整数也不准了:
9007199254740993 === 9007199254740992→ true - 这就是 JS 处理大整数要用 BigInt 的原因
负数的三种编码
历史上有三种方式表示负数:
原码(Sign-Magnitude)
最直接的方式:第一位 0 表示正,1 表示负:
+5 = 0 0000101
-5 = 1 0000101
问题:
- 零有两种:+0 =
00000000、-0 =10000000,运算时要特判 - 加减法要特殊电路:算
5 + (-3)不能直接按位加(结果会错),要判断符号
反码(Ones’ Complement)
负数 = 原码按位取反:
+5 = 00000101
-5 = 11111010
仍然有零的两种表示问题。
补码(Two’s Complement)
现代 CPU 实际用的——负数 = 反码 + 1:
+5 = 00000101
-5 = 11111011 (反码 11111010 + 1)
优点:
- 零只有一种:
00000000 - 加减法共用电路:
5 + (-3) = 00000101 + 11111101 = 00000010 = 2(自然进位舍弃) - 比较用普通整数比较就行
代价:表示范围不对称。8 位补码:
- 最大 =
01111111= +127 - 最小 =
10000000= -128(不是 -127!) - 范围 [-128, 127],比正数多一个负数槽位
这就是为什么:
INT32 范围: [-2147483648, 2147483647]
INT64 范围: [-9223372036854775808, 9223372036854775807]
补码转回十进制的速算
对负数补码:
- 方法 A:再次取反 + 1,得到原码绝对值
- 方法 B:从右往左找第一个 1,它左边的位全部取反,得到正数对应的二进制
举例 11111011 转十进制:
方法 A:取反 = 00000100,+1 = 00000101 = 5,所以是 -5
方法 B:从右第一个 1 在第 0 位(11111011)→ 左边 7 位取反 = 0000010 → 加上原 011 = 00000101 = 5,所以 -5
进制转换的快速心算
熟手在十六进制 ↔ 二进制之间秒切换——窍门是 4 位二进制 = 1 位十六进制:
| HEX | BIN | DEC |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| 2 | 0010 | 2 |
| 4 | 0100 | 4 |
| 8 | 1000 | 8 |
| F | 1111 | 15 |
举例:0xCAFE → BIN:
C = 1100
A = 1010
F = 1111
E = 1110
0xCAFE = 1100 1010 1111 1110
8 进制 ↔ 二进制类似,但 3 位二进制 = 1 位八进制(用于 Linux 文件权限:rwx = 3 bit = 1 个 8 进制位,所以 chmod 755 = rwxr-xr-x)。
十进制 ↔ 二进制没有这种快速对应——必须算。常用:
- 1024 = 2¹⁰(KB / 内存常用)
- 65536 = 2¹⁶(端口数 / UTF-16 BMP)
- 4294967296 = 2³² ≈ 43 亿(IPv4 地址数)
位运算的常见用法
1. 交换两个数(不用临时变量):
a = a ^ b;
b = a ^ b; // a^b ^ b = a
a = a ^ b; // a^b ^ a = b
实战极少用——可读性差,编译器优化也不依赖它。
2. 判断奇偶:
n & 1 // 末位是 1 = 奇数
比 n % 2 略快(但现代编译器都会优化)。
3. 乘除 2 的幂:
n << 3 // n × 8
n >> 2 // n ÷ 4(向下取整,注意负数)
4. 设置 / 清除 / 切换某一位:
n | (1 << k) // 设第 k 位为 1
n & ~(1 << k) // 清第 k 位为 0
n ^ (1 << k) // 翻转第 k 位
n & (1 << k) // 读第 k 位(结果非 0 = 1)
5. 权限位掩码(业务常用):
const READ = 1, WRITE = 2, DELETE = 4;
let perm = READ | WRITE; // 拥有读写
perm & READ; // 检查是否有读权限
perm |= DELETE; // 加删除权限
perm &= ~WRITE; // 撤销写权限
NaN 的奇怪行为
IEEE 754 规定 NaN ≠ 任何值,包括 NaN 自己:
NaN === NaN; // false
NaN !== NaN; // true(唯一一个 x !== x 成立的值)
isNaN(NaN); // true
Number.isNaN(NaN); // true(更严格)
理由:NaN 是”无效计算”的占位符,两个 NaN 不一定来自同一个计算,没理由相等。
Number.isNaN vs isNaN:
isNaN("abc"); // true(先转 Number → NaN,再判)
Number.isNaN("abc"); // false(严格判,"abc" 不是 NaN,是字符串)
新代码用 Number.isNaN,行为可预期。
NaN 的传染性:
NaN + 1; // NaN
Math.max(NaN, 1, 2); // NaN
JSON.stringify({x: NaN}); // '{"x":null}' ← JSON 不支持 NaN
数据处理时遇 NaN 要么跳过、要么替换成默认值,不能让它顺利往下传。
一句话总结
二进制存不下 0.1 不是 bug 是数学、负数靠补码不靠正负号、INT 范围不对称、NaN 不等于自己——四个事实记住,再不会被进制和浮点坑到。