📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

When would you use LinkedList<T> in C#? Explain how it is implemented, the LinkedListNode<T> API (AddBefore, AddAfter, Remove(node)), its real costs compared with List<T>, and the classic LRU cache that combines it with a Dictionary.

The .NET doubly linked list: node API, O(1) operations given a node, memory and cache costs, and the LRU cache pattern.

Mid-Level Coding Intermediate collections data-structures linkedlist lru-cache performance

🎯 Key points

  • `LinkedList<T>` is a doubly linked (internally circular) list of `LinkedListNode<T>` objects
  • `AddBefore`, `AddAfter`, `Remove(node)`, `AddFirst`, `AddLast` are O(1) given a node
  • `Find`, `Remove(value)` and `Contains` are O(n); there is no indexer
  • Each node is a heap object (~48 bytes for an `int`), with poor cache locality and extra GC work
  • `List<T>` is usually faster, even for middle inserts at modest sizes
  • A node belongs to one list and can be re-added after removal without allocating
  • LRU cache: `Dictionary<TKey, LinkedListNode<T>>` plus `LinkedList<T>` gives O(1) get, put and evict
  • Hand-written caches need a lock for thread safety; prefer `MemoryCache` in production

⏱ 30-second answer

LinkedList<T> is a doubly linked list of LinkedListNode<T> objects, each with Value, Next, Previous and List. Given a node, AddBefore, AddAfter, Remove(node), AddFirst and AddLast are O(1). But finding a node by value (Find, Remove(value), Contains) is O(n), there is no indexer, every element is a separate heap object (about 48 bytes for an int on 64-bit), and walking it misses the CPU cache. So List<T> is faster for almost everything. Use LinkedList<T> when you already hold node references and need O(1) reordering, the classic case being an LRU cache: Dictionary<TKey, LinkedListNode<Entry>> plus a LinkedList<Entry> ordered by recent use.

🗣 Full answer (senior)

How I would answer this in an interview

LinkedList<T> is a doubly linked, circular list of nodes. Its one real superpower is O(1) insertion and removal when you already hold a LinkedListNode<T>. Everything else — indexing, searching, iterating — is slower than List<T> in practice because every element is a separate heap object. So I use it rarely, and the classic legitimate use is an LRU cache combined with a Dictionary.

1. How it's implemented

  • Each element lives in a LinkedListNode<T> object with Value, Next, Previous and a List back-reference.
  • The list stores a reference to the First node and a Count; internally it's circular (the head's previous is the tail), and Last is head.prev.
  • No contiguous storage, no indexer.

2. The node API

var list = new LinkedList<string>();
LinkedListNode<string> a = list.AddLast("A");
LinkedListNode<string> c = list.AddLast("C");
list.AddBefore(c, "B");          // O(1) given the node
list.AddAfter(c, "D");           // O(1)
list.AddFirst("Start");          // O(1)
list.Remove(a);                  // O(1) - remove BY NODE
list.Remove("D");                // O(n) - remove BY VALUE (searches first)
list.RemoveFirst(); list.RemoveLast(); // O(1)
LinkedListNode<string>? found = list.Find("B");  // O(n)
  • Methods returning nodes (AddFirst, AddLast, AddBefore, AddAfter) let you keep references for later O(1) operations.
  • A node belongs to one list at a time; adding a node that's already in a list throws InvalidOperationException.
  • You can move a node: list.Remove(node); list.AddFirst(node); — no allocation.

3. Real costs compared with List<T>

Operation LinkedList<T> List<T>
Add/remove at the end O(1) + node allocation Amortised O(1), no per-item allocation
Add/remove at the front O(1) O(n) shift
Insert/remove in the middle with a node reference O(1) O(n) shift
Insert/remove in the middle by index or value O(n) to find + O(1) O(n)
Access by index O(n) (no indexer) O(1)
Search O(n), pointer chasing O(n), contiguous, vectorised for primitives
Iterate O(n), cache-unfriendly O(n), cache-friendly
Memory per element ~48 bytes node object (64-bit) + the value Just the slot (4–8 bytes for small types)

In benchmarks, iterating a List<int> is often 5–10× faster than a LinkedList<int> of the same size because of cache locality, and even List<T>.Insert in the middle beats LinkedList for small-to-medium sizes when you have to find the position first. Big-O hides constant factors and memory behaviour.

4. When I would use it

  • You hold node references and need O(1) moves/removals: LRU/LFU caches, schedulers that reorder tasks, ordered free-lists.
  • Frequent insertions/removals at both ends where a Queue<T> or deque doesn't fit (though a circular buffer is usually better).
  • Splicing elements while iterating (you can remove the current node safely by saving node.Next first).

