跳转至

第 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 这条尤其重要:

>>> x = 10 ** 18
>>> int(x ** 0.5)        # 999999999    错了!
>>> math.isqrt(x)        # 1000000000   对

判素数、找因子时用 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

两个关键细节

  1. 必须从字符串构造 DecimalDecimal(1.005) 会先经过 float, 拿到的是 1.00499999...,白搭:
Decimal("1.005")     # Decimal('1.005')            ✅ 精确
Decimal(1.005)       # Decimal('1.00499999...')    ❌ 已经错了
  1. 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() 直接拿到符号、数字序列、指数:

>>> Decimal("1.234e5").as_tuple()
DecimalTuple(sign=0, digits=(1, 2, 3, 4), exponent=2)

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 浮点题 输出位数宁多勿少