Practice · Low Level Design · Model the Domain
Design an Expense Splitter
Splitwise এর মতো খরচ ভাগাভাগির app: সমান, নির্দিষ্ট আর শতাংশ হিসেবে ভাগ, প্রতিটা group এর balance sheet, আর সবচেয়ে কম লেনদেনে কে কাকে কত দেবে মিটিয়ে দেওয়া।
Learn mode: পুরো solution নিচে দেখানো আছে।
Problem এর পরিচয়
| Difficulty | Intermediate |
| সময় | 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
- একটা group এর member আছে।
- একটা খরচের একজন payer, একটা পরিমাণ আর একটা split আছে।
- Split এর রকম: equal (বাছাই করা member দের মধ্যে সমান), exact (প্রত্যেকের ভাগ টাকায়, যোগফল পরিমাণের সমান হতে হবে), percent (যোগফল ১০০ হতে হবে)।
- ভাগ গুলোর যোগফল ঠিক পরিমাণের সমান হতে হবে; ভাগ করার পর বাড়তি টাকা কারো কাছে যাবে, হারিয়ে যাবে না।
- ভুল খরচ (ভাগের যোগফল মেলে না, member না) ফিরিয়ে দেওয়া হবে, আর কিছুই বদলাবে না।
- প্রতিটা member এর net balance দেখাবে।
- সব 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 | এক বাক্যে কাজ | কী রাখে | কী করে |
|---|---|---|---|
Group | Member আর তাদের net balance এর মালিক | member (ক্রম সহ), প্রতিজনের balance, lock | AddExpense, 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 নির্ভর করে Split interface এর উপর, কখনো সরাসরি Equal, Exact বা Percent এর উপর না; ওই dashed তীরটাই পুরো Strategy pattern। নতুন split type মানে নিচে আরেকটা box আর তার নিজের dashed ত্রিভুজ, উপরের কিছুই বদলায় না। Payment একটা সাধারণ value, SettleUp সেটা ফেরত দেয়।
চিহ্ন গুলো: + মানে public, - মানে private। ভরা diamond ◆ মানে মালিক (অংশটা মালিকের সাথেই আসে, সাথেই যায়), সাধারণ তীর মানে reference ধরে রাখে, dashed তীর মানে ব্যবহার করে, আর ফাঁপা ত্রিভুজওয়ালা dashed তীর মানে এই interface implement করে।
Sequence: একটা খরচ ঠিক, একটা বাতিল
কীভাবে পড়বে: প্রতিটা খাড়া dashed লাইন একটা object, আর সময় চলে উপর থেকে নিচে। ভরা তীর মানে call, dashed তীর মানে উত্তর ফেরত আসা, আর সরু bar গুলো দেখায় কোন object কখন কাজে ব্যস্ত। Balance ছোঁয়ার আগেই Group share গুলো চেয়ে নেয়। দ্বিতীয় call এ error আসে Exact থেকে, আর Group কিছু না লিখেই সেটা ফেরত পাঠায়: এই ক্রমের কারণেই একটা খরচ হয় পুরোটা ঢোকে, নয় একটুও না।
Strategy: ভাগের তিনটা উপায়
কীভাবে পড়বে: AddExpense কখনো দেখে না তার হাতে কোন split। সে interface এর Shares ডাকে, আর প্রতিটা রকম নিজের rule আর check চালায়। নতুন রকমের split মানে ডান দিকে নতুন একটা box, বাম দিকে কোনো change নেই।
হিসাব মেটানো
কীভাবে পড়বে: সব খরচের পরে রহিম +২২৬৬, বাকিরা minus এ। প্রতিটা ধাপে সবচেয়ে বড় দেনাদার আর সবচেয়ে বড় পাওনাদার নিয়ে তাদের মধ্যে যত সম্ভব টাকা সরানো হয়। চারজন হলে কখনো তিনটার বেশি লেনদেন লাগে না।
Design principle
- জোড়া না, net balance রাখো। প্রতিজনের একটা সংখ্যা ঠিক রাখা সহজ (যোগফল শূন্য হতেই হবে), আর মেটানোও সহজ।
- হয় সব, নয়তো কিছুই না।
AddExpenseআগে ভাগ গুলো বের করে check করে, তারপরই balance বদলায়। ফিরিয়ে দেওয়া খরচ কোনো দাগ রাখে না। - Open/closed। নতুন রকমের split
Splitimplement করে;Groupবদলায় না। - টাকার জন্য integer। ভাগ গুলো পুরো টাকায়; বাড়তি টাকা কোথায় যাবে সেটা পরিষ্কার আর প্রতিবার একই।
Design pattern
Split এর রকমের জন্য Strategy
- কেন: equal, exact আর percent প্রত্যেকের নিজের অঙ্ক আর নিজের check।
- কী সমাধান করে:
AddExpenseকেif splitType == …থেকে মুক্ত রাখে। - না থাকলে: প্রতিটা নতুন রকম (share ধরে, কয় রাত থেকেছে ধরে) এ
AddExpenseedit। - দাম: একটা interface আর তিনটা ছোট type; দুইটা রকম আসলেই লাভ।
- কোথায়:
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}")// Divide by weights, rounding down; leftover taka go one by one to the first people.
function spread(amount: number, people: string[], weights: number[]): Map<string, number> {
const total = weights.reduce((a, b) => a + b, 0);
const shares = new Map(people.map((p, i) => [p, Math.floor((amount * weights[i]!) / total)] as [string, number]));
let given = [...shares.values()].reduce((a, b) => a + b, 0);
for (let i = 0; given < amount; i++, given++) {
const p = people[i % people.length]!;
shares.set(p, shares.get(p)! + 1);
}
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
interface Split {
shares(amount: number, people: string[]): Map<string, number>;
describe(): string;
}
class Equal implements Split {
describe() {
return "split equally";
}
shares(amount: number, people: string[]) {
if (people.length === 0) throw new Error("no one to split with");
return spread(amount, people, people.map(() => 1));
}
}
class Exact implements Split {
readonly given: Map<string, number>;
constructor(given: Record<string, number>) {
this.given = new Map(Object.entries(given));
}
describe() {
return "exact split";
}
shares(amount: number) {
const total = [...this.given.values()].reduce((a, b) => a + b, 0);
if (total !== amount) throw new Error(`exact shares add up to ${total}, not ${amount}`);
return new Map(this.given);
}
}
class Percent implements Split {
readonly people: string[];
readonly pct: number[];
constructor(people: string[], pct: number[]) {
this.people = people;
this.pct = pct;
}
describe() {
return "percent split";
}
shares(amount: number) {
const total = this.pct.reduce((a, b) => a + b, 0);
if (total !== 100) throw new Error(`percentages add up to ${total}, not 100`);
return spread(amount, this.people, this.pct);
}
}
// Net balance per member: positive = others owe them. Balances always add up to 0.
// No lock: one thread, and nothing here awaits.
class Group {
readonly members: string[];
readonly #balance: Map<string, number>;
constructor(...members: string[]) {
this.members = members;
this.#balance = new Map(members.map((m) => [m, 0]));
}
addExpense(payer: string, amount: number, people: string[], split: Split): void {
if (amount <= 0) throw new Error("amount must be positive");
if (!this.#balance.has(payer)) throw new Error(`${payer} is not in the group`);
const shares = split.shares(amount, people);
for (const p of shares.keys()) if (!this.#balance.has(p)) throw new Error(`${p} is not in the group`);
this.#balance.set(payer, this.#balance.get(payer)! + amount); // only now, when everything is valid
for (const [p, s] of shares) this.#balance.set(p, this.#balance.get(p)! - s);
}
balances(): [string, number][] {
return this.members.map((m) => [m, this.#balance.get(m)!]);
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
settleUp(): [string, string, number][] {
const left = new Map(this.#balance);
const pays: [string, string, number][] = [];
for (;;) {
let debtor = "";
let creditor = "";
for (const m of this.members) {
if (left.get(m)! < 0 && (debtor === "" || left.get(m)! < left.get(debtor)!)) debtor = m;
if (left.get(m)! > 0 && (creditor === "" || left.get(m)! > left.get(creditor)!)) creditor = m;
}
if (debtor === "" || creditor === "") return pays;
const amt = Math.min(-left.get(debtor)!, left.get(creditor)!);
pays.push([debtor, creditor, amt]);
left.set(debtor, left.get(debtor)! + amt);
left.set(creditor, left.get(creditor)! - amt);
}
}
}
const g = new Group("Rahim", "Nadia", "Tania", "Sabbir");
const everyone = ["Rahim", "Nadia", "Tania", "Sabbir"];
const expenses: [string, string, number, string[], Split][] = [
["Rahim", "hotel", 4000, everyone, new Equal()],
["Nadia", "dinner", 1200, [], new Exact({ Rahim: 300, Nadia: 400, Tania: 500 })],
["Tania", "boat", 1000, [], new Percent(["Rahim", "Sabbir"], [40, 60])],
["Sabbir", "tea", 100, ["Rahim", "Tania", "Sabbir"], new Equal()],
["Rahim", "taxi", 1200, [], new Exact({ Rahim: 600, Nadia: 500 })],
];
expenses.forEach(([payer, what, amount, people, split], i) => {
try {
g.addExpense(payer, amount, people, split);
console.log(`expense ${i + 1}: ${payer} paid ${amount} for ${what}, ${split.describe()}`);
} catch (e) {
console.log(`expense ${i + 1} refused: ${(e as Error).message}`);
}
});
console.log("balances:");
for (const [m, b] of g.balances()) console.log(` ${m.padEnd(6)} ${(b >= 0 ? "+" + b : String(b)).padStart(6)}`);
const pays = g.settleUp();
console.log(`settle up (${pays.length} payments):`);
for (const [from, to, amt] of pays) console.log(` ${from} pays ${to} ${amt}`);use std::collections::HashMap;
use std::sync::Mutex;
// Divide by weights, rounding down; leftover taka go one by one to the first people.
fn spread(amount: i64, people: &[&'static str], weights: &[i64]) -> HashMap<&'static str, i64> {
let total: i64 = weights.iter().sum();
let mut shares: HashMap<_, _> = people.iter().zip(weights).map(|(&p, &w)| (p, amount * w / total)).collect();
let mut given: i64 = shares.values().sum();
let mut i = 0;
while given < amount {
*shares.get_mut(people[i % people.len()]).unwrap() += 1;
given += 1;
i += 1;
}
shares
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
trait Split {
fn shares(&self, amount: i64, people: &[&'static str]) -> Result<HashMap<&'static str, i64>, String>;
fn describe(&self) -> &str;
}
struct Equal;
impl Split for Equal {
fn describe(&self) -> &str { "split equally" }
fn shares(&self, amount: i64, people: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
if people.is_empty() { return Err("no one to split with".into()); }
Ok(spread(amount, people, &vec![1; people.len()]))
}
}
struct Exact(Vec<(&'static str, i64)>);
impl Split for Exact {
fn describe(&self) -> &str { "exact split" }
fn shares(&self, amount: i64, _: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
let total: i64 = self.0.iter().map(|(_, v)| v).sum();
if total != amount { return Err(format!("exact shares add up to {total}, not {amount}")); }
Ok(self.0.iter().copied().collect())
}
}
struct Percent { people: Vec<&'static str>, pct: Vec<i64> }
impl Split for Percent {
fn describe(&self) -> &str { "percent split" }
fn shares(&self, amount: i64, _: &[&'static str]) -> Result<HashMap<&'static str, i64>, String> {
let total: i64 = self.pct.iter().sum();
if total != 100 { return Err(format!("percentages add up to {total}, not 100")); }
Ok(spread(amount, &self.people, &self.pct))
}
}
// Net balance per member: positive = others owe them. Balances always add up to 0.
struct Group { members: Vec<&'static str>, balance: Mutex<HashMap<&'static str, i64>> }
impl Group {
fn new(members: &[&'static str]) -> Self {
Group { members: members.to_vec(), balance: Mutex::new(members.iter().map(|&m| (m, 0)).collect()) }
}
fn add_expense(&self, payer: &str, amount: i64, people: &[&'static str], split: &dyn Split) -> Result<(), String> {
let mut bal = self.balance.lock().unwrap();
if amount <= 0 { return Err("amount must be positive".into()); }
if !bal.contains_key(payer) { return Err(format!("{payer} is not in the group")); }
let shares = split.shares(amount, people)?;
if let Some(p) = shares.keys().find(|p| !bal.contains_key(*p)) { return Err(format!("{p} is not in the group")); }
*bal.get_mut(payer).unwrap() += amount; // only now, when everything is valid
for (p, s) in shares { *bal.get_mut(p).unwrap() -= s; }
Ok(())
}
fn balances(&self) -> Vec<(&'static str, i64)> {
let bal = self.balance.lock().unwrap();
self.members.iter().map(|&m| (m, bal[m])).collect()
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
fn settle_up(&self) -> Vec<(&'static str, &'static str, i64)> {
let mut left = self.balance.lock().unwrap().clone();
let mut pays = vec![];
loop {
let (mut debtor, mut creditor): (Option<&'static str>, Option<&'static str>) = (None, None);
for &m in &self.members {
if left[m] < 0 && debtor.map_or(true, |d| left[m] < left[d]) { debtor = Some(m); }
if left[m] > 0 && creditor.map_or(true, |c| left[m] > left[c]) { creditor = Some(m); }
}
let (Some(d), Some(c)) = (debtor, creditor) else { return pays };
let amt = (-left[d]).min(left[c]);
pays.push((d, c, amt));
*left.get_mut(d).unwrap() += amt;
*left.get_mut(c).unwrap() -= amt;
}
}
}
fn main() {
let everyone = ["Rahim", "Nadia", "Tania", "Sabbir"];
let g = Group::new(&everyone);
let expenses: Vec<(&str, &str, i64, Vec<&'static str>, Box<dyn Split>)> = vec![
("Rahim", "hotel", 4000, everyone.to_vec(), Box::new(Equal)),
("Nadia", "dinner", 1200, vec![], Box::new(Exact(vec![("Rahim", 300), ("Nadia", 400), ("Tania", 500)]))),
("Tania", "boat", 1000, vec![], Box::new(Percent { people: vec!["Rahim", "Sabbir"], pct: vec![40, 60] })),
("Sabbir", "tea", 100, vec!["Rahim", "Tania", "Sabbir"], Box::new(Equal)),
("Rahim", "taxi", 1200, vec![], Box::new(Exact(vec![("Rahim", 600), ("Nadia", 500)]))),
];
for (i, (payer, what, amount, people, split)) in expenses.iter().enumerate() {
match g.add_expense(payer, *amount, people, split.as_ref()) {
Ok(()) => println!("expense {}: {payer} paid {amount} for {what}, {}", i + 1, split.describe()),
Err(e) => println!("expense {} refused: {e}", i + 1),
}
}
println!("balances:");
for (m, b) in g.balances() { println!(" {m:<6} {b:+6}"); }
let pays = g.settle_up();
println!("settle up ({} payments):", pays.len());
for (from, to, amt) in pays { println!(" {from} pays {to} {amt}"); }
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Main {
static final class SplitException extends Exception {
private static final long serialVersionUID = 1L;
SplitException(String msg) { super(msg); }
}
// Divide by weights, rounding down; leftover taka go one by one to the first people.
static Map<String, Integer> spread(int amount, List<String> people, List<Integer> weights) {
int total = weights.stream().mapToInt(Integer::intValue).sum();
Map<String, Integer> shares = new LinkedHashMap<>();
int given = 0;
for (int i = 0; i < people.size(); i++) {
int s = amount * weights.get(i) / total;
shares.put(people.get(i), s);
given += s;
}
for (int i = 0; given < amount; i++, given++) shares.merge(people.get(i % people.size()), 1, Integer::sum);
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
interface Split {
Map<String, Integer> shares(int amount, List<String> people) throws SplitException;
String describe();
}
static final class Equal implements Split {
public String describe() { return "split equally"; }
public Map<String, Integer> shares(int amount, List<String> people) throws SplitException {
if (people.isEmpty()) throw new SplitException("no one to split with");
return spread(amount, people, people.stream().map(p -> 1).toList());
}
}
record Exact(Map<String, Integer> given) implements Split {
public String describe() { return "exact split"; }
public Map<String, Integer> shares(int amount, List<String> people) throws SplitException {
int total = given.values().stream().mapToInt(Integer::intValue).sum();
if (total != amount) throw new SplitException("exact shares add up to " + total + ", not " + amount);
return new HashMap<>(given);
}
}
record Percent(List<String> people, List<Integer> pct) implements Split {
public String describe() { return "percent split"; }
public Map<String, Integer> shares(int amount, List<String> ignored) throws SplitException {
int total = pct.stream().mapToInt(Integer::intValue).sum();
if (total != 100) throw new SplitException("percentages add up to " + total + ", not 100");
return spread(amount, people, pct);
}
}
record Payment(String from, String to, int amount) {}
// Net balance per member: positive = others owe them. Balances always add up to 0.
static final class Group {
final List<String> members;
private final Map<String, Integer> balance = new HashMap<>();
Group(String... members) {
this.members = List.of(members);
for (String m : members) balance.put(m, 0);
}
synchronized void addExpense(String payer, int amount, List<String> people, Split split) throws SplitException {
if (amount <= 0) throw new SplitException("amount must be positive");
if (!balance.containsKey(payer)) throw new SplitException(payer + " is not in the group");
Map<String, Integer> shares = split.shares(amount, people);
for (String p : shares.keySet()) if (!balance.containsKey(p)) throw new SplitException(p + " is not in the group");
balance.merge(payer, amount, Integer::sum); // only now, when everything is valid
shares.forEach((p, s) -> balance.merge(p, -s, Integer::sum));
}
synchronized int balanceOf(String m) { return balance.get(m); }
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
List<Payment> settleUp() {
Map<String, Integer> left;
synchronized (this) { left = new HashMap<>(balance); }
List<Payment> pays = new ArrayList<>();
while (true) {
String debtor = null, creditor = null;
for (String m : members) {
if (left.get(m) < 0 && (debtor == null || left.get(m) < left.get(debtor))) debtor = m;
if (left.get(m) > 0 && (creditor == null || left.get(m) > left.get(creditor))) creditor = m;
}
if (debtor == null || creditor == null) return pays;
int amt = Math.min(-left.get(debtor), left.get(creditor));
pays.add(new Payment(debtor, creditor, amt));
left.merge(debtor, amt, Integer::sum);
left.merge(creditor, -amt, Integer::sum);
}
}
}
record Expense(String payer, String what, int amount, List<String> people, Split split) {}
public static void main(String[] args) {
Group g = new Group("Rahim", "Nadia", "Tania", "Sabbir");
List<String> everyone = List.of("Rahim", "Nadia", "Tania", "Sabbir");
List<Expense> expenses = List.of(
new Expense("Rahim", "hotel", 4000, everyone, new Equal()),
new Expense("Nadia", "dinner", 1200, List.of(), new Exact(Map.of("Rahim", 300, "Nadia", 400, "Tania", 500))),
new Expense("Tania", "boat", 1000, List.of(), new Percent(List.of("Rahim", "Sabbir"), List.of(40, 60))),
new Expense("Sabbir", "tea", 100, List.of("Rahim", "Tania", "Sabbir"), new Equal()),
new Expense("Rahim", "taxi", 1200, List.of(), new Exact(Map.of("Rahim", 600, "Nadia", 500))));
for (int i = 0; i < expenses.size(); i++) {
Expense e = expenses.get(i);
try {
g.addExpense(e.payer(), e.amount(), e.people(), e.split());
System.out.printf("expense %d: %s paid %d for %s, %s%n", i + 1, e.payer(), e.amount(), e.what(), e.split().describe());
} catch (SplitException ex) {
System.out.printf("expense %d refused: %s%n", i + 1, ex.getMessage());
}
}
System.out.println("balances:");
for (String m : g.members) System.out.printf(" %-6s %+6d%n", m, g.balanceOf(m));
List<Payment> pays = g.settleUp();
System.out.printf("settle up (%d payments):%n", pays.size());
for (Payment p : pays) System.out.printf(" %s pays %s %d%n", p.from(), p.to(), p.amount());
}
}#include <algorithm>
#include <cstdio>
#include <map>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <tuple>
#include <vector>
using Shares = std::map<std::string, int>;
struct SplitError : std::runtime_error {
using std::runtime_error::runtime_error;
};
// Divide by weights, rounding down; leftover taka go one by one to the first people.
Shares spread(int amount, const std::vector<std::string>& people, const std::vector<int>& weights) {
int total = 0, given = 0;
for (int w : weights) total += w;
Shares shares;
for (size_t i = 0; i < people.size(); i++) given += shares[people[i]] = amount * weights[i] / total;
for (size_t i = 0; given < amount; i++, given++) shares[people[i % people.size()]]++;
return shares;
}
// The Strategy: each way of dividing an expense turns (amount, people) into shares.
class Split {
public:
virtual ~Split() = default;
virtual Shares shares(int amount, const std::vector<std::string>& people) const = 0;
virtual std::string describe() const = 0;
};
class Equal : public Split {
public:
std::string describe() const override { return "split equally"; }
Shares shares(int amount, const std::vector<std::string>& people) const override {
if (people.empty()) throw SplitError("no one to split with");
return spread(amount, people, std::vector<int>(people.size(), 1));
}
};
class Exact : public Split {
public:
explicit Exact(Shares given) : given_(std::move(given)) {}
std::string describe() const override { return "exact split"; }
Shares shares(int amount, const std::vector<std::string>&) const override {
int total = 0;
for (const auto& [p, v] : given_) total += v;
if (total != amount)
throw SplitError("exact shares add up to " + std::to_string(total) + ", not " + std::to_string(amount));
return given_;
}
private:
Shares given_;
};
class Percent : public Split {
public:
Percent(std::vector<std::string> people, std::vector<int> pct) : people_(std::move(people)), pct_(std::move(pct)) {}
std::string describe() const override { return "percent split"; }
Shares shares(int amount, const std::vector<std::string>&) const override {
int total = 0;
for (int p : pct_) total += p;
if (total != 100) throw SplitError("percentages add up to " + std::to_string(total) + ", not 100");
return spread(amount, people_, pct_);
}
private:
std::vector<std::string> people_;
std::vector<int> pct_;
};
// Net balance per member: positive = others owe them. Balances always add up to 0.
class Group {
public:
explicit Group(std::vector<std::string> members) : members(std::move(members)) {
for (const auto& m : this->members) balance_[m] = 0;
}
const std::vector<std::string> members;
void addExpense(const std::string& payer, int amount, const std::vector<std::string>& people, const Split& split) {
std::lock_guard lock(mu_);
if (amount <= 0) throw SplitError("amount must be positive");
if (!balance_.count(payer)) throw SplitError(payer + " is not in the group");
Shares shares = split.shares(amount, people);
for (const auto& [p, s] : shares)
if (!balance_.count(p)) throw SplitError(p + " is not in the group");
balance_[payer] += amount; // only now, when everything is valid
for (const auto& [p, s] : shares) balance_[p] -= s;
}
int balanceOf(const std::string& m) {
std::lock_guard lock(mu_);
return balance_.at(m);
}
// Biggest debtor pays biggest creditor, repeatedly. Ties go to the earlier member.
std::vector<std::tuple<std::string, std::string, int>> settleUp() {
Shares left;
{
std::lock_guard lock(mu_);
left = balance_;
}
std::vector<std::tuple<std::string, std::string, int>> pays;
for (;;) {
std::string debtor, creditor;
for (const auto& m : members) {
if (left[m] < 0 && (debtor.empty() || left[m] < left[debtor])) debtor = m;
if (left[m] > 0 && (creditor.empty() || left[m] > left[creditor])) creditor = m;
}
if (debtor.empty() || creditor.empty()) return pays;
int amt = std::min(-left[debtor], left[creditor]);
pays.emplace_back(debtor, creditor, amt);
left[debtor] += amt;
left[creditor] -= amt;
}
}
private:
std::mutex mu_;
Shares balance_;
};
int main() {
std::vector<std::string> everyone{"Rahim", "Nadia", "Tania", "Sabbir"};
Group g(everyone);
struct Expense {
std::string payer, what;
int amount;
std::vector<std::string> people;
std::unique_ptr<Split> split;
};
std::vector<Expense> expenses;
expenses.push_back({"Rahim", "hotel", 4000, everyone, std::make_unique<Equal>()});
expenses.push_back({"Nadia", "dinner", 1200, {}, std::make_unique<Exact>(Shares{{"Rahim", 300}, {"Nadia", 400}, {"Tania", 500}})});
expenses.push_back({"Tania", "boat", 1000, {}, std::make_unique<Percent>(std::vector<std::string>{"Rahim", "Sabbir"}, std::vector<int>{40, 60})});
expenses.push_back({"Sabbir", "tea", 100, {"Rahim", "Tania", "Sabbir"}, std::make_unique<Equal>()});
expenses.push_back({"Rahim", "taxi", 1200, {}, std::make_unique<Exact>(Shares{{"Rahim", 600}, {"Nadia", 500}})});
for (size_t i = 0; i < expenses.size(); i++) {
const auto& e = expenses[i];
try {
g.addExpense(e.payer, e.amount, e.people, *e.split);
std::printf("expense %zu: %s paid %d for %s, %s\n", i + 1, e.payer.c_str(), e.amount, e.what.c_str(), e.split->describe().c_str());
} catch (const SplitError& err) {
std::printf("expense %zu refused: %s\n", i + 1, err.what());
}
}
std::printf("balances:\n");
for (const auto& m : g.members) std::printf(" %-6s %+6d\n", m.c_str(), g.balanceOf(m));
auto pays = g.settleUp();
std::printf("settle up (%zu payments):\n", pays.size());
for (const auto& [from, to, amt] : pays) std::printf(" %s pays %s %d\n", from.c_str(), to.c_str(), 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
- কে কাকে কত দেবে এমন জোড়া না রেখে প্রতিজনের একটা net balance কেন?
- তিনজনের মধ্যে ১০০ টাকা কীভাবে ভাগ করবে?
Intermediate
- "Share ধরে ভাগ" কীভাবে যোগ করবে?
- ভুল খরচ যেন কিছুই না বদলায়, কীভাবে নিশ্চিত করবে?
Advanced
- Greedy settle-up কি সবসময় সবচেয়ে ভালো? সবচেয়ে ভালোটা পেতে কী খরচ?
- পুরনো একটা খরচ 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 ট্র্যাক হবে।