分摊账单不能每份各自四舍五入:用整数分验证金额守恒
署名:lgbisha
AI辅助说明:本文由AI辅助起草,代码及合成数据检查由AI助手在本地Python 3.12.3执行。未使用真实账单、客户数据或支付凭据;没有对接支付平台,也不代表生产结算、税务规则或并发处理已经验证。
一、用Decimal不等于分摊后自动对账
假设一笔合成费用是10.00元,要平均分给3人。每人先按十进制算出金额,再各自保留两位小数,结果会是3.33元。三个结果相加是9.99元,而不是原来的10.00元。
这并不是只能归咎于二进制浮点的现象。即使使用Decimal,独立舍入仍然不能保证总额守恒:
from decimal import Decimal, ROUND_HALF_UP
share = (Decimal("10.00") / 3).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
print(share, share * 3)
本地输出为3.33 9.99。Decimal让数值表示和舍入方式更明确,但不会替业务决定那一分钱归谁。
因此,这个小实验先明确两项要求:每个人的结果必须以整数分表示;所有结果的总和必须严格等于原账单分值。在平均分摊条件下,再增加“任意两份最多相差1分”的约束。
二、输入边界先说清楚
为了不把演示做成含糊的金额解析器,本文只接受一个很窄的输入格式:
- 金额必须是字符串,包含1到10位ASCII数字作为整数部分,可以有1到2位小数。
- 允许
0、0.1、10.00及0007.50,不自动修剪空白,不接受正负号。 - 不接受科学计数法、千位分隔符、全角数字、超过两位的小数,也不把
NaN或Infinity当金额。 - 本例仅处理非负费用;退款、负数、汇率换算、不同货币的最小单位不在范围内。
这些是演示契约,而不是所有系统都应采用的通用规则。比如有的界面允许用户输入空格,那应当在独立输入层明确清洗,并保留必要的确认流程,而不是在计算中悄悄修正。
下面直接从整数部分和小数部分构造整数分,整个解析过程不经过float:
import re
def parse_cents(amount):
if not isinstance(amount, str):
raise ValueError("金额必须是十进制字符串")
if re.fullmatch(r"[0-9]{1,10}(?:\.[0-9]{1,2})?", amount) is None:
raise ValueError("仅接受1到10位整数及可选的1到2位小数")
whole, separator, fraction = amount.partition(".")
return int(whole) * 100 + int(fraction.ljust(2, "0") or "0")
这里使用[0-9]而不是宽泛的数字字符范围,是为了让格式限制和上面的ASCII约定一致。fullmatch要求整个输入都符合格式,因此"10.00\n"也不能悄悄通过。输入最多10位整数部分,是此示例设定的上限;它不是Python整数本身的上限。
三、先取商和余数,再分配多出的1分
将1000分分给3人,可以写成1000 = 333 × 3 + 1。每个人先分333分,剩下1分交给列表中的第一人,结果为[334, 333, 333]。
一般情况下,总分值除以人数得到base与remainder。让前remainder个位置多拿1分,就能同时满足总额守恒和差值约束:
def split_cents(total_cents, people):
if type(total_cents) is not int or total_cents < 0:
raise ValueError("总分值必须是非负整数,不接受布尔值")
if type(people) is not int or not 1 <= people <= 10000:
raise ValueError("人数必须是1到10000之间的整数")
base, remainder = divmod(total_cents, people)
return [base + (position < remainder) for position in range(people)]
布尔条件在这里参与的是整数加法,输出元素仍为整数。入口使用type(...) is int,是为了明确排除布尔值以及其他非内置整数类型,避免把True当成1个人。人数上限10000只是为了约束这个返回完整列表的演示,避免无界分配内存。
把两个函数放在同一个Python文件后,可以运行:
print(parse_cents("10.00"))
print(split_cents(parse_cents("10.00"), 3))
print(split_cents(parse_cents("0.02"), 5))
输出依次为1000、[334, 333, 333]、[1, 1, 0, 0, 0]。费用少于人数时,部分人分到0分是这个平均分摊规则的正常结果,不应为避免0而凭空增加总金额。
四、比示例输出更重要的是不变量
只看10元分3人远远不够。本次本地检查将不同职责分开验证,共215项通过、0项失败:
| 检查类别 | 数量 | 主要范围 |
|---|---|---|
| 合法金额解析 | 7 | 零、1位小数、前导零、输入上限 |
| 非法金额拒绝 | 21 | 空白、负数、浮点、布尔、超长、非有限值及格式错误 |
| 明确分配结果 | 6 | 整除、有余数、金额少于人数、零费用及单人 |
| 非法总分值拒绝 | 5 | 负数、浮点、布尔、字符串及None |
| 非法人数拒绝 | 7 | 零、负数、超上限、浮点、布尔等 |
| 分配不变量 | 168 | 7个总分值与6个人数的42个组合,每组4项 |
| 独立舍入反例 | 1 | Decimal每份3.33元合计9.99元 |
42个组合使用总分值0、1、2、99、1000、1001、999999999999,人数1、2、3、7、100、10000。每组都检查以下四点,而不是只检查一张“看起来合理”的结果表:
shares = split_cents(1000, 3)
assert len(shares) == 3
assert sum(shares) == 1000
assert min(shares) >= 0
assert max(shares) - min(shares) <= 1
215是本次确定性检查的计数,不代表穷举所有输入、随机性质测试或生产级质量认证。尤其是参与者顺序、重复请求、并发写入和账单存储,本例都没有实现。
五、总额守恒不是完整的公平规则
这个算法把余数固定分给靠前的位置,单次结果清楚且可复现,却不能据此声称长期公平。如果每期都让同一个人排第一,他就可能持续承担额外1分。
真实系统需要另外确定参与者的稳定标识、顺序规则及可能的轮换策略;更不能依赖集合遍历顺序或临时查询顺序决定多付的钱归谁。涉及按权重分摊、优惠、税费、退款时,也应重新定义约束,而不是把本例平均分摊函数直接套上去。
展示层可以把334分显示为3.34元,但账本仍应保存清晰的单位和原始整数值。显示格式正确,不等于计算规则、对账记录和实际支付都正确。
六、小结与参考资料
本次实验的结论不是“Decimal没用”,而是数值表示、舍入方式与余数分配属于不同问题。先把金额转成明确的最小单位,再用商和余数分配,并对总额、人数、非负值和差值建立检查,才能避免一笔合成账单在分摊后少掉1分。
参考资料为Python 3.12官方文档的decimal — Decimal fixed-point and floating-point arithmetic、内置函数divmod、re.fullmatch与布尔类型说明。代码为本文演示实现,非文档原文复制。
最后再次说明:以上结果来自本地合成数据,AI辅助起草和执行验证;没有使用真实资金、进行云部署或连接任何支付接口。支付系统还需要独立评审业务规则、权限、审计、幂等性及适用的监管要求,不能由这215项检查替代。
- 点赞
- 收藏
- 关注作者
评论(0)