dimesum

首页 / 博客 / 工程

工程

分账舍入:为什么你的应用和电子表格差了一分钱

· 阅读约 12 分钟 ·

一笔 $100.00 的账单三人平分,会剩下一分钱,表格把它丢掉,Dimesum 则把它交给一个人,由这笔账单的固定哈希值来选。这里有算好的表格、我们替换掉的规则,以及只有表格才会犯的两种币种错误。

你的电子表格显示 $66.66,应用显示 $66.67。这一分钱就是分账舍入,也是那些拿 Dimesum 和 Excel 对账的人发给我们最多的问题。简短的答案是:表格把每个份额各自舍入,丢掉了多出的那一分钱;而 Dimesum 把这一分钱交给一个人,由这笔账单的一个固定哈希值来选,所以各份加起来总能回到总额。

问题、原因和解决办法,用三句可以直接引用的话来说。一笔 $100.00 的账单三人平分,无法用三个相等的金额付清,所以总有一个人要承担 $33.34。一张对每个单元格都做舍入的表格会显示三次 $33.33,悄悄丢掉一分钱,而 Dimesum 保留这一分钱,并用这笔账单自己的哈希值决定由谁承担。在每一笔能除尽的账单上,两者都会一致;在每一笔除不尽的账单上,两者每人最多相差一分钱。

症状:一方有、另一方没有的一分钱

这类反馈通常是这样来的。有人导出一个月的账单,在表格里重建余额,然后发现几分钱的出入。两边在每笔账单上都没有缺超过每人一分钱,可谁也没法让两边对上。

我们在为什么共享余额总是对不上里写过群组余额看起来出错的四个原因。这篇文章拿出其中的第一个,也就是被舍入的那一分钱,一路追进电子表格里,因为细心的人正是去那里核对我们。它还讲了表格会出错、而应用不会的两个地方:在一列里把两种币种相加,以及给日元两位它本来没有的小数。

一笔 $100.00 的账单,三人平分,在表格里和在应用里

Emma 付了 $100.00 的晚餐。Lucas、Sofia 和 Emma 平分。一百美元除以三是 $33.333,无限循环下去,没有哪张卡能付这个数。

表格能装下这个无尽的数字。在三个单元格里输入 =100/3,把格式设为两位小数,每个都显示 $33.33。SUM 单元格显示 $100.00,因为它加的是隐藏的数字,而不是屏幕上显示的数字。现在,一个读者用计算器把三个看得见的单元格加起来,得到 $99.99。

细心的表格会改用舍入:在每个单元格里写 =ROUND(100/3, 2)。现在每个单元格里真的是 $33.33,SUM 真的是 $99.99,而那一分钱离开了表格。Lucas 欠 Emma $33.33,Sofia 欠 Emma $33.33,所以表格说她能拿回 $66.66。她自己的份额本该是 $33.33,可她实际上为这顿晚餐付了 $33.34,却没有任何人把它记下来。

一顿由 Emma 付款的 $100.00 晚餐三人平分:两张电子表格和两笔 Dimesum 账单
在哪里分摊Emma 的份额Lucas 的份额Sofia 的份额各份加起来Emma 被欠
Dimesum,一笔哈希值让 Emma 承担多出那一分的账单$33.34$33.33$33.33$100.00$66.66
Dimesum,一笔哈希值让 Lucas 承担多出那一分的账单$33.33$33.34$33.33$100.00$66.67
表格,=100/3 显示两位小数$33.33$33.33$33.33SUM 中为 $100.00,屏幕上为 $99.99屏幕上为 $66.67
表格,=ROUND(100/3, 2)$33.33$33.33$33.33$99.99$66.66

每一行都是你可以重算一遍的算术。

在第二行 Dimesum 里,Lucas 欠 $33.34,Sofia 欠 $33.33,$33.34 加 $33.33 是 $66.67。在舍入过的表格里,$33.33 加 $33.33 是 $66.66。两者都不是错误。它们是关于同一分钱的两条不同规则,而只有其中一条把这一分钱留在了记录上。

为什么每个份额先向下取整

Dimesum 用最大余额法来分摊,这是一些议会用来把得票比例换算成整数席位的规则。每个金额都以该币种最小单位的整数来存储,所以 $100.00 就是 10,000 美分。每个人的确切份额先算成一个分数,然后每个人先拿到它下面的整数个分:每人 3,333,合计 9,999。

这就剩下了一分钱。剩下的分会一次一分地发出去,发给那些分数被截掉最多的人。在平均分摊中,每个人的分数都一样,都是三分之一分,所以这条规则需要一个平局决胜办法。正是这个平局决胜办法,让我们的第一个版本出了错。

这种方法给出的保证,是表格给不了的:各份加起来正好等于总额,而且没有哪一份和它的确切值相差超过一分钱。同样的算术适用于平均分摊、按百分比分摊、按权重分摊、一笔逐项账单的每一行,以及其中的每一个税费或小费池。我们关于按菜品分账的应用的文章,在一顿带折扣的四人晚餐上展示了它。

