সব problem (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Shared State and Contention

Design an LRU Cache

IntermediateHashMapDoubly linked listO(1)Thread safety⏱ 1-2 ঘণ্টা

এমন একটা cache যেটা ভরে গেলে সবচেয়ে বেশি সময় ধরে কেউ ছোঁয়নি এমন জিনিসটা ফেলে দেয়, get আর put দুটোই O(1): hash map আর doubly linked list, তারপর thread-safe করা।

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

Problem এর পরিচয়

DifficultyIntermediate
সময়Design আর code মিলিয়ে ১–২ ঘণ্টা; interview তে মোটামুটি ৩০–৪৫ মিনিট
আগে জানা দরকারHash map, pointer বা reference, lock নিয়ে একটু ধারণা
যা শিখবেHash map আর doubly linked list একসাথে, sentinel node, O(1) get আর put, evict হলে খবর দেওয়ার callback, thread-safe wrapper, আর কেন এখানে "read" এর জন্যও write lock লাগে
বাস্তবে কোথায়Redis এর maxmemory-policy allkeys-lru, database এর buffer pool, OS এর page cache, CDN এর edge cache, mobile app এর image cache

LRU cache সবচেয়ে সম্প্রতি ব্যবহার হওয়া জিনিস গুলো রাখে, আর যেটা সবচেয়ে বেশি সময় ধরে কেউ ছোঁয়নি সেটা ফেলে দেয়। Interview তে এটা বারবার আসে, কারণ সহজ সমাধান গুলো ধীর, আর দ্রুত সমাধানে দুইটা data structure কে একসাথে কাজ করাতে হয়। LLD এর দিক থেকে আরও দুইটা প্রশ্ন আসে: cache এর নিজের code থেকে lock এর ঝামেলা আলাদা রাখবে কীভাবে, আর Get কেন read lock ভাগ করে নিতে পারে না।

Problem statement

একটা food delivery app প্রতিটা screen এ customer এর profile (নাম, ঠিকানা) দেখায়। প্রতিবার database থেকে আনলে ধীর হয়, তাই server সম্প্রতি ব্যবহার হওয়া profile গুলো memory তে রাখে। Memory সীমিত: cache এ নির্দিষ্ট কয়টা profile ই রাখা যায়। Cache ভরা অবস্থায় নতুন profile এলে, যেটা সবচেয়ে আগে শেষবার ব্যবহার হয়েছিল সেটা বাদ যায়।

আমরা কী বানাচ্ছি: একটা নির্দিষ্ট size এর, যেকোনো type এর জন্য চলে এমন LRU cache, যার Get, Put আর Delete তিনটাই O(1), সাথে এমন একটা version যেটা অনেক goroutine একসাথে ব্যবহার করতে পারে।

কেন জরুরি: ভুল জিনিস evict করলে hit গুলো database call হয়ে যায়; প্রতি call এ O(n) হলে cache নিজেই ধীর অংশ হয়ে যায়; আর data race থাকলে cache নিজের list নিজেই নষ্ট করে, ভুল value দেয় বা crash করে।

এক লাইনে: যেকোনো key O(1) এ খুঁজে পাও, সবচেয়ে পুরনো key কোনটা সেটা O(1) এ জানো, আর একটা key কে O(1) এ "সবচেয়ে নতুন" বানাও।

Requirement

Functional requirement

  1. New(capacity): capacity কমপক্ষে ১ হতে হবে।
  2. Get(key): value ফেরত দাও আর key টাকে "এইমাত্র ব্যবহার হলো" ধরো; key না থাকলে সেটা miss।
  3. Put(key, value): নতুন যোগ করো বা আগেরটা বদলাও; বদলালেও key টা "এইমাত্র ব্যবহার হলো" ধরা হয়।
  4. ভরা cache এ Put নতুন key আনলে, আগে সবচেয়ে কম সম্প্রতি ব্যবহার হওয়া key টা বাদ যায়।
  5. Delete(key): key থাকলে সরিয়ে দাও।
  6. চাইলে একটা callback দেওয়া যায়, যেটা প্রতিটা evict এর খবর পায় (metric বা resource বন্ধ করার জন্য)।
  7. Hit আর miss গুনে রাখো।

Non-functional requirement

Requirementএখানে মানে কী
গতিGet, Put আর Delete O(1), size যত বড়ই হোক
Memoryকখনোই capacity র বেশি entry না
Concurrency তেও ঠিক থাকাঅনেক goroutine একটা cache ভাগ করে ব্যবহার করলেও list ভাঙবে না
আবার ব্যবহারযেকোনো key আর value type এর জন্য চলবে

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

  • একটা process, capacity গোনা হয় entry দিয়ে (byte দিয়ে না)। সময় ধরে মেয়াদ (TTL) নেই, disk এ রাখা নেই।

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

  • দুইটা data structure আর তারা একে অপরকে কীভাবে চেনে।
  • Get, put, delete আর evict এর code, সাথে thread-safe version।
  • Evict এর ক্রম, update, edge case আর concurrency র test।
Hint 1: শুধু একটা map কেন না?

Map O(1) এ key খুঁজে দেয়, কিন্তু সব ঘেঁটে না দেখে বলতে পারে না কোন key সবচেয়ে পুরনো।

Hint 2: শুধু একটা list কেন না?

"সবচেয়ে নতুন আগে" ক্রমে রাখা list জানে সবচেয়ে পুরনো কোনটা (শেষেরটা), কিন্তু তার ভেতরে একটা key খুঁজতে O(n) লাগে।

Hint 3: দুইটাই

ক্রমের জন্য list রাখো, আর key থেকে list এর node এ যাওয়ার একটা map রাখো। Map node টা খুঁজে দেয়; doubly linked list সেই node কে O(1) এ খুলে নিতে পারে, কারণ সে দুই পাশের প্রতিবেশীকেই চেনে।

Hint 4: Get কি শুধু পড়ে?

Get list এর সাথে কী করে দেখো। তারপর ঠিক করো ওর কোন lock লাগবে।

মূল entity

Entityএক কথায় কাজকী রাখেকী করে
nodeকে কবে ব্যবহার হলো সেই list এর একটা entrykey, value, prev, nextকিছু না: শুধু data
Cache[K, V]LRU এর logic, এক thread এর জন্যcapacity, key → node এর map, head আর tail sentinel, evict এর callback, hit আর miss এর হিসাবGet, Put, Delete, Len, Keys, Stats
Safe[K, V]একই cache, যেটা অনেক goroutine ভাগ করে নিতে পারেএকটা mutex আর একটা Cacheএকই method গুলো, প্রতিটা lock এর ভেতরে

Node কেন নিজের key রাখে: শেষের node টা evict করার সময় map থেকেও ওটাকে মুছতে হয়, আর node নিজে ছাড়া কেউ জানে না সে কোন key এর।

কে কার সাথে কীভাবে যুক্ত: map আর list দুটোর মালিক Cache; map এর value গুলো list এর ভেতরের node এর দিকে point করে। Safe একটা Cache কে মুড়ে রাখে, lock ছাড়া আর কিছুই যোগ করে না।

Diagram

Class diagram

Safe[K, V]- mu: sync.Mutex- c: *Cache[K, V]+ Get(key) (V, bool)+ Put(key, val)+ Delete(key) bool+ Len / Keys / StatsCache[K, V]- cap: int- items: map[K]*node- head, tail: *node- onEvict: func(K, V)- hits, misses: int+ Get(key) (V, bool)+ Put(key, val)+ Delete(key) bool- unlink(n)- pushFront(n)- moveToFront(n)node[K, V]key: Kval: Vprev, next: *nodewrapsitems1*prev / nextSafe wraps Cache and adds only the lock (Decorator)Cache owns the nodes: the map finds one, the list keeps the ordereach node knows prev and next: a doubly linked listonly Get, Put and Delete are public (+); list moves stay private (-)
Play চাপো। আগে Cache কে মুড়ে রাখা Safe, তারপর Cache এর নিজের node গুলো, তারপর node দের মাঝে prev আর next এর link, শেষে কোন method গুলো public।

কীভাবে পড়বে: Safe এর ভেতরে একটা Cache, আর সে শুধু একটা mutex যোগ করে। Node গুলোর মালিক Cache; map আর list দুটোই একই node object এর দিকে point করে, তাই প্রতিটা entry র একটাই copy। node থেকে নিজের দিকে ফেরা তীরটাই doubly linked list: প্রতিটা node আগেরটা আর পরেরটাকে চেনে।

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

Sequence: Safe দিয়ে একটা Get

callerSafeCacheitems mapGet(rahim)mu.Lock()Get(rahim)items[rahim]nodemoveToFront, hits++Mirpur, truemu.Unlock()Mirpur, true
Play চাপো। Get rahim: Safe lock নেয়, Cache map এ key টা খোঁজে, node পায়, সেটাকে সামনে আনে আর একটা hit গোনে, তারপর Safe lock ছেড়ে উত্তর ফেরত দেয়।

কীভাবে পড়বে: প্রতিটা খাড়া dashed লাইন একটা object, আর সময় চলে উপর থেকে নিচে। ভরা তীর মানে call, dashed তীর মানে উত্তর ফেরত আসা, আর সরু bar গুলো দেখায় কোন object কখন কাজে ব্যস্ত। সামনে আনার কাজটা হয় lock এর ভেতরে, mu.Lock() আর mu.Unlock() এর মাঝে। Get কেন read lock নিতে পারে না তার পুরো কারণ এটাই: সে লেখে, diagram এর ঠিক মাঝখানে।

আগে get, তারপর ভরা cache এ put

HEADTAILmap[key] → nodeO(1) lookupnewestoldesttanianadiarahimsabbirthe list: tania, nadia, rahim (rahim is the oldest)get rahim: the map points straight at its nodeunlink it and push it to the front: O(1)put sabbir, but the cache is fullthe node just before TAIL goes: nadia is evictedfront = most recently used, back = next to go
Play চাপো। List এ আছে tania, nadia, rahim। Get rahim: map সোজা rahim এর node এ চলে যায়, আর node টা সামনে চলে আসে। তারপর ভরা cache এ put sabbir: TAIL এর ঠিক আগের node (nadia) evict হয়, আর sabbir সামনে বসে।

কীভাবে পড়বে: list এর সামনে সবচেয়ে সম্প্রতি ব্যবহার হওয়া, পেছনে যেটা এরপর বাদ যাবে। Get কখনো খোঁজে না: map node টা হাতে তুলে দেয়, আর সরাতে লাগে কয়েকটা pointer বদল। Evict ও খোঁজে না: সবচেয়ে পুরনোটা সবসময় tail.prev।

Get কেন read lock নিতে পারে না

goroutine 1Get(rahim)goroutine 2Get(tania)the lockRLock, then Lockthe listhead.next = ?G1G2G1G2RLock: readers may go in togetherbut Get moves a node: both rewrite head.next, one change is losta full Lock: G2 waits until G1 is doneone Get at a time, the list stays right
Play চাপো। RLock দিলে দুইটা Get একসাথে ঢুকে যায়, দুজনেই একটা node সামনে আনে, তাই দুজনেই head.next নতুন করে লেখে আর একটা বদল হারিয়ে যায়। পুরো Lock দিলে দ্বিতীয় Get অপেক্ষা করে, আর list ঠিক থাকে।

কীভাবে পড়বে: read lock একসাথে অনেক reader কে ঢুকতে দেয়, যেটা তখনই নিরাপদ যখন তারা আসলেই শুধু পড়ে। Get node সরায়, মানে লেখে। দুইটা একসাথে চললে list নষ্ট হয়।

Design principle

  • যে কাজ লাগবে সেই অনুযায়ী structure বাছো: O(1) এ খোঁজা → hash map; O(1) এ "সবচেয়ে পুরনো" আর O(1) এ "সবচেয়ে নতুন বানাও" → doubly linked list। কোনোটা একা যথেষ্ট না।
  • Sentinel থাকলে আলাদা case থাকে না: নকল একটা head আর tail থাকলে list কখনো খালি হয় না, আর প্রতিটা আসল node এর দুই পাশেই প্রতিবেশী থাকে, তাই unlink আর pushFront এ কোনো nil check নেই।
  • Logic আর lock আলাদা: Cache সোজা এক-thread এর code, যেটা আলাদা করে test করা যায়। Mutex যোগ করে Safe। যে code cache টা একটাই goroutine থেকে ব্যবহার করে, তাকে lock এর খরচ দিতে হয় না।
  • Hard-code না করে hook দাও: evict এর callback দিয়ে caller evict গুনতে পারে বা একটা file বন্ধ করতে পারে, cache কে এসব জানতে হয় না।

Design pattern

Decorator (Cache এর উপর Safe)

  1. কেন: thread safety আর "কোন key বাদ যাবে" দুইটা আলাদা ব্যাপার।
  2. কী সমাধান করে: Safe এর method গুলো Cache এর মতোই, প্রতিটার চারপাশে একটা lock বসিয়ে call টা ভেতরে পাঠিয়ে দেয়।
  3. না থাকলে: LRU logic এর প্রতিটা লাইনের মাঝে mu.Lock() ঢুকে যায়, আর এক-thread এর user রাও তার খরচ দেয়।
  4. Trade-off: আরও একটা ছোট type, আর Cache এ নতুন method আনলে Safe এও একটা লাগে।
  5. কোথায়: safe.go।

Observer, একদম ছোট রূপে (evict এর callback)

onEvict একটা function, যেটা কোনো entry বের হয়ে গেলে cache ডাকে। এর মানে কী হবে সেটা caller ঠিক করে (একটা metric, একটা connection বন্ধ করা)। এখানে একজন listener ই যথেষ্ট, তাই পুরো listener list এর চেয়ে একটা সাধারণ function ই ভালো।

Eviction policy র জন্য Strategy কেন না?

LRU কে LFU আর FIFO এর পাশে একটা EvictionPolicy বানানোর লোভ হয়। কিন্তু এখানে policy আর data structure একই জিনিস: LRU এর লাগে কে কবে ব্যবহার হলো তার list, LFU এর লাগে কে কতবার ব্যবহার হলো সেই হিসাবের bucket। একটা common interface এই পার্থক্য লুকিয়ে ফেলে আর সাধারণ case টাকে ধীর করে। একাধিক policy লাগলে একটা ছোট Cache interface এর পেছনে আলাদা আলাদা cache লেখো।

Go তে পুরো solution

Project structure

lru/
├── go.mod
├── cache.go           node, Cache: the map, the list and the LRU logic
├── safe.go            Safe: the same cache behind a mutex
├── cache_test.go      tests, including a 10,000-step check against a simple model
└── cmd/demo/main.go   a profile cache for a delivery app

ধাপ ১: cache

Put টা আস্তে পড়ো: আগে থেকে থাকা key বদলায় আর সামনে আসে; ভরা cache এ নতুন key এলে আগে tail.prev evict হয়, তারপর নতুনটা সামনে বসে।

// cache.go
package lru

import "fmt"

// node is one entry in the recency list.
type node[K comparable, V any] struct {
	key        K
	val        V
	prev, next *node[K, V]
}

// Cache is a fixed-size LRU cache: when it's full, the entry nobody has touched
// for the longest time goes. Get and Put are O(1). Not safe for many goroutines: see Safe.
type Cache[K comparable, V any] struct {
	cap     int
	items   map[K]*node[K, V] // key -> its node, so we find it in O(1)
	head    *node[K, V]       // sentinel: head.next is the most recently used
	tail    *node[K, V]       // sentinel: tail.prev is the least recently used
	onEvict func(K, V)
	hits    int
	misses  int
}

// New makes a cache that holds at most capacity entries. onEvict may be nil.
func New[K comparable, V any](capacity int, onEvict func(K, V)) (*Cache[K, V], error) {
	if capacity <= 0 {
		return nil, fmt.Errorf("capacity must be positive, got %d", capacity)
	}
	c := &Cache[K, V]{cap: capacity, items: make(map[K]*node[K, V], capacity),
		head: &node[K, V]{}, tail: &node[K, V]{}, onEvict: onEvict}
	c.head.next, c.tail.prev = c.tail, c.head
	return c, nil
}

// Get returns the value and marks it as just used.
func (c *Cache[K, V]) Get(key K) (V, bool) {
	n, ok := c.items[key]
	if !ok {
		c.misses++
		var zero V
		return zero, false
	}
	c.hits++
	c.moveToFront(n)
	return n.val, true
}

// Put adds or updates a value. If the cache is full, the least recently used entry goes first.
func (c *Cache[K, V]) Put(key K, val V) {
	if n, ok := c.items[key]; ok {
		n.val = val
		c.moveToFront(n)
		return
	}
	if len(c.items) == c.cap {
		lru := c.tail.prev
		c.unlink(lru)
		delete(c.items, lru.key)
		if c.onEvict != nil {
			c.onEvict(lru.key, lru.val)
		}
	}
	n := &node[K, V]{key: key, val: val}
	c.items[key] = n
	c.pushFront(n)
}

// Delete removes a key; it reports whether the key was there.
func (c *Cache[K, V]) Delete(key K) bool {
	n, ok := c.items[key]
	if ok {
		c.unlink(n)
		delete(c.items, key)
	}
	return ok
}

func (c *Cache[K, V]) Len() int { return len(c.items) }

// Keys lists the keys from most to least recently used. O(n), for tests and debugging.
func (c *Cache[K, V]) Keys() []K {
	keys := make([]K, 0, len(c.items))
	for n := c.head.next; n != c.tail; n = n.next {
		keys = append(keys, n.key)
	}
	return keys
}

func (c *Cache[K, V]) Stats() (hits, misses int) { return c.hits, c.misses }

// The three list moves. Thanks to the sentinels there are no nil checks.
func (c *Cache[K, V]) unlink(n *node[K, V]) {
	n.prev.next, n.next.prev = n.next, n.prev
	n.prev, n.next = nil, nil
}

func (c *Cache[K, V]) pushFront(n *node[K, V]) {
	n.prev, n.next = c.head, c.head.next
	c.head.next.prev = n
	c.head.next = n
}

func (c *Cache[K, V]) moveToFront(n *node[K, V]) {
	c.unlink(n)
	c.pushFront(n)
}

ধাপ ২: thread-safe wrapper

// safe.go
package lru

import "sync"

// Safe wraps a Cache with a mutex so many goroutines can share it.
// Get moves an entry to the front, so even a "read" writes: it needs the full lock, not RLock.
type Safe[K comparable, V any] struct {
	mu sync.Mutex
	c  *Cache[K, V]
}

func NewSafe[K comparable, V any](capacity int, onEvict func(K, V)) (*Safe[K, V], error) {
	c, err := New(capacity, onEvict)
	if err != nil {
		return nil, err
	}
	return &Safe[K, V]{c: c}, nil
}

func (s *Safe[K, V]) Get(key K) (V, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.c.Get(key)
}

func (s *Safe[K, V]) Put(key K, val V) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.c.Put(key, val)
}

