beam: O(1) LFU cache with doubly-linked freq buckets + tests
This commit is contained in:
1 parent
44750cb9e0
commit
5886bddabc
2 files changed
+241
No files matched your search
@@ -0,0 +1,108 @@
|
||||
"""O(1) Least-Frequently-Used cache.
|
||||
|
||||
Design: each frequency bucket is a doubly-linked list ordered by recency
|
||||
(most-recently-used at the head). A node stores key, value, freq. On access
|
||||
the node moves from its current bucket to the next-frequency bucket's head.
|
||||
min_freq tracks the bucket containing the least-frequent (and among those,
|
||||
least-recently-used) node for O(1) eviction.
|
||||
"""
|
||||
|
||||
|
||||
class _Node:
|
||||
__slots__ = ("key", "value", "freq", "prev", "next")
|
||||
|
||||
def __init__(self, key, value):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.freq = 1
|
||||
self.prev = None
|
||||
self.next = None
|
||||
|
||||
|
||||
class _DLList:
|
||||
"""Doubly linked list with sentinel head/tail; most-recent at head side."""
|
||||
|
||||
__slots__ = ("head", "tail", "size")
|
||||
|
||||
def __init__(self):
|
||||
self.head = _Node(None, None) # sentinel
|
||||
self.tail = _Node(None, None) # sentinel
|
||||
self.head.next = self.tail
|
||||
self.tail.prev = self.head
|
||||
self.size = 0
|
||||
|
||||
def add_front(self, node):
|
||||
node.prev = self.head
|
||||
node.next = self.head.next
|
||||
self.head.next.prev = node
|
||||
self.head.next = node
|
||||
self.size += 1
|
||||
|
||||
def remove(self, node):
|
||||
node.prev.next = node.next
|
||||
node.next.prev = node.prev
|
||||
node.prev = None
|
||||
node.next = None
|
||||
self.size -= 1
|
||||
|
||||
def pop_tail(self):
|
||||
# least recent = node before tail sentinel
|
||||
if self.size == 0:
|
||||
return None
|
||||
node = self.tail.prev
|
||||
self.remove(node)
|
||||
return node
|
||||
|
||||
def is_empty(self):
|
||||
return self.size == 0
|
||||
|
||||
|
||||
class LFUCache:
|
||||
def __init__(self, capacity):
|
||||
if capacity < 0:
|
||||
raise ValueError("capacity must be non-negative")
|
||||
self.capacity = capacity
|
||||
self._key = {} # key -> _Node
|
||||
self._freq = {} # freq -> _DLList
|
||||
self._min_freq = 0
|
||||
|
||||
def _touch(self, node):
|
||||
# move node from its current freq bucket to freq+1 bucket head
|
||||
old_list = self._freq[node.freq]
|
||||
old_list.remove(node)
|
||||
if old_list.is_empty():
|
||||
del self._freq[node.freq]
|
||||
if self._min_freq == node.freq:
|
||||
self._min_freq += 1
|
||||
node.freq += 1
|
||||
self._freq.setdefault(node.freq, _DLList()).add_front(node)
|
||||
|
||||
def get(self, key):
|
||||
if key not in self._key:
|
||||
return -1
|
||||
node = self._key[key]
|
||||
self._touch(node)
|
||||
return node.value
|
||||
|
||||
def put(self, key, value):
|
||||
if self.capacity == 0:
|
||||
return
|
||||
if key in self._key:
|
||||
node = self._key[key]
|
||||
node.value = value
|
||||
self._touch(node)
|
||||
return
|
||||
# eviction if needed
|
||||
if len(self._key) >= self.capacity:
|
||||
evict_list = self._freq.get(self._min_freq)
|
||||
if evict_list is not None:
|
||||
node = evict_list.pop_tail()
|
||||
if node is not None:
|
||||
del self._key[node.key]
|
||||
if evict_list.is_empty():
|
||||
del self._freq[self._min_freq]
|
||||
# insert new node at freq 1
|
||||
node = _Node(key, value)
|
||||
self._key[key] = node
|
||||
self._freq.setdefault(1, _DLList()).add_front(node)
|
||||
self._min_freq = 1
|
||||
@@ -0,0 +1,133 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/root/.hermes/wizard-outputs/beam")
|
||||
from lfu_cache import LFUCache
|
||||
|
||||
|
||||
def test_basic_get_put():
|
||||
c = LFUCache(2)
|
||||
c.put(1, 10)
|
||||
c.put(2, 20)
|
||||
assert c.get(1) == 10
|
||||
assert c.get(2) == 20
|
||||
assert c.get(3) == -1
|
||||
|
||||
|
||||
def test_evict_least_freq():
|
||||
c = LFUCache(2)
|
||||
c.put(1, 10)
|
||||
c.put(2, 20)
|
||||
c.get(1) # freq 1->2
|
||||
c.put(3, 30) # evict 2 (freq1)
|
||||
assert c.get(2) == -1
|
||||
assert c.get(1) == 10
|
||||
assert c.get(3) == 30
|
||||
|
||||
|
||||
def test_lru_within_same_freq():
|
||||
c = LFUCache(2)
|
||||
c.put(1, 10)
|
||||
c.put(2, 20)
|
||||
# both freq1; touch nothing extra; insert 3 evicts LRU among freq1 -> key1
|
||||
c.put(3, 30)
|
||||
assert c.get(1) == -1
|
||||
assert c.get(2) == 20
|
||||
assert c.get(3) == 30
|
||||
|
||||
|
||||
def test_update_existing():
|
||||
c = LFUCache(2)
|
||||
c.put(1, 10)
|
||||
c.put(1, 11)
|
||||
assert c.get(1) == 11
|
||||
# updating counts as access; freq now 2
|
||||
|
||||
|
||||
def test_capacity_zero():
|
||||
c = LFUCache(0)
|
||||
c.put(1, 10)
|
||||
assert c.get(1) == -1
|
||||
|
||||
|
||||
def test_evict_after_freq_tiebreak():
|
||||
c = LFUCache(3)
|
||||
c.put(1, 1)
|
||||
c.put(2, 2)
|
||||
c.put(3, 3)
|
||||
c.get(1)
|
||||
c.get(2)
|
||||
# freq: 1->2, 2->2, 3->1 ; min freq bucket {3}
|
||||
c.put(4, 4) # evict 3
|
||||
assert c.get(3) == -1
|
||||
assert c.get(1) == 1 and c.get(2) == 2 and c.get(4) == 4
|
||||
|
||||
|
||||
def test_negative_capacity_raises():
|
||||
try:
|
||||
LFUCache(-1)
|
||||
assert False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_many_ops_stress():
|
||||
# brute-force compare against a naive dict-based LFU
|
||||
import random
|
||||
random.seed(42)
|
||||
cap = 5
|
||||
c = LFUCache(cap)
|
||||
# naive
|
||||
store = {}
|
||||
freq = {}
|
||||
order = {} # key -> insertion/access counter for LRU tiebreak
|
||||
counter = [0]
|
||||
|
||||
def naive_get(k):
|
||||
if k not in store:
|
||||
return -1
|
||||
freq[k] += 1
|
||||
counter[0] += 1
|
||||
order[k] = counter[0]
|
||||
return store[k]
|
||||
|
||||
def naive_put(k, v):
|
||||
nonlocal store
|
||||
if cap == 0:
|
||||
return
|
||||
if k in store:
|
||||
store[k] = v
|
||||
freq[k] += 1
|
||||
counter[0] += 1
|
||||
order[k] = counter[0]
|
||||
return
|
||||
if len(store) >= cap:
|
||||
# evict min freq, then min order
|
||||
minf = min(freq[x] for x in store)
|
||||
candidates = [x for x in store if freq[x] == minf]
|
||||
victim = min(candidates, key=lambda x: order[x])
|
||||
del store[victim]; del freq[victim]; del order[victim]
|
||||
store[k] = v
|
||||
freq[k] = 1
|
||||
counter[0] += 1
|
||||
order[k] = counter[0]
|
||||
|
||||
ops = 2000
|
||||
keys = list(range(1, 12))
|
||||
for _ in range(ops):
|
||||
k = random.choice(keys)
|
||||
if random.random() < 0.5:
|
||||
a = c.get(k)
|
||||
b = naive_get(k)
|
||||
assert a == b, (a, b, k)
|
||||
else:
|
||||
v = random.randint(0, 99)
|
||||
c.put(k, v)
|
||||
naive_put(k, v)
|
||||
# final consistency: all keys
|
||||
for k in keys:
|
||||
assert c.get(k) == naive_get(k), k
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in [n for n in globals() if n.startswith("test_")]:
|
||||
globals()[name]()
|
||||
print("ALL TESTS PASSED")
|
||||
Reference in new issue
Block a user