原文の背景メモ: https://www.aceround.app/ja/blog/backend-developer-interview-ai
この記事は、バックエンド面接で出やすい「キャッシュをどう設計・実装するか」という論点を、Qiita向けにLRU Cacheの実装問題として再構成したものです。
結論
LRU Cacheを面接で説明するなら、Mapだけの小技よりも、HashMap + 双方向リストの構成で話すのが安全です。
-
get(key)は値を返し、そのキーを「最近使った」位置へ移動します。 -
put(key, value)は値を追加・更新し、容量を超えたら「最も使われていない」キーを捨てます。 - HashMapでキーからノードへ直接到達し、双方向リストで順序の入れ替えと末尾削除をします。
- これで
get/putともに平均O(1)になります。
JavaScript / TypeScriptでは Map が挿入順を保持するため、実務では delete して set し直す簡易LRUも書けます。ただしライブコーディング面接では、データ構造の理解を見られることが多いので、ここでは明示的に双方向リストを実装します。
問題設定
容量 capacity を持つキャッシュを作ります。
const cache = new LRUCache<string, number>(2);
cache.put('A', 1); // A
cache.put('B', 2); // B, A
cache.get('A'); // A, B になる
cache.put('C', 3); // C, A になり、B が削除される
ここで、左側を「最も最近使った要素」、右側を「最も古い要素」とします。
必要な操作は次の2つです。
get(key): value | undefined
put(key, value): void
get したキーも「使われた」とみなす点が重要です。読み取りだけでも順序が変わります。
なぜ HashMap だけでは足りないのか
HashMapは key -> value の検索を O(1) にできます。しかし、「どのキーが最も古いか」を直接は教えてくれません。
一方、配列や単方向リストで順序を管理すると、途中の要素を削除して先頭へ移動する操作が O(n) になります。
そこで、役割を分けます。
| 役割 | データ構造 | 理由 |
|---|---|---|
| キーから該当要素を探す | Map<K, Node> |
平均 O(1) でノードへ到達するため |
| 最近使った順序を保つ | 双方向リスト | 任意ノードの削除・先頭追加を O(1) にするため |
この2つを組み合わせると、検索と順序更新の両方を定数時間で処理できます。
不変条件を先に決める
実装前に、不変条件を3つに絞るとバグが減ります。
-
mapはkey -> list nodeを持ちます。 -
head.nextが最も最近使ったノード、tail.prevが最も古いノードです。 - キャッシュ内の実データは、必ず
mapとリストの両方に存在します。
head と tail は番兵ノードです。番兵を置くと、空リスト・先頭・末尾の分岐を減らせます。
head <-> most recent <-> ... <-> least recent <-> tail
TypeScript実装
type NodeRef<K, V> = {
key?: K;
value?: V;
prev: NodeRef<K, V> | null;
next: NodeRef<K, V> | null;
};
class LRUCache<K, V> {
private readonly map = new Map<K, NodeRef<K, V>>();
private readonly head: NodeRef<K, V> = { prev: null, next: null };
private readonly tail: NodeRef<K, V> = { prev: null, next: null };
constructor(private readonly capacity: number) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new Error('capacity must be a positive integer');
}
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
const node = this.map.get(key);
if (!node) return undefined;
this.moveToFront(node);
return node.value;
}
put(key: K, value: V): void {
const existing = this.map.get(key);
if (existing) {
existing.value = value;
this.moveToFront(existing);
return;
}
const node: NodeRef<K, V> = { key, value, prev: null, next: null };
this.map.set(key, node);
this.addToFront(node);
if (this.map.size > this.capacity) {
this.evictLeastRecentlyUsed();
}
}
entriesMostRecentFirst(): Array<[K, V]> {
const result: Array<[K, V]> = [];
let current = this.head.next;
while (current && current !== this.tail) {
result.push([current.key as K, current.value as V]);
current = current.next;
}
return result;
}
private moveToFront(node: NodeRef<K, V>): void {
this.remove(node);
this.addToFront(node);
}
private addToFront(node: NodeRef<K, V>): void {
const first = this.head.next;
if (!first) throw new Error('broken list: head.next is missing');
node.prev = this.head;
node.next = first;
first.prev = node;
this.head.next = node;
}
private remove(node: NodeRef<K, V>): void {
if (!node.prev || !node.next) {
throw new Error('broken list: node is detached');
}
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
}
private evictLeastRecentlyUsed(): void {
const lru = this.tail.prev;
if (!lru || lru === this.head || lru.key === undefined) {
throw new Error('broken list: LRU node is missing');
}
this.remove(lru);
this.map.delete(lru.key);
}
}
entriesMostRecentFirst は動作確認用です。本番のキャッシュAPIには必須ではありません。
動かして確認する
const cache = new LRUCache<string, number>(2);
cache.put('A', 1); // A
cache.put('B', 2); // B, A
console.log(cache.get('A')); // 1 => A, B
cache.put('C', 3); // C, A(Bが削除される)
console.log(cache.get('B')); // undefined
cache.put('A', 10); // A, C
console.log(cache.entriesMostRecentFirst());
出力は次のようになります。
1
undefined
[ [ 'A', 10 ], [ 'C', 3 ] ]
Bunならこのまま実行できます。
bun lru-cache.ts
get の流れを分解する
get('A') でやっていることは2つだけです。
-
map.get('A')でノードを探します。 - 見つかったノードをリストの先頭へ移動します。
先頭へ移動する処理は、さらに次の2段階です。
this.remove(node);
this.addToFront(node);
双方向リストなので、ノード自身が前後の参照を持っています。そのため、リスト全体を走査せずに取り外せます。
before:
head <-> B <-> A <-> tail
get('A') after remove + addToFront:
head <-> A <-> B <-> tail
この「途中ノードを O(1) で外せる」点が、単方向リストではなく双方向リストを使う理由です。
put の流れを分解する
put は、既存キーか新規キーかで分岐します。
既存キーの場合
値を上書きし、最近使った扱いにします。
const existing = this.map.get(key);
if (existing) {
existing.value = value;
this.moveToFront(existing);
return;
}
ここで容量は増えません。削除も不要です。
新規キーの場合
新しいノードを作って先頭に追加します。
const node: NodeRef<K, V> = { key, value, prev: null, next: null };
this.map.set(key, node);
this.addToFront(node);
その後、容量を超えた場合だけ末尾側を削除します。
if (this.map.size > this.capacity) {
this.evictLeastRecentlyUsed();
}
最も古いノードは常に tail.prev です。ここも走査しません。
計算量
| 操作 | 計算量 | 理由 |
|---|---|---|
get |
平均 O(1)
|
Map検索 + 双方向リストの付け替え |
put |
平均 O(1)
|
Map更新 + 先頭追加 + 必要なら末尾削除 |
| 空間 | O(capacity) |
最大で容量分のノードとMap要素を持つ |
厳密にはJavaScriptの Map はハッシュテーブル相当の平均 O(1) として扱います。面接では「平均」と一言添えると誠実です。
面接での説明テンプレート
ライブコーディングでは、コードを書く前に次の順番で説明すると通じやすいです。
- 「キー検索はHashMapで
O(1)にします」 - 「LRU順序は双方向リストで持ちます」
- 「リストの先頭をmost recent、末尾をleast recentにします」
- 「
getされたノードは先頭へ移動します」 - 「
putで容量を超えたら末尾の直前を削除します」
この説明を先に置くと、実装中に多少詰まっても、面接官は設計意図を追いやすくなります。
よくあるバグ
1. get で順序を更新し忘れる
LRUの「used」は書き込みだけではありません。読み取りも使用です。get で先頭へ移動しないと、頻繁に読まれているキーが誤って削除されます。
2. map.delete を忘れる
リストからノードを外しただけでは、map に古い参照が残ります。削除時は必ず両方を更新します。
this.remove(lru);
this.map.delete(lru.key);
3. 先頭・末尾の分岐が増えすぎる
番兵ノードを使わない実装では、空リスト、要素1個、先頭削除、末尾削除の分岐が増えます。面接ではバグりやすいので、head / tail の番兵を使うのがおすすめです。
4. 容量1のケースを確認しない
capacity = 1 は良いテストケースです。
const c = new LRUCache<string, number>(1);
c.put('A', 1);
c.put('B', 2);
console.log(c.get('A')); // undefined
console.log(c.get('B')); // 2
ここで壊れる実装は、削除処理か番兵のつなぎ替えに問題があります。
Mapの挿入順だけで書く簡易版について
TypeScript実務では、次のような簡易実装も可能です。
class SimpleLRU<K, V> {
private readonly map = new Map<K, V>();
constructor(private readonly capacity: number) {}
get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}
put(key: K, value: V): void {
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, value);
if (this.map.size > this.capacity) {
const oldestKey = this.map.keys().next().value;
this.map.delete(oldestKey);
}
}
}
これは短く、用途によっては十分です。ただし、面接で「なぜ delete して set し直すと順序が変わるのか」「undefined を値として保存したい場合はどうするのか」と聞かれる可能性があります。
データ構造の理解を示したい場面では、HashMap + 双方向リスト版を説明できるようにしておく方が堅いです。
まとめ
LRU Cacheは、単に「キャッシュを作る問題」ではありません。HashMapの検索性能と、双方向リストの順序更新を組み合わせる問題です。
押さえるポイントは次の4つです。
-
Map<K, Node>でキーからノードへ直接到達します。 - 双方向リストで最近使った順序を管理します。
-
getでも順序を更新します。 - 容量超過時は
tail.prevを削除します。
この形で説明できると、LRU Cacheだけでなく、キャッシュ設計、メモリ管理、バックエンドのホットデータ管理の話にも自然につなげられます。