func (s *Safe[K, V]) Delete(key K) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.c.Delete(key)
}

func (s *Safe[K, V]) Len() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.c.Len()
}

func (s *Safe[K, V]) Keys() []K {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.c.Keys()
}

func (s *Safe[K, V]) Stats() (hits, misses int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.c.Stats()
}

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

// cmd/demo/main.go
package main

import (
	"fmt"
	"strings"

	"lru"
)

func main() {
	evicted := ""
	c, _ := lru.New(3, func(k, _ string) { evicted = k })
	fmt.Println("profile cache, capacity 3, keys from most to least recent")

	show := func(action string) {
		note := ""
		if evicted != "" {
			note = "   evicted " + evicted
			evicted = ""
		}
		fmt.Printf("%-26s [%s]%s\n", action, strings.Join(c.Keys(), " "), note)
	}
	put := func(k, v string) { c.Put(k, v); show("put " + k + " = " + v) }
	get := func(k string) {
		if v, ok := c.Get(k); ok {
			show("get " + k + " -> " + v)
		} else {
			show("get " + k + " -> miss")
		}
	}

	put("rahim", "Mirpur")
	put("nadia", "Dhanmondi")
	put("tania", "Banani")
	get("rahim")
	put("sabbir", "Uttara")
	get("nadia")
	get("tania")
	put("karim", "Gulshan")
	put("sabbir", "Bashundhara")

	hits, misses := c.Stats()
	fmt.Printf("hits %d, misses %d, size %d\n", hits, misses, c.Len())
}
$ go run ./cmd/demo
profile cache, capacity 3, keys from most to least recent
put rahim = Mirpur         [rahim]
put nadia = Dhanmondi      [nadia rahim]
put tania = Banani         [tania nadia rahim]
get rahim -> Mirpur        [rahim tania nadia]
put sabbir = Uttara        [sabbir rahim tania]   evicted nadia
get nadia -> miss          [sabbir rahim tania]
get tania -> Banani        [tania sabbir rahim]
put karim = Gulshan        [karim tania sabbir]   evicted rahim
put sabbir = Bashundhara   [sabbir karim tania]
hits 2, misses 1, size 3

