Skip to content

Commit e586905

Browse files
committed
feat: add DoublyListNode
1 parent ad6325c commit e586905

File tree

3 files changed

+225
-5
lines changed

3 files changed

+225
-5
lines changed

leetcode/lru_cache/solution.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from collections import OrderedDict
22

3+
from leetcode_py.data_structures.doubly_list_node import DoublyListNode
4+
35

46
class LRUCache:
57
# Space: O(capacity)
@@ -31,3 +33,76 @@ def put(self, key: int, value: int) -> None:
3133
self.cache.popitem(last=False)
3234

3335
self.cache[key] = value
36+
37+
38+
class CacheNode(DoublyListNode[int]):
39+
def __init__(self, key: int = 0, val: int = 0) -> None:
40+
super().__init__(val)
41+
self.key = key
42+
43+
44+
class LRUCacheWithDoublyList:
45+
def __init__(self, capacity: int) -> None:
46+
self.capacity = capacity
47+
self.cache: dict[int, CacheNode] = {}
48+
49+
# Dummy head and tail nodes
50+
self.head = CacheNode()
51+
self.tail = CacheNode()
52+
self.head.next = self.tail
53+
self.tail.prev = self.head
54+
55+
def _add_node(self, node: CacheNode) -> None:
56+
"""Add node right after head"""
57+
node.prev = self.head
58+
node.next = self.head.next
59+
if self.head.next:
60+
self.head.next.prev = node
61+
self.head.next = node
62+
63+
def _remove_node(self, node: CacheNode) -> None:
64+
"""Remove node from list"""
65+
if node.prev:
66+
node.prev.next = node.next
67+
if node.next:
68+
node.next.prev = node.prev
69+
70+
def _move_to_head(self, node: CacheNode) -> None:
71+
"""Move node to head (most recent)"""
72+
self._remove_node(node)
73+
self._add_node(node)
74+
75+
def _pop_tail(self) -> CacheNode:
76+
"""Remove last node before tail"""
77+
last_node = self.tail.prev
78+
assert isinstance(last_node, CacheNode), "Expected CacheNode"
79+
self._remove_node(last_node)
80+
return last_node
81+
82+
def get(self, key: int) -> int:
83+
node = self.cache.get(key)
84+
if not node:
85+
return -1
86+
87+
# Move to head (most recent)
88+
self._move_to_head(node)
89+
return node.val
90+
91+
def put(self, key: int, value: int) -> None:
92+
node = self.cache.get(key)
93+
94+
if node:
95+
# Update existing
96+
node.val = value
97+
self._move_to_head(node)
98+
else:
99+
# Add new
100+
new_node = CacheNode(key, value)
101+
102+
if len(self.cache) >= self.capacity:
103+
# Remove LRU
104+
tail = self._pop_tail()
105+
del self.cache[tail.key]
106+
107+
self.cache[key] = new_node
108+
self._add_node(new_node)

leetcode/lru_cache/tests.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,44 @@
22

33
from leetcode_py.test_utils import logged_test
44

5-
from .solution import LRUCache
5+
from .solution import LRUCache, LRUCacheWithDoublyList
66

77

