LLD · Algorithms Meet OOP

Low Level Design: Splitwise (Expense Sharing + Debt Simplification)

Model splits without floating-point bugs, keep a balance sheet that never drifts, and simplify debts with an algorithm you can actually prove: greedy for speed, subset DP for the true minimum.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 22 min read
Friends sharing a bill with arrows of money between them simplifying into fewer arrows
TL;DR
  • Store money as paise in a long. Split uneven amounts with the largest remainder method so shares always add up to the bill.
  • Model split types (equal, exact, percent, shares) as a sealed interface or Strategy: each knows how to validate itself and compute owed shares.
  • Every expense becomes ledger entries "debtor owes payer x". Keep both net balances (for simplification) and a pairwise ledger (for "you owe Bhavya ₹100").
  • Debt simplification: greedy (max creditor vs max debtor) gives at most n−1 transfers in O(n log n) but is not always optimal. The true minimum is n − (max number of zero-sum groups), found by bitmask DP; the general problem is NP-hard.
  • Full Java code below with a Goa-trip dry run and a 5-person counterexample where greedy needs 4 transfers and optimal needs 3.

Splitwise is the LLD question interviewers pick when they want to see both halves of a candidate: can you design clean classes, and can you reason about an algorithm inside them? The object model is modest (users, groups, expenses, splits, balances), but the details are where people fail: rounding a ₹100 chai bill three ways, keeping balances consistent when expenses are edited, and answering "now minimise the number of payments" with something better than a vague "use a heap".

This guide covers the full design and then goes deeper on debt simplification than most resources, including why the greedy answer is not optimal and how to get the real minimum. For the general LLD interview format, see the LLD interview preparation guide.

Splitwise Requirements to Agree On

Functional requirements

  1. Users can create groups (a Goa trip, a flat) and add members.
  2. Any member can add an expense: who paid, total amount, and how it is split.
  3. Split types: equal, exact amounts, percentages, and shares (for example 2:1:1 because one person brought their partner).
  4. Show balances: each person's net position, and who owes whom.
  5. Record a settlement ("Diya paid Aarav ₹486.66 on UPI").
  6. Simplify debts: produce a minimal or near-minimal set of payments that settles the group.

Questions that change the design

  • Single payer or multiple payers per expense? Start with single payer; multi-payer is a common follow-up and is easy if your ledger is built on "debtor owes creditor" entries.
  • Expenses outside groups? Treat a one-to-one expense as a two-member implicit group; the same code works.
  • Currency? Assume INR only; multi-currency is an extension.
  • Edit and delete? Yes, but via reversals, which pushes us toward an append-only ledger.

Money and Rounding: Get This Right First

Three friends split a ₹100 chai bill. ₹100 / 3 = ₹33.333..., and with double you end up with three shares of 33.33 that sum to 99.99, and a balance sheet that is off by a paisa forever. Over thousands of expenses, those paise accumulate into balances that never reach zero, and "settle up" never clears.

The fix has two parts:

  1. Integer paise. ₹100 is 10000L. All arithmetic is exact. (BigDecimal with explicit scale is the other acceptable answer; it is slower and more verbose, but natural if you support multiple currencies with different minor units.)
  2. Largest remainder allocation. Compute each person's floor share, then hand the leftover paise, one each, to the people with the largest fractional remainders, breaking ties by a fixed order.
BillSplitFloor shares (paise)LeftoverFinal
₹100.00 (10000)Equal, 3 people3333, 3333, 333313334, 3333, 3333
₹1,000.00 (100000)Shares 2:1:150000, 25000, 25000050000, 25000, 25000
₹99.99 (9999)Percent 50/25/254999, 2499, 249924999, 2500, 2500

The last row is the one candidates get wrong by intuition ("the 50% person should get the extra paisa"). Do it mechanically: weights are 5000, 2500 and 2500 basis points on a total of 9999 paise. Floor shares are 9999×5000/10000 = 4999 (remainder 5000) and 9999×2500/10000 = 2499 (remainder 7500) twice. That uses 9997 paise, leaving 2. The two largest remainders are the 7500s, so the 25% people each get one paisa: 4999, 2500, 2500, summing to exactly 9999. Trust the rule, not intuition; interviewers like this question precisely because intuition fails.

