Intermediate30 min readMachine Coding Practicelive prototype

Splitwise

Four friends, one bill, one card. Every other problem in this set is graded on structure — this one is graded on arithmetic. There is a number that must be exactly zero after every single operation, and there are three ordinary-looking mistakes that quietly make it not zero.

The idea

What it is

“Design Splitwise.” Or, if the interviewer is being kind: “four friends go to dinner, one person pays, split the bill.” It sounds like the easiest problem in the set.

It is not, and the reason is unusual. Every other machine-coding problem is graded on structure — did you find the right classes, is the seam in the right place. This one is graded on money. There is a property that has to hold after every operation you perform, and if it ever stops holding, no amount of clean design saves you.

The whole lesson in one line

Across everyone in the group, the balances must always sum to exactly zero. Somebody is owed precisely what somebody else owes. Write it as an assertion — assert sum(balances.values()) == 0 — put it at the end of addExpense(), and let it fail loudly. Every design decision below exists to keep that line true.

the dinner table 🧑 Ravi 👩 Priya 🧔 Arjun 👧 Meera the bill ₹1000 💳 one card User id, name — nothing else Group named set of users + its expenses Expense payer, total, splits 100000 paise — not 1000.0 Split one row: user → paise SplitStrategy decides the four numbers and checks they add up BalanceSheet Ravi +750.00 Priya −250.00 Arjun −250.00 Meera −250.00 sum 0.00 ✓
Look at the green box. Four numbers that must add to exactly zero — not about zero. Everything else on this page is machinery for keeping that last line true.

Here is why that is harder than it looks. Split ₹100 three ways. Each person owes 33.33. Three times 33.33 is 99.99. One paisa has vanished, the sum is no longer zero, and you did nothing wrong — you just used the obvious type for money.

✗ double rupees — ₹100 ÷ 3 total = 100.0 // a double 33.33 + 33.33 + 33.33 = 99.99 ₹0.01 gone the sheet no longer sums to zero multiply by every expense in the app the books drift, forever, silently ✓ integer paise — 10000 ÷ 3 total = 10000 // a long 3334 + 3333 + 3333 = 10000 exactly the total base = total / n = 3333, rem = total % n = 1 hand the 1 leftover paisa to the first person deterministic — so it is testable
The rule to say out loud in the first two minutes: money is a long of paise, never a double. Then read the green box again — the leftover is given to someone, not rounded away.

What is actually being graded

  1. Is money an integer? Paise in a long, or BigDecimal with an explicit scale. A double anywhere near a balance is an automatic mark against you, and this is the problem where they look for it.
  2. Does every split validate that it sums to the total? And does that check live inside the strategy, so it cannot be forgotten by a caller?
  3. Are balances a derived net map, or a growing list of IOUs? “How much do I owe Priya?” should be one lookup, not a fold over every expense ever made.
  4. Can you add a split type without touching addExpense()? That is the Open/Closed (OCP) question, and this problem hands you a textbook place to answer it.
  5. Does it run, and can you simplify the debts? Six IOUs between four people collapsing to three payments is the follow-up they always ask. Have it working, and know that the greedy version is not provably minimal.

Mechanics

How it works

Step 1 · Clarify — 4 minutes

  • How are bills split? — the question that opens the whole design. Equally, by exact amounts, by percentage, by shares. Name all four; that list is what becomes your strategy interface.
  • Can one person pay for people who are not splitting it? — yes. The payer and the participants are two different lists. Getting this wrong collapses a lot of real cases.
  • Do expenses always belong to a group? — no. A one-off coffee between two friends has no group. Say so, or you will end up creating a two-person group for every lunch.
  • One currency or many? — assume one, and offer multi-currency as a follow-up. It is a real answer, not a dodge, and it keeps the first 40 minutes clean.
  • Do I need to minimise the number of payments? — ask it, because they want you to. If they say yes, that is your second act.
  • Login, notifications, receipt photos, the mobile app? — out of scope, in one sentence.
✓ IN — build these User · Group (a named set of users) Expense: payer, total, participants 4 split types behind one interface money as integer paise BalanceSheet — net, not a debt log settleUp(from, to, amount) simplifyDebts() — the follow-up and the zero-sum assertion, everywhere ✗ OUT — say it in one sentence login, signup, friend requests push notifications, email reminders receipt photos and attachments real payments — UPI, cards, banks the mobile UI persistence and schema design “settleUp just records that money moved — no gateway” is a complete answer.
Read the orange lines on the left. Integer paise and simplifyDebts are the two things the interviewer is actually shopping for; everything else on that list is expected.

Step 2 · Money is a long of paise

This costs you one sentence in the interview and it is the single highest-value sentence in the round. “I will store every amount as an integer number of paise, so 1000 rupees is 100000. Doubles cannot represent 0.1 exactly and money that drifts is a bug you find six months later in an audit.”

