Interview prep
LRU Cache β From Scratch in Go
Implementasi LRU Cache dari nol di Go: hashmap + doubly linked list, O(1) get/put, thread-safe dengan sync.Mutex. Lengkap dengan test dan benchmark.
LRU Cache
Konsep
LRU (Least Recently Used) β ketika cache penuh, evict item yang paling lama tidak diakses.
Data structure: Hash Map + Doubly Linked List
- Hash Map: O(1) lookup by key
- Doubly Linked List: O(1) move-to-front + remove-from-back
Head (MRU) β β ... β β Tail (LRU)
[key3] [key1]
(baru diakses) (paling lama tidak diakses)
Kalau cache penuh β evict Tail
Kalau akses key1 β pindahin key1 ke HeadImplementasi Go
package lru
import (
"container/list"
"sync"
)
// Cache is a thread-safe LRU cache.
type Cache struct {
mu sync.Mutex
capacity int
items map[string]*list.Element // key β node in eviction list
evictList *list.List // doubly linked list (most recent β least recent)
}
type entry struct {
key string
value interface{}
}
func New(capacity int) *Cache {
return &Cache{
capacity: capacity,
items: make(map[string]*list.Element, capacity),
evictList: list.New(),
}
}
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
elem, ok := c.items[key]
if !ok {
return nil, false
}
// Move to front (most recently used)
c.evictList.MoveToFront(elem)
return elem.Value.(*entry).value, true
}
func (c *Cache) Put(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
// Update existing
if elem, ok := c.items[key]; ok {
c.evictList.MoveToFront(elem)
elem.Value.(*entry).value = value
return
}
// Add new
elem := c.evictList.PushFront(&entry{key: key, value: value})
c.items[key] = elem
// Evict if over capacity
if c.evictList.Len() > c.capacity {
c.evict()
}
}
func (c *Cache) evict() {
// Remove from back (least recently used)
elem := c.evictList.Back()
if elem == nil {
return
}
c.evictList.Remove(elem)
delete(c.items, elem.Value.(*entry).key)
}
func (c *Cache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.evictList.Len()
}
func (c *Cache) Remove(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
c.evictList.Remove(elem)
delete(c.items, key)
}
}Versi Tanpa container/list (Interview)
Kadang interviewer minta implementasi doubly linked list sendiri.
type node struct {
key string
value interface{}
prev *node
next *node
}
type Cache struct {
mu sync.Mutex
capacity int
items map[string]*node
head *node // most recently used
tail *node // least recently used
}
func (c *Cache) moveToFront(n *node) {
if c.head == n {
return // already at front
}
// Remove from current position
if n.prev != nil {
n.prev.next = n.next
}
if n.next != nil {
n.next.prev = n.prev
}
if c.tail == n {
c.tail = n.prev
}
// Insert at front
n.prev = nil
n.next = c.head
if c.head != nil {
c.head.prev = n
}
c.head = n
if c.tail == nil {
c.tail = n
}
}
func (c *Cache) addToFront(n *node) {
n.prev = nil
n.next = c.head
if c.head != nil {
c.head.prev = n
}
c.head = n
if c.tail == nil {
c.tail = n
}
}
func (c *Cache) removeTail() {
if c.tail == nil {
return
}
delete(c.items, c.tail.key)
if c.tail.prev != nil {
c.tail.prev.next = nil
}
c.tail = c.tail.prev
if c.tail == nil {
c.head = nil
}
}Test
func TestCache_Basic(t *testing.T) {
c := New(2)
c.Put("a", 1)
c.Put("b", 2)
v, ok := c.Get("a")
assert.True(t, ok)
assert.Equal(t, 1, v)
// Evicts "b" (LRU)
c.Put("c", 3)
_, ok = c.Get("b")
assert.False(t, ok, "b should be evicted")
}
func TestCache_UpdateExisting(t *testing.T) {
c := New(2)
c.Put("a", 1)
c.Put("a", 100) // update
v, _ := c.Get("a")
assert.Equal(t, 100, v)
assert.Equal(t, 1, c.Len()) // still 1 item
}
func TestCache_Concurrent(t *testing.T) {
c := New(100)
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key-%d", i%50)
c.Put(key, i)
c.Get(key)
}(i)
}
wg.Wait()
// Should not panic (no concurrent map writes)
assert.LessOrEqual(t, c.Len(), 100)
}Bucket-Based LRU (Reduce Lock Contention)
Untuk high-throughput, satu mutex jadi bottleneck. Solusi: sharding.
type ShardedCache struct {
shards []*Cache
shardMask uint32
}
func NewSharded(shardCount int, capacityPerShard int) *ShardedCache {
// shardCount harus power of 2
shards := make([]*Cache, shardCount)
for i := range shards {
shards[i] = New(capacityPerShard)
}
return &ShardedCache{
shards: shards,
shardMask: uint32(shardCount - 1),
}
}
func (s *ShardedCache) getShard(key string) *Cache {
h := fnv.New32a()
h.Write([]byte(key))
return s.shards[h.Sum32()&s.shardMask]
}
func (s *ShardedCache) Get(key string) (interface{}, bool) {
return s.getShard(key).Get(key)
}
func (s *ShardedCache) Put(key string, value interface{}) {
s.getShard(key).Put(key, value)
}Complexity
| Operation | Time | Space |
|---|---|---|
| Get | O(1) | O(n) |
| Put | O(1) | O(n) |
| Evict | O(1) | O(1) |
Interview Talking Points
"Kenapa doubly linked list, bukan array?"
- Array: move-to-front butuh shift semua elemen β O(n)
- Linked list: move-to-front cuma update 4 pointers β O(1)
"Kenapa harus hashmap + linked list?"
- Hashmap sendiri: gak bisa track order
- Linked list sendiri: lookup O(n)
- Kombinasi: hashmap buat O(1) lookup, linked list buat O(1) reorder
"Apa alternatif LRU?"
- LFU (Least Frequently Used): evict yang jarang diakses, bukan yang lama. Cocok buat CDN (file populer tetap stay).
- ARC (Adaptive Replacement Cache): gabungan LRU + LFU, lebih baik tapi lebih kompleks.
- TTL-based: evict berdasarkan expiry time, bukan access pattern. Cocok buat session/token cache.
Edit on GitHub
Last updated on