Split Types: Validation and Computation

Split typeInputValidationComputation
EqualList of participantsNon-empty, members of groupAllocate with weight 1 each
ExactAmount per participantSum must equal total exactlyUse as-is
PercentPercent per participant (as basis points)Sum must equal 100% (10000 bp)Allocate with weights = basis points
SharesInteger units per participantAll positiveAllocate with weights = units

Notice that equal, percent and shares are all the same algorithm (weighted allocation) with different weights. Only the validation differs. A good design reuses one allocate function rather than writing four rounding routines. Percentages are stored as basis points (12.5% = 1250) to avoid floats even at the input boundary.

Should split types be an enum with a switch, or polymorphic classes? A switch works for four types but spreads each type's validation across the code. Polymorphism keeps "how to validate and compute this split" in one place, which is the Strategy pattern and the Open/Closed principle; the SOLID guide uses a similar example. In Java 17 a sealed interface with records gives you both: polymorphic behaviour and a closed, exhaustively known set of types.

Splitwise Class Design

┌───────────────────────────────────────────────────────────┐ │ Splitwise (facade) │ │ addUser · createGroup · addExpense · settleUp · simplify │ └──────┬───────────────────────┬────────────────────┬───────┘ │ 1..* │ uses │ uses ▼ ▼ ▼ ┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ │ Group │ │ «sealed» Split │ │ DebtSimplifier │ │ members │ │ shares(total) │ │ simplify(net) │ │ expenses ────┼──► └───────△────────┘ └────────△─────────┘ │ BalanceSheet │ Equal Exact Percent Share Greedy Optimal └──────┬───────┘ ▼ ┌───────────────────────────┐ Expense(id, desc, paidBy, │ BalanceSheet │ amount, owed map) │ net: user -> paise │ │ pair: (a,b) -> signed amt │ Transfer(from, to, amount) └───────────────────────────┘
ClassResponsibility
UserIdentity (id, name). No balance stored here: balances are per group.
GroupMembers, expense history, its own BalanceSheet.
ExpenseImmutable record of what happened: payer, amount, computed owed shares.
Split (sealed)Validate input and compute owed shares for a total.
BalanceSheetApply "debtor owes creditor x" entries; expose net and pairwise views.
DebtSimplifierStrategy: turn net balances into a list of transfers.
SplitwiseFacade; validates membership, orchestrates, guards concurrency.
💡
Don't put balance on UserA user's balance is meaningless without context: Aarav might be owed ₹500 in the Goa group and owe ₹2,000 in the flat group. Storing a single balance on User is one of the most common modelling errors in this problem.

The Balance Sheet: Net Balances and Pairwise Debts

Every expense decomposes into simple facts: "participant P owes payer Q amount x" for each participant other than the payer. A settlement is the same fact reversed: if Diya pays Aarav ₹200, record "Aarav owes Diya ₹200", which cancels the earlier debt. With that one primitive, record(debtor, creditor, amount), every operation is uniform.

Two views are maintained:

  • Net balance per user: positive means the group owes you, negative means you owe the group. Net balances always sum to zero; that is your invariant and a great assertion to mention.
  • Pairwise ledger: one signed number per unordered pair of users, stored under a canonical key (smaller id first). Positive means the first owes the second. This is what powers "you owe Bhavya ₹100" in the UI.

Both views update in O(1) per entry. An expense with k participants is O(k). Querying all pairwise debts is O(p) for p pairs with history.

Debt Simplification: Minimum Number of Transactions

After a trip, pairwise debts form a messy graph: six people might have fifteen pairwise debts. Simplification replaces it with fewer payments that leave every person's net balance unchanged. Only the net balances matter: if Chirag owes Bhavya ₹230 and Bhavya owes Diya ₹33, Chirag could pay Diya directly. (Product note: this means you may be asked to pay someone you never shared an expense with, which is why Splitwise offers simplification as a group setting rather than forcing it.)

