第 23 章 浮点与科学计数法¶
配套例题:BISHI14 特殊的科学计数法、PIO14 保留小数位数、PIO17 浮点误差 来源:菜鸟教程 数字(Number)、math 模块
浮点是算法题里最不起眼、却最容易无声无息 WA 的地方。
本章讲三件事:误差从哪来、怎么避开、以及什么时候必须换掉 float。
23.1 误差从哪来¶
float 是 IEEE 754 双精度:1 位符号 + 11 位指数 + 52 位尾数,
能精确表示的只有形如 \(m \cdot 2^e\) 的数。0.1 在二进制里是无限循环小数,
就像 \(1/3\) 在十进制里是 0.333… 一样:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
最后一行揭示了本质:0.1 这个字面量存进去的根本不是 0.1。
有效数字约 15–17 位。超过这个精度的整数,转成 float 就开始丢:
>>> float(10 ** 17) == 10 ** 17
True
>>> float(10 ** 17 + 1) == 10 ** 17 + 1
False # 已经分不清了
>>> int(10 ** 18 ** 0.5)
推论:算法题里凡是能用整数表达的,就绝不要经过 float。
| 想做的事 | ❌ 浮点写法 | ✅ 整数写法 |
|---|---|---|
| 判断 \(a/b = c/d\) | a/b == c/d |
a * d == b * c |
| 判断 \(a/b > c/d\)(\(b,d>0\)) | a/b > c/d |
a * d > b * c |
| 整除判断 | a / b == a // b |
a % b == 0 |
| 开方判完全平方 | int(x**0.5)**2 == x |
math.isqrt(x)**2 == x |
| 取中点 | (l + r) / 2 |
(l + r) // 2 |
| 平均数比较 | 算平均值 | 比较总和(同分母时) |
math.isqrt 这条尤其重要:
判素数、找因子时用 int(n ** 0.5) 作循环上界,会因为这 1 的误差漏掉最后一个因子。
永远用 math.isqrt(n)(Python 3.8+)。
23.2 浮点比较¶
EPS = 1e-9 # 判等的容差,按题目要求的精度往下压两三个数量级
abs(x - y) < EPS # 相等:差值落在容差内就算同一个数
x > y + EPS # 严格大于:要超出容差才算真的大
x < y - EPS # 严格小于
abs(x - y) < EPS * max(1.0, abs(x)) # 相对误差版:x 很大时绝对误差本来就会变大
EPS 该取多少,取决于题目要求的精度和运算规模:
| 题目要求 | 建议 EPS |
|---|---|
| 误差 \(10^{-6}\) | 1e-9 |
| 误差 \(10^{-9}\) | 1e-12 |
| 实数二分,坐标范围 \(10^9\) | 用固定迭代次数代替 EPS(见下) |
实数二分不要用 while r - l > EPS——量级大时可能永远不收敛。
改用固定次数,100 次足够把区间缩小到 \(2^{-100}\) 倍:
lo, hi = 0.0, 1e18 # 答案一定落在这个闭区间里
for _ in range(100): # 固定次数:每轮区间减半,100 轮后长度是原来的 2^-100
mid = (lo + hi) / 2
if check(mid): # mid 已经满足条件 ⇒ 答案不会更大,收右端
hi = mid
else: # mid 不满足 ⇒ 答案只可能在右半边,抬左端
lo = mid
print("%.6f" % lo) # 区间已经窄到远小于输出精度,取哪一端都行
详见 44-二分。
23.3 舍入:Python 用的是「四舍六入五成双」¶
这是 PIO14 里详细讲过的坑,这里补充完整规则。
round()、"%.nf"、f"{x:.nf}"、format() 全部使用
banker's rounding(向偶数舍入):遇到恰好 .5 时,舍入到最近的偶数。
>>> round(0.5), round(1.5), round(2.5), round(3.5)
(0, 2, 2, 4)
>>> "%.0f" % 2.5
'2'
>>> "%.2f" % 0.125
'0.12'
为什么这么设计?因为大批量数据下,「逢五进一」会系统性地偏大, 向偶数舍入在统计上无偏。但算法题要的是数学上的四舍五入。
正确做法:
from decimal import Decimal, ROUND_HALF_UP
# quantize 的第一个参数只看它有几位小数:Decimal("0.000") 就是保留 3 位并自动补零
Decimal("1.2345").quantize(Decimal("0.000"), rounding=ROUND_HALF_UP) # 1.235
Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP) # 3,不是默认的 2
两个关键细节:
- 必须从字符串构造
Decimal。Decimal(1.005)会先经过float, 拿到的是1.00499999...,白搭:
quantize的参数决定小数位数:Decimal("0.000")是 3 位,Decimal("1")是整数。
decimal 的其它舍入模式:
| 模式 | 含义 |
|---|---|
ROUND_HALF_UP |
数学四舍五入 ← 竞赛用这个 |
ROUND_HALF_EVEN |
银行家舍入(Python 默认) |
ROUND_DOWN |
向零截断 |
ROUND_FLOOR |
向下取整 |
ROUND_CEILING |
向上取整 |
23.4 什么时候该换掉 float¶
| 需求 | 用什么 |
|---|---|
| 高精度小数、金额、严格舍入 | decimal.Decimal |
| 精确分数(如概率、期望) | fractions.Fraction |
| 只需要判大小、不需要值 | 交叉相乘,全程整数 |
| 超大整数 | 原生 int |
decimal¶
from decimal import Decimal, getcontext
getcontext().prec = 50 # 有效数字位数(默认 28),管的是整体位数不是小数位数
a = Decimal("1") / Decimal("3") # 除不尽时按 prec 截断,不会像 float 那样悄悄丢精度
print(a) # 0.33333333333333333333333333333333333333333333333333
Decimal 支持 + - * / **、比较、sqrt(),也原生支持科学计数法输入。
代价是慢——比 float 慢一到两个数量级,只在真正需要精度时用。
fractions¶
from fractions import Fraction
p = Fraction(1, 3) + Fraction(1, 6) # 通分、相加、约分一步到位:Fraction(1, 2)
print(p.numerator, p.denominator) # 分子分母分开取,输出既约分数时直接用:1 2
Fraction("0.25") # 从字符串构造是精确的:Fraction(1, 4)
期望/概率题里,如果答案要求以既约分数形式输出,Fraction 会自动约分。
但分母会指数级膨胀,\(n\) 次运算后分母可能有上万位,慎用于大规模递推。
23.5 科学计数法¶
Python 原生支持科学计数法字面量与解析:
>>> 1.5e3
1500.0
>>> float("1.23e-5")
1.23e-05
>>> Decimal("1.23E+5") # Decimal 也支持,且精确
Decimal('1.23E+5')
输出成科学计数法¶
>>> "%.3e" % 123456
'1.235e+05'
>>> f"{123456:.3e}"
'1.235e+05'
>>> f"{123456:.3g}" # g 会自动在定点/科学之间选择
'1.23e+05'
e 格式的指数部分至少两位(e+05)。
如果题目要的是 1.235*10^5 这种自定义格式,就得自己拼——见下面的例题。
解析成 (尾数, 指数)¶
s = "1.234e5"
# partition 按第一个 "e" 切成三段;转小写是为了同时容纳 "E" 写法
mant, _, exp = s.lower().partition("e")
mant = Decimal(mant) # 尾数走 Decimal,避开 float 的二进制误差
exp = int(exp) if exp else 0 # 没有 e 的部分时 exp 是空串,指数按 0 处理
或者用 Decimal.as_tuple() 直接拿到符号、数字序列、指数:
23.6 例题¶
PIO14 单组_保留小数位数¶
见 20-输入输出处理 §20.6。核心是
Decimal(字符串) + ROUND_HALF_UP。题解:solutions/PIO14.py
PIO17 单组_spj判断浮点误差¶
求圆面积,误差 \(10^{-3}\) 以内即可。
import math
r = int(input())
# math.pi 带满双精度有效数字;输出 6 位小数远严于题目要求的 1e-3 误差
print("%.6f" % (math.pi * r * r))
两条准则:用 math.pi 不要手写 3.14159(\(r=1000\) 时少两位小数就差 10 的量级,
远超 \(10^{-3}\));输出位数宁多勿少。题解:solutions/PIO17.py
BISHI14 特殊的科学计数法(简单)¶
给定一个大整数 \(N\),输出其「特殊科学计数法」表示,形如
a.b*10^c。
这题是本章所有知识点的集合:大整数不能转 float(会丢精度)、 舍入必须是数学四舍五入、进位溢出要特判。
思路:设 \(N\) 有 \(L\) 位,则指数 \(c = L - 1\)。尾数取前几位数字,
按题目要求的位数做 ROUND_HALF_UP 舍入。
关键坑是进位溢出:如果尾数是 9.9x 且要舍入到 1 位小数,
结果会变成 10.0——这时必须改写成 1.0*10^(c+1),而不是输出 10.0*10^c。
from decimal import Decimal, ROUND_HALF_UP
s = input().strip() # 全程按字符串处理,不碰 int 也不碰 float
c = len(s) - 1 # L 位数写成 a.b 形式时,指数就是 L-1
# 只需要前几位,绝不把整个大整数转成 float
mant = Decimal(s[0] + "." + s[1:4]) # 首位当整数部分,后几位当小数部分
r = mant.quantize(Decimal("0.0"), rounding=ROUND_HALF_UP) # 保留 1 位小数,数学四舍五入
if r >= 10: # 进位溢出,如 9.95 -> 10.0
r /= 10 # 尾数退回 [1, 10)
c += 1 # 退掉的那一位加到指数上
print("{}*10^{}".format(r, c))
完整实现见
solutions/BISHI14.py, 已通过官方样例验证。该题面对尾数保留位数的描述存在歧义, 题解文件的 docstring 里记录了歧义的两种读法及取舍依据。
23.7 本章速查¶
| 场景 | 结论 |
|---|---|
| 浮点相等 | abs(x-y) < EPS,绝不用 == |
| 分数比大小 | 交叉相乘,全程整数 |
| 整数开方 | math.isqrt(n),绝不用 n ** 0.5 |
| 实数二分 | 固定 100 次迭代,不要用 while r-l > EPS |
| 严格四舍五入 | Decimal(字符串).quantize(..., ROUND_HALF_UP) |
round() / %.nf |
是银行家舍入,round(2.5) == 2 |
Decimal 构造 |
必须传字符串,传 float 白搭 |
| 精确分数 | fractions.Fraction,但分母会膨胀 |
| 科学计数法解析 | float(s) 或 Decimal(s),后者精确 |
| 科学计数法输出 | "%.3e";自定义格式要自己拼 |
| spj 浮点题 | 输出位数宁多勿少 |