Output টা পড়ি:

  • প্রথম তিনটা put: প্রতিটা নতুন key সামনে বসে; rahim, যে সবার আগে ঢুকেছে, পেছনে।
  • get rahim: hit, আর rahim লাফ দিয়ে সামনে চলে আসে। এখন সবচেয়ে পুরনো nadia।
  • put sabbir: cache ভরা, তাই nadia (পেছনেরটা) evict হয়, rahim না। ওই get না হলে rahim ই যেত।
  • get nadia: miss; miss হলে ক্রম বদলায় না।
  • put karim: get tania র পরে rahim আবার পেছনে, তাই rahim evict হয়।
  • put sabbir = Bashundhara: নতুন key না, update: কেউ evict হয় না, sabbir নতুন ঠিকানা নিয়ে সামনে আসে।
  • Stats: দুইটা hit (rahim, tania), একটা miss (nadia)।

বাকি পাঁচটা language

from __future__ import annotations

import threading
from collections.abc import Callable


class Node[K, V]:
    """One entry in the recency list."""
    __slots__ = ("key", "val", "prev", "next")

    def __init__(self, key: K | None = None, val: V | None = None):
        self.key, self.val = key, val
        self.prev: Node[K, V] | None = None
        self.next: Node[K, V] | None = None


class Cache[K, V]:
    """Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe.
    (collections.OrderedDict can do this in a few lines; we build the list by hand to see how.)"""

    def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
        if capacity <= 0:
            raise ValueError(f"capacity must be positive, got {capacity}")
        self.cap, self.on_evict = capacity, on_evict
        self.items: dict[K, Node[K, V]] = {}
        self.head: Node[K, V] = Node()  # sentinel: head.next is the most recently used
        self.tail: Node[K, V] = Node()  # sentinel: tail.prev is the least recently used
        self.head.next, self.tail.prev = self.tail, self.head
        self.hits = self.misses = 0

    def get(self, key: K) -> V | None:
        n = self.items.get(key)
        if n is None:
            self.misses += 1
            return None
        self.hits += 1
        self._move_to_front(n)
        return n.val

    def put(self, key: K, val: V) -> None:
        n = self.items.get(key)
        if n is not None:
            n.val = val
            self._move_to_front(n)
            return
        if len(self.items) == self.cap:
            lru = self.tail.prev
            assert lru is not None
            self._unlink(lru)
            del self.items[lru.key]  # type: ignore[arg-type]
            if self.on_evict:
                self.on_evict(lru.key, lru.val)  # type: ignore[arg-type]
        n = Node(key, val)
        self.items[key] = n
        self._push_front(n)

    def delete(self, key: K) -> bool:
        n = self.items.pop(key, None)
        if n is not None:
            self._unlink(n)
        return n is not None

    def __len__(self) -> int:
        return len(self.items)

    def keys(self) -> list[K]:
        """Most to least recently used. O(n), for tests and debugging."""
        out, n = [], self.head.next
        while n is not self.tail:
            out.append(n.key)
            n = n.next
        return out

    # The three list moves. Thanks to the sentinels there are no None checks.
    def _unlink(self, n: Node[K, V]) -> None:
        n.prev.next, n.next.prev = n.next, n.prev  # type: ignore[union-attr]
        n.prev = n.next = None

    def _push_front(self, n: Node[K, V]) -> None:
        n.prev, n.next = self.head, self.head.next
        self.head.next.prev = n  # type: ignore[union-attr]
        self.head.next = n

    def _move_to_front(self, n: Node[K, V]) -> None:
        self._unlink(n)
        self._push_front(n)