Greedy: match the biggest creditor with the biggest debtor

  1. Split by sign. Creditors (positive net) into a max-heap, debtors (negative net, stored as positive amounts) into another max-heap.
  2. Match. Pop the largest creditor c and largest debtor d. The debtor pays min(c, d).
  3. Push back the remainder. Whoever still has a non-zero balance goes back into its heap.
  4. Repeat until both heaps are empty.

Every transfer zeroes out at least one person, and the final transfer zeroes two, so greedy uses at most n − 1 transfers for n people with non-zero balances, in O(n log n) time. That bound is good, but it is not the minimum.

A counterexample where greedy is not optimal

Five people with net balances (in ₹): A +300, B +400, C −300, D −200, E −200.

StepGreedy picksTransferRemaining
1Biggest creditor B (400), biggest debtor C (300)C → B ₹300A +300, B +100, D −200, E −200
2A (300), D (200; tie with E broken by name)D → A ₹200A +100, B +100, E −200
3A (100), E (200)E → A ₹100B +100, E −100
4B, EE → B ₹100all zero

Four transfers. But look at the structure: {A, C} sums to zero (300 − 300), and {B, D, E} sums to zero (400 − 200 − 200). Settle each group independently: C → A ₹300, D → B ₹200, E → B ₹200. Three transfers.

The exact minimum: n minus the number of zero-sum groups

Any settlement plan, viewed as a graph of transfers, splits people into connected components, and each component must have balances summing to zero (money only moves within it). A component of size k needs at least k − 1 transfers to be connected. So the total is at least Σ(kᵢ − 1) = n − (number of components). To minimise transfers, maximise the number of disjoint zero-sum groups. And greedy within a group achieves k − 1, so the bound is tight.

Finding the maximum number of zero-sum groups is a subset problem. With bitmask DP over n non-zero balances:

  • sum[mask] = total balance of the people in mask.
  • dp[mask] = max number of complete zero-sum groups when people are removed one by one from mask = max over i in mask of dp[mask without i], plus 1 if sum[mask] == 0.
  • Answer: n − dp[full] transfers. Time O(2ⁿ · n), memory O(2ⁿ). Practical up to about 20 people.

Why does the recurrence work? Think of removing people one at a time in some order. Every time the removed-so-far set has sum zero, a group has just closed. The DP picks the order that closes the most groups. To recover the actual groups, walk back from the full mask and cut whenever the remaining mask sums to zero.

⚠️
It is NP-hard in generalDeciding whether any proper subset of balances sums to zero is already a subset-sum problem, which is NP-complete. So do not promise a polynomial exact algorithm. The professional answer: greedy for large groups, exact DP for groups up to about 20, which covers almost every real trip or flat.

Python implementation of both algorithms

This is also the solution shape for LeetCode 465 (Optimal Account Balancing). If backtracking feels more natural to you, the backtracking patterns guide covers the DFS version; for heap-based greedy reasoning see greedy algorithm problems.

Python
import heapq

def simplify_greedy(net: dict[str, int]) -> list[tuple[str, str, int]]:
    """net: user -> balance in paise (positive = is owed). Returns (from, to, amount)."""
    cred = [(-v, u) for u, v in net.items() if v > 0]
    debt = [(v, u) for u, v in net.items() if v < 0]
    heapq.heapify(cred)
    heapq.heapify(debt)
    out = []
    while cred and debt:
        c, cu = heapq.heappop(cred)          # c is -amount owed to cu
        d, du = heapq.heappop(debt)          # d is -amount du owes
        amt = min(-c, -d)
        out.append((du, cu, amt))
        if -c > amt:
            heapq.heappush(cred, (c + amt, cu))
        if -d > amt:
            heapq.heappush(debt, (d + amt, du))
    return out