Three ways beginners break the zero-sum invariant

1. Splits that do not add up. An EXACT split of 300 + 300 + 300 on a ₹1000 bill. The payer is credited 1000, the participants are debited 900, and ₹100 appears from nowhere. 2. Percentages that do not sum to 100. Same bug wearing a different unit. 3. The rounding leak. ₹100 three ways at 33.33 each is 99.99 — nobody typed anything wrong, and a paisa still vanished. The first two are caught by validation; the third is only caught by not using floating point at all.

the split that never leaks
/**
 * base = total / n, remainder = total % n, then hand the leftover paise out
 * ONE AT A TIME to the first "remainder" people. Deterministic, so it is testable.
 * 10000 paise among 3  ->  3334, 3333, 3333   (sums to exactly 10000)
 */
static long[] spread(long totalPaise, long[] weights) {
    long weightSum = 0;
    for (long w : weights) weightSum += w;

    long[] out = new long[weights.length];
    long assigned = 0;
    for (int i = 0; i < weights.length; i++) {
        out[i] = totalPaise * weights[i] / weightSum;   // integer division, always rounds DOWN
        assigned += out[i];
    }
    for (int i = 0; assigned < totalPaise; i++, assigned++) out[i]++;   // give the leftover away
    return out;                                          // sum(out) == totalPaise, exactly
}

Why the leftover goes to the first people, not a random one

Because a test has to be able to assert the answer. “Someone gets the extra paisa” is not a specification. Real apps do exactly this and then rotate who is first across expenses so the same person is not always paying the extra 0.01 — worth one sentence if you have it, but the determinism is the part that scores.

Step 3 · Four split types, one interface

SplitStrategy.split(totalPaise, participants) → Map<User, paise>. Four implementations. The design point is not that there is an interface — everyone writes an interface. It is where the validation lives.

same input to all four: total = 100000 paise · participants = [Ravi, Priya, Arjun, Meera] ⚖️ EqualSplit Ravi 25000 Priya 25000 Arjun 25000 Meera 25000 🔢 ExactSplit Ravi 40000 Priya 30000 Arjun 20000 Meera 10000 % PercentSplit 40% → 40000 30% → 30000 20% → 20000 10% → 10000 🧮 ShareSplit 2 shares → 33334 2 shares → 33333 1 share → 16667 1 share → 16666 🚪 THE GATE — inside every strategy, never in the caller if (sum(splits) != totalPaise) throw new IllegalArgumentException(...) ✓ 40000+30000+20000+10000 = 100000 passes the gate — the expense is recorded ✗ 30000+30000+30000 = 90000 refused: “splits must sum to ₹1000.00 — got ₹900.00”
Follow every arrow into the same gate. Put that check in addExpense() instead and it works — until the day someone calls split() from a new place and forgets it. This is Strategy doing its actual job, and it is why addExpense() never changes when a fifth split type arrives (Open/Closed (OCP)).

The sentence that wins this section

“Each strategy validates its own output before returning, so an invalid split cannot reach the balance sheet at all.” Say it while you are writing the interface, not afterwards.

Step 4 · Balances are a net map, not a list of debts

The naive model stores rows: “Arjun owes Ravi ₹250”, “Arjun owes Priya ₹300”, and appends forever. After ten expenses you have thirty rows, and answering “how much do I owe Priya?” means folding the entire history every time. Worse, nobody can look at it and tell.

Keep a net instead. Per user, Map<otherUser, netAmount>, with one rule enforced on every write: balance[a][b] == -balance[b][a]. Adding an expense becomes: for each participant who is not the payer, move their split amount across that one edge. owes(me, priya) is then an O(1) lookup.

after ONE expense: Ravi paid ₹1000, split equally among all four 🧑 Ravi +750 👩 Priya −250 🧔 Arjun −250 👧 Meera −250 ₹250 ₹250 ₹250 an arrow means “owes”. Ravi is not in his own debt — a payer never owes himself his own share. the rule written on EVERY move move(Priya → Ravi, 25000) bal[Priya][Ravi] += 25000 bal[Ravi][Priya] −= 25000 both lines, or neither. always. “what do I owe Priya?” ✓ bal[me][Priya] O(1) ✗ fold 30 IOU rows O(n) keep the Expense list too — as the audit
Both lines on the right, on every write. That is what makes the sheet self-balancing: an edge can never be half-applied, so the sum across everyone cannot drift.

Balances are computed state, not the source of truth

Keep the Expense list forever — it is the audit trail, and it is what lets you rebuild the balance sheet from scratch after a bug, edit an old expense, or show “why do I owe this?”. The balance map is a cache of a fold over that list, kept up to date incrementally. Say that sentence and you have answered three follow-ups before they are asked.

