সব problem (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Model the Domain

Design an Expense Splitter

IntermediateExpensesBalancesStrategySettle up⏱ 2-3 ঘণ্টা

Splitwise এর মতো খরচ ভাগাভাগির app: সমান, নির্দিষ্ট আর শতাংশ হিসেবে ভাগ, প্রতিটা group এর balance sheet, আর সবচেয়ে কম লেনদেনে কে কাকে কত দেবে মিটিয়ে দেওয়া।

Learn mode: পুরো solution নিচে দেখানো আছে।

Problem এর পরিচয়

DifficultyIntermediate
সময়Design আর code মিলিয়ে ২–৩ ঘণ্টা; interview তে মোটামুটি ৪৫ মিনিট
আগে জানা দরকারInterface, map, integer এর হিসাব, lock সম্পর্কে একটু ধারণা
যা শিখবেSplit এর রকমভেদের জন্য Strategy, প্রতিজনের একটা net balance, টাকা না হারিয়ে ভাগ করা, greedy ভাবে হিসাব মেটানো
বাস্তবে কোথায়Splitwise, দল বেঁধে ঘুরতে যাওয়া, মেসে ভাড়া আর বাজার ভাগ, অফিসের দুপুরের খাবারের fund

Expense splitter দেখতে সাধারণ অঙ্ক মনে হয়, আর এজন্যই এটা ভালো interview প্রশ্ন: design এর আসল বিষয় হলো rule গুলো কোথায় থাকবে (প্রতিটা split এর রকম), কী রাখবে (net balance, "কে কাকে কত দেবে" এর list না), আর টাকার কোণার ক্ষেত্রগুলো (তিনজনের মধ্যে ১০০ টাকা)।

Problem statement

রহিম, নাদিয়া, তানিয়া আর সাব্বির, চার বন্ধু কক্সবাজার গেল। আলাদা আলাদা জিনিসের টাকা দিলো আলাদা জন: রহিম hotel book করলো, নাদিয়া একটা dinner এর টাকা দিলো যেটা তিনজন খেয়েছে, তানিয়া একটা boat ride এর টাকা দিলো যেটায় দুইজন উঠেছে, সাব্বির চা কিনলো।

Trip শেষে তারা দুইটা উত্তর চায়: কে কত দেবে বা ফেরত পাবে, আর সবচেয়ে কম কয়টা লেনদেনে সব মেটানো যায়?

আমরা কী বানাচ্ছি: খরচ ভাগাভাগির একটা app এর মূল অংশ: group, আলাদা আলাদা ভাবে ভাগ করা খরচ, balance আর হিসাব মেটানোর plan।

এক লাইনে: কে কী দিয়েছে আর কীভাবে ভাগ হবে সেটা লিখে রাখো, সবার balance টাকা পর্যন্ত ঠিক রাখো, আর যত কম সম্ভব লেনদেনে সব মেটানোর পরামর্শ দাও।

Requirement

Functional requirement

  1. একটা group এর member আছে।
  2. একটা খরচের একজন payer, একটা পরিমাণ আর একটা split আছে।
  3. Split এর রকম: equal (বাছাই করা member দের মধ্যে সমান), exact (প্রত্যেকের ভাগ টাকায়, যোগফল পরিমাণের সমান হতে হবে), percent (যোগফল ১০০ হতে হবে)।
  4. ভাগ গুলোর যোগফল ঠিক পরিমাণের সমান হতে হবে; ভাগ করার পর বাড়তি টাকা কারো কাছে যাবে, হারিয়ে যাবে না।
  5. ভুল খরচ (ভাগের যোগফল মেলে না, member না) ফিরিয়ে দেওয়া হবে, আর কিছুই বদলাবে না।
  6. প্রতিটা member এর net balance দেখাবে।
  7. সব balance মেটানোর মতো লেনদেনের পরামর্শ দেবে, কম লেনদেনে।

Non-functional requirement

Requirementএখানে এর মানে
ঠিক হিসাবBalance গুলোর যোগফল সবসময় শূন্য; কোনো টাকা তৈরিও হয় না, হারায়ও না
Extensibilityনতুন রকমের split (share ধরে, "বাচ্চাদের ভাগ আমি দেবো") group edit না করেই
Concurrency তেও ঠিক থাকাদুইটা phone থেকে একসাথে খরচ যোগ হলে একটাও হারাবে না
Testabilityপ্রতিটা split আর settle-up সাধারণ value দিয়ে test করা যায়

যা ধরে নিচ্ছি আর যা এখানে নেই

  • একটাই মুদ্রা (টাকা), পুরো টাকা। আসল payment নেই, notification নেই, একটা খরচ একাধিক group এ না।

যা জমা দিতে হবে

  • Entity গুলো, আর কোন rule কোথায় থাকে।
  • খরচ যোগ করা (তিন রকম split), balance আর settle-up এর code।
  • ভাগ করার পরের বাড়তি টাকা, ভুল খরচ, শূন্য যোগফল আর settle-up এর test।
Hint 1: কী রাখবে?

"A এর কাছে B কত পায়" এমন প্রতিটা জোড়া রাখলে খুব তাড়াতাড়ি বাড়ে আর মেটানোও কঠিন। প্রতিজনের জন্য একটা সংখ্যা রেখে দেখো: সব মিলিয়ে সে কত উপরে বা নিচে।

Hint 2: split এর রকম

প্রতিটা রকম (amount, people) কে ভাগে বদলায়, আর তার নিজের check আছে। মানে একটা interface, তিনটা implementation।

Hint 3: তিনজনের মধ্যে ১০০ টাকা

সবাইকে ৩৩ দাও, তারপর বাড়তি ১ টাকা একটা নির্দিষ্ট ক্রমে দিয়ে দাও। ভাগের যোগফল ঠিক ১০০ হতে হবে।

Hint 4: হিসাব মেটানো

বারবার যার কাছে সবচেয়ে বেশি পাওনা, তাকে যে সবচেয়ে বেশি দেবে সে যত সম্ভব দিয়ে দিক।

মূল entity

Entityএক বাক্যে কাজকী রাখেকী করে
GroupMember আর তাদের net balance এর মালিকmember (ক্রম সহ), প্রতিজনের balance, lockAddExpense, Balances, SettleUp
Split (interface)একটা পরিমাণকে ভাগে বদলায়রকম অনুযায়ীShares(amount, people), Describe()
Equal, Exact, Percentতিন রকম ভাগের ruleনির্দিষ্ট ভাগ বা শতাংশcheck করে আর ভাগ বের করে
Paymentপরামর্শ দেওয়া একটা লেনদেনfrom, to, amountকিছু না: একটা value

এখানে Expense class কেন নেই: app এর শুধু প্রতিটা খরচের balance এর উপর প্রভাবটা লাগে। আসল app খরচ গুলোও রাখবে (ইতিহাস আর edit এর জন্য), আর balance সেখান থেকে বের হবে; কিন্তু interview এর মূল অংশ হলো split আর balance।

কে কার সাথে কীভাবে যুক্ত: Group প্রতিটা খরচের জন্য একটা Split ব্যবহার করে (Strategy)। Balance এর মালিক সে। SettleUp balance এর একটা copy পড়ে, কিছু না বদলে Payment value ফেরত দেয়।

Diagram

Class diagram

Group- members: []string- balance: map[string]int- mu: sync.Mutex+ AddExpense(payer, amount, people, s) error+ Balances() []Payment+ SettleUp() []PaymentPayment+ From, To: string+ Amount: int«interface»Split+ Shares(amount, people) (map, error)+ Describe() stringEqual+ Shares(...)+ Describe()Exactmap[string]int+ Shares(...)+ Describe()Percent+ People: []string+ Pct: []int+ Shares(...)+ Describe()usesSettleUp returnsthree split rules behind one interface (Strategy)AddExpense asks the Split for shares and never checks which one it gotGroup keeps one net balance per person, under one lockSettleUp turns the balances into Payments
Play চাপো। আগে তিনটা split rule আর তারা যে interface implement করে সেটা জ্বলে, তারপর Group এর সাথে তার link, তারপর Group নিজে, শেষে Group যে Payment গুলো ফেরত দেয়।

কীভাবে পড়বে: Group নির্ভর করে Split interface এর উপর, কখনো সরাসরি Equal, Exact বা Percent এর উপর না; ওই dashed তীরটাই পুরো Strategy pattern। নতুন split type মানে নিচে আরেকটা box আর তার নিজের dashed ত্রিভুজ, উপরের কিছুই বদলায় না। Payment একটা সাধারণ value, SettleUp সেটা ফেরত দেয়।

চিহ্ন গুলো: + মানে public, - মানে private। ভরা diamond ◆ মানে মালিক (অংশটা মালিকের সাথেই আসে, সাথেই যায়), সাধারণ তীর মানে reference ধরে রাখে, dashed তীর মানে ব্যবহার করে, আর ফাঁপা ত্রিভুজওয়ালা dashed তীর মানে এই interface implement করে।

Sequence: একটা খরচ ঠিক, একটা বাতিল

AppGroupExactAddExpense(Nadia, 1200, Exact)lock, check membersShares(1200, people)300+400+500 = 1200Rahim 300, Nadia 400, Tania 500Nadia +1200, each -sharenilAddExpense(taxi 1200, Exact)Shares(1200, people)error: 600+500 = 1100error, balances unchanged
Play চাপো। Dinner এর exact share যোগ করলে ১২০০, তাই balance বদলায়। Taxi র share যোগ করলে ১১০০, তাই Exact error দেয় আর Group কিছুই বদলায় না।

কীভাবে পড়বে: প্রতিটা খাড়া dashed লাইন একটা object, আর সময় চলে উপর থেকে নিচে। ভরা তীর মানে call, dashed তীর মানে উত্তর ফেরত আসা, আর সরু bar গুলো দেখায় কোন object কখন কাজে ব্যস্ত। Balance ছোঁয়ার আগেই Group share গুলো চেয়ে নেয়। দ্বিতীয় call এ error আসে Exact থেকে, আর Group কিছু না লিখেই সেটা ফেরত পাঠায়: এই ক্রমের কারণেই একটা খরচ হয় পুরোটা ঢোকে, নয় একটুও না।

Strategy: ভাগের তিনটা উপায়

AddExpensepayer, amountSplitinterfaceEqual4000 / 4 = 1000 eachExact300 + 400 + 500Percent40% 400, 60% 600hotel 4000dinner 1200boat 1000shares always add up to the amount
Play চাপো। একই AddExpense call প্রতিটা পরিমাণ সেই Split এর হাতে দেয় যেটা সে পেয়েছে: hotel যায় Equal এ, dinner Exact এ, boat Percent এ। প্রত্যেকে এমন ভাগ ফেরত দেয় যার যোগফল ঠিক পরিমাণের সমান।

কীভাবে পড়বে: AddExpense কখনো দেখে না তার হাতে কোন split। সে interface এর Shares ডাকে, আর প্রতিটা রকম নিজের rule আর check চালায়। নতুন রকমের split মানে ডান দিকে নতুন একটা box, বাম দিকে কোনো change নেই।

হিসাব মেটানো

Rahim+2266 → 0Nadia-200Tania-533Sabbir-15331533533200biggest debtor pays biggest creditor3 payments, every balance is 0
Play চাপো। সাব্বিরের দেনা সবচেয়ে বেশি, তাই সে রহিমকে ১৫৩৩ দেয়; তারপর তানিয়া ৫৩৩; তারপর নাদিয়া ২০০। তিনটা লেনদেন, আর সবার balance শূন্য।

কীভাবে পড়বে: সব খরচের পরে রহিম +২২৬৬, বাকিরা minus এ। প্রতিটা ধাপে সবচেয়ে বড় দেনাদার আর সবচেয়ে বড় পাওনাদার নিয়ে তাদের মধ্যে যত সম্ভব টাকা সরানো হয়। চারজন হলে কখনো তিনটার বেশি লেনদেন লাগে না।

Design principle

  • জোড়া না, net balance রাখো। প্রতিজনের একটা সংখ্যা ঠিক রাখা সহজ (যোগফল শূন্য হতেই হবে), আর মেটানোও সহজ।
  • হয় সব, নয়তো কিছুই না। AddExpense আগে ভাগ গুলো বের করে check করে, তারপরই balance বদলায়। ফিরিয়ে দেওয়া খরচ কোনো দাগ রাখে না।
  • Open/closed। নতুন রকমের split Split implement করে; Group বদলায় না।
  • টাকার জন্য integer। ভাগ গুলো পুরো টাকায়; বাড়তি টাকা কোথায় যাবে সেটা পরিষ্কার আর প্রতিবার একই।

Design pattern

Split এর রকমের জন্য Strategy

  1. কেন: equal, exact আর percent প্রত্যেকের নিজের অঙ্ক আর নিজের check।
  2. কী সমাধান করে: AddExpense কে if splitType == … থেকে মুক্ত রাখে।
  3. না থাকলে: প্রতিটা নতুন রকম (share ধরে, কয় রাত থেকেছে ধরে) এ AddExpense edit।
  4. দাম: একটা interface আর তিনটা ছোট type; দুইটা রকম আসলেই লাভ।
  5. কোথায়: split.go; Group.AddExpense ডাকে s.Shares(amount, people)।

Greedy settle-up

এটা GoF pattern না, একটা চেনা algorithm: সবচেয়ে বড় দেনাদারকে সবচেয়ে বড় পাওনাদারের সাথে মেলাও। এটা সবসময় n − 1 এর বেশি লেনদেন ছাড়াই সবাইকে মিটিয়ে দেয়। সত্যিকারের সবচেয়ে কম সংখ্যা বের করা অনেক কঠিন সমস্যা (সাধারণভাবে NP-hard), আর আসল app গুলো greedy ই ব্যবহার করে।

Go তে পুরো solution

Project structure

splitter/
├── go.mod
├── split.go         the Split strategy: Equal, Exact, Percent, and rounding
├── group.go         Group: balances, AddExpense, SettleUp, the lock
├── group_test.go    tests, including a concurrency test
└── cmd/demo/main.go the Cox's Bazar trip

ধাপ ১: split strategy আর বাড়তি টাকা

spread সবাইকে নিচের দিকে round করা ভাগ দেয়, তারপর বাড়তি টাকা list এর ক্রমে একটা একটা করে দেয়, তাই ভাগের যোগফল সবসময় পরিমাণের সমান।

// split.go
package splitter

import (
	"errors"
	"fmt"
)

// Split is the Strategy: each way of dividing an expense turns (amount, people) into shares.
type Split interface {
	Shares(amount int, people []string) (map[string]int, error)
	Describe() string
}

// spread divides amount by weights, rounding down, and gives the leftover taka one by one
// to the first people in the list, so shares always add up exactly to the amount.
func spread(amount int, people []string, weights []int) map[string]int {
	total := 0
	for _, w := range weights {
		total += w
	}
	shares, given := map[string]int{}, 0
	for i, p := range people {
		shares[p] = amount * weights[i] / total
		given += shares[p]
	}
	for i := 0; given < amount; i++ {
		shares[people[i%len(people)]]++
		given++
	}
	return shares
}

type Equal struct{}

func (Equal) Describe() string { return "split equally" }
func (Equal) Shares(amount int, people []string) (map[string]int, error) {
	if len(people) == 0 {
		return nil, errors.New("no one to split with")
	}
	w := make([]int, len(people))
	for i := range w {
		w[i] = 1
	}
	return spread(amount, people, w), nil
}

// Exact lists each person's share in taka; they must add up to the amount.
type Exact map[string]int

func (Exact) Describe() string { return "exact split" }
func (e Exact) Shares(amount int, _ []string) (map[string]int, error) {
	sum := 0
	for _, v := range e {
		sum += v
	}
	if sum != amount {
		return nil, fmt.Errorf("exact shares add up to %d, not %d", sum, amount)
	}
	return map[string]int(e), nil
}

// Percent lists each person's percentage; they must add up to 100.
type Percent struct {
	People []string
	Pct    []int
}

func (p Percent) Describe() string { return "percent split" }
func (p Percent) Shares(amount int, _ []string) (map[string]int, error) {
	sum := 0
	for _, v := range p.Pct {
		sum += v
	}
	if sum != 100 {
		return nil, fmt.Errorf("percentages add up to %d, not 100", sum)
	}
	return spread(amount, p.People, p.Pct), nil
}

ধাপ ২: group

// group.go
package splitter

import (
	"errors"
	"fmt"
	"sync"
)

type Payment struct {
	From, To string
	Amount   int
}

// Group owns the members and one net balance per member:
// positive = others owe them, negative = they owe others. The balances always add up to 0.
type Group struct {
	mu      sync.Mutex
	members []string // order is kept for stable output
	balance map[string]int
}

func NewGroup(members ...string) *Group {
	g := &Group{members: members, balance: map[string]int{}}
	for _, m := range members {
		g.balance[m] = 0
	}
	return g
}

func (g *Group) isMember(p string) bool { _, ok := g.balance[p]; return ok }

// AddExpense records that payer paid amount, divided by the split. It changes balances
// only if the whole expense is valid.
func (g *Group) AddExpense(payer string, amount int, people []string, s Split) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if amount <= 0 {
		return errors.New("amount must be positive")
	}
	if !g.isMember(payer) {
		return fmt.Errorf("%s is not in the group", payer)
	}
	shares, err := s.Shares(amount, people)
	if err != nil {
		return err
	}
	for p := range shares {
		if !g.isMember(p) {
			return fmt.Errorf("%s is not in the group", p)
		}
	}
	g.balance[payer] += amount
	for p, share := range shares {
		g.balance[p] -= share
	}
	return nil
}