我们最初的做法:最早加入的成员承担每一个多出的分

第一个平局决胜办法是最显而易见的那个。成员按加入时间排序,所以平局时归最早加入的成员。它是确定性的,容易测试,而且在任何一笔单独的账单上,谁也说不出它不公平。

可一年下来,它就成了一笔附加费。一套三人合租房分摊房租、电费、网费和采购,其中很多账单都会剩下一分钱。每一分都落到了同一个人身上,也就是建群的那个人。一分钱不算什么;几百分钱都朝一个方向流,就是一个总有人会在表格里注意到的规律。

创始人在 2026 年 8 月 21 日给出的答案是,应当由一个随机的参与者来承担它,这样在许多笔账单之后就会均匀分布。随机对我们来说有一个问题。Dimesum 在一笔账单被编辑时会重新计算它的分摊,而一次编辑会被记为一条重述分摊的新记录,正如为什么编辑必须重述它的分摊所描述的。如果用真正的随机抽取,一次只改了账单名称的编辑也会重新抽一次,把这一分钱挪给别人,并为一笔从没动过的钱记下一条更正。

解决办法:用账单的固定哈希值轮转多出的那一分

于是随机变成了伪随机、并且固定。每笔账单都有自己的 ID,我们用 FNV-1a 对它做哈希,这是一个在每台机器、每个构建版本上都给出同样答案的小型哈希算法。哈希值决定剩下的分从有序列表的哪个位置开始发。同一笔账单,每次计算都给出同样的答案;在许多笔账单之间,起点大致均匀地落在每个人身上。

这个有序列表仍然是最大余额的顺序,平局时按最早加入的成员优先。哈希值只改变发放从哪里开始。在三人平均分摊中,这意味着每笔账单会选出三人中的一个来承担 $33.34,而对这笔账单来说,这个选择永远不会改变。

这就是为什么你的表格和应用在不同的账单上会有不同的出入。在一顿 $100.00 的晚餐上,哈希值把这一分给了 Emma,两边都认为她被欠 $66.66。在下一顿上,它把这一分给了 Lucas,应用就显示 $66.67。一张每一行都用同一条规则的表格无法重现这一点,除非它把哈希也照搬过来。

我们接受的代价

两笔内容完全相同的账单,现在可能相差一分钱。同样三个人的两顿 $100.00 晚餐,可能一顿是 Emma 承担 $33.34,另一顿是 Sofia 承担 $33.34。我们自己有两个端到端测试,把一笔账单的份额和另一笔做比较,在这个改动上线的那天就失败了;它们现在改为检查规则的输出。

我们认为这是正确的取舍。每个余额底下的账本都是只追加的,所以一分钱如果无缘无故地移动,就会被永远记下来;设计写在每一次分摊背后的只追加账本里。一分在每笔账单上固定、又分散到不同人身上的钱,永远不需要更正。

一列把美元和欧元加在一起,不是一个总额

第二个差别比一分钱大,而且错的总是表格。在一次旅行中,Emma 付了 €90 的一晚酒店和 $60 的博物馆门票,表格只有一个“金额”列,底下一个 SUM。它显示 150。这个数字没有币种,所以谁也不可能欠它。

Dimesum 按币种保留每一个余额,从不跨币种相加。群组看到的是欠多少欧元,以及另外欠多少美元。如果群组商定一个汇率,Dimesum 可以把余额折算成一种币种来显示,并标明这个汇率,而债务本身仍然留在花钱时的那种币种里。

不这样做会带来的错误,写在第二种币种会带来的资金错误里。如果你是因为一次旅行才来比较的,多币种分账应用列出了其他应用如何处理第二种币种。

折算会带回它自己的舍入。一次换算会舍入到整数个分,采用远离零的四舍五入,所以在日元里加起来为零的余额,换成美元后可能多出一分钱。我们让成员 ID 最小的人在每次换算中最多吸收一个最小单位,超过这个范围,结算方案就会被拒绝。一个悄悄错了 $1 的方案比一个报错更糟,因为真的会有人去付它。

日元没有“分”,表格却硬给它两位小数

第三个差别会出现在每一次日本之旅中。日元没有辅币单位:在 ISO 4217 币种标准中,它的指数是零,所以 ¥1 就是任何人能付的最小金额。一顿 ¥10,000 的晚餐在 Dimesum 里三人平分,结果是 ¥3,334、¥3,333 和 ¥3,333,加起来是 ¥10,000。

一张按货币格式设置的表格会显示三次 ¥3,333.33,这是没有任何硬币能付的数字。把它舍入到整日元,就显示三次 ¥3,333,加起来是 ¥9,999。日元丢掉一个单位的方式,和美元丢掉一分钱的方式一样。

在修好它之前,我们自己也犯过这个错。

从一行文本中读出金额的代码,曾经把金额乘以 100 来换算成辅币单位,这对美元和欧元是对的,对日元却是错的:一趟 ¥2,000 的出租车变成了 ¥200,000。那是一个 100 倍的错误,任何舍入规则都抓不住它。