net position after five expenses — positive = is owed, negative = owes member net (paise) 🧑 Ravi + 61666 👩 Priya − 38333 🧔 Arjun + 21667 👧 Meera − 45000 TOTAL 0 the assertion you write assert balances.values() .sum() == 0; at the end of addExpense() at the end of settleUp() at the end of simplifyDebts() and in every property test a double here would show TOTAL = 0.000000000001 — true enough to pass by eye, false enough to fail an audit integers make the assertion an exact equality, which is the only kind worth asserting
The bottom row is the whole grade. Notice that it is == 0, not < 0.01 — with integer paise you get to write the strict version, and a strict assertion is one that actually catches things.

The class diagram

Group + addExpense(...) + settleUp(from, to, paise) + simplifyDebts() User - id : String - name : String 2..* Settlement - from, to : User - paise : long - at : Instant Expense - payer : User - totalPaise : long - splits : List<Split> immutable — the audit row Split - user : User - paise : long 1..* «interface» SplitStrategy + split(totalPaise, users) …and validates its own sum EqualSplit ExactSplit PercentSplit ShareSplit a fifth one costs one new file and zero edits elsewhere BalanceSheet «derived» - net : Map<User, Map<User, long>> + move(from, to, paise) + owes(a, b) : long + netOf(user) : long + simplify() : List<Settlement> invariant: Σ netOf(u) == 0 exactly one, per group what is NOT here ✗ class Debt { a, b, amount } a growing IOU log, folded on read ✗ double amount the single fastest way to lose
Two arrows carry this diagram: Expense → SplitStrategy (the swappable seam) and Group → BalanceSheet (exactly one, and it is derived). The bottom-right box is what a weak answer draws instead. Notation: Class diagrams.
Client Group SplitStrategy BalanceSheet addExpense(Ravi, 100000, [R,P,A,M], EqualSplit) split(100000, participants) base = 25000 · rem = 0 🚪 assert Σ splits == total [R 25000, P 25000, A 25000, M 25000] move(Priya → Ravi, 25000) ×3, payer skipped bal[a][b] += p bal[b][a] −= p assert Σ netOf(u) == 0 ← before returning Expense (the audit row)
Two gates on one path: the strategy checks its own sum, and the group checks the invariant before it returns. Neither is expensive; both turn a silent corruption into a stack trace. Notation: Sequence diagrams.

Step 5 · Simplify debts — the follow-up they always ask

“Four friends, a weekend, five expenses. Nobody wants to make six payments. Can you reduce it?” This is where the round is won, and it takes about fifteen lines.

✗ BEFORE — 6 open edges 🧑Ravi 👩Priya 🧔Arjun 👧Meera 283.33 100.00 400.00 250.00 83.33 200.00 ✓ AFTER — 3 payments, same nets 🧑Ravi +616.66 👩Priya −383.33 🧔Arjun +216.67 👧Meera −450.00 450.00 216.67 166.66 6 payments 3 payments every net position is unchanged — only the routing is bound: at most n−1 = 3 transfers for 4 people
Compare the two nets rows, not the arrows. Nobody is better or worse off — Ravi is still owed ₹616.66. What changed is how many times a phone has to be picked up.

The algorithm is short. Compute each person's net position, which sums to zero by the invariant. Then repeatedly match the biggest creditor with the biggest debtor, transfer min(|debt|, credit), and put whatever is left back in the pool.

greedy: biggest creditor ↔ biggest debtor, settle min(|debtor|, creditor), repeat round biggest creditor biggest debtor transfer who zeroes out 1 Ravi +616.66 Meera −450.00 Meera → Ravi 450.00 Meera ✔ (Ravi → 166.66) 2 Arjun +216.67 Priya −383.33 Priya → Arjun 216.67 Arjun ✔ (Priya → −166.66) 3 Ravi +166.66 Priya −166.66 Priya → Ravi 166.66 both ✔ — pool empty at least one side hits zero every round → terminates in at most n−1 = 3 transfers but n−1 is a BOUND, not the minimum — the true minimum-transaction problem is NP-hard (it is subset-sum in disguise: any subset of people whose nets cancel could settle among themselves)
Read the last two lines out loud in the interview. Saying that the greedy is not provably minimal, and that n−1 is fine anyway, is what separates a good answer from a memorised one.

Simplification changes who pays whom — and that is a product decision

Look at the figure again. Before simplifying, Arjun owed Priya ₹100. Afterwards, Priya pays Arjun ₹216.67 — the direction flipped, and Priya is now sending money to someone she was owed by. The totals are perfectly right and the routing is unrecognisable. Real Splitwise makes simplification opt-in per group for exactly this reason: people trust a ledger they can trace back to a dinner. Mention it. It costs one sentence and it shows you thought past the algorithm.