Otherwise: List<T>, Queue<T>, Stack<T>, or SortedSet<T>.

5. The classic LRU cache

A Least Recently Used cache needs O(1) lookup and O(1) "move to most-recent" and "evict least-recent":

public sealed class LruCache<TKey, TValue>(int capacity) where TKey : notnull
{
    private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new(capacity);
    private readonly LinkedList<(TKey Key, TValue Value)> _order = new();   // First = most recent
    private readonly Lock _lock = new();                                     // .NET 9 Lock type

    public bool TryGet(TKey key, out TValue value)
    {
        lock (_lock)
        {
            if (_map.TryGetValue(key, out var node))
            {
                _order.Remove(node);        // O(1)
                _order.AddFirst(node);      // O(1) - mark as most recent, no allocation
                value = node.Value.Value;
                return true;
            }
            value = default!;
            return false;
        }
    }

    public void Set(TKey key, TValue value)
    {
        lock (_lock)
        {
            if (_map.TryGetValue(key, out var existing))
            {
                existing.Value = (key, value);
                _order.Remove(existing);
                _order.AddFirst(existing);
                return;
            }
            if (_map.Count == capacity)
            {
                var lru = _order.Last!;        // least recently used
                _order.RemoveLast();
                _map.Remove(lru.Value.Key);
            }
            _map[key] = _order.AddFirst((key, value));
        }
    }
}

The dictionary maps keys to nodes, so finding a node is O(1); the linked list keeps recency order, so moving and evicting are O(1).

In production I'd usually reach for IMemoryCache (with SizeLimit and sliding expiration) or HybridCache (.NET 9) rather than hand-rolling, unless I need strict LRU semantics.

6. A production example

A pricing service cached computed quotes in a Dictionary with no eviction and memory grew until the container was OOM-killed every few days. A first attempt at LRU used a List<string> of keys with Remove(key) + Insert(0, key) on every hit — O(n) per access — and CPU rose sharply at ~100,000 entries. Replacing it with the Dictionary + LinkedList design made hits O(1); after load testing we eventually moved to IMemoryCache with a size limit because it also handled expiry and memory pressure.

🔍 What the interviewer is checking

Checks whether the candidate knows the textbook claims and the real-world costs, and can apply the structure correctly.

  • Green flags: explains node API and O(1) with a node reference, mentions no indexer, memory and cache costs, and describes the dictionary-plus-list LRU.
  • Red flags: recommends linked lists for general use, uses Remove(value) in an LRU, or thinks LinkedList<T> supports indexing.

📚 Learn in depth

1. Node layout and memory

public sealed class LinkedListNode<T>
{
    internal LinkedList<T>? list;
    internal LinkedListNode<T>? next;
    internal LinkedListNode<T>? prev;
    internal T item;
}

On 64-bit: 16 bytes of object header + MethodTable, three 8-byte references, plus T (8 bytes for a reference, padded). That's ~48 bytes per node for LinkedList<int> — versus 4 bytes per element in List<int>. For a million elements, ~48 MB vs ~4 MB, plus a million objects for the GC to trace.

2. Why linked lists are cache-unfriendly

CPUs load memory in 64-byte cache lines and prefetch sequential addresses. Traversing a List<T> walks contiguous memory, so the prefetcher keeps data ready. Traversing a linked list follows next pointers to arbitrary addresses — each hop can be a cache miss (~100 ns from DRAM). Nodes allocated close together initially may be adjacent, but after the GC compacts and the list is reordered, locality degrades.

This is why Bjarne Stroustrup's well-known benchmark showed vectors beating linked lists even for middle insertion: finding the position (a linear scan) dominates, and scanning arrays is far faster.

3. The circular design

.NET's LinkedList<T> keeps only head; head.prev is the tail. AddLast(x) is "insert before head" (because the list is circular), and Last returns head.prev. The public Next/Previous properties return null at the ends even though the internal links wrap — the property getters hide the circularity.

4. Version checks and safe removal during iteration

LinkedList<T> has a version field and its enumerator throws if the list is modified during foreach. But you can traverse manually and modify safely:

for (var node = list.First; node is not null; )
{
    var next = node.Next;          // save before removal
    if (ShouldRemove(node.Value)) list.Remove(node);
    node = next;
}

5. LRU cache analysis

Operation Dictionary LinkedList Total
Get (hit) O(1) lookup O(1) move to front O(1)
Get (miss) O(1) — O(1)
Set (new, not full) O(1) insert O(1) AddFirst (allocates a node) O(1)
Set (new, full) O(1) remove LRU + insert O(1) RemoveLast + AddFirst O(1)