// Balances returns each member's net balance, in member order.
func (g *Group) Balances() []Payment {
	g.mu.Lock()
	defer g.mu.Unlock()
	out := make([]Payment, 0, len(g.members))
	for _, m := range g.members {
		out = append(out, Payment{From: m, Amount: g.balance[m]})
	}
	return out
}

// SettleUp suggests payments that clear every balance: the biggest debtor pays the biggest
// creditor, repeatedly. It needs at most (members - 1) payments. Balances aren't changed.
func (g *Group) SettleUp() []Payment {
	g.mu.Lock()
	left := map[string]int{}
	for k, v := range g.balance {
		left[k] = v
	}
	members := g.members
	g.mu.Unlock()

	var pays []Payment
	for {
		debtor, creditor := "", ""
		for _, m := range members { // ties go to the earlier member, so the result is stable
			if left[m] < 0 && (debtor == "" || left[m] < left[debtor]) {
				debtor = m
			}
			if left[m] > 0 && (creditor == "" || left[m] > left[creditor]) {
				creditor = m
			}
		}
		if debtor == "" || creditor == "" {
			return pays
		}
		amt := min(-left[debtor], left[creditor])
		pays = append(pays, Payment{debtor, creditor, amt})
		left[debtor] += amt
		left[creditor] -= amt
	}
}

