Banka hesap sistemi: transfer, top-K, zamanlanmış ödeme ve hesap birleştirme
Görev
Bellekte çalışan bir bankacılık simülasyonu yazacaksın: hesap açma, para yatırma/çekme, transfer; sonra en çok harcama yapan hesaplar, zamanlanmış ödemeler, hesap birleştirme ve geçmiş bir andaki bakiye. Her işlem o anın timestamp'ini parametre olarak alır ve zaman hiç geri gitmez. Aday raporlarında CodeSignal'ın dört seviyeli pratik formatında (90 dakika) in-memory veritabanının yanında sık anılan senaryolardan biri bu "banking system" görevi; hangi şirkette birebir sorulduğuna dair güvenilir bir kaynak olmadığı için şirket etiketi verilmedi. Format aynı olduğu için OpenAI ve Anthropic'in pratik turlarına hazırlıkta in-memory veritabanıyla birlikte çalışılmalı.
Bu görev, bir live coding mülakatında sorulan deposit/withdraw/exchange case'inin (bkz. Bölüm 22) bellek içi ve tek thread'li kuzenidir. Bölüm 22'de asıl problem eşzamanlılık ve veritabanıydı: atomik koşullu UPDATE, idempotency key, ledger tablosu, 3rd party çağrısı. Burada ise veri modeli, zaman sıralaması ve seviyeler arası refactor ölçülüyor. İkisinin ortak dersi aynı: para işlemlerinde "bakiye yetersizse ne olur", "aynı anda iki işlem gelirse ne olur" ve "geçmişi nasıl kanıtlarım" sorularının cevabı tasarımın merkezindedir. Seviye 4'teki bakiye geçmişi, Bölüm 22'deki append-only ledger fikrinin bellek içi karşılığıdır.
Netleştirme soruları
- Tutarlar hangi tipte? (Kuruş cinsinden
long. Gerçek sistemdeBigDecimal+NUMERIC; bkz. Bölüm 22. Bu kata'da tam sayı kuruş hem doğru hem hızlı.) - Başarısız işlem nasıl bildirilir? (Exception değil,
Optional.empty()ya dafalse; geçersiz girdi, yani sıfır ya da negatif tutar iseIllegalArgumentException.) - Kendi kendine transfer? (Reddedilir.)
- Aynı timestamp'te zamanlanmış ödeme ile normal işlem çakışırsa? (Ödemeler önce çalışır.)
- Birleştirilen hesabın id'si yeniden kullanılabilir mi? (Hayır; hesap numaraları tekrar dağıtılmaz.)
- Zaman geri gidebilir mi? (Hayır; giderse
IllegalArgumentException.)
Seviye 1
boolean createAccount(long timestamp, String accountId);
Optional<Long> deposit(long timestamp, String accountId, long amount); // yeni bakiye
Optional<Long> withdraw(long timestamp, String accountId, long amount); // yeni bakiye
Optional<Long> transfer(long timestamp, String sourceId, String targetId, long amount); // kaynağın yeni bakiyesi
createAccount(1, "A") -> true
createAccount(2, "B") -> true
createAccount(3, "A") -> false
deposit(4, "A", 1000) -> Optional[1000]
withdraw(5, "A", 300) -> Optional[700]
transfer(6, "A", "B", 200) -> Optional[500]
withdraw(7, "A", 10000) -> Optional.empty (yetersiz bakiye)
Bakiye kontrolü ile düşme aynı metotta ve aynı thread'de olduğu için burada race condition yok. Mülakatçı "bu bir servis olsaydı?" diye sorarsa cevap Bölüm 22'de: kontrolü uygulamada değil, veritabanında UPDATE ... SET balance = balance - :amount WHERE id = :id AND balance >= :amount ile atomik yapmak.
Seviye 2
En çok para çıkışı olan ilk n hesabı döndür. Çıkış = çekilen para + gönderilen transferler + (Seviye 3'ten sonra) başarıyla çalışan ödemeler. Eşitlikte hesap id'si artan sırada.
List<String> topSpenders(long timestamp, int n); // "id(outgoing)"
// C 500 çekti, B 300 transfer etti, D 300 çekti, A hiç harcamadı
topSpenders(6, 3) -> [C(500), B(300), D(300)]
Tüm hesapları sıralamak O(A log A). n küçükse boyutu n olan bir min-heap O(A log n) verir: kök, şu anki ilk n'in en zayıfıdır; yeni hesap ondan iyiyse kök atılır (Bölüm 19'daki top-K deseni). Eşitlik kuralını comparator'a doğru yansıtmak en sık hata: heap'te "zayıf" olan, eşit çıkışta id'si büyük olandır.
Seviye 3
Ödemeler ileri bir zamana planlanabilir ve iptal edilebilir.
Optional<String> schedulePayment(long timestamp, String accountId, long amount, long delay); // "payment1", "payment2", ...
boolean cancelPayment(long timestamp, String accountId, String paymentId);
Kurallar:
- Ödeme
timestamp + delayanında çalışır. Her işlem başlamadan önce o ana kadar vadesi gelmiş tüm ödemeler, vade sırasıyla (eşitlikte planlanma sırasıyla) işlenir. - Bakiye yetmezse ödeme atlanır ve harcama sayılmaz.
- İptal sadece ödemenin sahibi tarafından ve ödeme hâlâ bekliyorsa yapılabilir.
deposit(2, "A", 100)
schedulePayment(3, "A", 80, 7) -> Optional[payment1] // t=10'da çalışacak
withdraw(9, "A", 30) -> Optional[70]
withdraw(10, "A", 70) -> Optional[0] // t=10: önce ödeme denenir (70 < 80, atlanır), sonra çekim
Burada bir simülasyon saati kuruyorsun: her public metodun ilk satırı advanceTo(timestamp) olur; vadesi gelen ödemeleri bir PriorityQueue (vade, sıra numarası) üzerinden işler. İptal edilen ödemeyi heap'ten silmek O(n) olduğu için silmezsin; pendingPayments map'inden çıkarırsın ve heap'ten çıktığında map'te yoksa atlarsın (lazy deletion). Bu, Dijkstra'da da kullanılan bir desendir.
Seviye 4
İki hesabı birleştir ve geçmişteki bir andaki bakiyeyi sorgula.
boolean mergeAccounts(long timestamp, String survivorId, String mergedId);
Optional<Long> getBalance(long timestamp, String accountId, long timeAt);
Kurallar:
- Birleştirmede
mergedId'nin bakiyesi, toplam çıkışı ve bekleyen ödemelerisurvivorId'ye geçer;mergedIdkapanır ve yeniden açılamaz. getBalance,timeAtanındaki (o andaki tüm işlemler dahil) bakiyeyi döner. Hesap o an henüz yoksa ya da birleştirilip kapanmışsaOptional.empty().- Kapanan hesabın birleşmeden önceki geçmişi sorgulanabilir kalır.
createAccount(10, "A"); createAccount(10, "B")
deposit(20, "A", 100); deposit(25, "B", 70); withdraw(30, "A", 40)
mergeAccounts(40, "A", "B")
getBalance(50, "A", 29) -> Optional[100]
getBalance(50, "A", 40) -> Optional[130]
getBalance(50, "B", 39) -> Optional[70]
getBalance(50, "B", 40) -> Optional.empty
Geçmiş bakiye için iki yol var: her sorguda tüm işlemleri baştan oynatmak (O(işlem sayısı)) ya da her bakiye değişiminde TreeMap<timestamp, balance>'a yazıp floorEntry(timeAt) ile O(log n)'de okumak. İkincisi, değişmez bir hareket defteri (ledger) tutup bakiyeyi ondan türetmenin bellek içi halidir. Bakiye değişimini tek bir metoda (changeBalance) toplamak, geçmişin hiçbir yerde unutulmamasını garanti eder; Seviye 4'ün refactor dersi budur.
Birleştirmede bekleyen ödemelerin sahibini değiştirmek için heap'teki elemanı güncellemek gerekmez: heap sadece id ve vadeyi taşır, güncel sahip pendingPayments map'indeki record'dan okunur.
İpuçları
İpucu 1
Her public metodu advanceTo(timestamp) ile başlat: zamanın geri gitmediğini doğrulasın ve vadesi gelen ödemeleri önce çalıştırsın. Seviye 3 gelince sadece bu metoda kod eklersin.
İpucu 2
Top-K için boyutu n olan min-heap kullan; comparator önce çıkışa göre artan, eşitlikte id'ye göre azalan olmalı ki eşit çıkışta büyük id atılsın. Sonucu döndürmeden önce ters sırala.
İpucu 3
Bakiye değişikliklerini tek bir metotta topla ve orada TreeMap<Long, Long> geçmişine yaz; getBalance için floorEntry(timeAt) yeter. Birleştirilen hesabı silme, closedAt ile işaretle ki geçmişi sorgulanabilsin.
Java çözümü
Hesap, iç durumu değişen bir sınıf (Account); zamanlanmış ödeme ise değişmez bir record. Birleştirmede ödemenin sahibi değişince map'teki record yenisiyle değiştiriliyor (withOwner). Kodlar Java 21 ile derlenip testlerle çalıştırıldı.
Dosya: BankingSystem.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.PriorityQueue;
import java.util.TreeMap;
/**
* Single-threaded banking simulation. Amounts are in minor units (kuruş) as long.
* Every operation takes the current timestamp; timestamps never go backwards.
* Level 1: create/deposit/withdraw/transfer, Level 2: top spenders,
* Level 3: scheduled payments and cancel, Level 4: merge accounts and balance history.
*/
public final class BankingSystem {
private static final class Account {
final String id;
final long createdAt;
long balance;
long outgoing; // withdrawals + transfers out + executed payments
Long closedAt; // set when merged into another account
final NavigableMap<Long, Long> balanceHistory = new TreeMap<>();
Account(String id, long createdAt) {
this.id = id;
this.createdAt = createdAt;
balanceHistory.put(createdAt, 0L);
}
boolean isActive() {
return closedAt == null;
}
void changeBalance(long timestamp, long delta) {
balance = Math.addExact(balance, delta);
balanceHistory.put(timestamp, balance); // last write at a timestamp wins
}
}
/** Immutable; on merge a new copy with the new owner replaces the old one in {@code pendingPayments}. */
private record ScheduledPayment(String id, String accountId, long amount, long executeAt, long sequence) {
ScheduledPayment withOwner(String newOwner) {
return new ScheduledPayment(id, newOwner, amount, executeAt, sequence);
}
}
private final Map<String, Account> accounts = new HashMap<>(); // includes closed accounts
private final Map<String, ScheduledPayment> pendingPayments = new HashMap<>();
private final PriorityQueue<ScheduledPayment> paymentQueue = new PriorityQueue<>(
Comparator.comparingLong(ScheduledPayment::executeAt).thenComparingLong(ScheduledPayment::sequence));
private long paymentCounter = 0;
private long lastTimestamp = Long.MIN_VALUE;
// ---------- Level 1 ----------
/** Account ids are never reused, not even after a merge closes the account. */
public boolean createAccount(long timestamp, String accountId) {
advanceTo(timestamp);
Objects.requireNonNull(accountId, "accountId");
if (accounts.containsKey(accountId)) {
return false;
}
accounts.put(accountId, new Account(accountId, timestamp));
return true;
}
/** Returns the new balance, or empty if the account does not exist. */
public Optional<Long> deposit(long timestamp, String accountId, long amount) {
advanceTo(timestamp);
requirePositive(amount);
Account account = activeAccount(accountId);
if (account == null) {
return Optional.empty();
}
account.changeBalance(timestamp, amount);
return Optional.of(account.balance);
}
/** Returns the new balance, or empty if the account does not exist or funds are insufficient. */
public Optional<Long> withdraw(long timestamp, String accountId, long amount) {
advanceTo(timestamp);
requirePositive(amount);
Account account = activeAccount(accountId);
if (account == null || account.balance < amount) {
return Optional.empty();
}
debit(timestamp, account, amount);
return Optional.of(account.balance);
}
/** Returns the source balance after the transfer, or empty if the transfer is not possible. */
public Optional<Long> transfer(long timestamp, String sourceId, String targetId, long amount) {
advanceTo(timestamp);
requirePositive(amount);
Account source = activeAccount(sourceId);
Account target = activeAccount(targetId);
if (source == null || target == null || source == target || source.balance < amount) {
return Optional.empty();
}
debit(timestamp, source, amount);
target.changeBalance(timestamp, amount);
return Optional.of(source.balance);
}
// ---------- Level 2 ----------
/** Top n active accounts by outgoing amount, ties broken by account id; format "id(outgoing)". */
public List<String> topSpenders(long timestamp, int n) {
advanceTo(timestamp);
if (n <= 0) {
return List.of();
}
Comparator<Account> bySpending = Comparator.<Account>comparingLong(a -> a.outgoing)
.thenComparing(a -> a.id, Comparator.reverseOrder());
// Min-heap of size n: the root is the weakest of the current top n. O(A log n).
PriorityQueue<Account> heap = new PriorityQueue<>(bySpending);
for (Account account : accounts.values()) {
if (!account.isActive()) {
continue;
}
heap.offer(account);
if (heap.size() > n) {
heap.poll();
}
}
List<Account> top = new ArrayList<>(heap);
top.sort(bySpending.reversed());
return top.stream().map(a -> a.id + "(" + a.outgoing + ")").toList();
}
// ---------- Level 3 ----------
/** Schedules a payment at timestamp + delay; returns its id, or empty if the account does not exist. */
public Optional<String> schedulePayment(long timestamp, String accountId, long amount, long delay) {
advanceTo(timestamp);
requirePositive(amount);
if (delay < 0) {
throw new IllegalArgumentException("delay must not be negative: " + delay);
}
if (activeAccount(accountId) == null) {
return Optional.empty();
}
paymentCounter++;
var payment = new ScheduledPayment("payment" + paymentCounter, accountId, amount,
Math.addExact(timestamp, delay), paymentCounter);
pendingPayments.put(payment.id(), payment);
paymentQueue.offer(payment);
processDuePayments(timestamp); // delay 0 executes immediately
return Optional.of(payment.id());
}
/** Only the owning account can cancel, and only while the payment is still pending. */
public boolean cancelPayment(long timestamp, String accountId, String paymentId) {
advanceTo(timestamp);
ScheduledPayment payment = pendingPayments.get(paymentId);
if (payment == null || !payment.accountId().equals(accountId)) {
return false;
}
pendingPayments.remove(paymentId); // the queue entry is skipped lazily
return true;
}
// ---------- Level 4 ----------
/** Moves balance, spending and pending payments of {@code mergedId} into {@code survivorId}. */
public boolean mergeAccounts(long timestamp, String survivorId, String mergedId) {
advanceTo(timestamp);
Account survivor = activeAccount(survivorId);
Account merged = activeAccount(mergedId);
if (survivor == null || merged == null || survivor == merged) {
return false;
}
survivor.changeBalance(timestamp, merged.balance);
survivor.outgoing = Math.addExact(survivor.outgoing, merged.outgoing);
pendingPayments.replaceAll((id, p) -> p.accountId().equals(mergedId) ? p.withOwner(survivorId) : p);
merged.closedAt = timestamp;
return true;
}
/**
* Balance of the account at {@code timeAt} (inclusive of all operations at that timestamp).
* Empty if the account did not exist at that time or had already been merged away.
*/
public Optional<Long> getBalance(long timestamp, String accountId, long timeAt) {
advanceTo(timestamp);
if (timeAt > timestamp) {
throw new IllegalArgumentException("cannot query the future");
}
Account account = accounts.get(accountId);
if (account == null || timeAt < account.createdAt
|| (account.closedAt != null && timeAt >= account.closedAt)) {
return Optional.empty();
}
return Optional.of(account.balanceHistory.floorEntry(timeAt).getValue());
}
// ---------- internals ----------
/** Validates the clock and runs every payment due at or before {@code timestamp} first. */
private void advanceTo(long timestamp) {
if (timestamp < lastTimestamp) {
throw new IllegalArgumentException("timestamps must not go backwards: " + timestamp);
}
lastTimestamp = timestamp;
processDuePayments(timestamp);
}
private void processDuePayments(long timestamp) {
while (!paymentQueue.isEmpty() && paymentQueue.peek().executeAt() <= timestamp) {
ScheduledPayment queued = paymentQueue.poll();
ScheduledPayment current = pendingPayments.remove(queued.id());
if (current == null) {
continue; // cancelled
}
Account owner = activeAccount(current.accountId());
if (owner != null && owner.balance >= current.amount()) {
debit(current.executeAt(), owner, current.amount());
}
// Insufficient funds: the payment is dropped (a real system would record a failure).
}
}
private void debit(long timestamp, Account account, long amount) {
account.changeBalance(timestamp, -amount);
account.outgoing = Math.addExact(account.outgoing, amount);
}
private Account activeAccount(String accountId) {
Account account = accounts.get(accountId);
return account != null && account.isActive() ? account : null;
}
private static void requirePositive(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive: " + amount);
}
}
}
Karmaşıklık (A: hesap sayısı, P: ödeme sayısı, H: bir hesabın bakiye değişim sayısı):
| İşlem | Zaman |
|---|---|
createAccount, deposit, withdraw, transfer | O(log H) (geçmişe yazma) + vadesi gelen ödemeler |
topSpenders | O(A log n) |
schedulePayment | O(log P) |
cancelPayment | O(1) (lazy deletion) |
mergeAccounts | O(P) (bekleyen ödemelerin sahibini güncelleme) |
getBalance | O(log H) |
Testler
javac -d out *.java && java -cp out BankingSystemTest ile çalışır; çıktı 10/10 tests passed.
Dosya: BankingSystemTest.java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class BankingSystemTest {
public static void main(String[] args) {
Map<String, Runnable> tests = new LinkedHashMap<>();
tests.put("level1_depositWithdrawTransfer", BankingSystemTest::level1_depositWithdrawTransfer);
tests.put("level1_invalidOperationsReturnEmpty", BankingSystemTest::level1_invalidOperationsReturnEmpty);
tests.put("level1_timestampsCannotGoBack", BankingSystemTest::level1_timestampsCannotGoBack);
tests.put("level2_topSpendersOrderAndTies", BankingSystemTest::level2_topSpendersOrderAndTies);
tests.put("level3_paymentRunsBeforeOtherOperationsAtSameTimestamp", BankingSystemTest::level3_paymentRunsBeforeOtherOperationsAtSameTimestamp);
tests.put("level3_cancelOnlyByOwnerAndOnlyWhilePending", BankingSystemTest::level3_cancelOnlyByOwnerAndOnlyWhilePending);
tests.put("level3_insufficientFundsSkipsPayment", BankingSystemTest::level3_insufficientFundsSkipsPayment);
tests.put("level4_mergeMovesBalanceSpendingAndPayments", BankingSystemTest::level4_mergeMovesBalanceSpendingAndPayments);
tests.put("level4_balanceHistory", BankingSystemTest::level4_balanceHistory);
tests.put("level4_mergedIdCannotBeReused", BankingSystemTest::level4_mergedIdCannotBeReused);
int passed = 0;
for (var test : tests.entrySet()) {
test.getValue().run();
System.out.println("PASS " + test.getKey());
passed++;
}
System.out.println(passed + "/" + tests.size() + " tests passed");
}
static void level1_depositWithdrawTransfer() {
var bank = new BankingSystem();
check(bank.createAccount(1, "A"), "create A");
check(bank.createAccount(2, "B"), "create B");
check(!bank.createAccount(3, "A"), "duplicate id");
check(bank.deposit(4, "A", 1_000).equals(Optional.of(1_000L)), "deposit");
check(bank.withdraw(5, "A", 300).equals(Optional.of(700L)), "withdraw");
check(bank.transfer(6, "A", "B", 200).equals(Optional.of(500L)), "transfer returns source balance");
check(bank.getBalance(7, "B", 7).equals(Optional.of(200L)), "target credited");
}
static void level1_invalidOperationsReturnEmpty() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.deposit(2, "A", 100);
check(bank.deposit(3, "X", 10).isEmpty(), "unknown account");
check(bank.withdraw(4, "A", 101).isEmpty(), "insufficient funds");
check(bank.transfer(5, "A", "A", 10).isEmpty(), "self transfer");
check(bank.transfer(6, "A", "X", 10).isEmpty(), "unknown target");
check(bank.getBalance(7, "A", 7).equals(Optional.of(100L)), "balance unchanged");
expectThrows(IllegalArgumentException.class, () -> bank.deposit(8, "A", 0));
}
static void level1_timestampsCannotGoBack() {
var bank = new BankingSystem();
bank.createAccount(10, "A");
expectThrows(IllegalArgumentException.class, () -> bank.deposit(9, "A", 1));
}
static void level2_topSpendersOrderAndTies() {
var bank = new BankingSystem();
List<String> ids = List.of("A", "B", "C", "D");
ids.forEach(id -> bank.createAccount(1, id));
ids.forEach(id -> bank.deposit(2, id, 1_000));
bank.withdraw(3, "C", 500);
bank.transfer(4, "B", "A", 300);
bank.withdraw(5, "D", 300);
// C=500, B=300, D=300, A=0 -> tie between B and D broken by id
check(bank.topSpenders(6, 3).equals(List.of("C(500)", "B(300)", "D(300)")), "top 3: " + bank.topSpenders(6, 3));
check(bank.topSpenders(7, 10).size() == 4, "n larger than account count");
check(bank.topSpenders(8, 0).isEmpty(), "n = 0");
}
static void level3_paymentRunsBeforeOtherOperationsAtSameTimestamp() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.deposit(2, "A", 100);
check(bank.schedulePayment(3, "A", 80, 7).equals(Optional.of("payment1")), "id"); // runs at 10
check(bank.withdraw(9, "A", 30).equals(Optional.of(70L)), "before payment");
// At t=10 the payment (80) runs first and fails (70 < 80); the withdraw then succeeds.
check(bank.withdraw(10, "A", 70).equals(Optional.of(0L)), "payment skipped, withdraw ok");
check(bank.topSpenders(11, 1).equals(List.of("A(100)")), "failed payment is not spending");
}
static void level3_cancelOnlyByOwnerAndOnlyWhilePending() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.createAccount(1, "B");
bank.deposit(2, "A", 100);
String id = bank.schedulePayment(3, "A", 40, 10).orElseThrow(); // runs at 13
check(!bank.cancelPayment(4, "B", id), "other account cannot cancel");
check(bank.cancelPayment(5, "A", id), "owner cancels");
check(!bank.cancelPayment(6, "A", id), "already cancelled");
check(bank.getBalance(20, "A", 20).equals(Optional.of(100L)), "cancelled payment never ran");
String second = bank.schedulePayment(21, "A", 40, 1).orElseThrow(); // runs at 22
check(!bank.cancelPayment(22, "A", second), "too late: executed at 22 before cancel");
check(bank.getBalance(23, "A", 23).equals(Optional.of(60L)), "executed");
}
static void level3_insufficientFundsSkipsPayment() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.schedulePayment(2, "A", 50, 0);
check(bank.getBalance(3, "A", 3).equals(Optional.of(0L)), "nothing debited");
}
static void level4_mergeMovesBalanceSpendingAndPayments() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.createAccount(1, "B");
bank.deposit(2, "A", 100);
bank.deposit(2, "B", 500);
bank.withdraw(3, "B", 100); // B spent 100
String paymentOfB = bank.schedulePayment(4, "B", 50, 10).orElseThrow(); // runs at 14
check(bank.mergeAccounts(5, "A", "B"), "merge");
check(!bank.mergeAccounts(6, "A", "B"), "B is gone");
check(bank.deposit(7, "B", 1).isEmpty(), "merged account is closed");
check(!bank.cancelPayment(8, "B", paymentOfB), "old owner cannot cancel");
check(bank.getBalance(15, "A", 15).equals(Optional.of(450L)), "100 + 400 - 50 payment");
check(bank.topSpenders(16, 1).equals(List.of("A(150)")), "spending moved: " + bank.topSpenders(16, 1));
}
static void level4_balanceHistory() {
var bank = new BankingSystem();
bank.createAccount(10, "A");
bank.createAccount(10, "B");
bank.deposit(20, "A", 100);
bank.deposit(25, "B", 70);
bank.withdraw(30, "A", 40);
bank.mergeAccounts(40, "A", "B");
check(bank.getBalance(50, "A", 5).isEmpty(), "before creation");
check(bank.getBalance(50, "A", 10).equals(Optional.of(0L)), "at creation");
check(bank.getBalance(50, "A", 29).equals(Optional.of(100L)), "between operations");
check(bank.getBalance(50, "A", 30).equals(Optional.of(60L)), "inclusive of ops at timeAt");
check(bank.getBalance(50, "A", 40).equals(Optional.of(130L)), "after merge");
check(bank.getBalance(50, "B", 39).equals(Optional.of(70L)), "merged account history kept");
check(bank.getBalance(50, "B", 40).isEmpty(), "merged account gone from merge time");
expectThrows(IllegalArgumentException.class, () -> bank.getBalance(60, "A", 61));
}
static void level4_mergedIdCannotBeReused() {
var bank = new BankingSystem();
bank.createAccount(1, "A");
bank.createAccount(1, "B");
bank.mergeAccounts(2, "A", "B");
check(!bank.createAccount(3, "B"), "ids are not reused");
check(!bank.mergeAccounts(4, "A", "A"), "cannot merge with itself");
}
// ---------- tiny assertion helpers ----------
static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
static void expectThrows(Class<? extends Throwable> expected, Runnable action) {
try {
action.run();
} catch (Throwable t) {
if (expected.isInstance(t)) {
return;
}
throw new AssertionError("expected " + expected.getSimpleName() + " but got " + t);
}
throw new AssertionError("expected " + expected.getSimpleName() + " but nothing was thrown");
}
}
En değerli iki test: aynı timestamp'te ödemenin önce çalışması (level3_paymentRunsBeforeOtherOperationsAtSameTimestamp) ve birleşen hesabın ödemesinin yeni sahibinin bakiyesinden düşmesi (level4_mergeMovesBalanceSpendingAndPayments). İkisi de tek bir satırlık sıralama hatasıyla kırılır ve kırıldığında hangi kuralın bozulduğunu adıyla söyler.
Mülakatçı için
- Gereksinimi netleştirme: Tutar tipini (kuruş
longya daBigDecimal), başarısızlığın nasıl bildirileceğini, kendine transferi, aynı timestamp'teki sıralamayı ve kapanan hesabın id'sinin kaderini soruyor; cevapları koda sabit ya da yorum olarak yansıtıyor. - Seviyeler arası refactor: Seviye 3'te zaman ilerletmeyi tek bir
advanceTometoduna topluyor; Seviye 4'te bakiye değişikliklerini tek noktaya (changeBalance) çekerek geçmişi hiçbir yolda kaçırmıyor. Önceki seviyelerin testleri değişmeden geçiyor. - Test yazma: Yetersiz bakiye, eşit harcamada sıralama, iptalin yetki ve zaman sınırı, birleşme sonrası ödeme ve "birleşme anında kapalı" sınırı için ayrı testler yazıyor; zamanın geri gitmesini test ediyor.
- Kod kalitesi:
doublekullanmıyor; taşmaya karşıMath.addExactkullanıyor; dönüş tiplerindeOptionalile hata durumunu açık ediyor; değişmez veriyirecord, değişen durumu sınıf olarak modelliyor. - Zaman yönetimi: Seviye 1'i 15 dakikada testleriyle bitiriyor; Seviye 3'teki lazy deletion'ı fark edemezse önce O(n) silmeyle çalışan sürümü gönderip sonra iyileştiriyor.
Takip soruları:
- Bu sınıfı bir REST servisinin arkasına koysak ve istekler eşzamanlı gelse ilk ne bozulur? Hesap başına kilit mi, tek writer thread (actor) mı, yoksa veritabanında atomik
UPDATEmi (bkz. Bölüm 22)? İki hesaplı transferde deadlock'u nasıl önlersin? - Zamanlanmış ödeme bakiye yetmediği için atlandı; müşteri bunu nasıl öğrenir? Ödeme durum makinesi (
PENDING,EXECUTED,FAILED,CANCELLED) ve yeniden deneme politikası tasarla. topSpenderssaniyede binlerce kez çağrılırsa? Her çağrıda O(A log n) yerine harcama değiştikçe güncellenen birTreeSet(çıkış, id) ile O(n) okuma; yazma maliyeti ne olur?
Hızlı tekrar
S: Top-K için neden boyutu K olan min-heap?
C: Kök, şu anki ilk K'nın en zayıfıdır; her eleman bir kez karşılaştırılır, toplam O(N log K). Sonuç için heap boşaltılıp ters sıralanır.
S: Zamanlanmış ödemeyi iptal etmek için heap'ten neden silmiyoruz?
C: PriorityQueue.remove(Object) O(n). Bunun yerine ödeme bekleyenler map'inden çıkarılır, heap'ten çıktığında map'te yoksa atlanır (lazy deletion).
S: Geçmiş bir andaki bakiyeyi O(log n)'de nasıl bulursun?
C: Her bakiye değişiminde TreeMap<timestamp, balance>'a yaz; sorguda floorEntry(timeAt).
S: Aynı timestamp'te zamanlanmış ödeme ve normal işlem varsa sıra nedir ve bunu kodda nereye koyarsın?
C: Ödemeler önce; her public metodun ilk satırındaki advanceTo(timestamp) vadesi gelen ödemeleri işler.