simplify, in full
/** Greedy: biggest creditor meets biggest debtor. At most n-1 transfers. */
List<Settlement> simplify(List<User> members) {
    PriorityQueue<long[]> creditors = new PriorityQueue<>((a, b) -> Long.compare(b[1], a[1]));
    PriorityQueue<long[]> debtors   = new PriorityQueue<>((a, b) -> Long.compare(b[1], a[1]));

    for (int i = 0; i < members.size(); i++) {
        long net = netOf(members.get(i));                 // sums to 0 across everyone
        if (net > 0) creditors.add(new long[]{i, net});
        else if (net < 0) debtors.add(new long[]{i, -net});
    }

    List<Settlement> out = new ArrayList<>();
    while (!creditors.isEmpty() && !debtors.isEmpty()) {
        long[] credit = creditors.poll(), debt = debtors.poll();
        long amount = Math.min(credit[1], debt[1]);       // one of them hits zero — always
        out.add(new Settlement(members.get((int) debt[0]), members.get((int) credit[0]), amount));

        if (credit[1] > amount) creditors.add(new long[]{credit[0], credit[1] - amount});
        if (debt[1]   > amount) debtors.add(new long[]{debt[0],   debt[1]   - amount});
    }
    return out;                                           // size <= members.size() - 1
}

Settle up, groups, and the things they ask next

  • Settle up is an operation, not a reset. settleUp(from, to, paise) moves the balance the other way and records a Settlement row — who, whom, how much, when. Silently zeroing an edge destroys the only evidence that a payment happened, and it is the first thing an angry user asks about.
  • Group vs non-group. A Group is a named set of users plus its expenses. A one-off coffee between two friends belongs to no group — the expense just carries a payer and participants. Do not force a two-person group into existence for every lunch; say this unprompted and it reads as experience.
  • Multi-currency. Store the currency with the amount, and never mix currencies on one balance edge — keep one edge per currency pair, or normalise to a base currency. Convert only at display time, using the rate as of the expense date, not today's. A ₹ balance that changes when the dollar moves is a bug report.
  • Concurrency. Two people add an expense to the same group at the same instant; both read bal[a][b], both write it, one write is lost. That is the same check-then-act shape as Coffee Machine. Take one lock per group around addExpense — the contended region is a few map writes, so a coarse lock costs nothing. More in Locks, Mutex, Semaphore.
  • Editing or deleting a settled expense. Do not mutate — reverse it. Apply the opposite balance movement and keep the original row plus a reversal row. An event log rather than an in-place edit means history is still explainable, and the invariant holds at every step.
  • The activity feed and reminders. The group publishes ExpenseAdded and SettlementRecorded; feeds, notifications and analytics subscribe. Observer — and it keeps all of that out of addExpense(), which stays about money.
  • Recurring expenses. A schedule that creates a normal Expense on a timer. No new balance logic at all, which is the answer they want to hear.
  • How would you test it? A property test: generate any sequence of random expenses, settlements and simplifications, and assert after every single one that the nets sum to zero and that bal[a][b] == -bal[b][a]. That test finds every bug on this page.
what a new feature actually costs feature files touched verdict a fifth split type (“by weight”) 1 new class — addExpense untouched free settle up 1 method + 1 Settlement record free simplify debts 1 method on BalanceSheet, ~15 lines free activity feed / reminders 1 publisher + N subscribers free multi-currency a Money value type — every amount field expensive
Only the last row is expensive — which is exactly why you introduce Money as a value type on day one if there is any chance of a second currency. Everything above it is free because the seams were put in the right places (Single Responsibility (SRP)).

The 60 minutes

a 60-minute budget that actually fits 4m 6m 6m 22m 12m 10m clarify — how are bills split? one currency? minimise payments? entities + say “money is integer paise” out loud, and why APIs + class diagram — the SplitStrategy seam, the BalanceSheet code: strategies with their own validation → BalanceSheet.move → addExpense + the assertion simplify() → then main(): 5 expenses, print the edges, simplify, print 3 transfers leave the last 10 minutes: run it, show the zero-sum line, take follow-ups
The orange block is not negotiable and the green one is where the offer lives. If you are at minute 40 with no simplify(), stop polishing the strategies and write it — a rough simplification beats a beautiful PercentSplit.