ধাপ ৩: চালিয়ে দেখো

// cmd/demo/main.go
package main

import (
	"fmt"

	"splitter"
)

func main() {
	g := splitter.NewGroup("Rahim", "Nadia", "Tania", "Sabbir")
	all := []string{"Rahim", "Nadia", "Tania", "Sabbir"}

	expenses := []struct {
		payer, what string
		amount      int
		people      []string
		split       splitter.Split
	}{
		{"Rahim", "hotel", 4000, all, splitter.Equal{}},
		{"Nadia", "dinner", 1200, nil, splitter.Exact{"Rahim": 300, "Nadia": 400, "Tania": 500}},
		{"Tania", "boat", 1000, nil, splitter.Percent{People: []string{"Rahim", "Sabbir"}, Pct: []int{40, 60}}},
		{"Sabbir", "tea", 100, []string{"Rahim", "Tania", "Sabbir"}, splitter.Equal{}},
		{"Rahim", "taxi", 1200, nil, splitter.Exact{"Rahim": 600, "Nadia": 500}},
	}
	for i, e := range expenses {
		if err := g.AddExpense(e.payer, e.amount, e.people, e.split); err != nil {
			fmt.Printf("expense %d refused: %v\n", i+1, err)
			continue
		}
		fmt.Printf("expense %d: %s paid %d for %s, %s\n", i+1, e.payer, e.amount, e.what, e.split.Describe())
	}

	fmt.Println("balances:")
	for _, b := range g.Balances() {
		fmt.Printf("  %-6s %+6d\n", b.From, b.Amount)
	}
	pays := g.SettleUp()
	fmt.Printf("settle up (%d payments):\n", len(pays))
	for _, p := range pays {
		fmt.Printf("  %s pays %s %d\n", p.From, p.To, p.Amount)
	}
}
$ go run ./cmd/demo
expense 1: Rahim paid 4000 for hotel, split equally
expense 2: Nadia paid 1200 for dinner, exact split
expense 3: Tania paid 1000 for boat, percent split
expense 4: Sabbir paid 100 for tea, split equally
expense 5 refused: exact shares add up to 1100, not 1200
balances:
  Rahim   +2266
  Nadia    -200
  Tania    -533
  Sabbir  -1533