class Safe[K, V]:
    """Wraps a Cache with a lock. get moves an entry, so even a read needs the lock."""

    def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
        self._c: Cache[K, V] = Cache(capacity, on_evict)
        self._lock = threading.Lock()

    def get(self, key: K) -> V | None:
        with self._lock:
            return self._c.get(key)

    def put(self, key: K, val: V) -> None:
        with self._lock:
            self._c.put(key, val)

    def delete(self, key: K) -> bool:
        with self._lock:
            return self._c.delete(key)

    def keys(self) -> list[K]:
        with self._lock:
            return self._c.keys()


def main() -> None:
    evicted = [""]

    def remember(k: str, _: str) -> None:
        evicted[0] = k

    c: Cache[str, str] = Cache(3, remember)
    print("profile cache, capacity 3, keys from most to least recent")

    def show(action: str) -> None:
        note = f"   evicted {evicted[0]}" if evicted[0] else ""
        evicted[0] = ""
        print(f"{action:<26} [{' '.join(c.keys())}]{note}")

    def put(k: str, v: str) -> None:
        c.put(k, v)
        show(f"put {k} = {v}")

    def get(k: str) -> None:
        v = c.get(k)
        show(f"get {k} -> {v if v is not None else 'miss'}")

    put("rahim", "Mirpur")
    put("nadia", "Dhanmondi")
    put("tania", "Banani")
    get("rahim")
    put("sabbir", "Uttara")
    get("nadia")
    get("tania")
    put("karim", "Gulshan")
    put("sabbir", "Bashundhara")
    print(f"hits {c.hits}, misses {c.misses}, size {len(c)}")