How this round is lost

  • double for money. The single fastest way to lose this specific problem. It is often the first thing typed and the last thing noticed.
  • Splits that are never validated. 300 + 300 + 300 on a ₹1000 bill goes straight into the sheet, ₹100 appears from nowhere, and the invariant is dead with no error anywhere.
  • A growing list of pairwise IOUs. Thirty rows after ten expenses, folded on every read, and no human can look at it and say what they owe.
  • Simplification that does not terminate. Usually because a settled person is put back in the pool, or because a stale float never quite reaches zero. Integers and “remove whoever hit zero” make it provably finite.
  • A new split type that requires editing addExpense. The interface was decoration; the seam was never real.
  • No zero-sum check anywhere. The invariant existed only in your head, so nothing in the code could ever tell you it broke.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A live ledger for four friends. Press ➕ Add expense and watch two things at once: edges appear in the balance graph, and the total: ₹0.00 chip flashes green — the invariant, re-checked on every action. Press 🔢 Exact and then switch a participant off: the ➕ button turns red and refuses, because 250+250+250 is not 1000. Press 🪙 Odd split for ₹100 among three and read the paise. Then 🎲 Messy weekend for five real expenses, and ⚡ Simplify debts to watch 6 payments → 3 payments.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Add one expense and watch the chip

Leave the defaults — payer Ravi, ₹1000, all four participants, ⚖️ Equal — and press ➕ Add expense. Three edges appear pointing at Ravi, everyone's net moves, and the total: ₹0.00 chip flashes green. That chip is the assertion from the lesson, running on every action. It should never move off ₹0.00, no matter what you press.

try 02

Break the sum on purpose

Press 🔢 Exact — the per-person boxes appear, pre-filled with ₹250 each, and the validity line reads sum: ₹1000.00 ✓. Now click Meera in the participants row to drop her. The boxes still say 250, 250, 250. The line turns red — splits must sum to ₹1000.00 — got ₹750.00 — and ➕ Add expense goes red and refuses. Nothing reached the balance sheet. That refusal is the design point: the strategy caught it, not the caller.

try 03

Find the missing paisa

Press 🪙 Odd split: ₹100 equally among Ravi, Priya and Arjun. Read the three boxes — ₹33.34 / ₹33.33 / ₹33.33 — and the readout sum: ₹100.00 ✓. With a double those would be 33.33 each and the sum would be ₹99.99. The leftover paisa was not rounded away; it was given to the first person, deterministically, so a test can assert it.

try 04

Make a real mess

Press 🎲 Messy weekend — five expenses, four different payers, equal, share and exact splits. Now count the arrows in the balance graph: six open edges between four people. Read the stat row: expenses 5 · open edges 6 · payments needed 3. Six IOUs, and only three payments are actually required.

try 05

Simplify, then look at who pays whom

Press ⚡ Simplify debts. Three transfers land one at a time and the counter reads 6 payments → 3 payments. Now check the second explain line: before you pressed it, Arjun owed Priya ₹100; afterwards Priya pays Arjun ₹216.67. The nets are identical and the direction flipped. That is why real Splitwise makes this opt-in.

try 06

Settle one edge, then build it from memory

Press 💸 Settle up — the largest edge is paid, zeroed, and recorded as a payment row, not silently deleted. Then close this and write it blank-file, in this order: money as long paise → SplitStrategy with split() that validates its own sumBalanceSheet.move() writing both bal[a][b] and bal[b][a]Group.addExpense() ending in assert Σ net == 0simplify() with two heaps → a main() that runs the five expenses and prints three transfers. If your assertion ever fires, you have found a real bug.

In practice

When to use it — and what trips people up

The shape you just learned

Strip the friends and the dinner away and this is a system with a conservation law. Some quantity moves between parties, and the total across all parties must never change. Once you see it that way, the same three moves apply everywhere: integers for the quantity, one operation that moves it across exactly one edge, and an assertion on the total after every move.

  • Double-entry bookkeeping — the 500-year-old version of this exact idea. Every debit has a matching credit, and the ledger balances or something is wrong.
  • Inventory transfers between warehouses — stock leaves one and arrives at another; the global count is the invariant. A partial move is a lost pallet.
  • Wallet and payment ledgers — money out of one account, into another, and never a double. This is where the paise rule stops being an interview trick and becomes a compliance requirement.
  • Token or credit allocation — a quota moved between teams. Same shape, smaller stakes.
  • Any graph reduced to net flow — the simplification step is just netting a flow network before routing it, which shows up in clearing houses and settlement systems.

The 20-second version to say out loud

“Money is an integer number of paise. Balances are a net map with bal[a][b] == -bal[b][a] written on every move, so the whole sheet always sums to zero — and I assert that after every operation. Splits go behind one interface, and each strategy validates that its own output sums to the total, so an invalid split can never reach the sheet. Simplification is greedy — biggest creditor against biggest debtor — which is at most n−1 transfers but is not provably minimal.”

