Bài toán kinh điển

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.

Mục lục
  1. 1. Một đồng rơi mất ở đâu
  2. 2. Phương pháp phần dư lớn nhất
  3. 3. Half-up, half-even và mặc định của từng nơi
  4. 4. Đo trên 100.000 đơn
  5. 5. Cùng thuật toán: trả góp và tách vận đơn
  6. 6. Tính một lần, lưu lại
  7. Những chỗ hay hiểu sai
  8. Đọc tiếp
  9. Nguồn

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(), Decimal và C# Math.Round biến 12.812,5 thành 12.812, SQL Server ROUND thà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.

Dòng hàng Phần đúng Phần dư Làm tròn từng dòng Phần dư lớn nhất Điện thoại 8.990.000 176.794,49 0,49 176.794 +1 176.795 Ốp lưng 190.000 3.736,48 0,48 3.736 3.736 Tai nghe 990.000 19.469,03 0,03 19.469 19.469 Giảm cả đơn trên 10.170.000 200.000,00 cộng 1,00 199.999 thiếu 1 đồng 200.000 khớp
Làm tròn từng dòng bỏ mất ba phần dư cộng lại đúng 1 đồng. Phần dư lớn nhất trả đồng đó cho dòng có phần dư lớn nhất.

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):

  1. Mỗi phần nhận ⌊qᵢ⌋.
  2. 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.
  3. 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)]

Làm tròn từng dòng lệch gần một phần năm số đơn, kiểu làm tròn nào cũng vậy

Float, half-up19.511 đơnHalf-up19.512 đơnHalf-even19.480 đơnDồn vào dòng cuối0 đơnPhần dư lớn nhất0 đơn
Số đơn có tổng giảm giá các dòng khác số giảm của mã, trên 100.000 đơn mô phỏng, hạt giống 2026, Python 3.14.3.
Bảng số liệu
Giá trị
Float, half-up19.511 đơn
Half-up19.512 đơn
Half-even19.480 đơn
Dồn vào dòng cuối0 đơn
Phần dư lớn nhất0 đơn

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 càng nhiều dòng càng dễ lệch khi làm tròn từng dòng

1 dòng0,0%2 dòng0,1%3 dòng26,6%4 dòng35,1%5 dòng41,8%6 dòng47,6%
Tỷ lệ đơn lệch theo số dòng, half-up trên số nguyên, cùng mô phỏng.
Bảng số liệu
Giá trị
1 dòng0,0%
2 dòng0,1%
3 dòng26,6%
4 dòng35,1%
5 dòng41,8%
6 dòng47,6%

Đơ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 decimal thay float là hết lệch." decimal giữ đú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

Nguồn

Đọc tiếp