C# · Course navigation

C# LRU cache with Dictionary and LinkedList

Combine a hash table and a doubly linked list so that get, put, and eviction all take constant time.

On this page
Before you start

C# Dictionary and LinkedList<T>.

  • Combine a hash table and a doubly linked list so that get, put, and eviction all take constant time.
  • Predict the sample output and explain the state changes.
  • Complete the practice task and check the boundary cases.

The concept

A least-recently-used (LRU) cache keeps a fixed number of entries and evicts the one that has not been used for the longest time. It needs two things to be fast: finding an entry by key, and finding and updating the order of use. A Dictionary gives O(1) lookup by key. A doubly linked list gives O(1) removal from anywhere and O(1) insertion at the ends, if you hold a reference to the node.

The list keeps the most recently used entry at the front. The dictionary maps each key to its list node. A get moves the node to the front. A put either updates and moves an existing node, or adds a new one at the front and, if the capacity is exceeded, removes the node at the back and its dictionary entry. Every operation is O(1). This is the structure behind many in-memory caches.

Code example

C#
using System;
using System.Collections.Generic;

class LruCache {
  private readonly int capacity;
  private readonly Dictionary<int, LinkedListNode<(int Key, int Value)>> map = new Dictionary<int, LinkedListNode<(int Key, int Value)>>();
  private readonly LinkedList<(int Key, int Value)> order = new LinkedList<(int Key, int Value)>();

  public LruCache(int capacity) {
    this.capacity = capacity;
  }

  public int Get(int key) {
    if (!map.TryGetValue(key, out var node)) {
      return -1;
    }
    order.Remove(node);
    order.AddFirst(node);
    return node.Value.Value;
  }

  public void Put(int key, int value) {
    if (map.TryGetValue(key, out var existing)) {
      order.Remove(existing);
      map.Remove(key);
    } else if (map.Count == capacity) {
      var oldest = order.Last;
      order.RemoveLast();
      map.Remove(oldest.Value.Key);
      Console.WriteLine("evicted " + oldest.Value.Key);
    }
    var node = order.AddFirst((key, value));
    map[key] = node;
  }
}

class Program {
  static void Main() {
    var cache = new LruCache(2);
    cache.Put(1, 10);
    cache.Put(2, 20);
    Console.WriteLine(cache.Get(1));
    cache.Put(3, 30);
    Console.WriteLine(cache.Get(2));
    cache.Put(4, 40);
    Console.WriteLine(cache.Get(1) + " " + cache.Get(3) + " " + cache.Get(4));
  }
}
Output
10
evicted 2
-1
evicted 1
-1 30 40

Follow the code

Open the walkthrough to inspect the example one step at a time.

Show guided walkthrough
Guided walkthroughReady
1using System;
2using System.Collections.Generic;
3
4class LruCache {
5 private readonly int capacity;
6 private readonly Dictionary<int, LinkedListNode<(int Key, int Value)>> map = new Dictionary<int, LinkedListNode<(int Key, int Value)>>();
7 private readonly LinkedList<(int Key, int Value)> order = new LinkedList<(int Key, int Value)>();
8
9 public LruCache(int capacity) {
10 this.capacity = capacity;
11 }
12
13 public int Get(int key) {
14 if (!map.TryGetValue(key, out var node)) {
15 return -1;
16 }
17 order.Remove(node);
18 order.AddFirst(node);
19 return node.Value.Value;
20 }
21
22 public void Put(int key, int value) {
23 if (map.TryGetValue(key, out var existing)) {
24 order.Remove(existing);
25 map.Remove(key);
26 } else if (map.Count == capacity) {
27 var oldest = order.Last;
28 order.RemoveLast();
29 map.Remove(oldest.Value.Key);
30 Console.WriteLine("evicted " + oldest.Value.Key);
31 }
32 var node = order.AddFirst((key, value));
33 map[key] = node;
34 }
35}
36
37class Program {
38 static void Main() {
39 var cache = new LruCache(2);
40 cache.Put(1, 10);
41 cache.Put(2, 20);
42 Console.WriteLine(cache.Get(1));
43 cache.Put(3, 30);
44 Console.WriteLine(cache.Get(2));
45 cache.Put(4, 40);
46 Console.WriteLine(cache.Get(1) + " " + cache.Get(3) + " " + cache.Get(4));
47 }
48}

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
A get moves the entry to the front: remove it from its place and add it at the front. Both steps are O(1) with the node in hand.—
—
When the cache is full, the entry at the back is the least recently used.—
—
Evict it from the dictionary too, so the two structures stay in step.—
—
Key 1 is found and moves to the front. The order is now 1, 2.—
10
The cache is full, and 2 is now the least recently used, so it is evicted.—
10
evicted 2
Key 2 was evicted.—
10
evicted 2
-1
The order was 3, 1, so 1 is the oldest and is evicted.—
10
evicted 2
-1
evicted 1
1 is gone; 3 and 4 remain.—
10
evicted 2
-1
evicted 1
-1 30 40

Behind the scenes

How C# processes this code

C# compiles to code for the .NET runtime, which invokes the program entry point.

Cost and common mistakes

Get and Put are O(1); the space is O(capacity).

  • If the dictionary and the list disagree, for example an entry removed from one but not the other, the cache returns wrong answers or leaks memory.
  • This cache is not thread-safe. Concurrent use needs a lock around each operation.
  • LinkedList<T> is a doubly linked list in .NET, which is what makes removal from the middle O(1).

Try it yourself

Why not use a List<T> to keep the order of use?

Reveal answer and reasoning

Removing an item from the middle of a List<T> shifts the remaining items, which is O(n), so the cache would no longer run in constant time.

Further reading