DEV Community

Eric Mollenthiel
Eric Mollenthiel

Posted on • Originally published at kotisso.com

Splitting €10 three ways: the largest remainder method, and the tiebreak everyone forgets

Split €10 three ways and you get €3.33, €3.33, €3.33. That is €9.99. One cent has gone missing, and you now have to decide, in code, who pays it.

It sounds like a rounding detail. It is not: it is the difference between an app whose numbers close and an app whose numbers almost close. I hit it while building an expense-sharing app, and the fix turned out to be a voting-theory algorithm from the 1790s.

The three wrong answers

Round each share and hope. round(1000 / 3) = 333 per person, 999 total. You are one cent short of the expense. Every balance downstream inherits that error, and it compounds: fifty three-way expenses and the group's books are off by fifty cents with no line item to point at.

Give the remainder to the largest share. This is the common fix, and it is fine at €10. It is not fine when the split is uneven. If someone entered exact amounts that do not add up to the total, "absorb the difference on the biggest share" silently decides that one person pays €15 more than they typed. Three cents of rounding go unnoticed. Fifteen euros go unnoticed too, right up until someone checks.

Use floats. 0.1 + 0.2 != 0.3. You know this. The whole domain is integer cents or nothing.

The right answer is a 1792 apportionment method

The problem (divide a whole number of indivisible units proportionally to weights) is the same problem as allocating seats in a parliament to parties by vote share. Alexander Hamilton proposed a solution for the US House of Representatives in 1792. It is called the largest remainder method, and it is three steps:

  1. Compute each participant's exact (fractional) share.
  2. Give everyone the floor of it.
  3. Hand the leftover units, one each, to whoever has the largest fractional remainder.

For €10 among three people: exact share is 333.33 cents each, floor is 333, allocated is 999, one cent left over. All three remainders are 0.33, so one of them gets the extra cent: 334 / 333 / 333.

The sum is exactly 1000. Always. Not approximately.

Here is the core of it, in PHP, working entirely in integer cents:

/**
 * @param array<int, float> $weights  participant id => weight
 * @return array<int, int>            participant id => cents
 */
private function prorate(int $totalCents, array $weights): array
{
    $sum = array_sum($weights);
    if ($sum <= 0) {
        throw new \InvalidArgumentException('No shares to split.');
    }

    $amounts = [];
    $remainders = [];
    $allocated = 0;

    foreach ($weights as $id => $weight) {
        $exact = $totalCents * $weight / $sum;
        $floor = (int) floor($exact);

        $amounts[$id] = $floor;
        $remainders[$id] = $exact - $floor;
        $allocated += $floor;
    }

    $left = $totalCents - $allocated;

    if ($left > 0) {
        $order = array_keys($remainders);
        usort($order, static function ($a, $b) use ($remainders, $weights) {
            $cmp = $remainders[$b] <=> $remainders[$a];
            if ($cmp !== 0) {
                return $cmp;
            }
            $cmp = $weights[$b] <=> $weights[$a];

            return $cmp !== 0 ? $cmp : ($a <=> $b);
        });

        foreach (array_slice($order, 0, $left) as $id) {
            ++$amounts[$id];
        }
    }

    return $amounts;
}
Enter fullscreen mode Exit fullscreen mode

$left is bounded by the number of participants minus one, so this is never more than a handful of increments.

The tiebreak is the part people skip

Look at the usort comparator. It does not stop at comparing remainders. When two remainders are equal, which is exactly what happens in the €10-among-three case and is the case you will hit most often, it falls through to the weight, and then to the participant id.

Without that fallback you have a non-deterministic split. PHP's usort is not stable across all inputs, and even a stable sort leaves you at the mercy of insertion order. The same expense, recalculated after an edit, can hand the cent to someone else. Balances shift by a cent for no visible reason. Someone notices, does not trust the app any more, and they are right not to.

So the rule is: the tiebreak chain must terminate in something total and immutable. The id works. "Whoever was added to the group first" works. "Whatever order the hash table gave me" does not.

What this buys you: an invariant you can assert

Once every split sums exactly to its expense, a much stronger property falls out of the model for free. Each participant's balance is:

balance = what they paid - what they owe + what they sent - what they received
Enter fullscreen mode Exit fullscreen mode

Sum that across every member of a group and every term cancels: every euro paid is owed by someone, every transfer sent is received. The balances of a group always sum to exactly zero.

That is not a nice-to-have, it is a test oracle. It turns "did I get the money maths right" into a single assertion you can run after every operation:

public function testBalancesAlwaysSumToZero(): void
{
    $balances = $this->calculator->forGroup($group);

    self::assertSame(0, array_sum(array_map(
        static fn (Balance $b) => $b->cents(),
        $balances,
    )));
}
Enter fullscreen mode Exit fullscreen mode

Any bug that loses or invents a cent anywhere trips this: a bad split, a mishandled refund, a currency conversion, a deleted participant. It is the cheapest high-value test in the codebase, and it only exists because the splitter is exact.

Three edge cases worth stealing

Negative totals. Refunds and corrections are negative expenses. floor(-333.33) is -334, not -333, so the remainder logic inverts and you over-allocate. Take the absolute value, split that, negate at the end. Two lines, and it stops a whole category of sign bugs.

Exact amounts should refuse, not repair. If a mode lets people type each share by hand and the total does not match, do not silently fix it. Reject the input and name the gap: "you entered €85.00, the expense is €100.00, €15.00 missing". Someone who does not want to do the arithmetic has the other modes. Someone who does want to do it deserves to be told they got it wrong rather than have it quietly rewritten.

Zero-decimal currencies. Store everything in hundredths regardless. ¥1,500 is 150000. Then one integer travels the entire calculation without ever needing to know what currency it is, and formatting stays a presentation concern where it belongs.

Where this came from

I ran into all of this building Kotisso, a shared-expense tracker for flatshares, group holidays and separated parents. The zero-sum invariant is the whole design: everything else in the app is arranged so that it cannot be violated.

The largest remainder method is old, well-studied, and takes about thirty lines. If you are dividing indivisible units by proportion anywhere (money, seats, inventory, rate limits) it is probably the algorithm you want, and the tiebreak is probably the part you are about to forget.

Top comments (0)