settle up (3 payments):
  Sabbir pays Rahim 1533
  Tania pays Rahim 533
  Nadia pays Rahim 200

Output টা পড়ি:

  • Hotel ৪০০০ সমান ভাগে: প্রত্যেকে ১০০০; রহিম দিয়েছে ৪০০০, তাই এটার পরে সে ৩০০০ উপরে।
  • Dinner ১২০০ exact: রহিম ৩০০, নাদিয়া ৪০০, তানিয়া ৫০০।
  • Boat ১০০০, ৪০/৬০: রহিম ৪০০, সাব্বির ৬০০।
  • তিনজনের মধ্যে চা ১০০: প্রত্যেকে ৩৩, আর বাড়তি ১ টাকা প্রথমজনের (রহিম ৩৪)।
  • Taxi ফিরিয়ে দেওয়া: ৬০০ + ৫০০ = ১১০০, ১২০০ না। কিছুই বদলায়নি।
  • Balance: রহিম দিয়েছে ৪০০০ আর তার ভাগ ১০০০ + ৩০০ + ৪০০ + ৩৪ = ১৭৩৪, তাই +২২৬৬। চারটা সংখ্যার যোগফল ০।
  • হিসাব মেটানো: তিনটা লেনদেন, সবগুলো রহিমের কাছে, বড়টা আগে।