if __name__ == "__main__":
    main()

Language ভেদে কী আলাদা: Python, TypeScript আর Java একই list হাতে বানায়, কিন্তু আসল code এর জন্য প্রত্যেকের একটা shortcut আছে: OrderedDict.move_to_end, Map (যেটা ঢোকানোর ক্রম মনে রাখে, তাই delete করে আবার set করলেই হয়), আর access order সহ LinkedHashMap। C++ এ std::list, যার splice কোনো copy ছাড়াই node কে সামনে আনে, তাই map list এর iterator রেখে দিতে পারে। Rust টাই মজার: যে list এর node দুই দিকে point করে, সেটা borrow checker এর সাথে লড়াই করে, তাই node গুলো একটা Vec এ থাকে আর index দিয়ে একে অপরকে চেনে, আর put callback ডাকার বদলে evict হওয়া entry ফেরত দেয়। TypeScript এ class এর নাম LruCache আর Entry, কারণ browser এ আগে থেকেই global Cache আর Node type আছে।

Testing

// cache_test.go
package lru

import (
	"fmt"
	"math/rand"
	"slices"
	"sync"
	"testing"
)

func newCache(t *testing.T, capacity int) (*Cache[string, int], *[]string) {
	var evicted []string
	c, err := New(capacity, func(k string, _ int) { evicted = append(evicted, k) })
	if err != nil {
		t.Fatal(err)
	}
	return c, &evicted
}