def simplify_optimal(net: dict[str, int]) -> list[tuple[str, str, int]]:
    people = [u for u, v in net.items() if v != 0]
    bal = [net[u] for u in people]
    n = len(people)
    full = (1 << n) - 1
    s = [0] * (1 << n)
    dp = [0] * (1 << n)
    for m in range(1, 1 << n):
        low = (m & -m).bit_length() - 1
        s[m] = s[m & (m - 1)] + bal[low]
        best = max(dp[m ^ (1 << i)] for i in range(n) if m >> i & 1)
        dp[m] = best + (1 if s[m] == 0 else 0)

    groups, cur, m = [], [], full
    while m:
        bonus = 1 if s[m] == 0 else 0
        for i in range(n):
            if m >> i & 1 and dp[m ^ (1 << i)] + bonus == dp[m]:
                cur.append(i)
                m ^= 1 << i
                break
        if s[m] == 0:                         # the people removed since the last cut net to zero
            groups.append(cur)
            cur = []

    out = []
    for g in groups:                          # greedy inside a zero-sum group uses k-1 transfers
        out += simplify_greedy({people[i]: bal[i] for i in g})
    return out

net = {"A": 30000, "B": 40000, "C": -30000, "D": -20000, "E": -20000}
print(simplify_greedy(net))   # 4 transfers
print(simplify_optimal(net))  # [('C','A',30000), ('D','B',20000), ('E','B',20000)]

Full Java Code for Splitwise

Single file, Java 17+. Run with java SplitwiseDemo.java.

Java
import java.util.*;

public class SplitwiseDemo {

    record User(String id, String name) { }

    record Expense(String id, String description, String paidBy, long amountPaise, Map<String, Long> owed) { }

    record Transfer(String from, String to, long amountPaise) {
        @Override public String toString() { return from + " pays " + to + " Rs " + fmt(amountPaise); }
    }

    static String fmt(long paise) {
        long a = Math.abs(paise);
        return (paise < 0 ? "-" : "") + (a / 100) + "." + String.format("%02d", a % 100);
    }

    /** Largest remainder method: shares are proportional to weights and sum exactly to total. */
    static Map<String, Long> allocate(long total, List<String> users, List<Long> weights) {
        long w = weights.stream().mapToLong(Long::longValue).sum();
        if (users.isEmpty() || w <= 0) throw new IllegalArgumentException("Need positive weights");
        int n = users.size();
        long[] base = new long[n];
        long[] rem = new long[n];
        long used = 0;
        for (int i = 0; i < n; i++) {
            if (weights.get(i) < 0) throw new IllegalArgumentException("Negative weight");
            base[i] = total * weights.get(i) / w;
            rem[i] = total * weights.get(i) % w;
            used += base[i];
        }
        Integer[] order = new Integer[n];
        for (int i = 0; i < n; i++) order[i] = i;
        Arrays.sort(order, (a, b) -> rem[a] != rem[b] ? Long.compare(rem[b], rem[a]) : Integer.compare(a, b));
        for (int k = 0; k < total - used; k++) base[order[k]]++;
        Map<String, Long> out = new LinkedHashMap<>();
        for (int i = 0; i < n; i++) out.merge(users.get(i), base[i], Long::sum);
        return out;
    }

    // ===================== Split types =====================
    sealed interface Split permits EqualSplit, ExactSplit, PercentSplit, ShareSplit {
        /** Who owes how much; values always sum to totalPaise. */
        Map<String, Long> shares(long totalPaise);
    }

    record EqualSplit(List<String> users) implements Split {
        public Map<String, Long> shares(long total) {
            return allocate(total, users, Collections.nCopies(users.size(), 1L));
        }
    }

    record ExactSplit(Map<String, Long> amounts) implements Split {
        public Map<String, Long> shares(long total) {
            long sum = amounts.values().stream().mapToLong(Long::longValue).sum();
            if (sum != total) {
                throw new IllegalArgumentException("Exact amounts add to " + fmt(sum) + ", bill is " + fmt(total));
            }
            return new LinkedHashMap<>(amounts);
        }
    }

    /** Percentages in basis points: 12.5% = 1250. Must add to 10000. */
    record PercentSplit(Map<String, Long> basisPoints) implements Split {
        public Map<String, Long> shares(long total) {
            long sum = basisPoints.values().stream().mapToLong(Long::longValue).sum();
            if (sum != 10_000) throw new IllegalArgumentException("Percentages must add to 100");
            return allocate(total, new ArrayList<>(basisPoints.keySet()), new ArrayList<>(basisPoints.values()));
        }
    }