বাকি পাঁচটা language

from __future__ import annotations

import threading
from typing import Protocol


class SplitError(Exception):
    pass


def spread(amount: int, people: list[str], weights: list[int]) -> dict[str, int]:
    """Divide by weights, rounding down; leftover taka go one by one to the first people."""
    total = sum(weights)
    shares = {p: amount * w // total for p, w in zip(people, weights)}
    given, i = sum(shares.values()), 0
    while given < amount:
        shares[people[i % len(people)]] += 1
        given, i = given + 1, i + 1
    return shares


class Split(Protocol):
    """The Strategy: each way of dividing an expense turns (amount, people) into shares."""
    def shares(self, amount: int, people: list[str]) -> dict[str, int]: ...
    def describe(self) -> str: ...


class Equal:
    def describe(self) -> str:
        return "split equally"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        if not people:
            raise SplitError("no one to split with")
        return spread(amount, people, [1] * len(people))


class Exact:
    def __init__(self, shares: dict[str, int]):
        self._shares = shares

    def describe(self) -> str:
        return "exact split"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        total = sum(self._shares.values())
        if total != amount:
            raise SplitError(f"exact shares add up to {total}, not {amount}")
        return dict(self._shares)


class Percent:
    def __init__(self, people: list[str], pct: list[int]):
        self.people, self.pct = people, pct

    def describe(self) -> str:
        return "percent split"

    def shares(self, amount: int, people: list[str]) -> dict[str, int]:
        if sum(self.pct) != 100:
            raise SplitError(f"percentages add up to {sum(self.pct)}, not 100")
        return spread(amount, self.people, self.pct)


class Group:
    """Net balance per member: positive = others owe them. Balances always add up to 0."""

    def __init__(self, *members: str):
        self._lock = threading.Lock()
        self.members = list(members)
        self._balance = {m: 0 for m in members}

    def add_expense(self, payer: str, amount: int, people: list[str], split: Split) -> None:
        with self._lock:
            if amount <= 0:
                raise SplitError("amount must be positive")
            if payer not in self._balance:
                raise SplitError(f"{payer} is not in the group")
            shares = split.shares(amount, people)
            for p in shares:
                if p not in self._balance:
                    raise SplitError(f"{p} is not in the group")
            self._balance[payer] += amount  # only now, when everything is valid
            for p, share in shares.items():
                self._balance[p] -= share

    def balances(self) -> list[tuple[str, int]]:
        with self._lock:
            return [(m, self._balance[m]) for m in self.members]

    def settle_up(self) -> list[tuple[str, str, int]]:
        """Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member."""
        with self._lock:
            left = dict(self._balance)
        pays = []
        while True:
            debtors = [m for m in self.members if left[m] < 0]
            creditors = [m for m in self.members if left[m] > 0]
            if not debtors or not creditors:
                return pays
            d = min(debtors, key=lambda m: left[m])  # min() keeps the first of equals
            c = max(creditors, key=lambda m: left[m])
            amt = min(-left[d], left[c])
            pays.append((d, c, amt))
            left[d] += amt
            left[c] -= amt


g = Group("Rahim", "Nadia", "Tania", "Sabbir")
everyone = ["Rahim", "Nadia", "Tania", "Sabbir"]
expenses = [
    ("Rahim", "hotel", 4000, everyone, Equal()),
    ("Nadia", "dinner", 1200, [], Exact({"Rahim": 300, "Nadia": 400, "Tania": 500})),
    ("Tania", "boat", 1000, [], Percent(["Rahim", "Sabbir"], [40, 60])),
    ("Sabbir", "tea", 100, ["Rahim", "Tania", "Sabbir"], Equal()),
    ("Rahim", "taxi", 1200, [], Exact({"Rahim": 600, "Nadia": 500})),
]
for i, (payer, what, amount, people, split) in enumerate(expenses, 1):
    try:
        g.add_expense(payer, amount, people, split)
        print(f"expense {i}: {payer} paid {amount} for {what}, {split.describe()}")
    except SplitError as e:
        print(f"expense {i} refused: {e}")

print("balances:")
for m, b in g.balances():
    print(f"  {m:<6} {b:+6d}")
pays = g.settle_up()
print(f"settle up ({len(pays)} payments):")
for frm, to, amt in pays:
    print(f"  {frm} pays {to} {amt}")

Language ভেদে কী আলাদা: প্রতিটা version এ একই Split interface (Python এ Protocol, Rust এ trait, C++ এ abstract class)। Settle-up এ সমান হলে ছয়টাতেই member এর ক্রম দেখে ঠিক হয়, এজন্যই output হুবহু এক। Rust এর balance থাকে Mutex এর ভেতরে; Java synchronized ব্যবহার করে আর মেটানোর আগে balance copy করে নেয়; TypeScript এর lock লাগে না।

Testing

// group_test.go
package splitter

import (
	"fmt"
	"sync"
	"testing"
)

func bal(g *Group) map[string]int {
	out := map[string]int{}
	for _, b := range g.Balances() {
		out[b.From] = b.Amount
	}
	return out
}

func TestEqualSplitRoundsWithoutLosingTaka(t *testing.T) {
	s, _ := Equal{}.Shares(100, []string{"A", "B", "C"})
	if s["A"] != 34 || s["B"] != 33 || s["C"] != 33 {
		t.Fatalf("got %v", s)
	}
}

func TestPercentMustAddTo100(t *testing.T) {
	if _, err := (Percent{[]string{"A", "B"}, []int{40, 50}}).Shares(1000, nil); err == nil {
		t.Fatal("expected an error")
	}
}

func TestInvalidExpenseChangesNothing(t *testing.T) {
	g := NewGroup("A", "B")
	if err := g.AddExpense("A", 100, nil, Exact{"A": 50, "B": 40}); err == nil {
		t.Fatal("expected an error")
	}
	if err := g.AddExpense("A", 100, nil, Exact{"A": 50, "Z": 50}); err == nil {
		t.Fatal("non-member share should be refused")
	}
	for k, v := range bal(g) {
		if v != 0 {
			t.Fatalf("%s balance %d after refused expenses", k, v)
		}
	}
}

func TestBalancesAlwaysSumToZero(t *testing.T) {
	g := NewGroup("A", "B", "C")
	g.AddExpense("A", 100, []string{"A", "B", "C"}, Equal{})
	g.AddExpense("B", 77, []string{"A", "C"}, Equal{})
	g.AddExpense("C", 250, nil, Percent{[]string{"A", "B"}, []int{30, 70}})
	sum := 0
	for _, v := range bal(g) {
		sum += v
	}
	if sum != 0 {
		t.Fatalf("balances sum to %d", sum)
	}
}

func TestSettleUpClearsEverything(t *testing.T) {
	g := NewGroup("A", "B", "C", "D")
	g.AddExpense("A", 900, []string{"A", "B", "C"}, Equal{})
	g.AddExpense("D", 400, []string{"B", "D"}, Equal{})
	left := bal(g)
	pays := g.SettleUp()
	for _, p := range pays {
		left[p.From] += p.Amount
		left[p.To] -= p.Amount
	}
	for k, v := range left {
		if v != 0 {
			t.Fatalf("%s left with %d", k, v)
		}
	}
	if len(pays) > 3 {
		t.Fatalf("%d payments for 4 people", len(pays))
	}
}

// Expenses added from many goroutines must all count. Run with -race.
func TestConcurrentExpenses(t *testing.T) {
	g := NewGroup("A", "B")
	var wg sync.WaitGroup
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := g.AddExpense("A", 10, []string{"A", "B"}, Equal{}); err != nil {
				panic(fmt.Sprint(i, err))
			}
		}()
	}
	wg.Wait()
	if b := bal(g); b["A"] != 500 || b["B"] != -500 {
		t.Fatalf("got %v", b)
	}
}
$ go test -race -v ./...
=== RUN   TestEqualSplitRoundsWithoutLosingTaka
--- PASS: TestEqualSplitRoundsWithoutLosingTaka (0.00s)
=== RUN   TestPercentMustAddTo100
--- PASS: TestPercentMustAddTo100 (0.00s)
=== RUN   TestInvalidExpenseChangesNothing
--- PASS: TestInvalidExpenseChangesNothing (0.00s)
=== RUN   TestBalancesAlwaysSumToZero
--- PASS: TestBalancesAlwaysSumToZero (0.00s)
=== RUN   TestSettleUpClearsEverything
--- PASS: TestSettleUpClearsEverything (0.00s)
=== RUN   TestConcurrentExpenses
--- PASS: TestConcurrentExpenses (0.00s)
PASS
ok  	splitter	1.012s
?   	splitter/cmd/demo	[no test files]
Testযা check করে
TestEqualSplitRoundsWithoutLosingTakaতিনজনের মধ্যে ১০০ হলো ৩৪/৩৩/৩৩
TestPercentMustAddTo100৪০ + ৫০ ফিরিয়ে দেওয়া হয়
TestInvalidExpenseChangesNothingভুল যোগফল আর member না হলে balance শূন্যই থাকে
TestBalancesAlwaysSumToZeroঅদ্ভুত পরিমাণ আর split এর পরেও balance এর যোগফল ০
TestSettleUpClearsEverythingপরামর্শের লেনদেন গুলো করলে সবাই শূন্য, ৩টার বেশি লেনদেন ছাড়াই
TestConcurrentExpenses১০০টা goroutine খরচ যোগ করে: একটাও হারায় না

