Skip to main content
Low-Level Design · lesson 8 of 18
Open the Low-Level Design page →
Low-Level Design intermediate Lesson 8 of 18

Design an Expense-Splitting App: Balances and Minimal Settlements

Three people, two shared expenses, and a greedy algorithm that settles every balance to zero in the fewest possible payments — not one payment per expense.

By SkillByExample · Published

lld oop python splitwise

Two expenses among three friends shouldn’t require three separate payments to settle up.

Tracking balances, then settling them

class ExpenseSplitter:
    def __init__(self):
        self.balances = {}  # positive = is owed money, negative = owes money

    def add_expense(self, payer, amount, participants):
        share = amount / len(participants)
        for person in participants:
            self.balances[person] = self.balances.get(person, 0) - share
        self.balances[payer] = self.balances.get(payer, 0) + amount
        print(f"{payer} paid {amount} for {participants} (share: {share:.2f} each)")

    def settle(self):
        creditors = [(p, b) for p, b in self.balances.items() if b > 0.01]
        debtors = [(p, -b) for p, b in self.balances.items() if b < -0.01]
        creditors.sort(key=lambda x: -x[1])
        debtors.sort(key=lambda x: -x[1])
        i = j = 0
        transactions = []
        while i < len(debtors) and j < len(creditors):
            debtor, debt = debtors[i]
            creditor, credit = creditors[j]
            payment = min(debt, credit)
            transactions.append((debtor, creditor, payment))
            debtors[i] = (debtor, debt - payment)
            creditors[j] = (creditor, credit - payment)
            if debtors[i][1] < 0.01:
                i += 1
            if creditors[j][1] < 0.01:
                j += 1
        return transactions

splitter = ExpenseSplitter()
splitter.add_expense("alice", 90, ["alice", "bob", "carol"])
splitter.add_expense("bob", 30, ["alice", "bob"])

print("Balances:", {p: round(b, 2) for p, b in splitter.balances.items()})
for debtor, creditor, amount in splitter.settle():
    print(f"{debtor} pays {creditor}: {amount:.2f}")
$ python splitter.py
alice paid 90 for ['alice', 'bob', 'carol'] (share: 30.00 each)
bob paid 30 for ['alice', 'bob'] (share: 15.00 each)
Balances: {'alice': 45.0, 'bob': -15.0, 'carol': -30.0}
carol pays alice: 30.00
bob pays alice: 15.00

Two expenses were recorded, but settle() outputs exactly two payments too — in this case that’s unavoidable (two people owe money, so at least two payments are needed), but with more people the algorithm often collapses what would naively be one-payment-per-expense into far fewer transactions, because it settles net balances, not individual expenses.

Practice

1. Confirm balances always sum to (approximately) zero, no matter how many expenses.
class ExpenseSplitter:
    def __init__(self):
        self.balances = {}
    def add_expense(self, payer, amount, participants):
        share = amount / len(participants)
        for person in participants:
            self.balances[person] = self.balances.get(person, 0) - share
        self.balances[payer] = self.balances.get(payer, 0) + amount

splitter = ExpenseSplitter()
splitter.add_expense("alice", 90, ["alice", "bob", "carol"])
splitter.add_expense("bob", 30, ["alice", "bob"])
splitter.add_expense("carol", 60, ["alice", "bob", "carol"])
total = sum(splitter.balances.values())
print("Balances:", {p: round(b, 2) for p, b in splitter.balances.items()})
print(f"Sum of all balances: {total:.10f}")
$ python zero_sum.py
Balances: {'alice': 25.0, 'bob': -35.0, 'carol': 10.0}
Sum of all balances: 0.0000000000

Every dollar credited to a payer is debited across the participants in the same call, so the group’s balances always net to zero — this is a useful invariant to assert in a real test suite, not just something to eyeball.

2. Settle a simple two-person expense.
class ExpenseSplitter:
    def __init__(self):
        self.balances = {}
    def add_expense(self, payer, amount, participants):
        share = amount / len(participants)
        for person in participants:
            self.balances[person] = self.balances.get(person, 0) - share
        self.balances[payer] = self.balances.get(payer, 0) + amount
    def settle(self):
        creditors = [(p, b) for p, b in self.balances.items() if b > 0.01]
        debtors = [(p, -b) for p, b in self.balances.items() if b < -0.01]
        creditors.sort(key=lambda x: -x[1])
        debtors.sort(key=lambda x: -x[1])
        i = j = 0
        transactions = []
        while i < len(debtors) and j < len(creditors):
            debtor, debt = debtors[i]
            creditor, credit = creditors[j]
            payment = min(debt, credit)
            transactions.append((debtor, creditor, payment))
            debtors[i] = (debtor, debt - payment)
            creditors[j] = (creditor, credit - payment)
            if debtors[i][1] < 0.01:
                i += 1
            if creditors[j][1] < 0.01:
                j += 1
        return transactions

splitter = ExpenseSplitter()
splitter.add_expense("alice", 50, ["alice", "bob"])
for debtor, creditor, amount in splitter.settle():
    print(f"{debtor} pays {creditor}: {amount:.2f}")
$ python two_person.py
bob pays alice: 25.00

One expense between two people always reduces to exactly one payment: half of 50 back from whoever didn’t pay.

3. Confirm paying for only yourself nets to a zero balance.
class ExpenseSplitter:
    def __init__(self):
        self.balances = {}
    def add_expense(self, payer, amount, participants):
        share = amount / len(participants)
        for person in participants:
            self.balances[person] = self.balances.get(person, 0) - share
        self.balances[payer] = self.balances.get(payer, 0) + amount
        print(f"{payer} paid {amount} for {participants}")

splitter = ExpenseSplitter()
splitter.add_expense("alice", 20, ["alice"])
print("Balances:", splitter.balances)
$ python solo_expense.py
alice paid 20 for ['alice']
Balances: {'alice': 0.0}

participants == [payer] means the one deduction and the one credit are the same size and land on the same key, canceling out exactly — add_expense doesn’t need a special case for this, the general formula already handles it.

Next: a real race condition in the parking lot from earlier in this track — two threads, one spot, and the bug that appears only under concurrency.

Frequently Asked Questions

Why is the balance sign convention 'positive means owed money' instead of the more intuitive 'positive means owes money'?
Either convention works as long as it's applied consistently — this lesson picked positive-means-creditor because it reads naturally in `add_expense`: the payer's balance goes *up* by the full amount (they're owed it back), and every participant's balance goes *down* by their share (they owe it). The important thing for an interview is stating your convention explicitly, not which one you pick.
Why does settle() sort debtors and creditors by amount before matching them?
Matching the largest debtor against the largest creditor first is what keeps the number of transactions minimal — it's a greedy algorithm, and while it isn't provably optimal for every possible balance distribution, it performs well in practice and is the standard approach real expense-splitting apps use, which makes it a reasonable answer in an LLD interview without requiring exact-minimum-transactions optimality.
Why compare against 0.01 instead of exactly 0 when deciding who's a creditor or debtor?
Floating-point division (`amount / len(participants)`) accumulates tiny rounding errors — after several expenses, a balance that's conceptually zero might actually be `0.0000000000000004`. Comparing against a small threshold like `0.01` (one cent) treats near-zero balances as settled instead of generating a payment for essentially nothing.
What would change if a group needed to split an expense unequally (e.g. by percentage) instead of evenly?
Only `add_expense` would change — instead of `amount / len(participants)`, it would take an explicit share (or percentage) per participant and subtract that instead of an equal split. `settle()` doesn't care how balances were produced, only what they currently are, so it wouldn't need to change at all.