    record ShareSplit(Map<String, Long> units) implements Split {
        public Map<String, Long> shares(long total) {
            if (units.values().stream().anyMatch(u -> u <= 0)) throw new IllegalArgumentException("Shares must be positive");
            return allocate(total, new ArrayList<>(units.keySet()), new ArrayList<>(units.values()));
        }
    }

    // ===================== Balance sheet =====================
    static final class BalanceSheet {
        private final Map<String, Long> net = new TreeMap<>();
        private final Map<String, Long> pair = new TreeMap<>();   // "a|b" (a < b): >0 means a owes b

        void record(String debtor, String creditor, long amount) {
            if (debtor.equals(creditor) || amount == 0) return;
            net.merge(creditor, amount, Long::sum);
            net.merge(debtor, -amount, Long::sum);
            boolean ordered = debtor.compareTo(creditor) < 0;
            String key = ordered ? debtor + "|" + creditor : creditor + "|" + debtor;
            pair.merge(key, ordered ? amount : -amount, Long::sum);
        }

        Map<String, Long> netBalances() { return new TreeMap<>(net); }

        List<Transfer> pairwiseDebts() {
            List<Transfer> out = new ArrayList<>();
            pair.forEach((k, v) -> {
                String[] p = k.split("\\|");
                if (v > 0) out.add(new Transfer(p[0], p[1], v));
                else if (v < 0) out.add(new Transfer(p[1], p[0], -v));
            });
            return out;
        }
    }

    static final class Group {
        final String id;
        final String name;
        final Set<String> members;
        final List<Expense> expenses = new ArrayList<>();
        final BalanceSheet balances = new BalanceSheet();

        Group(String id, String name, Collection<String> members) {
            this.id = id;
            this.name = name;
            this.members = new LinkedHashSet<>(members);
        }
    }

    // ===================== Simplification strategies =====================
    interface DebtSimplifier {
        List<Transfer> simplify(Map<String, Long> net);
    }

    record Bal(String user, long amount) { }

    static final class GreedySimplifier implements DebtSimplifier {
        private static final Comparator<Bal> BIGGEST_FIRST =
            Comparator.comparingLong(Bal::amount).reversed().thenComparing(Bal::user);

        public List<Transfer> simplify(Map<String, Long> net) {
            PriorityQueue<Bal> cred = new PriorityQueue<>(BIGGEST_FIRST);
            PriorityQueue<Bal> debt = new PriorityQueue<>(BIGGEST_FIRST);
            net.forEach((u, v) -> {
                if (v > 0) cred.add(new Bal(u, v));
                else if (v < 0) debt.add(new Bal(u, -v));
            });
            List<Transfer> out = new ArrayList<>();
            while (!cred.isEmpty() && !debt.isEmpty()) {
                Bal c = cred.poll();
                Bal d = debt.poll();
                long amt = Math.min(c.amount(), d.amount());
                out.add(new Transfer(d.user(), c.user(), amt));
                if (c.amount() > amt) cred.add(new Bal(c.user(), c.amount() - amt));
                if (d.amount() > amt) debt.add(new Bal(d.user(), d.amount() - amt));
            }
            return out;
        }
    }

    /** Exact minimum via bitmask DP over zero-sum groups; falls back to greedy for large groups. */
    static final class OptimalSimplifier implements DebtSimplifier {
        private static final int MAX_PEOPLE = 20;
        private final DebtSimplifier greedy = new GreedySimplifier();