AddExpense থেকে lock সরালে race detector ১৯টা data race দেখিয়েছে, আর concurrency test fail করেছে।

Design review

যা ভালো: প্রতিজনের একটা সংখ্যা model টাকে ছোট আর check করার মতো রাখে; বদলানোর আগে check; Strategy split এর rule গুলো আলাদা রাখে।

"যদি…"কী বদলাতে হবে
একটা খরচ edit বা deleteখরচ গুলো রাখো আর সেখান থেকে balance আবার বানাও (বা উল্টো একটা খরচ যোগ করো)। Balance তখন একটা cache।
একাধিক মুদ্রাপ্রতিটা মুদ্রার জন্য আলাদা balance; মুদ্রা ধরে মেটাও, বা রেখে দেওয়া rate এ বদলাও।
Share ধরে ভাগ (২:১:১)নতুন একটা ByShares type যেটা weight দিয়ে spread ডাকে।
বিশাল groupএই loop দিয়ে settle-up O(n²); দুইটা heap (দেনাদার, পাওনাদার) দিয়ে O(n log n)।
কয়েকটা group জুড়ে দেনা মেলানোমেটানোর আগে প্রতিজনের balance group গুলো জুড়ে যোগ করো; এটা যতটা technical, ততটাই product এর সিদ্ধান্ত।

