这个专题讲什么:一个成年人,想搞明白计算机到底建立在什么数学之上。 不是数学系的课程表,也不是"数学之美"那种读完很爽、用不上、然后就忘的东西。
第一篇从这里开始,因为这个坑人人都踩过,而且它一次性能把计算机数学的入口推开。
耗时:读完约 40 分钟,动手约 2 小时。
开头先给你一件事:这不是一个知识问题,是一个身份问题
先看这行代码,你一定见过它的变体:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
大多数人的反应是三步:愣一下 → 搜"为什么" → 看到"因为浮点数精度问题" → 记住这句话 → 下次写代码还是用 == 比较两个算出来的金额。
这句话没错,但它什么都没告诉你。
它没告诉你:什么情况下会错、错多少、错到什么时候算严重、该怎么防。所以你"知道了",但还是没底。
这就是很多人对数学没信心的真实样子——不是不会算,是不知道自己站在哪块地上。
这篇要做的事很具体:把"0.1 + 0.2 ≠ 0.3"这一个现象,一路推到计算机数学的整个体系里去。 推完你会发现,后面那些看着很高的东西(离散数学、数理逻辑、可计算性、复杂度、信息论),入口全在这里。
一、先把事故现场看全:它错得比你想象的多
“精度问题"这四个字太软了。我们把它拆成六个具体的坑,全部是真实运行的结果。
坑 1:加法不满足结合律
数学里 (a + b) + c = a + (b + c),这是公理级别的东西。在机器上:
double a = 0.1, b = 0.2, c = 0.3;
printf("%.20f\n", (a + b) + c); // 0.60000000000000008882
printf("%.20f\n", a + (b + c)); // 0.59999999999999997780
同一个加法,换个括号顺序,结果就不一样。 这不是 bug,是这台机器的正常运行结果。
坑 2:大数会吃掉小数
double x = 1e16;
printf("%.1f\n", x + 1.0); // 10000000000000000.0
加 1,没有任何变化。加一百万次也一样:
double x = 1e16;
for (int i = 0; i < 1000000; i++) x += 1.0;
printf("%.1f\n", x); // 10000000000000000.0
一百万个 1,全被吃掉了,一个都没进去。
坑 3:累加会漂,而且漂得很离谱(用 float 的话)
用单精度(float,C 里的 32 位浮点)把 0.1 加一千万次:
| 累加次数 | 结果 | 应该是 | 误差 |
|---|---|---|---|
| 10 万 | 9998.5566 | 10000 | −0.01% |
| 100 万 | 100958.3438 | 100000 | +0.96% |
| 200 万 | 197024.7812 | 200000 | −1.49% |
| 400 万 | 384524.7812 | 400000 | −3.87% |
| 600 万 | 587937.0625 | 600000 | −2.01% |
| 800 万 | 837937.0625 | 800000 | +4.74% |
| 1000 万 | 1087937.0000 | 1000000 | +8.79% |
加一千万次 0.1,误差接近 9%。 而且注意中间那一行的规律:误差在正负之间来回跳,不是单调变坏。这说明它不是"越算越不准"这么简单——它有结构。
换成双精度(double,64 位)呢?同样一千万次,结果是 999999.9998389754——绝对误差 −0.00016,相对误差约 −1.6×10⁻¹⁰。好得多,但也不是零。
坑 4:比较会撒谎
>>> 0.1 + 0.2 > 0.3
True
>>> (0.1 + 0.2) - 0.3
5.551115123125783e-17
左边比右边大,大了一点点。所以 == 判不等,> 判大。你以为相等的两个东西,在机器的排序里是两个位置。
坑 5:二分查找会卡死
这是个不写代码想不到的坑。用浮点数做二分求 √0.5:
while (hi - lo > 0.0) {
double mid = lo + (hi - lo) / 2.0;
if (mid == lo || mid == hi) break; // 必须加这一句
...
}
实测:53 次迭代后,lo 和 hi 变成相邻的两个可表示数,mid 算出来永远等于其中一个。如果没写那个 break,循环永不结束。hi - lo > 0.0 这个判断永远不会变假。
“循环总会结束"这个信念,在浮点数上是假的。
坑 6:排序不满足传递性
a = 0.1 + 0.2 # 0.30000000000000004
b = 0.3 # 0.29999999999999999
c = 0.30000000000000004
a == b # False
b == c # False
a == c # True
如果用它当排序的键、当哈希的键、当去重的依据,去重逻辑会偷偷漏掉元素。
二、为什么?一句话:机器里的数不是数,是格点
现在进正题。上面六个坑,一个原因,一个解释,能全部推出来。
数学里的数:稠密、连续、无限可分
在数学里,取 0 和 1 之间任意两个数,中间永远还有别的数。这叫稠密性。实数甚至更进一步——不可数,比有理数还多得多。
所以数学里你可以说"让 x 无限接近 0.3”。没有障碍。
机器里的数:64 个二进制位,能表示的个数是有限的
一个 double 是 64 位。它能表示的不同值有多少个?最多 2⁶⁴ 个,还得去掉一批非法组合(NaN、无穷),实际约 1.8×10¹⁹ 个。
听着很多。但实数在 0 到 1 之间就有不可数无穷多个。
所以机器能做的事只有一件:从这 1.8×10¹⁹ 个格点里,挑最近的。
这就是全部。 记住这一句,后面都不用背。
机器里的每一个浮点数,是一个格点。运算 = 算出准确结果,然后挪到最近的格点上。
格点不是均匀的,这才是关键
如果格点是均匀的,误差就是固定的,好办。真实情况是这样(double 实测):
| 数值附近 | 相邻格点间距 | 相对误差 |
|---|---|---|
| 1.0 | 2.22×10⁻¹⁶ | 2.22×10⁻¹⁶ |
| 1024 | 2.27×10⁻¹³ | 2.22×10⁻¹⁶ |
| 10⁶ | 1.16×10⁻¹⁰ | 1.16×10⁻¹⁶ |
| 10¹⁵ | 0.125 | 1.25×10⁻¹⁶ |
看出规律没有:相对误差基本不变,绝对间距随数值指数级变大。
这就是为什么 1e16 + 1.0 等于 1e16——在 10¹⁶ 附近,格点间距是 2。加 1 只走半格,挪到最近格点,还在原地。加一万次也还在原地。
一个 float 更夸张:
| 数值附近 | 格点间距 |
|---|---|
| 1.0 | 1.19×10⁻⁷ |
| 1024 | 1.22×10⁻⁴ |
| 65536 | 7.8×10⁻³ |
| 10⁶ | 0.0625 |
| 10⁹ | 64 |
| 10¹⁰ | 1024 |
在 10⁶ 附近,格点间距是 0.0625。你要往里加 0.1——换算成格数就是 1.6 格,机器只好挪到 2 格。在这个量级上,每加一次就多进 0.025。
注意这只是举例。换成别的量级,1.6 格会变成 0.8 格、12.8 格、102.4 格…… 有时多进、有时少进,正负交替——这就是坑 3 那张表里误差在正负之间来回跳的原因。
这就是坑 3 的答案:误差不是随机噪声,是系统性的、有方向的舍入偏差。 所以它会一路涨到 8.79%,而且涨的方向取决于当前数值落在格点网格的哪个位置。
有效位数:7 位和 16 位
float:24 位二进制有效位 → 约 7.2 位十进制有效数字double:53 位二进制有效位 → 约 16 位十进制有效数字
所以 float 里存 0.1,实际存的是 0.100000001490116119384765625。double 里存 0.1,存的是 0.1000000000000000055511151231257827021181583404541015625。
都不是 0.1。 都是最近的那个格点。
为什么偏偏是 0.1 出问题
因为 0.1 写成二进制是无限循环小数:
0.1 = 0.0001100110011001100110011001100110...(循环)
0.2 = 0.0011001100110011001100110011001100...(循环)
0.3 = 0.0100110011001100110011001100110011...(循环)
0.5 = 0.1(精确)
0.25 = 0.01(精确)
十进制里有限位的数,二进制里不一定有限位。 因为 10 的质因子是 2 和 5,二进制只有 2——分母带因子 5 的数(0.1 = 1/10、0.2 = 1/5)在二进制里必然循环。
这跟十进制里 1/3 = 0.333… 是同一件事,只不过换了个进制。
一句话:坑不在浮点数,坑在"你以为你输进去的是 0.1”。
三、第一个交叉口:既然分数表示不了,那就别用分数
到这里,路分两条。
第一条:接受误差,学会和它相处(后面几篇讲:误差分析、数值稳定性、怎么判断"这个误差会不会要命")。
第二条:绕开它——干脆不用小数,用整数。 这条路的尽头叫"定点数",再往深处走,叫模运算。
定点数:把"元"换成"分"
金额不用 12.34 存,用 1234 分 存。全整数,加减精确,一分不差。
代价是范围。32 位有符号整数能存多大?
int32 最大值 = 2,147,483,647
以"分"为单位 → 21,474,836.47 元
两千一百多万。 一家中等公司的账都存不下。所以得用 64 位:
int64 最大值 = 9,223,372,036,854,775,807 分
≈ 9.2×10¹⁶ 元(九亿亿元)
够了。
但整数也会崩,而且崩得比小数更安静
int32_t x = 2147483647;
printf("%d\n", x + 1); // -2147483648
最大值加一,变成最小值。不报错,不崩溃,程序继续跑,账目变成负数。
uint32_t u = 0;
printf("%u\n", u - 1); // 4294967295
0 减 1,变成 42 亿。
这个现象有个正式名字:整数溢出。但数学上它有个更本质的名字——
这不是溢出,这是模运算。
int32的世界是 模 2³² 的环,不是整数轴。
在模 2³² 里,2147483647 + 1 之后应该是 2147483648,但这个数不在环上,它绕回去变成了 −2147483648。完全合法,只是不是你想要的。
于是这里出现了计算机数学的第一块砖
你现在已经被迫面对两个概念:
- 有限性:机器只能表示有限个值
- 模运算:有限个值的加法,会自动"绕圈子"
这两个概念一展开,就是离散数学的第一章:同余、模运算、群环域的结构。
再往下走一步,就是密码学的地基——RSA 加密的全部安全性,建立在"模一个大数求逆很慢、但验证很快"这件事上。你手机里的每一次 HTTPS 握手,用的就是这一套。
从"金额差一分",一路走到"HTTPS 为什么安全"——中间只隔着一个"模"字。
四、第二个交叉口:为什么时间会在 2038 年崩
再用一次同一个工具,看一个更有名的事故。
很多系统用32 位有符号整数存 Unix 时间戳,单位是秒,起点 1970-01-01。
最大值 2,147,483,647 秒,对应:
2038-01-19 03:14:07 UTC
再过一秒,全部绕回 1901 年。
这个就叫 2038 问题(Year 2038 problem)。不是因为程序员笨,是因为当年用 32 位省空间是对的——1970 年代的机器,内存按 KB 算。
顺便算一下,同一个 32 位整数换成别的单位能撑多久:
| 单位 | 能表示的时间跨度 |
|---|---|
| 秒(1970 起) | 68 年 → 2038 |
| 毫秒(从 0 起算) | 24.86 天 |
| 百分之一秒 | 248.55 天 |
这就是"表示范围"这件事的真实代价。 换个单位,问题从"68 年后"变成"24 天后"。写游戏、写实时系统的人,天天在跟这个打交道。
到这里你已经拿到第二块砖:表示范围 + 边界 = 必须算清楚,不能猜。
再往下,就是离散数学里的"有限性"和"计数",以及数理逻辑里的"边界条件"。后面几篇会专门讲:为什么 off-by-one 是真数学问题,不是手滑。
五、第三个交叉口:为什么"测着对"不等于对
上面全是能算出来的坑。还有一类坑,算不出来。
>>> round(2.675, 2)
2.67
>>> round(1.005, 2)
1.0
你以为会四舍五入成 2.68 和 1.01。都不是。
原因还是格点:2.675 在机器里其实是 2.67499999999999982236431605997495353221893310546875,比 2.675 小一点点 → 舍到 2.67 是对的。
但更麻烦的是 round(0.5) 等于 0:
>>> [round(x) for x in [0.5, 1.5, 2.5, 3.5]]
[0, 2, 2, 4]
这不是 bug,这是银行家舍入(round half to even):遇到正好在半格上,舍到偶数。它在统计上是无偏的,长期结算不会积累偏差——所以在金融系统里是正确选择,只是跟你小学学的不一样。
问题在于:没人告诉你它换了规则。 你做对账,跑一次差一分,再跑一次不差了。这种"偶发、不可复现"的 bug,是所有人最怕的东西。
它的根源不在代码,在于你以为"舍入"是唯一的,其实它有很多种,每一种都对应一套不同的数学。
而"到底取哪种舍入、会不会偏、偏多少、多少次之后会出问题"——这不是编程问题,这是误差分析,是数值分析(Numerical Analysis)这门学科的第一课。
这就是"没彻底掌握"的真实样子:不是不会写
round,是不知道round背后有四五种合法定义,各自适用于不同场合。
六、把这一篇放回整个体系:计算机数学到底学什么
到现在,我们从一个"0.1 + 0.2"出发,已经碰到了四样东西:
| 碰到的 | 数学里叫什么 | 再往下是什么 |
|---|---|---|
| 格点、有效位数、舍入 | 数值分析 / 误差分析 | 数值稳定性、条件数 |
| 模运算、绕圈 | 离散数学 · 同余 | 群环域、密码学 |
| 表示范围、边界 | 离散数学 · 计数与有限性 | 组合数学、概率 |
| 舍入规则的选择 | 数值分析 · 舍入理论 | 区间算术、任意精度 |
这四样,就是计算机数学的四条主脉的入口。
完整的图是这样的:
| 层 | 这块在解决什么问题 | 数学系对应课程 | 本专题哪篇讲 |
|---|---|---|---|
| 一 · 数怎么装进机器 | 有限表示、误差、溢出 | 数值分析 | 本篇 |
| 二 · 离散结构 | 集合、关系、图、计数 | 离散数学 | 第二篇 |
| 三 · 怎么保证对 | 命题、量词、证明、不变量 | 数理逻辑 | 第三篇 |
| 四 · 什么能算 | 自动机、可计算性、停机问题 | 计算理论 | 第四篇 |
| 五 · 算得多快 | 渐近代价、复杂度、NP | 算法分析 | 已在/cs/analysis/ 讲过,本篇不重复 |
| 六 · 不确定的世界 | 概率、贝叶斯、随机化 | 概率论与统计 | 第五篇 |
| 七 · 连续的近似 | 导数、梯度、优化 | 微积分 | 第六篇 |
| 八 · 高维的表示 | 向量、矩阵、特征值 | 线性代数 | 第七篇 |
这张表就是这个专题的目录。 八层,从"数装不下"到"神经网络在算什么",一条线走完。
它跟 /cs/ 那一组是什么关系
一句话分开:
- /cs/ 那一组回答:我在计算机学科的哪一格(地图)
/math/这一组回答:这一步凭什么说它是对的(尺子)
地图告诉你往哪走,尺子告诉你走到没有。两个都要。
它跟"数学系的数学"有什么不同
这是我最想跟你说清楚的一点。
| 数学系的数学 | 计算机数学 | |
|---|---|---|
| 关心什么 | 对象本身的性质 | 在有限资源下,能不能算、算得对不对、算得多快 |
| 典型问题 | 这个定理成不成立 | 这个算法在 32 位下会不会崩 |
| 对"无穷"的态度 | 核心研究对象 | 必须消灭的东西——无穷不能装进内存 |
| 对"误差"的态度 | 追求精确 | 量化误差、控制误差、容忍误差 |
| 最好的结果 | 一个漂亮的证明 | 一个能跑、能验、能扩展的实现 |
所以计算机数学不是"简化版数学"。它是一门不同的学科。 它把数学系的"无限与精确",换成"有限与足够好"。
这个转换,才是整个计算机科学的立身之本。你看下面这条线:
数学是连续的、无限的、精确的 → 计算机是离散的、有限的、近似的 → 这两者之间的翻译工作,就是计算机数学
读懂了这句话,你就不会再觉得"数学跟我写代码没关系"了。
七、动手:把上面每一条自己跑一遍
不跑一遍,你只是又"知道"了一件事。下面每一步都能在你自己机器上跑。
实验 1:看清格点(Python,不用装任何东西)
import struct
def 相邻上格点(x):
b = struct.unpack('<q', struct.pack('<d', x))[0] + 1
return struct.unpack('<d', struct.pack('<q', b))[0]
for x in [1.0, 1024.0, 1e6, 1e15, 1e16]:
up = 相邻上格点(x)
print(f"{x:>18.0f} 格点间距 = {up - x:.6g}")
跑出来的间距会从 2.22e-16 一路涨到 2.0。自己看到"越来越粗",比读文字有说服力。
实验 2:让一万个 1 消失
x = 1e16
for _ in range(1_000_000):
x += 1.0
print(x) # 10000000000000000.0
实验 3:让 float 漂起来(C 或 Go,Python 得用 numpy)
float s = 0;
for (long i = 0; i < 10000000L; i++) s += 0.1f;
printf("%.4f\n", s); // 1087937.0000
再换 double 跑一遍,对比一下。亲眼看到 8.79% 和 0.0000000000002% 的差距,你就记住这件事了。
实验 4:把整数加到溢出
#include <stdint.h>
#include <stdio.h>
int main() {
int32_t x = 2147483647;
printf("%d\n", x + 1); // -2147483648
uint32_t u = 0;
printf("%u\n", u - 1); // 4294967295
}
实验 5:让二分卡死
lo, hi = 0.0, 1.0
n = 0
while hi - lo > 0:
mid = lo + (hi - lo) / 2
if mid == lo or mid == hi:
print(f"第 {n} 次:卡住了,lo={lo:.20f}, hi={hi:.20f}")
break
if mid * mid < 0.5: lo = mid
else: hi = mid
n += 1
会看到 n = 53。
实验 6:证明 == 不能用来比金额
用 float 把 0.01 累加 1000 次:
float s = 0;
for (int i = 0; i < 1000; i++) s += 0.01f;
printf("%.10f\n", s); // 10.0001335144
结果比 10 大了一点点。同样代码换成 double,得到 9.99999999999983124610——又小了一点点。
两种都对不上 10。 而用「整数分」做:int cents 累加 1000 次,结果是 1000,一分不差。
再写一句 if (s == 10.0f) 试试,永远不成立。这就是为什么财务系统不能拿浮点直接判等。
八、带走的东西
如果这一篇你只记一句话,记这句:
机器里的数不是数,是格点。所有"浮点精度问题",都是"结果落回格点"的必然结果,不是 bug。
如果记三句:
- 相对误差基本不变,绝对间距随数值指数增长 —— 所以大数会吃小数,所以小数据算得准、大数据算得糙。
- 整数会绕圈,这是模运算,不是溢出 —— 绕圈的结构就是密码学的地基。
- 舍入有多套合法定义 —— “没彻底掌握"往往就是不知道这里有多套。
下一篇
(二)离散结构:为什么计算机必须先"离散”
从一个更根本的问题开始:既然世界上到处是连续的(时间、温度、声音、图像),计算机凭什么能用离散的东西去表示它们? 误差在这里以什么形式出现?什么时候够用,什么时候不够用?
然后是那套真正的地基:集合、关系、函数、图、计数。为什么这些是"计算机的数学",而微积分不是。
深水区(想真学的,从这里进)
- 数值分析:任何一本《Numerical Analysis》教材(Burden & Faires 那一本是最常见的入门选择)。第一篇讲的就是浮点表示与误差 —— 你会发现跟这篇高度重合,但严谨得多。
- 浮点标准原文:IEEE 754-2019。不用通读,第 3、4 章讲格式与舍入,值得看。
- 为什么浮点这么设计:David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic(ACM Computing Surveys, 1991)。这篇文章是行业公认的必读,讲的全是这篇里浅尝辄止的东西。
- 离散数学:Rosen《Discrete Mathematics and Its Applications》是标准教材;想轻一点,可以看 MIT 6.042 的公开课资料。
上面这些书我不做页码引用——不同版本页码不一样,写了就是骗你。要查具体内容,按书名和章节找。
附:本篇所有数字的来源
文中所有实测数字都是在 Ubuntu / gcc 13 / Python 3.8 / x86-64 上跑出来的,不是引用别人的:
0.1 + 0.2 = 0.30000000000000004441,0.3 = 0.29999999999999998890float累加 0.1 一千万次 =1087937.0000(误差 +8.79%)double累加 0.1 一千万次 =999999.9998389754(相对误差 −1.6×10⁻¹⁰)(0.1+0.2)+0.3 = 0.60000000000000008882,0.1+(0.2+0.3) = 0.599999999999999977801e16加 1 一百万次,结果不变- 浮点二分 53 次后
lo、hi相邻,mid卡死 int32最大值 2,147,483,647;以"分"为单位上限 21,474,836.47 元- 32 位秒级时间戳上限:2038-01-19 03:14:07 UTC
你自己机器上的结果可能差最后几位(编译器优化、平台浮点单元不同)。差几位不影响结论 —— 结论是结构性的,不是某个数字。