        public List<Transfer> simplify(Map<String, Long> net) {
            List<String> people = new ArrayList<>();
            List<Long> bal = new ArrayList<>();
            net.forEach((u, v) -> { if (v != 0) { people.add(u); bal.add(v); } });
            int n = people.size();
            if (n > MAX_PEOPLE) return greedy.simplify(net);

            int full = (1 << n) - 1;
            long[] sum = new long[1 << n];
            int[] dp = new int[1 << n];
            for (int m = 1; m <= full; m++) {
                int low = Integer.numberOfTrailingZeros(m);
                sum[m] = sum[m & (m - 1)] + bal.get(low);
                int best = 0;
                for (int i = 0; i < n; i++) {
                    if (((m >> i) & 1) == 1) best = Math.max(best, dp[m ^ (1 << i)]);
                }
                dp[m] = best + (sum[m] == 0 ? 1 : 0);
            }

            List<Transfer> out = new ArrayList<>();
            Map<String, Long> group = new LinkedHashMap<>();
            int m = full;
            while (m != 0) {
                int bonus = sum[m] == 0 ? 1 : 0;
                for (int i = 0; i < n; i++) {
                    if (((m >> i) & 1) == 1 && dp[m ^ (1 << i)] + bonus == dp[m]) {
                        group.put(people.get(i), bal.get(i));
                        m ^= 1 << i;
                        break;
                    }
                }
                if (sum[m] == 0) {                       // closed a zero-sum group
                    out.addAll(greedy.simplify(group));
                    group.clear();
                }
            }
            return out;
        }
    }

    // ===================== Facade =====================
    static final class Splitwise {
        private final Map<String, User> users = new LinkedHashMap<>();
        private final Map<String, Group> groups = new HashMap<>();
        private final DebtSimplifier simplifier;
        private long expenseSeq;

        Splitwise(DebtSimplifier simplifier) { this.simplifier = simplifier; }

        synchronized void addUser(String id, String name) { users.putIfAbsent(id, new User(id, name)); }

        synchronized Group createGroup(String id, String name, List<String> memberIds) {
            memberIds.forEach(this::requireUser);
            Group g = new Group(id, name, memberIds);
            groups.put(id, g);
            return g;
        }

        Expense addExpense(String groupId, String desc, String paidBy, long amountPaise, Split split) {
            if (amountPaise <= 0) throw new IllegalArgumentException("Amount must be positive");
            Group g = group(groupId);
            synchronized (g) {
                requireMember(g, paidBy);
                Map<String, Long> owed = split.shares(amountPaise);
                owed.keySet().forEach(u -> requireMember(g, u));
                Expense e = new Expense(nextExpenseId(), desc, paidBy, amountPaise, Collections.unmodifiableMap(owed));
                g.expenses.add(e);
                owed.forEach((u, amt) -> g.balances.record(u, paidBy, amt));
                return e;
            }
        }

        /** 'from' paid 'to' outside the app (cash/UPI); cancels debt. */
        void settleUp(String groupId, String from, String to, long amountPaise) {
            Group g = group(groupId);
            synchronized (g) {
                requireMember(g, from);
                requireMember(g, to);
                g.balances.record(to, from, amountPaise);
            }
        }

        Map<String, Long> netBalances(String groupId) {
            Group g = group(groupId);
            synchronized (g) { return g.balances.netBalances(); }
        }

        List<Transfer> pairwiseDebts(String groupId) {
            Group g = group(groupId);
            synchronized (g) { return g.balances.pairwiseDebts(); }
        }

        List<Transfer> simplify(String groupId) { return simplifier.simplify(netBalances(groupId)); }

        private synchronized String nextExpenseId() { return "EXP" + (++expenseSeq); }

        private synchronized Group group(String id) {
            Group g = groups.get(id);
            if (g == null) throw new NoSuchElementException("No group " + id);
            return g;
        }

        private void requireUser(String id) {
            if (!users.containsKey(id)) throw new NoSuchElementException("No user " + id);
        }

        private static void requireMember(Group g, String id) {
            if (!g.members.contains(id)) throw new IllegalArgumentException(id + " is not in " + g.name);
        }
    }

    static Map<String, Long> ordered(String[] keys, long... values) {
        Map<String, Long> m = new LinkedHashMap<>();
        for (int i = 0; i < keys.length; i++) m.put(keys[i], values[i]);
        return m;
    }

