1. 什么是分摊

分摊算法主要适用于需要将 总量(如金额、资源、费用、收益)按某种规则分配给多个部分的场景。

为了更好地理解“分摊”概念,可以通过一个常见的电商购物场景来说明。假设有一个满减优惠活动:满20元减10元。你凑单购买了两件商品, 最终实付20元,每个商品各自的实付金额计算过程如下

  1. 计算每个商品的支付占比
  2. 计算实付金额
商品 单价 数量 分摊比例(四舍五入,2位精度) 实付金额
商品A 12 1 12/30 = 0.4 $0.4 * 20 =8$
商品B 18 1 18/30 = 0.6 $0.6 * 20 =12$

如果用户后续在其中一个商品上产生了退款操作,那么退款金额应该是他在这个商品上实际支付的价格,而不应该是原价

2. 使用“分”作为金额单位进行计算

在讨论分摊算法的实现前, 需要先说明一下处理“钱”相关逻辑时,都是以“分”作为最小单位进行处理的。

在各种场景下,比如领取微信红包、 微信支付宝余额展示、 凑单时每单的实付金额、股票账户,都可以清楚得看到,在展示给用户时,金额单位都是“元”,并且精确到两位小数, 即“分”。

但在代码层面处理金额时,很多支付系统、银行系统和电商平台中,都是使用 “分” 作为单位。以上那些展示给用户带小数点的数字,其实都是以整数的形式存在,数据类型为int。

在数据库中存储金额时,使用 BIGINT 类型。

使用分作为金额处理的单位,可以避免使用浮点数类型如 floatdouble

浮点数类型在存储和参与计算的过程中,是会产生精度误差的。尤其在多次加减时,这种误差可能累积并产生不精确的结果,影响到数据的正确性。

举个简单的例子,如果我们在计算金额时用浮点数:

1
0.1 + 0.2 == 0.3

在某些情况下,实际计算结果可能不等于0.3,而是0.30000000000000004,这是因为浮点数精度有限。即使这种误差在日常应用中很小,但在金融系统中,这样的误差一旦累计,就可能导致资金的偏差,影响账务的准确性。

同时 分也是处理金额的最小单位,如果在以分为最小单位的处理逻辑中出现了精度更高的小数位,一般都会舍弃掉。

以下内容讨论如何实现一个分摊算法

3. 分摊算法最重要的问题-分摊后的总和必须等于原始值

在分摊算法中,最重要的一点就是确保分摊后各个部分的总和与原始值一致。 不能多算或者少算。

举例依然是满20减10, 最终总计实付20元

商品 单价 数量 分摊比例(四舍五入,2位精度) 实付金额
商品A 10 1 10/30 = 0.33 6.6
商品B 10 1 10/30 = 0.33 6.6
商品B 10 1 10/30 = 0.33 6.6

此时分摊后各金额相加之和是 19.8 元, 与用户实际支付的20元有误差。

分摊算法在实现时要避免误差,否则会出现数据不一致的情况。

确保分摊后各个部分的总和与原始值不一致的本质,来源于计算分摊比例时,往往需要进行除法运算,

  1. 四舍五入:可能产生无限小数(如 1/3 = 0.333…)。为了实际应用,这些小数需要被截断或舍入,从而产生误差。
  2. 精度限制:即使比例计算结果是有限小数,但由于精度限制(如保留两位小数)会导致部分精度被舍弃,也会产生误差

通过更精确的比例计算(如使用 BigDecimal),结合合适的舍入规则(如四舍五入)和修正策略,可以有效减少这种误差的发生。

3.1 误差修正的方法

最后一次分摊不按照比例计算

最后一个商品不再按照比例进行分摊,而是直接通过计算已分配的金额与原始总额之间的差值,直接将这个差值分配给最后一项商品。

这是因为在前面的分摊过程中,由于四舍五入和精度限制,可能会有一些小的误差,最后一次分摊就是用来修正这些误差的,确保误差被完全消除,且总和精准。

在这种情况下,最后一个商品充当了“误差调节器”,承担了所有因四舍五入或其他计算原因导致的微小误差。

按比例平衡误差

在分摊过程中,每个商品的金额会按比例计算,所有商品的分摊金额可能会因为舍入造成总和小于或大于原始金额。此时,可以将误差按比例重新分配给所有商品,而不是仅仅分配给最后一个商品。

最大值法(集中修正)

在分摊过程中,如果出现误差,可以选择将误差集中到金额最大的商品上,通常选取商品价格最贵、最具代表性的商品来承担误差。

金额最大的商品指的是商品总价最贵的商品,而不是单价最贵的商品。这是因为分摊逻辑通常与商品的总金额相关,而总金额(单价 × 数量)更能反映商品对整体金额的贡献程度。

4. 实现分摊算法

  1. 使用 BigDecimal 确保高精度计算
  2. 使用 最后一次分摊不按照比例计算 来修正误差
  3. 处理最后一项不够减 的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

public class Order {
int totalAmount;
List<Product> productList;

public void calculateAmount() {
// 计算订单中所有商品的原始总金额
BigDecimal originalTotal = BigDecimal.ZERO;
for (Product product : productList) {
BigDecimal productTotal = BigDecimal.valueOf(product.price)
.multiply(BigDecimal.valueOf(product.quantity));
originalTotal = originalTotal.add(productTotal);
}

if (originalTotal.compareTo(BigDecimal.ZERO) == 0) {
throw new IllegalArgumentException("Original total amount cannot be zero");
}

// 按比例计算每个商品分摊的金额
BigDecimal remainingAmount = BigDecimal.valueOf(totalAmount); // 总分摊金额

// 剩余分摊金额
for (int i = 0; i < productList.size(); i++) {
Product product = productList.get(i);

// // 如果剩余金额已分摊完毕,退出循环。剩余商品默认分摊 0
if (remainingAmount.compareTo(BigDecimal.ZERO) <= 0) {
break;
}

BigDecimal productTotal = BigDecimal.valueOf(product.price)
.multiply(BigDecimal.valueOf(product.quantity));

// 最后一个商品分配剩余金额, 以用于吸收误差
if (i == productList.size() - 1) {
product.amount = remainingAmount.intValue();
} else {
// 计算比例
BigDecimal ratio = productTotal.divide(originalTotal, 10, RoundingMode.HALF_UP);
BigDecimal allocatedAmount = ratio.multiply(BigDecimal.valueOf(totalAmount));

// 保证“分”是金额的最小单位, 更高精度的小数部分应该舍弃
allocatedAmount = allocatedAmount.setScale(0, RoundingMode.HALF_UP);

// 剩余分摊已经不够
if (remainingAmount.subtract(allocatedAmount).compareTo(BigDecimal.ZERO) <= 0) {
allocatedAmount = remainingAmount;
}

product.amount = allocatedAmount.intValue();
remainingAmount = remainingAmount.subtract(allocatedAmount);
}
}
}



public static class Product {
int price;
int quantity;
int amount;

public Product(int price, int quantity) {
this.price = price;
this.quantity = quantity;
}
}
}