88
class TestLRUCache:
9+
@pytest.mark.parametrize("lru_class", [LRUCache, LRUCacheWithDoublyList])
910
@pytest.mark.parametrize(
1011
"operations, inputs, expected",
1112
[
1213
(
1314
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"],
1415
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]],
1516
[None, None, None, 1, None, -1, None, -1, 3, 4],
16-
)
17+
),
18+
(
19+
["LRUCache", "get", "put", "get", "put", "put", "get", "get"],
20+
[[2], [2], [2, 6], [1], [1, 5], [1, 2], [1], [2]],
21+
[None, -1, None, -1, None, None, 2, 6],
22+
),
23+
(
24+
["LRUCache", "put", "get", "put", "get", "get"],
25+
[[1], [2, 1], [2], [3, 2], [2], [3]],
26+
[None, None, 1, None, -1, 2],
27+
),
1728
],
1829
)
1930
@logged_test
20-
def test_lru_cache(self, operations: list[str], inputs: list[list[int]], expected: list[int | None]):
21-
cache: LRUCache | None = None
31+
def test_lru_cache(
32+
self,
33+
lru_class: type[LRUCache | LRUCacheWithDoublyList],
34+
operations: list[str],
35+
inputs: list[list[int]],
36+
expected: list[int | None],
37+
):
38+
cache = None
2239
results: list[int | None] = []
2340
for i, op in enumerate(operations):
2441
if op == "LRUCache":
25-
cache = LRUCache(inputs[i][0])
42+
cache = lru_class(inputs[i][0])
2643
results.append(None)
2744
elif op == "get" and cache is not None:
2845
results.append(cache.get(inputs[i][0]))
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from typing import Generic, TypeVar
2+
3+
# TODO: Remove TypeVar when minimum Python version is 3.12+ (use class DoublyListNode[T]: syntax)
4+
T = TypeVar("T")
5+
6+
7+
class DoublyListNode(Generic[T]):
8+
def __init__(
9+
self, val: T, prev: "DoublyListNode[T] | None" = None, next: "DoublyListNode[T] | None" = None
10+
):
11+
self.val = val
12+
self.prev = prev
13+
self.next = next
14+
15+
@classmethod
16+
def from_list(cls, arr: list[T]) -> "DoublyListNode[T] | None":
17+
if not arr:
18+
return None
19+
head = cls(arr[0])
20+
current = head
21+
for val in arr[1:]:
22+
new_node = cls(val, prev=current)
23+
current.next = new_node
24+
current = new_node
25+
return head
26+
27+
def _has_cycle(self) -> bool:
28+
"""Detect cycle using Floyd's algorithm in forward direction."""
29+
slow = fast = self
30+
while fast and fast.next and fast.next.next:
31+
assert slow.next is not None
32+
slow = slow.next
33+
fast = fast.next.next
34+
if slow is fast:
35+
return True
36+
return False
37+
38+
def to_list(self, max_length: int = 1000) -> list[T]:
39+
result: list[T] = []
40+
current: "DoublyListNode[T] | None" = self
41+
visited: set[int] = set()
42+
43+
while current and len(result) < max_length:
44+
if id(current) in visited:
45+
break
46+
visited.add(id(current))
47+
result.append(current.val)
48+
current = current.next
49+
return result
50+
51+
def __str__(self) -> str:
52+
if self._has_cycle():
53+
result: list[str] = []
54+
current: "DoublyListNode[T] | None" = self
55+
visited: dict[int, int] = {}
56+
position = 0
57+
58+
while current:
59+
if id(current) in visited:
60+
cycle_pos = visited[id(current)]
61+
cycle_val = result[cycle_pos]
62+
result_str = " <-> ".join(result)
63+
return f"{result_str} <-> ... (cycle back to {cycle_val})"
64+
65+
visited[id(current)] = position
66+
result.append(str(current.val))
67+
current = current.next
68+
position += 1
69+
70+
values = self.to_list()
71+
result_str = " <-> ".join(str(val) for val in values)
72+
if len(values) >= 1000:
73+
result_str += " <-> ... (long list)"
74+
return result_str
75+
76+
def __repr__(self) -> str:
77+
return f"{self.__class__.__name__}({self.to_list()})"
78+
79+
def _repr_html_(self) -> str:
80+
"""Generate HTML representation with bidirectional arrows."""
81+
try:
82+
import graphviz
83+
except ImportError:
84+
return f"<pre>{self.__str__()}</pre>"
85+
86+
dot = graphviz.Digraph(comment="DoublyLinkedList")
87+
dot.attr(rankdir="LR")
88+
dot.attr("node", shape="box", style="rounded,filled", fillcolor="lightgreen")
89+
dot.attr("edge", color="black")
90+
91+
current: "DoublyListNode[T] | None" = self
92+
visited: dict[int, int] = {}
93+
node_id = 0
94+
95+
while current:
96+
if id(current) in visited:
97+
cycle_target = visited[id(current)]
98+
dot.edge(
99+
f"node_{node_id - 1}",
100+
f"node_{cycle_target}",
101+
color="red",
102+
style="dashed",
103+
label="cycle",
104+
)
105+
break
106+
107+
visited[id(current)] = node_id
108+
dot.node(f"node_{node_id}", str(current.val))
109+
110+
if current.next and id(current.next) not in visited:
111+
# Forward edge
112+
dot.edge(f"node_{node_id}", f"node_{node_id + 1}", label="next")
113+
# Backward edge
114+
dot.edge(f"node_{node_id + 1}", f"node_{node_id}", label="prev", color="blue")
115+
116+
current = current.next
117+
node_id += 1
118+
119+
return dot.pipe(format="svg", encoding="utf-8")
120+
121+
def __eq__(self, other: object) -> bool:
122+
if not isinstance(other, DoublyListNode):
123+
return False
124+
125+
if self._has_cycle() or other._has_cycle():
126+
return False
127+
128+
return self.to_list() == other.to_list()

0 commit comments

Comments
 (0)