    public static void main(String[] args) {
        Splitwise app = new Splitwise(new OptimalSimplifier());
        List<String> all = List.of("Aarav", "Bhavya", "Chirag", "Diya");
        all.forEach(u -> app.addUser(u, u));
        app.createGroup("goa", "Goa trip", all);

        app.addExpense("goa", "Dinner", "Aarav", 120000, new EqualSplit(all));
        app.addExpense("goa", "Cab", "Bhavya", 100000,
            new ExactSplit(ordered(new String[] {"Aarav", "Bhavya", "Chirag"}, 20000, 30000, 50000)));
        app.addExpense("goa", "Snacks", "Chirag", 90000,
            new PercentSplit(ordered(new String[] {"Aarav", "Bhavya", "Diya"}, 2000, 3000, 5000)));
        app.addExpense("goa", "Chai", "Diya", 10000, new EqualSplit(List.of("Aarav", "Bhavya", "Chirag")));

        System.out.println("Pairwise: " + app.pairwiseDebts("goa"));
        app.netBalances("goa").forEach((u, v) -> System.out.println("  " + u + " " + fmt(v)));
        System.out.println("Simplified: " + app.simplify("goa"));

        app.settleUp("goa", "Diya", "Aarav", 48666);
        System.out.println("After Diya settles with Aarav: " + app.netBalances("goa"));

        Map<String, Long> tricky = ordered(new String[] {"A", "B", "C", "D", "E"}, 30000, 40000, -30000, -20000, -20000);
        System.out.println("Greedy:  " + new GreedySimplifier().simplify(tricky));
        System.out.println("Optimal: " + new OptimalSimplifier().simplify(tricky));
    }
}

Dry Run: The Goa Trip

Four friends, four expenses. Amounts in rupees for readability; the code works in paise.

ExpensePaid bySplitOwed shares
Dinner ₹1,200AaravEqual, 4 peopleA 300, B 300, C 300, D 300
Cab ₹1,000BhavyaExactA 200, B 300, C 500
Snacks ₹900ChiragPercent 20/30/50A 180, B 270, D 450
Chai ₹100DiyaEqual, 3 peopleA 33.34, B 33.33, C 33.33

For the chai, 10000 paise / 3 = 3333 rem 1 each; all remainders tie, so the first in order (Aarav) gets the extra paisa. Shares sum to exactly 10000.

Pairwise ledger after netting

  • Bhavya owes Aarav ₹100 (300 for dinner minus 200 Aarav owes Bhavya for the cab)
  • Chirag owes Aarav ₹120 (300 − 180)
  • Diya owes Aarav ₹266.66 (300 − 33.34)
  • Chirag owes Bhavya ₹230 (500 − 270)
  • Bhavya owes Diya ₹33.33
  • Diya owes Chirag ₹416.67 (450 − 33.33)

Net balances

+486.66
Aarav (is owed)
+96.67
Bhavya
+66.67
Chirag
−650.00
Diya (owes)

Check the invariant: 486.66 + 96.67 + 66.67 − 650.00 = 0. Six pairwise debts simplify to three payments: Diya pays Aarav ₹486.66, Bhavya ₹96.67 and Chirag ₹66.67. With four people and one non-splittable group, n − 1 = 3 is optimal. After Diya settles with Aarav, Aarav's balance is 0 and Diya's is −163.34.

The final two lines of the demo print the five-person counterexample: greedy produces four transfers, the optimal simplifier produces C → A ₹300, D → B ₹200, E → B ₹200.

Extensions Interviewers Ask

Multiple payers

Two people split the hotel payment: Aarav paid ₹3,000 and Bhavya ₹2,000. Represent paid amounts as a map too. Net effect per user = paid − owed, and net balances still sum to zero. Pairwise attribution needs a rule (for example, each debtor owes each payer in proportion to what they paid); that is a product decision worth stating explicitly. Simplification needs no change because it only uses net balances.

Edit and delete via an append-only ledger

Never mutate history. Deleting an expense appends a reversal (every record(debtor, creditor, x) becomes record(creditor, debtor, x)), and editing is reversal plus a new expense. Balances remain correct, and the activity feed can show "Bhavya edited Cab: ₹1,000 → ₹1,100". This is event sourcing in miniature; the full pattern is in event sourcing and CQRS explained.

Notifications and activity feed