Where this design stops working

  • When there are millions of members in a group. The net map is a dense pairwise structure; at that size you store per-user net totals against the group instead of edges, and reconstruct pair balances on demand.
  • When the balance must survive a crash. The in-memory map plus an in-process lock becomes a database transaction — the expense row and the balance updates must commit together, or you get exactly the half-applied edge the antisymmetry was protecting you from.
  • When there are real payments involved. settleUp here is an assertion that money moved. With a real gateway it becomes a two-phase thing: record an intent, wait for the callback, then move the balance — and now you need idempotency on the callback.
  • When currencies float. One balance edge per currency is fine until someone settles a ₹ debt in $. At that point the exchange itself is an expense, and somebody has to own the difference.

If you only remember one thing

Write the invariant as code, not as a comment. assert sum(nets) == 0 at the end of every mutating method costs one line and turns every bug on this page — bad splits, a lost paisa, a half-applied edge, a broken simplification — into a stack trace on the very operation that caused it.

What it gives you

  • Integer paise make the zero-sum check an exact equality, so the invariant can be asserted rather than approximated — and no expense can ever leak a fraction.
  • Validation inside each SplitStrategy means an invalid split is impossible to record, no matter which caller invokes it or how many callers appear later.
  • A net balance map answers “what do I owe you?” in O(1) and stays readable to a human, instead of growing one IOU row per participant per expense.
  • Writing bal[a][b] and bal[b][a] together makes a half-applied edge unrepresentable, which is what keeps the sheet self-balancing under any sequence of operations.
  • Greedy simplification is about fifteen lines, provably terminates in at most n−1 transfers, and is the follow-up interviewers reach for most often.

Common mistakes

  • The net map is pairwise, so memory grows with the square of group size — fine for a dinner, wrong for a ten-thousand-member group.
  • Greedy simplification is not guaranteed minimal; the true minimum-transaction problem is NP-hard, so you are shipping a bound rather than an optimum.
  • Simplification rewrites who pays whom, which is arithmetically correct and socially confusing — real products have to make it opt-in.
  • Because balances are derived, any bug in a mutation silently diverges them from the expense log until something recomputes from scratch; the assertion catches sums, not attribution.
  • One lock per group serialises concurrent expense entry for that group, and a very active group would need the balance updates pushed into the database rather than held in memory.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

/* ------------------------------------------------------------------ money */
/** Every amount in this file is an integer number of PAISE. Never a double. */
final class Money {
    static String fmt(long paise) {
        long abs = Math.abs(paise);
        return (paise < 0 ? "-" : "") + "Rs." + (abs / 100) + "." + String.format("%02d", abs % 100);
    }
}

record User(String id, String name) {
    @Override public String toString() { return name; }
}

record Split(User user, long paise) {}

/* -------------------------------------------------------------- strategies */
interface SplitStrategy {
    /** Returns one row per participant. MUST sum to totalPaise. */
    List<Split> split(long totalPaise, List<User> participants);

    /** The gate. Lives here so no caller can forget it. */
    static List<Split> validated(long totalPaise, List<Split> splits) {
        long sum = 0;
        for (Split s : splits) sum += s.paise();
        if (sum != totalPaise)
            throw new IllegalArgumentException(
                "splits must sum to " + Money.fmt(totalPaise) + " - got " + Money.fmt(sum));
        return List.copyOf(splits);
    }

    /**
     * base = total * weight / weightSum (rounds DOWN), then hand the leftover
     * paise out one at a time to the first people. Sum is exact, and it is
     * deterministic, so a test can assert every number.
     */
    static long[] spread(long totalPaise, long[] weights) {
        long weightSum = 0;
        for (long w : weights) {
            if (w < 0) throw new IllegalArgumentException("negative weight");
            weightSum += w;
        }
        if (weightSum == 0) throw new IllegalArgumentException("weights must not all be zero");

        long[] out = new long[weights.length];
        long assigned = 0;
        for (int i = 0; i < weights.length; i++) {
            out[i] = totalPaise * weights[i] / weightSum;
            assigned += out[i];
        }
        for (int i = 0; assigned < totalPaise; i = (i + 1) % out.length, assigned++) out[i]++;
        return out;
    }

    static List<Split> zip(List<User> users, long[] amounts) {
        List<Split> out = new ArrayList<>(users.size());
        for (int i = 0; i < users.size(); i++) out.add(new Split(users.get(i), amounts[i]));
        return out;
    }
}

class EqualSplit implements SplitStrategy {
    public List<Split> split(long totalPaise, List<User> participants) {
        long[] ones = new long[participants.size()];
        Arrays.fill(ones, 1L);
        return SplitStrategy.validated(totalPaise,
                SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, ones)));
    }
}

class ExactSplit implements SplitStrategy {
    private final long[] amounts;
    ExactSplit(long... amounts) { this.amounts = amounts.clone(); }
    public List<Split> split(long totalPaise, List<User> participants) {
        // No spreading here: the user typed these. validated() is the only defence.
        return SplitStrategy.validated(totalPaise, SplitStrategy.zip(participants, amounts));
    }
}

