这个专题讲什么:一个成年人,想搞明白计算机到底建立在什么数学之上。 不是数学系的课程表,也不是"数学之美"那种读完很爽、用不上、然后就忘的东西。

第一篇从这里开始,因为这个坑人人都踩过,而且它一次性能把计算机数学的入口推开。

耗时:读完约 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.556610000−0.01%
100 万100958.3438100000+0.96%
200 万197024.7812200000−1.49%
400 万384524.7812400000−3.87%
600 万587937.0625600000−2.01%
800 万837937.0625800000+4.74%
1000 万1087937.00001000000+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.02.22×10⁻¹⁶2.22×10⁻¹⁶
10242.27×10⁻¹³2.22×10⁻¹⁶
10⁶1.16×10⁻¹⁰1.16×10⁻¹⁶
10¹⁵0.1251.25×10⁻¹⁶

看出规律没有:相对误差基本不变,绝对间距随数值指数级变大。

这就是为什么 1e16 + 1.0 等于 1e16——在 10¹⁶ 附近,格点间距是 2。加 1 只走半格,挪到最近格点,还在原地。加一万次也还在原地。

一个 float 更夸张:

数值附近格点间距
1.01.19×10⁻⁷
10241.22×10⁻⁴
655367.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。完全合法,只是不是你想要的。

于是这里出现了计算机数学的第一块砖

你现在已经被迫面对两个概念:

  1. 有限性:机器只能表示有限个值
  2. 模运算:有限个值的加法,会自动"绕圈子"

这两个概念一展开,就是离散数学的第一章:同余、模运算、群环域的结构。

再往下走一步,就是密码学的地基——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。

如果记三句:

  1. 相对误差基本不变,绝对间距随数值指数增长 —— 所以大数会吃小数,所以小数据算得准、大数据算得糙。
  2. 整数会绕圈,这是模运算,不是溢出 —— 绕圈的结构就是密码学的地基。
  3. 舍入有多套合法定义 —— “没彻底掌握"往往就是不知道这里有多套。

下一篇

(二)离散结构:为什么计算机必须先"离散”

从一个更根本的问题开始:既然世界上到处是连续的(时间、温度、声音、图像),计算机凭什么能用离散的东西去表示它们? 误差在这里以什么形式出现?什么时候够用,什么时候不够用?

然后是那套真正的地基:集合、关系、函数、图、计数。为什么这些是"计算机的数学",而微积分不是。

深水区(想真学的,从这里进)

  • 数值分析:任何一本《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.29999999999999998890
  • float 累加 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.59999999999999997780
  • 1e16 加 1 一百万次,结果不变
  • 浮点二分 53 次后 lo、hi 相邻,mid 卡死
  • int32 最大值 2,147,483,647;以"分"为单位上限 21,474,836.47 元
  • 32 位秒级时间戳上限:2038-01-19 03:14:07 UTC

你自己机器上的结果可能差最后几位(编译器优化、平台浮点单元不同)。差几位不影响结论 —— 结论是结构性的,不是某个数字。