现在的换算会从 ISO 表中读取每种币种的指数,而一个比该币种最小单位更细的数字会被拒绝,而不是被舍入。从文本中读取金额,在我们这里有一段更长的历史,写在我们为什么删掉了自己的开支解析器里。一次用日元的旅行如何分摊而不出这种麻烦,见日本的分账应用。

电子表格还会对钱做些什么

Excel 用二进制浮点数存储数字,而有些小数没有精确的二进制形式。微软自己关于 Excel 中浮点运算的说明,解释了为什么一个总和可能和你预期的数字差上一丝。大多数时候,显示会把它藏起来,而这正是你看到的单元格和你相加的值可能不同的原因。

舍入函数也有自己的规则。Excel 的 ROUND 函数和 Google 表格中的 ROUND 都是对每个单元格各自舍入,对分摊中的其他单元格一无所知。全部的差别就在这里:一次分摊需要把各份放在一起舍入,这样它们加起来才仍然等于账单。

Dimesum 从不把钱存为一个单位的分数。每个金额都是整数个分或整数个日元,旁边写着它的币种,而每一次分摊都精确守恒它的总额。如果你仍然更愿意把群组的钱记在表格里,我们的共同开支记录模板是一个不错的起点,而共同开支该用表格还是应用讲了表格从哪里开始不够用。

如何让你的表格和应用一致

你可以亲手重现 Dimesum 的数字。用整数个分来计算。给每个人他确切份额以下的整数个分,数一数剩下多少分,再一次一分地发出去。

  1. 把账单换算成分:$100.00 是 10,000。
  2. 给每个人他确切份额的向下取整:每人 3,333,合计 9,999。
  3. 把剩下的那一分交给应用显示承担它的那个人,并检查各份加起来是否为 10,000。
  4. 每种币种用一张表,永远不要在两种币种之间做 SUM。

第三步是唯一一个没有账单哈希值就无法预测的步骤,所以直接从账单上读出来。任何分摊的更完整算术,包括权重和百分比,在分账计算方法里;而把同样的检查应用到一个群组的全部历史上,在旅行中如何记录谁欠谁里。

这仍然做不到的事

它不能让你的表格和应用不费力地在每一笔账单上都一致。一张每列只有一条舍入规则的表格,在几乎每一笔留下余数的账单上都会和 Dimesum 相差一分钱,而承担这一分的人会随账单而变。这正是规则在起作用。

它也不能让一个折算后的总额变得精确。按商定汇率以一种币种显示的余额只是参考,据此生成的结算方案在每次换算中可能差一个最小单位。真正算数的债务仍然留在花钱时的那种币种里,这就是为什么结清一个群组要一种币种一种币种地来。

核对总和,而不是每个单元格

当应用和你的表格相差一分钱时,把账单上的各份加起来:如果它们等于总额,那一分钱就是有意落在某一个人身上的。想看是哪一个人,打开 Dimesum,点开这笔账单,找到那个比其他人多一分钱的份额。

常见问题

为什么我的分账应用和 Excel 差了一分钱?

因为 Excel 把每个份额各自舍入,而应用把各份放在一起舍入。一笔 $100.00 的账单三人平分,在舍入过的表格里是三次 $33.33,加起来是 $99.99。Dimesum 让一个人承担 $33.34,好让各份加起来等于 $100.00,而由这笔账单的一个固定哈希值决定谁来承担这一分钱。

账单三人平分时,多出的那一分钱由谁付?

由一个人付,而在 Dimesum 里,由这笔账单自己的哈希值决定是哪一个。各份按最大余额法算出,而剩下的分从哪里开始发,由这笔账单的一个固定哈希值来轮转。同一笔账单总是给出同样的答案,而在许多笔账单之间,这一分钱大致均匀地落在每个人身上。

为什么 Dimesum 不随机挑一个人来承担多出的那一分?

因为真正的随机抽取会在每次编辑账单时挪动这一分钱。Dimesum 在每次编辑时都会重述分摊,所以一次新的抽取会为一笔从没动过的钱记下一条更正。账单的固定哈希值在许多笔账单之间表现得像随机选择,而对同一笔账单每次都给出同样的答案。

我能在表格的同一列里把两种币种的余额相加吗?

不能,这个总额毫无意义。€90 和 $60 加起来得到 150,是一个没有币种的数字,所以谁也不可能欠它。Dimesum 按币种保留余额,从不跨币种相加;群组可以按商定的汇率查看折算后的余额,并标明这个汇率,而每一笔债务都留在花钱时的那种币种里。

日元没有“分”,Dimesum 怎么分摊日元?

按整日元分,因为日元的最小单位是 ¥1。一顿 ¥10,000 的晚餐三人平分,是 ¥3,334、¥3,333 和 ¥3,333,加起来是 ¥10,000。设成两位小数的表格会显示 ¥3,333.33,这是没有任何硬币能付的数字;而把每个单元格舍入到整日元,会丢掉 ¥1,就像美元表格丢掉一分钱一样。