func TestEvictsLeastRecentlyUsed(t *testing.T) {
	c, evicted := newCache(t, 2)
	c.Put("a", 1)
	c.Put("b", 2)
	c.Put("c", 3) // a is the oldest
	if _, ok := c.Get("a"); ok || !slices.Equal(*evicted, []string{"a"}) {
		t.Fatalf("a should be gone, evicted %v", *evicted)
	}
}

func TestGetMakesAnEntryRecent(t *testing.T) {
	c, evicted := newCache(t, 2)
	c.Put("a", 1)
	c.Put("b", 2)
	c.Get("a")    // now b is the oldest
	c.Put("c", 3) // so b goes, not a
	if !slices.Equal(*evicted, []string{"b"}) || !slices.Equal(c.Keys(), []string{"c", "a"}) {
		t.Fatalf("evicted %v, keys %v", *evicted, c.Keys())
	}
}

func TestUpdateDoesNotGrow(t *testing.T) {
	c, evicted := newCache(t, 2)
	c.Put("a", 1)
	c.Put("b", 2)
	c.Put("a", 10)
	if v, _ := c.Get("a"); v != 10 || c.Len() != 2 || len(*evicted) != 0 {
		t.Fatalf("v %d, len %d, evicted %v", v, c.Len(), *evicted)
	}
}

func TestCapacityOne(t *testing.T) {
	c, _ := newCache(t, 1)
	c.Put("a", 1)
	c.Put("b", 2)
	if !slices.Equal(c.Keys(), []string{"b"}) {
		t.Fatalf("keys %v", c.Keys())
	}
}

func TestBadCapacityRefused(t *testing.T) {
	for _, n := range []int{0, -1} {
		if _, err := New[string, int](n, nil); err == nil {
			t.Fatalf("capacity %d should be refused", n)
		}
	}
}

func TestDeleteKeepsListConsistent(t *testing.T) {
	c, _ := newCache(t, 3)
	c.Put("a", 1)
	c.Put("b", 2)
	c.Put("c", 3)
	if !c.Delete("b") || c.Delete("b") {
		t.Fatal("first delete true, second false")
	}
	c.Put("d", 4)
	if !slices.Equal(c.Keys(), []string{"d", "c", "a"}) {
		t.Fatalf("keys %v", c.Keys())
	}
}