Performance refinements:

  • Reuse the evicted node instead of allocating a new one (update its value, move it to the front).
  • Store (Key, Value) in the node so eviction can remove the dictionary entry without a reverse lookup.
  • For high concurrency, a single lock becomes a bottleneck: use striped/sharded caches (N independent LRUs keyed by hash), or approximate LRU like CLOCK or W-TinyLFU (as in the BitFaster.Caching library), which avoid moving entries on every hit.
  • An array-based variant (nodes as indexes into struct arrays) avoids per-node objects entirely.

6. IMemoryCache and HybridCache

Microsoft.Extensions.Caching.Memory.MemoryCache:

  • Supports absolute and sliding expiration, priorities, and SizeLimit with per-entry Size.
  • When over the size limit, it compacts asynchronously, removing expired entries, then low-priority and least-recently-used ones — approximately LRU, not strict.
  • Thread-safe.

HybridCache (.NET 9) layers an in-memory cache over a distributed cache (Redis) with stampede protection (only one caller computes a missing value). For most service caching needs, these are better than a custom LRU.

7. Alternatives to LinkedList<T>

  • Deque needs (add/remove at both ends): a circular buffer (e.g. implement one over an array, or use Queue<T> for one end).
  • Ordered with fast insert: SortedSet<T>/SortedDictionary.
  • Frequent middle edits on text: rope or gap-buffer structures.
  • Intrusive lists: storing Next/Prev in your own objects (e.g. pooled objects) avoids separate node allocations.

Try it yourself

  1. Benchmark iterating and summing 1 million ints in List<int> vs LinkedList<int>.
  2. Benchmark inserting 10,000 items in the middle of each — including the time to find the insertion point.
  3. Implement the LRU cache, add node reuse on eviction, and test eviction order.
  4. Compare your LRU with IMemoryCache configured with SizeLimit under a load test.

💻 Code

public sealed class LruCache<TKey, TValue>(int capacity) where TKey : notnull
{
    private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new(capacity);
    private readonly LinkedList<(TKey Key, TValue Value)> _order = new();

    public bool TryGet(TKey key, out TValue value)
    {
        if (_map.TryGetValue(key, out var node))
        {
            _order.Remove(node);        // O(1): we hold the node
            _order.AddFirst(node);      // reuse the node, no allocation
            value = node.Value.Value;
            return true;
        }
        value = default!;
        return false;
    }

    public void Put(TKey key, TValue value)
    {
        if (_map.TryGetValue(key, out var existing))
        {
            _order.Remove(existing);
            _map.Remove(key);
        }
        else if (_map.Count >= capacity)
        {
            var lru = _order.Last!;     // least recently used
            _order.RemoveLast();
            _map.Remove(lru.Value.Key);
        }
        _map[key] = _order.AddFirst((key, value));
    }
}

// Usage
var cache = new LruCache<string, byte[]>(capacity: 3);
cache.Put("a", [1]); cache.Put("b", [2]); cache.Put("c", [3]);
cache.TryGet("a", out _);   // order now a, c, b
cache.Put("d", [4]);        // evicts b

⚠ Common mistakes

1. Choosing LinkedList<T> for "fast insertion" without node references

What happens: LinkedList<T> used because textbooks say inserts are O(1). Why it is wrong: you must first find the position (O(n) pointer chasing), which is slower than shifting an array for most sizes. Do instead: use List<T> unless you hold LinkedListNode<T> references.

2. Accessing by index

What happens: list.ElementAt(i) in loops. Why it is wrong: each call walks from the start — O(n) per access, O(n²) overall. Do instead: iterate with nodes or use a List<T>.

3. Removing by value when you have the node

What happens: list.Remove(value) after already finding the node. Why it is wrong: searches again — O(n). Do instead: keep the node and call list.Remove(node) — O(1).

4. Using List<T> for LRU ordering

What happens: keys.Remove(k); keys.Insert(0, k); on every cache hit. Why it is wrong: O(n) per hit; CPU grows with cache size. Do instead: Dictionary + LinkedList with node references, or IMemoryCache.

5. Forgetting thread safety in caches

What happens: a hand-written LRU cache used as a singleton without locking. Why it is wrong: concurrent moves corrupt the linked list and dictionary consistency. Do instead: lock (or shard), or use thread-safe caches.

6. Adding a node that already belongs to a list

What happens: otherList.AddFirst(node) without removing it first. Why it is wrong: throws InvalidOperationException — nodes belong to one list. Do instead: Remove(node) from the current list first, or add the value instead.

7. Ignoring memory overhead

What happens: millions of small values stored in a linked list. Why it is wrong: ~48 bytes of overhead per element plus GC tracing of millions of objects. Do instead: use arrays/lists, or an index-based node pool.