Observer: the facade publishes ExpenseAdded, SettlementRecorded events; an email notifier, a push notifier and the activity feed subscribe. The core does not depend on any of them.

Multi-currency

Store each expense in its original currency with the exchange rate at the time, and keep balances per currency (or convert to a group base currency at entry). Simplification runs per currency. Use BigDecimal or per-currency minor units because not every currency has 100 subunits.

Scale and persistence

In a real service, expenses and ledger entries live in a relational database with a transaction around "insert expense + insert ledger rows + update balance rows". Net balance per (group, user) can be a materialised row updated in the same transaction. Integration with UPI for "settle up" links into payment-system concerns covered in the digital wallet design and UPI payment system design.

How to Ace Splitwise in an LLD or Machine Coding Round

  1. Clarify scope in 3 minutes. Groups, split types, single payer, settle up, simplify. Confirm INR and paise.
  2. State the money rule early. "All amounts are long paise; uneven splits use the largest remainder method." This single sentence prevents the most common follow-up trap.
  3. Draw entities and the ledger primitive. Everything reduces to record(debtor, creditor, amount).
  4. Code splits and the balance sheet first. That is the demoable core. Add simplification after.
  5. For simplification, lead with greedy, then volunteer its limitation. Show the counterexample, state the n − (zero-sum groups) result and the DP. Mention NP-hardness.

Common mistakes

  • Using double for money, or rounding each share independently so shares do not sum to the bill.
  • Storing a global balance on User instead of per group.
  • Recomputing balances by replaying all expenses on every read (fine for a demo, but say it is O(E) and offer the incremental version).
  • Claiming greedy gives the minimum number of transactions.
  • Percent splits that accept 33.33 + 33.33 + 33.33 = 99.99% silently.
  • A switch on split type repeated in three places.

For timing strategy in a 90-minute machine coding round, read the machine coding round guide; for a contrasting problem that stresses concurrency instead of algorithms, try the parking lot LLD.

Frequently Asked Questions

How does Splitwise simplify debts?▾
Any correct simplification keeps every person's net balance unchanged and only changes who pays whom. A common approach is greedy: repeatedly match the person owed the most with the person who owes the most. Splitwise has not published its exact implementation, so in an interview present greedy plus the optimal subset-based method and their trade-offs.
What is the minimum number of transactions to settle debts?▾
If n people have non-zero balances, the minimum is n minus the maximum number of disjoint groups whose balances each sum to zero. Greedy guarantees at most n minus 1 transfers but can miss the optimum; a bitmask DP over subsets finds the exact minimum in O(2^n * n) time.
Is minimizing transactions in Splitwise NP-hard?▾
Yes, in general. Even deciding whether some proper subset of balances sums to zero is a subset-sum problem, which is NP-complete. That is why exact algorithms are exponential and practical systems use greedy or cap the exact method to small groups.
How should money be stored in an expense sharing app?▾
As integers in the smallest unit, paise for rupees, or as BigDecimal with explicit rounding. Never use double. When a split does not divide evenly, distribute leftover paise deterministically, for example with the largest remainder method, so shares always sum exactly to the bill.
Which design patterns are used in Splitwise LLD?▾
Strategy (or a sealed hierarchy) for split types, Strategy for the debt simplification algorithm, a Facade service for the API, and Observer for activity feeds and notifications. Factories are sometimes used to build splits from user input.
What is LeetCode 465 Optimal Account Balancing?▾
It is the algorithmic core of Splitwise: given transactions, return the minimum number of transfers to settle everyone. The standard solutions are backtracking over non-zero balances or the bitmask DP that counts zero-sum subsets, both covered in this guide.
How do you handle editing or deleting an expense?▾
Treat the ledger as append-only: deleting an expense records a reversal that subtracts the original shares, and editing is a reversal plus a new expense. Balances stay consistent and you keep a full audit trail, which matters when friends dispute who paid for what.

Practise LLD with Real Feedback

Splitwise is a favourite at Indian startups because it mixes clean OOP with a real algorithm; Prepflix's course drills exactly this combination with mock machine coding rounds.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.