// 10,000 random operations, checked against a slow but obviously correct model:
// a slice with the most recent key first.
func TestMatchesSimpleModel(t *testing.T) {
	c, _ := newCache(t, 4)
	var model []string
	r := rand.New(rand.NewSource(1))
	for i := range 10_000 {
		k := fmt.Sprint(r.Intn(8))
		at := slices.Index(model, k)
		switch r.Intn(3) {
		case 0: // get
			_, ok := c.Get(k)
			if ok != (at >= 0) {
				t.Fatalf("op %d: get %s ok=%v", i, k, ok)
			}
			if ok {
				model = append([]string{k}, slices.Delete(model, at, at+1)...)
			}
		case 1: // put
			c.Put(k, i)
			if at >= 0 {
				model = slices.Delete(model, at, at+1)
			} else if len(model) == 4 {
				model = model[:3]
			}
			model = append([]string{k}, model...)
		case 2: // delete
			c.Delete(k)
			if at >= 0 {
				model = slices.Delete(model, at, at+1)
			}
		}
		if !slices.Equal(c.Keys(), model) {
			t.Fatalf("op %d: cache %v, model %v", i, c.Keys(), model)
		}
	}
}

// Many goroutines share one Safe cache. Run with -race.
func TestSafeUnderConcurrency(t *testing.T) {
	s, _ := NewSafe[int, int](50, nil)
	var wg sync.WaitGroup
	for g := range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := range 2000 {
				k := (g*7 + i) % 100
				if i%3 == 0 {
					s.Put(k, i)
				} else {
					s.Get(k)
				}
			}
		}()
	}
	wg.Wait()
	keys := s.Keys()
	slices.Sort(keys)
	if s.Len() > 50 || len(keys) != s.Len() || len(slices.Compact(keys)) != len(keys) {
		t.Fatalf("len %d, keys %d", s.Len(), len(keys))
	}
	if hits, misses := s.Stats(); hits+misses != 8*1333 { // 1333 gets per goroutine
		t.Fatalf("counted %d gets, want %d", hits+misses, 8*1333)
	}
}
$ go test -race -v ./...
=== RUN   TestEvictsLeastRecentlyUsed
--- PASS: TestEvictsLeastRecentlyUsed (0.00s)
=== RUN   TestGetMakesAnEntryRecent
--- PASS: TestGetMakesAnEntryRecent (0.00s)
=== RUN   TestUpdateDoesNotGrow
--- PASS: TestUpdateDoesNotGrow (0.00s)
=== RUN   TestCapacityOne
--- PASS: TestCapacityOne (0.00s)
=== RUN   TestBadCapacityRefused
--- PASS: TestBadCapacityRefused (0.00s)
=== RUN   TestDeleteKeepsListConsistent
--- PASS: TestDeleteKeepsListConsistent (0.00s)
=== RUN   TestMatchesSimpleModel
--- PASS: TestMatchesSimpleModel (0.03s)
=== RUN   TestSafeUnderConcurrency
--- PASS: TestSafeUnderConcurrency (0.02s)
PASS
ok  	lru	1.067s
?   	lru/cmd/demo	[no test files]
Testকী check করে
TestEvictsLeastRecentlyUsedCapacity 2 তে তৃতীয় key এলে প্রথমটা evict হয়
TestGetMakesAnEntryRecentএকটা Get key টাকে evict হওয়া থেকে বাঁচায়
TestUpdateDoesNotGrowআগে থেকে থাকা key এ put করলে update হয়, কেউ evict হয় না
TestCapacityOneসবচেয়ে ছোট cache ও ঠিক চলে
TestBadCapacityRefusedCapacity 0 আর -1 নেওয়া হয় না
TestDeleteKeepsListConsistentDelete এর পরেও list এর ক্রম আর পরের put গুলো ঠিক থাকে
TestMatchesSimpleModel১০,০০০টা random get, put আর delete এর পরেও ক্রম একটা সাধারণ slice এর সাথে মেলে
TestSafeUnderConcurrency৮টা goroutine একটা Safe cache ভাগ করে: size capacity র মধ্যে থাকে, কোনো key দুইবার নেই, প্রতিটা get গোনা হয়

Model test টাই সবচেয়ে শক্ত: slice এর version টা ধীর কিন্তু দেখেই বোঝা যায় ঠিক, তাই দ্রুত version কখনো এর সাথে না মিললে bug টা দ্রুত version এ।

আমরা Safe কে sync.RWMutex বানিয়ে Get এ RLock দিয়েও চালিয়েছি, মানে দ্বিতীয় diagram এর ভুলটা। প্রতিবার race detector data race ধরেছে (আমাদের run এ ১২ থেকে ১৯টা), Get, unlink আর pushFront এ, আর test fail করেছে।

Design review

যা ভালো: সব কাজ O(1); sentinel এর কারণে list এর code ছোট; LRU logic এক-thread এর আর আলাদা করে পুরো test করা; lock আছে শুধু একটা পাতলা wrapper এ।