class PercentSplit implements SplitStrategy {
    private final long[] percents;
    PercentSplit(long... percents) { this.percents = percents.clone(); }
    public List<Split> split(long totalPaise, List<User> participants) {
        long sum = 0;
        for (long p : percents) sum += p;
        if (sum != 100) throw new IllegalArgumentException("percentages must sum to 100 - got " + sum);
        return SplitStrategy.validated(totalPaise,
                SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, percents)));
    }
}

class ShareSplit implements SplitStrategy {
    private final long[] shares;                 // 2 for the couple, 1 each for the singles
    ShareSplit(long... shares) { this.shares = shares.clone(); }
    public List<Split> split(long totalPaise, List<User> participants) {
        return SplitStrategy.validated(totalPaise,
                SplitStrategy.zip(participants, SplitStrategy.spread(totalPaise, shares)));
    }
}

/* ------------------------------------------------------------ balancesheet */
record Settlement(User from, User to, long paise) {
    @Override public String toString() { return from + " -> " + to + "  " + Money.fmt(paise); }
}

/** Derived state: a NET map, not a log of IOUs. bal[a][b] > 0 means a owes b. */
class BalanceSheet {
    private final Map<String, Map<String, Long>> net = new LinkedHashMap<>();

    /** The antisymmetry is written on every single move. Both lines, or neither. */
    void move(User from, User to, long paise) {
        if (from.equals(to) || paise == 0) return;
        bump(from.id(), to.id(), paise);
        bump(to.id(), from.id(), -paise);
    }

    private void bump(String a, String b, long delta) {
        Map<String, Long> row = net.computeIfAbsent(a, k -> new LinkedHashMap<>());
        long v = row.getOrDefault(b, 0L) + delta;
        if (v == 0) row.remove(b); else row.put(b, v);
    }

    long owes(User a, User b) {                              // O(1) — that is the point
        return net.getOrDefault(a.id(), Map.of()).getOrDefault(b.id(), 0L);
    }

    /** positive = is owed, negative = owes. */
    long netOf(User u) {
        long owed = 0;
        for (long v : net.getOrDefault(u.id(), Map.of()).values()) owed -= v;
        return owed;
    }

    long totalOfAllNets(List<User> members) {
        long s = 0;
        for (User u : members) s += netOf(u);
        return s;                                            // must be exactly 0
    }

    List<Settlement> openEdges(List<User> members) {
        List<Settlement> out = new ArrayList<>();
        for (User a : members)
            for (User b : members)
                if (owes(a, b) > 0) out.add(new Settlement(a, b, owes(a, b)));
        return out;
    }

    /** Greedy: biggest creditor meets biggest debtor. At most n-1 transfers. */
    List<Settlement> simplify(List<User> members) {
        Comparator<long[]> byAmountDesc = (x, y) -> Long.compare(y[1], x[1]);
        PriorityQueue<long[]> creditors = new PriorityQueue<>(byAmountDesc);
        PriorityQueue<long[]> debtors = new PriorityQueue<>(byAmountDesc);

        for (int i = 0; i < members.size(); i++) {
            long n = netOf(members.get(i));
            if (n > 0) creditors.add(new long[]{i, n});
            else if (n < 0) debtors.add(new long[]{i, -n});
        }

        List<Settlement> out = new ArrayList<>();
        while (!creditors.isEmpty() && !debtors.isEmpty()) {
            long[] credit = creditors.poll(), debt = debtors.poll();
            long amount = Math.min(credit[1], debt[1]);      // one side always hits zero
            out.add(new Settlement(members.get((int) debt[0]), members.get((int) credit[0]), amount));
            if (credit[1] > amount) creditors.add(new long[]{credit[0], credit[1] - amount});
            if (debt[1] > amount) debtors.add(new long[]{debt[0], debt[1] - amount});
        }
        return out;
    }

    void replaceWith(List<User> members, List<Settlement> transfers) {
        net.clear();
        for (Settlement t : transfers) move(t.from(), t.to(), t.paise());
    }
}

/* -------------------------------------------------------------------- group */
record Expense(String description, User payer, long totalPaise, List<Split> splits) {}

class Group {
    final String name;
    final List<User> members;
    final List<Expense> expenses = new ArrayList<>();        // the audit trail — kept forever
    final List<Settlement> payments = new ArrayList<>();
    final BalanceSheet balances = new BalanceSheet();
    private final Object lock = new Object();                // one lock per group

    Group(String name, List<User> members) { this.name = name; this.members = List.copyOf(members); }