Interview প্রশ্ন

Practice প্রশ্ন

Beginner

  1. কে কাকে কত দেবে এমন জোড়া না রেখে প্রতিজনের একটা net balance কেন?
  2. তিনজনের মধ্যে ১০০ টাকা কীভাবে ভাগ করবে?

Intermediate

  1. "Share ধরে ভাগ" কীভাবে যোগ করবে?
  2. ভুল খরচ যেন কিছুই না বদলায়, কীভাবে নিশ্চিত করবে?

Advanced

  1. Greedy settle-up কি সবসময় সবচেয়ে ভালো? সবচেয়ে ভালোটা পেতে কী খরচ?
  2. পুরনো একটা খরচ edit করা কীভাবে support করবে?

Design এর সাধারণ ভুল

  • Floating-point এ টাকা, যেটা পয়সা এদিক-ওদিক করে।
  • ভাগ করার সময় বাড়তি টাকা হারিয়ে ফেলা।
  • Group এর ভেতরে split এর রকম দেখে switch।
  • পুরো খরচ check করার আগেই balance বদলানো।

উত্তর

১. Net balance কেন?

জোড়া বাড়ে n² হারে, আর দুইদিকের দেনা এমনিতেই কাটাকাটি করতে হয়। মেটানোর জন্য প্রতিজনের একটা সংখ্যাই যথেষ্ট, আর যোগফল শূন্যের check bug ধরে ফেলে।