"যদি…"কী বদলাতে হবে
একটা lock এ কুলায় না (অনেক core)Shard করো: N টা cache, প্রত্যেকের নিজের lock, আর key এর hash দেখে shard বাছো। তখন evict হয় "প্রতি shard এ LRU", যেটা বাস্তবে যথেষ্ট কাছাকাছি।
Entry র মেয়াদ থাকবেপ্রতিটা node এ মেয়াদ শেষের সময় রাখো; Get এ মেয়াদ পেরোনো entry কে miss ধরে সরিয়ে দাও।
গোনা হবে byte এ, সংখ্যায় নাপ্রতিটা entry নিজের size বলে; নতুনটা না ধরা পর্যন্ত Put পেছন থেকে evict করতে থাকে।
Callback নিজেই cache কে ডাকেSafe এর ভেতরে এতে deadlock হয়, কারণ lock আগে থেকেই ধরা। Evict হওয়া entry গুলো জমিয়ে রাখো, lock ছাড়ার পরে callback ডাকো।
একটা scan পুরো cache ধুয়ে দেয় (বড় একটা report সবকিছু একবার করে ছোঁয়)এমন policy নাও যেটা scan সামলাতে পারে, যেমন 2Q বা segmented LRU: নতুন key আগে একটা পরীক্ষামূলক list এ যায়।
অনেকগুলো serverShared cache (Redis) ব্যবহার করো, অথবা মেনে নাও প্রতিটা server এর নিজের cache; এই cache টা থাকে প্রতি process এর layer হিসেবে।

Interview প্রশ্ন

Practice প্রশ্ন

Beginner

  1. শুধু hash map দিয়ে কেন O(1) এ LRU হয় না?
  2. List এর ভাষায় "least recently used" মানে কী?

Intermediate

  1. Singly না, doubly linked list কেন?
  2. Sentinel node কী, আর এতে কী বাঁচে?

Advanced

  1. Get এর কেন read lock না, পুরো lock লাগে?
  2. 32-core machine এ cache এর lock টাই bottleneck। কী করবে?

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

  • প্রতি key তে একটা সময় রেখে সবচেয়ে পুরনোটা ঘেঁটে বের করা: O(n) evict।
  • Singly linked list: একটা node খুলতে তার আগের node লাগে, যেটা খুঁজতে হয়।
  • Get এ ক্রম update করতে ভুলে যাওয়া, এতে LRU হয়ে যায় FIFO।
  • Node এ key না রাখা, ফলে evict এর সময় map থেকে মোছা যায় না।
  • "এটা তো শুধু পড়া" ভেবে Get এ RLock।

উত্তর

১. শুধু map কেন না?

Map এর কোনো ক্রম নেই। সবচেয়ে কম সম্প্রতি ব্যবহার হওয়া key পেতে প্রতি key তে একটা সময় রেখে সবগুলো ঘাঁটতে হবে: প্রতি evict এ O(n)।

২. List এর ভাষায় LRU?

প্রতিবার ব্যবহার হলে key সামনে চলে আসে, তাই list এর পেছনে থাকে সেই key, যেটা সবচেয়ে বেশি সময় ধরে ব্যবহার হয়নি। Evict হয় ওটাই।

৩. Doubly কেন?

একটা node খুলতে হলে তার আগের node এর next বদলাতে হয়। prev pointer থাকলে সেটা O(1); singly linked list এ head থেকে হেঁটে খুঁজে বের করতে হতো।

৪. Sentinel?

নকল একটা head আর tail, যেগুলো সবসময় থাকে। তখন প্রতিটা আসল node এর দুই পাশেই প্রতিবেশী থাকে, তাই insert আর unlink এ "list কি খালি?" বা "এটা কি প্রথম node?" ধরনের কোনো branch লাগে না।

৫. Get এ পুরো lock কেন?

Get node টাকে সামনে আনে, মানে কয়েকটা pointer নতুন করে লেখে। Read lock দুইটা Get কে একসাথে এই কাজ করতে দেয়, আর তাদের pointer লেখা একটার মধ্যে আরেকটা ঢুকে list ভেঙে যায়। RLock দিয়ে আমাদের test ১২ থেকে ১৯টা data race দেখিয়েছে।

৬. Lock ই bottleneck?

Cache টাকে কয়েকটা shard এ ভাগ করো (ধরো ১৬টা), প্রত্যেকের নিজের lock, key এর hash দেখে shard বাছা। আলাদা shard ছোঁয়া thread গুলো একে অপরের জন্য অপেক্ষা করে না। পুরো cache জুড়ে একদম ঠিকঠাক LRU ক্রম আর থাকে না, কিন্তু সেটা খুব কমই দরকার হয়।

Interviewer রা যা দেখেন: code এর আগে map + doubly linked list এর জুটিটা বুঝিয়ে বলা, সবকিছু O(1), node এ key রাখা, পরিষ্কার pointer এর code (sentinel এতে সাহায্য করে), আর read lock এর ফাঁদটা চেনা।

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