分摊账单不能每份各自四舍五入:用整数分验证金额守恒

举报
yd_234095983 发表于 2026/10/31 00:47:33 2026/10/31
【摘要】 用10元分3人的合成账单说明独立舍入不守恒,再以整数分和商余数分配验证总额、人数及差值约束。本地215项检查通过,明确输入边界与公平性限制。AI辅助起草及执行验证。

署名: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项检查替代。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。