    Expense addExpense(String description, User payer, long totalPaise,
                       List<User> participants, SplitStrategy strategy) {
        synchronized (lock) {
            if (totalPaise <= 0) throw new IllegalArgumentException("amount must be positive");
            List<Split> splits = strategy.split(totalPaise, participants);   // validates itself
            for (Split s : splits)
                if (!s.user().equals(payer)) balances.move(s.user(), payer, s.paise());
            Expense e = new Expense(description, payer, totalPaise, splits);
            expenses.add(e);
            assertZeroSum();
            return e;
        }
    }

    /** A payment is a balance movement AND a recorded row. Never a silent zeroing. */
    void settleUp(User from, User to, long paise) {
        synchronized (lock) {
            balances.move(to, from, paise);                  // cancels what "from" owed "to"
            payments.add(new Settlement(from, to, paise));
            assertZeroSum();
        }
    }

    List<Settlement> simplifyDebts() {
        synchronized (lock) {
            List<Settlement> transfers = balances.simplify(members);
            balances.replaceWith(members, transfers);
            assertZeroSum();
            return transfers;
        }
    }

    private void assertZeroSum() {
        long total = balances.totalOfAllNets(members);
        if (total != 0) throw new IllegalStateException("BALANCES DO NOT SUM TO ZERO: " + total);
    }
}

/* --------------------------------------------------------------------- demo */
public class Main {
    public static void main(String[] args) {
        User ravi = new User("u1", "Ravi"), priya = new User("u2", "Priya");
        User arjun = new User("u3", "Arjun"), meera = new User("u4", "Meera");
        List<User> all = List.of(ravi, priya, arjun, meera);
        Group g = new Group("Weekend", all);

        g.addExpense("Dinner", ravi, 100_000, all, new EqualSplit());
        g.addExpense("Cab", priya, 90_000, List.of(priya, arjun, meera), new EqualSplit());
        g.addExpense("Movie", arjun, 120_000, all, new ShareSplit(1, 1, 2, 2));
        g.addExpense("Groceries", meera, 80_000, List.of(priya, meera), new ExactSplit(50_000, 30_000));
        g.addExpense("Coffee", ravi, 10_000, List.of(ravi, priya, arjun), new EqualSplit());

        System.out.println("open edges:");
        for (Settlement s : g.balances.openEdges(all)) System.out.println("  " + s);

        System.out.println("nets:");
        for (User u : all) System.out.println("  " + u + "  " + Money.fmt(g.balances.netOf(u)));
        System.out.println("  TOTAL " + g.balances.totalOfAllNets(all) + "   <- must be exactly 0");

        List<Settlement> transfers = g.simplifyDebts();
        System.out.println("simplified: " + g.balances.openEdges(all).size()
                + " payments (was 6, bound is n-1 = " + (all.size() - 1) + ")");
        for (Settlement s : transfers) System.out.println("  " + s);

        g.settleUp(meera, ravi, 45_000);
        System.out.println("after Meera pays Ravi: net(Meera) = " + Money.fmt(g.balances.netOf(meera))
                + ", payments recorded = " + g.payments.size());
        System.out.println("TOTAL still " + g.balances.totalOfAllNets(all));
    }
}

/* ---------------------------------------------------------------- output ---
open edges:
  Priya -> Ravi  Rs.283.33
  Arjun -> Ravi  Rs.83.33
  Arjun -> Priya  Rs.100.00
  Priya -> Meera  Rs.200.00
  Meera -> Ravi  Rs.250.00
  Meera -> Arjun  Rs.400.00
nets:
  Ravi  Rs.616.66
  Priya  -Rs.383.33
  Arjun  Rs.216.67
  Meera  -Rs.450.00
  TOTAL 0   <- must be exactly 0
simplified: 3 payments (was 6, bound is n-1 = 3)
  Meera -> Ravi  Rs.450.00
  Priya -> Arjun  Rs.216.67
  Priya -> Ravi  Rs.166.66
after Meera pays Ravi: net(Meera) = Rs.0.00, payments recorded = 1
TOTAL still 0
--------------------------------------------------------------------------- */

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

You split ₹100 equally among three people using doubles. What actually goes wrong?

question 02 / 08

Where should the check “these splits sum to the total” live?

question 03 / 08

Why keep a net balance map instead of a list of “A owes B ₹X” rows?

question 04 / 08

What single rule keeps the balance sheet from ever drifting?

question 05 / 08

Six IOUs between four people. The greedy simplification matches biggest creditor with biggest debtor. Why does it terminate?

question 06 / 08

The interviewer asks whether your simplification produces the minimum possible number of payments. What is the right answer?

question 07 / 08

Two people add an expense to the same group at the same instant. What breaks, and what is the fix?

question 08 / 08

Someone edits an expense that has already been partly settled. What is the cleanest way to handle it?

0/8 answered