Chia tiền không lệch một đồng: phương pháp phần dư lớn nhất
Chia mã giảm giá vào từng dòng hóa đơn mà tổng vẫn khớp từng đồng: vì sao làm tròn từng dòng bị lệch, và phương pháp phần dư lớn nhất.
08:40 ngày 01/10/2026, job đối chiếu của kế toán BanHang báo đơn 10107 lệch 1 đồng. Đơn tạo lúc 20:14 tối hôm trước, ba dòng: điện thoại 8.990.000, ốp lưng 190.000, tai nghe 990.000, cộng 10.170.000. Mã GIAM200K giảm 200.000 cho cả đơn, cổng thanh toán thu 9.970.000. Hóa đơn chia 200.000 vào ba dòng theo tỷ lệ giá trị, làm tròn từng dòng về đồng, được 199.999, nên tổng hóa đơn là 9.970.001. Sổ cái ghi theo hóa đơn, tiền về từ cổng ít hơn 1 đồng. Bài này dựng cách chia giữ đúng tổng và đo nó trên 100.000 đơn.
Đọc nhanh
- Làm tròn từng phần riêng rẽ không giữ được tổng: trên 100.000 đơn mô phỏng, 19,5% đơn có tổng giảm giá các dòng lệch số giảm của mã, tới 2 đồng. Half-even thay half-up chỉ bớt 32 đơn.
- Phương pháp phần dư lớn nhất (Hamilton, 1792): mỗi phần lấy phần nguyên, số đồng còn thiếu chia nốt cho các phần có phần dư lớn nhất. Tổng luôn đúng, mỗi dòng lệch phần đúng dưới 1 đồng.
- Python
round(),Decimalvà C#Math.Roundbiến 12.812,5 thành 12.812, SQL ServerROUNDthành 12.813. Tỷ lệ tính bằng float biến 12.812,5 thành 12.812,4999… và chọn sai đồng. - Chia bằng số nguyên, phá hòa theo thứ tự cố định, tính một lần lúc tạo đơn rồi lưu để mọi nơi đọc lại.
1. Một đồng rơi mất ở đâu
BanHang ghi giảm giá vào từng dòng: hóa đơn in giá sau giảm của từng mặt hàng, sổ cái tính lãi gộp theo nhóm hàng, khách trả ốp lưng được hoàn đúng số đã trả cho ốp lưng. Phần của mỗi dòng phải là số nguyên đồng: ISO 4217 ghi VND không có đơn vị phụ, và cổng thanh toán đối tác nhận số nguyên đồng.
Phần đúng của mỗi dòng là 200.000 × giá trị dòng / 10.170.000: 176.794,49, 3.736,48 và 19.469,03. Làm tròn về đồng gần nhất bỏ cả ba phần lẻ vì không phần nào đạt 0,5, nhưng ba phần lẻ cộng lại là 1,00.
Mô phỏng ở mục 4 cho 19,5% đơn có mã giảm giá lệch như vậy. Nếu 20% trong khoảng 13.000 đơn mỗi ngày dùng mã giảm cố định (minh họa), đó là khoảng 500 hóa đơn lệch mỗi ngày (ước lượng).
2. Phương pháp phần dư lớn nhất
Bài toán: chia D đồng thành n phần nguyên aᵢ theo trọng số gᵢ (tổng G), sao cho Σaᵢ = D và aᵢ gần phần đúng qᵢ = D·gᵢ/G. Làm tròn từng phần lệch mỗi qᵢ tối đa 0,5, nhưng các sai số không buộc phải triệt tiêu: tổng lệch là số nguyên có trị tuyệt đối tới ⌊n/2⌋.
Hạ viện Mỹ gặp bài toán này năm 1792, khi chia ghế cho các bang theo dân số. Cách Alexander Hamilton đề xuất nay gọi là phương pháp Hamilton, hay phần dư lớn nhất (largest remainder):
- Mỗi phần nhận ⌊qᵢ⌋.
- Số còn thiếu k = D − Σ⌊qᵢ⌋. Mỗi phần dư nhỏ hơn 1, nên k từ 0 đến n − 1.
- Cộng 1 cho k phần có phần dư qᵢ − ⌊qᵢ⌋ lớn nhất.
Tổng luôn bằng D, và mỗi aᵢ là ⌊qᵢ⌋ hoặc ⌊qᵢ⌋ + 1, nên lệch phần đúng dưới 1 đồng. Đồng còn thiếu của đơn 10107 về điện thoại, phần dư 0,49. Lưu thành chia_tien.py:
def chia_lam_tron(tong: int, trong_so: list[int], kieu: str = "half_up") -> list[int]:
"""Làm tròn từng phần riêng rẽ. Tổng các phần có thể lệch khỏi tong."""
T = sum(trong_so)
kq = []
for w in trong_so:
q, r = divmod(tong * w, T) # phần đúng = q + r/T, chính xác
if kieu == "half_up":
q += 1 if 2 * r >= T else 0
else: # half_even
q += 1 if 2 * r > T or (2 * r == T and q % 2 == 1) else 0
kq.append(q)
return kq
def chia_phan_du_lon_nhat(tong: int, trong_so: list[int]) -> list[int]:
"""Phương pháp Hamilton: lấy phần nguyên, chia nốt từng đồng cho phần dư lớn nhất."""
T = sum(trong_so)
phan = [divmod(tong * w, T) for w in trong_so]
kq = [q for q, _ in phan]
thieu = tong - sum(kq) # luôn từ 0 đến len(kq) - 1
# Hòa phần dư: dòng trọng số lớn hơn trước, rồi dòng đứng trước. Chạy lại ra đúng kết quả cũ.
thu_tu = sorted(range(len(kq)), key=lambda i: (-phan[i][1], -trong_so[i], i))
for i in thu_tu[:thieu]:
kq[i] += 1
return kq
Mọi phép tính là số nguyên: divmod cho phần nguyên và phần dư chính xác. Thứ tự phá hòa cố định để ký lại hóa đơn sau lỗi ra đúng số cũ. Khoản âm như hoàn tiền thì chia trên trị tuyệt đối rồi đổi dấu.
from chia_tien import chia_lam_tron, chia_phan_du_lon_nhat
dong = [8_990_000, 190_000, 990_000] # điện thoại, ốp lưng, tai nghe của đơn 10107
print(chia_lam_tron(200_000, dong), sum(chia_lam_tron(200_000, dong)))
print(chia_phan_du_lon_nhat(200_000, dong), sum(chia_phan_du_lon_nhat(200_000, dong)))
print(chia_phan_du_lon_nhat(25_990_000, [1] * 12)) # trả góp 12 kỳ, mục 5
[176794, 3736, 19469] 199999
[176795, 3736, 19469] 200000
[2165834, 2165834, 2165834, 2165834, 2165833, 2165833, 2165833, 2165833, 2165833, 2165833, 2165833, 2165833]
Phương pháp có một giới hạn đã biết: với số liệu điều tra dân số năm 1880, nó cho Alabama 8 ghế khi Hạ viện có 299 ghế nhưng 7 ghế khi có 300. Đó là nghịch lý Alabama, lý do Mỹ cuối cùng bỏ phương pháp này. Tiền cũng vậy: đơn 10107 giảm 200.005 thì ốp lưng nhận 3.737, giảm 200.006 thì còn 3.736, dù phần đúng tăng từ 3.736,5733 lên 3.736,5919. Vì thế khi số giảm của đơn đã xuất hóa đơn thay đổi, chia phần chênh rồi cộng vào số cũ: mỗi dòng chỉ tăng hoặc giữ nguyên.
3. Half-up, half-even và mặc định của từng nơi
Hai kiểu chỉ khác nhau ở phần lẻ đúng bằng 0,5. Half-up đẩy x,5 ra xa số 0: 12.812,5 thành 12.813. Half-even (banker's rounding) đưa x,5 về số chẵn gần nhất: 12.812,5 thành 12.812, 12.813,5 thành 12.814. Ba đoạn dưới kiểm 12.812,5 và một tỷ lệ float, kết quả ở chú thích:
from decimal import Decimal, ROUND_HALF_UP as HALF_UP
x, mot = Decimal("12812.5"), Decimal(1)
print(round(12812.5), round(12813.5)) # 12812 12814
print(x.quantize(mot)) # 12812
print(x.quantize(mot, rounding=HALF_UP)) # 12813
f = 7_790_000 / 30_400_000 * 50_000 # tỷ lệ float
print(repr(f)) # 12812.499999999998
print(Decimal(f).quantize(mot, rounding=HALF_UP)) # 12812
using System.Globalization; // dotnet run lam_tron.cs (.NET 10)
decimal x = 12812.5m;
var away = MidpointRounding.AwayFromZero;
var vn = new CultureInfo("vi-VN");
Console.WriteLine(Math.Round(x)); // 12812
Console.WriteLine(Math.Round(x, away)); // 12813
Console.WriteLine(x.ToString("N0", vn)); // 12.813
double f = 7_790_000.0 / 30_400_000 * 50_000;
Console.WriteLine(Math.Round(f, away)); // 12812
Console.WriteLine(1m / 3m * 3m); // 0.99…9 (28 chữ số 9)
SELECT ROUND(12812.5, 0), -- 12813.0
ROUND(-12812.5, 0), -- -12813.0
CAST(12812.5 AS decimal(18, 0)), -- 12813
CAST(12812.5 AS int), -- 12812
ROUND(7790000e0 / 30400000 * 50000, 0); -- 12812.0, float
Chạy trên Python 3.14.3, .NET 10.0.12 và SQL Server 2019 CU27:
| Lời gọi | Xử lý x,5 | 12.812,5 thành |
|---|---|---|
Python round() |
Về số chẵn | 12.812 |
Python Decimal.quantize() |
Theo ngữ cảnh, mặc định ROUND_HALF_EVEN |
12.812 |
C# Math.Round(decimal), Math.Round(double) |
MidpointRounding.ToEven |
12.812 |
C# decimal.ToString("N0") |
Ra xa số 0 (đo được) | 12.813 |
SQL Server ROUND(x, 0) |
Ra xa số 0 | 12.813 |
SQL Server CAST(x AS decimal(18, 0)) |
Làm tròn | 12.813 |
SQL Server CAST(x AS int) |
Cắt phần lẻ | 12.812 |
Cùng một dòng, API C# gọi Math.Round ra 12.812, báo cáo SQL gọi ROUND ra 12.813. Ghi chế độ làm tròn tường minh ở mọi lời gọi và chỉ làm tròn ở một nơi. ToString("N0") của decimal tự làm tròn ra xa số 0: làm tròn trước, định dạng sau. Phép tính decimal của C# cũng làm tròn kiểu banker's khi kết quả vượt độ chính xác của kiểu (đặc tả C# §8.3.8), nên 1m / 3m * 3m không ra 1.
Dòng float là ca duy nhất mục 4 bắt được trong 299.675 dòng: mã giảm 50.000, đơn 30.400.000, dòng 7.790.000. Phần đúng 12.812,5, half-up phải ra 12.813, nhưng tỷ lệ tính bằng float cho 12.812,499999999998 và cả ba nơi ra 12.812. Decimal không cứu được số đã đi qua float. Phần float cộng dồn sai số và money làm tròn tỷ lệ đã có ở Kiểu dữ liệu, collation và khóa chính.
4. Đo trên 100.000 đơn
Phân phối minh họa, hạt giống 2026: đơn 1 đến 6 dòng, trung bình 3 như dbo.ChiTietDonHang, mã giảm 20.000 đến 500.000 theo mức đơn tối thiểu. Năm cách chia, cột cuối là |aᵢ − qᵢ| lớn nhất gặp được:
import math, random, sys
from collections import Counter
from chia_tien import chia_lam_tron, chia_phan_du_lon_nhat
random.seed(2026)
def gia_ngau_nhien(): # log-đều 90.000 đến 35.000.000, đuôi 90.000 (minh họa)
g = math.exp(random.uniform(math.log(90_000), math.log(35_000_000)))
return max(90_000, 100_000 * round(g / 100_000) - 10_000)
GIA = [gia_ngau_nhien() for _ in range(5_000)]
MA = [(20_000, 0), (50_000, 500_000), (100_000, 1_000_000), (200_000, 2_000_000), (500_000, 5_000_000)]
def float_half_up(tong, trong_so): # tỷ lệ bằng float, rồi floor(x + 0,5)
return [math.floor(w / sum(trong_so) * tong + 0.5) for w in trong_so]
def don_dong_cuoi(tong, trong_so): # làm tròn từng dòng, dồn phần lệch vào dòng cuối
kq = chia_lam_tron(tong, trong_so)
return kq[:-1] + [tong - sum(kq[:-1])]
CACH = {"float, half-up": float_half_up, "half-up": chia_lam_tron,
"half-even": lambda t, w: chia_lam_tron(t, w, "half_even"),
"dồn dòng cuối": don_dong_cuoi, "phần dư lớn nhất": chia_phan_du_lon_nhat}
don_lech, lech_max, dong_xa, theo_n, so_don, float_sai = Counter(), Counter(), Counter(), Counter(), Counter(), []
for _ in range(100_000):
n = random.choices(range(1, 7), weights=[20, 25, 20, 15, 10, 10])[0]
dong = [g * random.choices([1, 2, 3], weights=[80, 15, 5])[0] for g in random.sample(GIA, n)]
T = sum(dong)
giam = random.choice([d for d, toi_thieu in MA if T >= toi_thieu])
so_don[n] += 1
for ten, chia in CACH.items():
kq = chia(giam, dong)
don_lech[ten] += sum(kq) != giam
lech_max[ten] = max(lech_max[ten], abs(sum(kq) - giam))
dong_xa[ten] = max(dong_xa[ten], max(abs(a * T - giam * w) / T for a, w in zip(kq, dong)))
theo_n[n] += sum(chia_lam_tron(giam, dong)) != giam
float_sai += [(giam, w, T) for a, b, w in zip(float_half_up(giam, dong), chia_lam_tron(giam, dong), dong) if a != b]
print(f"Python {sys.version.split()[0]}, {so_don.total()} đơn, {sum(n * k for n, k in so_don.items())} dòng")
print(f"{'cách':<18}{'đơn lệch':>9}{'lệch tổng max':>15}{'dòng xa phần đúng':>19}")
for ten in CACH:
print(f"{ten:<18}{don_lech[ten]:>9}{lech_max[ten]:>15}{dong_xa[ten]:>19.4f}")
print("half-up, % đơn lệch theo số dòng:", {n: round(100 * theo_n[n] / so_don[n], 1) for n in sorted(so_don)})
print("float khác half-up (giảm, dòng, tổng đơn):", float_sai)
Python 3.14.3, laptop Intel Core Ultra 5 125U, Windows 11. Ba lần chạy cho cùng các con số, mỗi lần 10 đến 16 giây tùy tải máy.
Python 3.14.3, 100000 đơn, 299675 dòng
cách đơn lệch lệch tổng max dòng xa phần đúng
float, half-up 19511 2 0.5000
half-up 19512 2 0.5000
half-even 19480 2 0.5000
dồn dòng cuối 0 0 2.1998
phần dư lớn nhất 0 0 0.8148
half-up, % đơn lệch theo số dòng: {1: 0.0, 2: 0.1, 3: 26.6, 4: 35.1, 5: 41.8, 6: 47.6}
float khác half-up (giảm, dòng, tổng đơn): [(50000, 7790000, 30400000)]
Ba cách làm tròn từng dòng lệch gần như cùng số đơn, tối đa 2 đồng. Float khác half-up trên số nguyên đúng 1 dòng, dòng ở mục 3: ca x,5 hiếm, nhưng khi có thì float chọn sai. Dồn phần lệch vào dòng cuối khớp tổng, nhưng một dòng có thể lệch phần đúng 2,2 đồng, tùy thứ tự dòng. Phần dư lớn nhất khớp mọi đơn, dòng xa nhất cách phần đúng 0,81 đồng.
Đơn hai dòng gần như không lệch: hai phần lẻ cộng lại đúng 1, nên một dòng làm tròn lên thì dòng kia xuống, trừ khi cả hai bằng 0,5.
5. Cùng thuật toán: trả góp và tách vận đơn
Điện thoại 25.990.000 trả góp 0% trong 12 kỳ (minh họa): mỗi kỳ 2.165.833,33, và mười hai kỳ 2.165.833 thiếu 4 đồng. Dòng cuối ví dụ ở mục 2 cho 4 kỳ đầu 2.165.834, 8 kỳ sau 2.165.833. Phần dư bằng nhau nên thứ tự phá hòa quyết định kỳ nào nhận thêm.
Tách vận đơn thì cộng các dòng đã chia, không chia lần nữa. Nếu đơn 10107 thu COD, điện thoại và tai nghe đi từ kho tổng TP.HCM, ốp lưng từ một cửa hàng: vận đơn 1 thu (8.990.000 − 176.795) + (990.000 − 19.469) = 9.783.736, vận đơn 2 thu 190.000 − 3.736 = 186.264, cộng đúng 9.970.000. Vận đơn 2 thu đúng số sẽ hoàn nếu khách trả ốp lưng; chia lại 9.970.000 theo giá trị vận đơn thì không bảo đảm điều đó.
Tách thuế GTGT khỏi giá từng dòng cũng phải làm tròn, nhưng quy tắc đó do văn bản pháp luật quy định. Bài không bàn tới, và các ví dụ không có thuế.
6. Tính một lần, lưu lại
API tạo đơn chia một lần theo thuật toán ở mục 2 và ghi kết quả trong cùng giao dịch của dbo.usp_DonHang_Tao, vào một bảng mới phân vùng như dbo.ChiTietDonHang:
CREATE TABLE dbo.GiamGiaDong (
NgayTao datetime2(0) NOT NULL,
DonHangId bigint NOT NULL,
DongSo smallint NOT NULL,
MaGiamGia varchar(20) NOT NULL,
SoTien decimal(18, 2) NOT NULL
CONSTRAINT CK_GiamGiaDong_SoTien CHECK (SoTien >= 0 AND SoTien = ROUND(SoTien, 0)),
CONSTRAINT PK_GiamGiaDong PRIMARY KEY CLUSTERED (NgayTao, DonHangId, DongSo, MaGiamGia)
) ON ps_DonHang_Ngay (NgayTao);
CK_GiamGiaDong_SoTien chặn phần lẻ: ROUND(SoTien, 0) chỉ bằng SoTien khi số đã tròn đồng. Chạy bù cho đơn cũ trong SQL Server thì dùng bigint với / và %, rồi ROW_NUMBER() theo phần dư giảm dần chọn dòng nhận thêm 1 đồng; bản T-SQL như vậy trùng kết quả chia_phan_du_lon_nhat ở 2.000 đơn ngẫu nhiên.
Hóa đơn, sổ cái, hoàn tiền và tiền thu hộ đọc số đã lưu, không nơi nào chia lại. Job đối chiếu so SUM(SoTien) theo đơn và mã với số giảm của mã; lệch lúc này là lỗi code.
Những chỗ hay hiểu sai
- "Dùng
decimalthay float là hết lệch."decimalgiữ đúng phần lẻ, nhưng làm tròn từng dòng về đồng vẫn lệch 19,5% đơn. Lệch đến từ cách chia, không từ kiểu dữ liệu. - "Banker's rounding giữ được tổng." Nó chỉ đổi cách xử lý x,5: 19.480 đơn lệch so với 19.512.
- "Dồn phần lệch vào dòng cuối là đủ." Tổng khớp, nhưng một dòng có thể lệch phần đúng của nó 2,2 đồng, và kết quả đổi khi thứ tự dòng đổi.
Đọc tiếp
- Kiểu dữ liệu, collation và khóa chính: vì sao tiền là
decimal(18, 2), khôngfloathaymoney. - Transaction, khóa và isolation:
dbo.usp_DonHang_Taoghi nhiều bảng trong một giao dịch. - Idempotency key: gọi cổng thanh toán và đối soát.
- Số hóa đơn liên tục: cấp số cho hóa đơn mà các dòng đã chia giảm giá ở bài này.
- Xếp tuyến giao hàng: tiền COD của từng vận đơn sau khi tách đơn, và trần tiền mặt mỗi shipper.
Nguồn
- Python, Built-in Functions:
round(). - Python,
decimal: ngữ cảnh mặc địnhROUND_HALF_EVEN,quantize(). - Microsoft Learn, Math.Round: mặc định
MidpointRounding.ToEven. - C# language specification, §8.3.8 The Decimal type: kết quả phép tính
decimallàm tròn kiểu banker's. - Microsoft Learn, ROUND (Transact-SQL): "breaks ties by rounding half away from zero".
- Microsoft Learn, CAST and CONVERT (Transact-SQL): Truncating and rounding results.
- S. Janson, S. Linusson, The probability of the Alabama paradox, arXiv:1104.2137: nghịch lý Alabama năm 1880, dẫn Balinski và Young.
- SIX, ISO 4217 list one, bản ngày 2026-09-17:
VND,CcyMnrUntsbằng 0.