২. তিনজনের মধ্যে ১০০?

Integer ভাগে প্রত্যেকে ৩৩ (মোট ৯৯), তারপর বাড়তি ১ টাকা একটা নির্দিষ্ট ক্রমে প্রথমজনকে: ৩৪/৩৩/৩৩। ভাগের যোগফল সবসময় পরিমাণের সমান।

৩. Share ধরে ভাগ?

Split implement করা নতুন একটা type, যেটা spread দিয়ে weight কে ভাগে বদলায়। আর কিছুই বদলায় না।

৪. ভুল খরচ কিছুই বদলায় না?

আগে সব ভাগ বের করে check করো (যোগফল, member কিনা), তারপরই balance এ হাত দাও, সবকিছু একটা lock এর ভেতরে।

৫. Greedy কি সবসময় সবচেয়ে ভালো?

সবসময় না: সবচেয়ে কম লেনদেনের সংখ্যা বের করা সাধারণভাবে NP-hard (মানুষ গুলোকে শূন্য-যোগফলের ছোট দলে ভাগ করার সাথে সম্পর্কিত)। Greedy সহজ, দ্রুত, আর n − 1 এর বেশি লেনদেন লাগে না, যা আসল app এর জন্য যথেষ্ট।

৬. পুরনো খরচ edit?

খরচ গুলোকেই সত্যের উৎস রাখো আর balance কে তা থেকে বের করা জিনিস হিসেবে ধরো: হয় খরচের list থেকে আবার হিসাব করো, নয়তো একটা transaction এ পুরনো খরচটা উল্টো করে আর নতুনটা সোজা করে বসাও।

Interviewer রা যা দেখেন: net balance এর model, split এর রকমের জন্য Strategy, নিখুঁত ভাগ, বদলানোর আগে check, আর বুঝেশুনে একটা settle-up।

নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।