8. Modifying during foreach

What happens: removing nodes inside a foreach loop over the list. Why it is wrong: the enumerator's version check throws. Do instead: iterate manually, saving node.Next before removal.

9. Hand-rolling caches when platform caches fit

What happens: custom LRU implementations without expiry, metrics or memory limits. Why it is wrong: reinvents functionality with more bugs (no stampede protection, no size accounting). Do instead: use IMemoryCache, HybridCache, or a proven library; hand-roll only for specific needs.

10. Not evicting the dictionary entry with the node

What happens: the LRU node is removed from the list but its key stays in the dictionary. Why it is wrong: the dictionary points to a detached node — memory leak and incorrect hits. Do instead: store the key in the node and remove both together.

🏛 Senior insight: trade-offs

Seniors treat LinkedList<T> as a specialist tool and can explain why the big-O advantage rarely shows up in practice.

Claim Reality in .NET
"O(1) insert in the middle" Only with a node in hand; finding it is O(n)
"No resizing cost" One allocation per element instead
"Good for large data" Poor cache locality and high GC load
"Good for LRU and splicing" Yes, with a dictionary of nodes
"Good as a deque" Yes, .NET has no built-in deque

For production caches they usually prefer MemoryCache or a proven library, and only hand-roll an LRU when they need exact behaviour and have benchmarks.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is asked about linked list theory Why: Checks singly versus doubly linked fundamentals
    How to answer

    A linked list stores each element in a node that points to the next node (singly linked) or to both neighbours (doubly linked). Nodes are separate heap objects, not contiguous.

    • Insert or remove when you already hold the node: O(1) (just relink pointers).
    • Find by index or value: O(n) (walk from the head).

    .NET's LinkedList<T> is a doubly linked, circular list of LinkedListNode<T> objects. In practice List<T> is faster for most workloads because arrays are cache-friendly and allocate less; linked list nodes are scattered and each costs an object allocation.

    Use a linked list when you need frequent O(1) removal or move of known nodes, such as an LRU cache (Dictionary of key → node + linked list for recency order).

  2. When: Candidate claims linked lists insert faster Why: Checks they understand List<T> costs in practice
    How to answer

    List<T> wraps a T[] plus a _size count and a _version counter. When the array is full, Add allocates a new array twice as large (starting at 4) and copies, so Add is amortised O(1). Insert and Remove shift elements, so they are O(n). Every change increments _version; the enumerator checks it on each MoveNext and throws InvalidOperationException if the list changed. In hot paths, pre-size with the constructor or EnsureCapacity, and use CollectionsMarshal.AsSpan to work on the backing array without copying (but never add or remove while holding that span).

  3. When: Candidate is asked to make the LRU cache thread-safe Why: Probes concurrency design
    How to answer

    System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.

🛠 Real-world problems: how you handle them

LRU tile cache spiking CPU because of List.Remove on every hit
Situation
A map tile server cached [50,000] rendered tiles in memory with a hand-written LRU: a `Dictionary` for data and a `List<string>` of keys for recency.
Signals
CPU climbed with cache size; flame graphs showed `List<T>.Remove` and `IndexOf`; latency rose as the cache warmed up, the opposite of what a cache should do.
Investigation
Every cache hit called `keys.Remove(key)` then `keys.Insert(0, key)`, both O(n) on a list of [50,000]. Benchmarking showed hits cost microseconds instead of nanoseconds.
Resolution
Rewrote the cache with `Dictionary<string, LinkedListNode<Entry>>` and `LinkedList<Entry>`, moving nodes with `Remove(node)` and `AddFirst(node)`, all O(1). Wrapped operations in a lock.
Prevention
Added BenchmarkDotNet tests at several cache sizes, a load test that checks latency improves as the cache warms, and a review guideline to prefer `MemoryCache` unless there is a measured reason not to.

How to tell it (STAR)

**Situation:** Our tile server at [company] got slower as its in-memory cache filled, which made no sense for a cache. **Task:** I needed to find out why and fix it before a [marketing launch] that would raise traffic. **Action:** A flame graph showed most CPU in `List<T>.Remove` and `Insert`. The LRU tracked recency in a `List<string>`, so each hit shifted [50,000] keys twice. I rewrote it with a dictionary of linked list nodes so moving an entry to the front is O(1), added a lock because requests run in parallel, and wrote benchmarks across cache sizes. **Result:** Cache-hit latency dropped from [X microseconds] to [Y nanoseconds], CPU fell [about X%], and latency now improves as the cache warms. The benchmark runs in CI so the regression cannot return quietly.

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me