Singly and doubly linked lists, O(1) node insert/remove vs O(n) search, poor cache locality, LinkedList<T> in .NET, and the LRU cache as a classic use.
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.
.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).
Singly linked: head → [A|•] → [B|•] → [C|•] → null
Doubly linked (LinkedList<T>):
┌───────────────────────────────┐
▼ │
[•|A|•] ⇄ [•|B|•] ⇄ [•|C|•] ──────┘ (circular internally; First/Last exposed)
List<T> versus LinkedList<T>| Operation | List<T> |
LinkedList<T> |
|---|---|---|
| Access by index | O(1) | O(n) (no indexer) |
| Add at end | Amortised O(1) (O(n) on the occasional resize) | O(1) |
| Add/remove at front | O(n) | O(1) |
| Insert/remove with a node reference | O(n) | O(1) |
| Find value | O(n) | O(n), slower in practice |
| Memory per element | Element size (+ spare capacity) | A node object: header plus list, next and previous references plus the value (about 48 bytes for an int) |
| Cache locality | Excellent | Poor |
| GC pressure | One array | One object per node |
Big-O hides constants: iterating a linked list risks a cache miss per node, so a linear scan can be several times slower than over an array.
A team built an in-memory recent-items cache with List<string>, calling Remove(key) then Insert(0, key) on every hit. With 50,000 items, each hit was O(n) twice and CPU went to 70% at peak. Rewriting it as an LRU cache with Dictionary<string, LinkedListNode<Entry>> plus LinkedList<Entry> made hits O(1): look up the node, Remove(node), AddFirst(node). Eviction removes Last. CPU dropped below 10%. (For most real caches we would use MemoryCache or HybridCache, but the pattern shows when linked lists shine.)
Checks understanding of pointer-based structures and whether the candidate can weigh big-O against real hardware behaviour.
Green flags
Red flags
LinkedList<T> supports fast indexing, or that List<T> is a linked list.Remove B (holding node B):
A.next = C; C.prev = A; O(1), no shifting
[A] ⇄ [B] ⇄ [C] ──► [A] ⇄ [C]
Insert X after A:
X.prev = A; X.next = A.next; A.next.prev = X; A.next = X;
Compare with List<T>.RemoveAt(1), which copies every later element one slot left.
Array scan: [A][B][C][D][E][F][G][H] one cache line → many elements
Linked list scan: [A]────────►[B]───►[C]──────────►[D] each hop likely a cache miss
The CPU's prefetcher cannot predict the next address because it is stored in the current node. That makes linked lists latency-bound on memory. Nodes allocated together may sit close at first, but after the GC compacts and the list is reordered, they scatter.
LinkedList<T> APIAddFirst, AddLast, AddBefore(node, value), AddAfter, Remove(node), RemoveFirst, RemoveLast: O(1).Find(value), Contains(value) and Remove(value): O(n), because they walk the list.InvalidOperationException.ConcurrentQueue<T> when you only need FIFO.ConcurrentStack<T> uses linked nodes; ConcurrentQueue<T> uses linked segments of arrays, a hybrid that keeps locality.Dictionary<TKey, TValue>, chains are indices inside an entries array, not separate node objects.| Problem | Technique | Cost |
|---|---|---|
| Reverse a singly linked list | Three pointers (prev, current, next) | O(n) time, O(1) space |
| Detect a cycle | Floyd's tortoise and hare | O(n) time, O(1) space |
| Find the middle | Slow pointer moves 1, fast moves 2 | O(n) time, O(1) space |
| Merge two sorted lists | Dummy head, compare and relink | O(n + m) |
| Remove nth from end | Gap of n between two pointers | O(n), one pass |
List<int> versus LinkedList<int>.// LRU cache: O(1) get, put and eviction
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
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); // O(1)
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!;
_order.RemoveLast(); // evict least recently used
_map.Remove(lru.Value.Key);
}
_map[key] = _order.AddFirst((key, value));
}
}
// Reverse a singly linked list in place
public sealed class Node(int value) { public int Value = value; public Node? Next; }
public static Node? Reverse(Node? head)
{
Node? prev = null;
while (head is not null)
{
var next = head.Next;
head.Next = prev;
prev = head;
head = next;
}
return prev;
}
Mistake: Choosing LinkedList<T> because "insert is O(1)".
Why it is wrong: Insert is O(1) only if you already hold the node; finding the position is O(n), and the scattered nodes make that walk slow.
Instead: Default to List<T> and use a linked list only when you keep node references.
Mistake: Calling LinkedList<T>.Remove(value) in a hot path.
Why it is wrong: It searches the list from the head, which is O(n).
Instead: Store LinkedListNode<T> references (for example in a dictionary) and call Remove(node).
Mistake: Ignoring per-node allocation.
Why it is wrong: Each node is a heap object, adding GC pressure and several times the memory of an array element.
Instead: Measure; for large collections an array-based structure, Queue<T> (a ring buffer) or Deque-style buffer usually wins.
Mistake: Sharing a LinkedList<T> between threads without a lock.
Why it is wrong: Two threads relinking at once can leave broken Next/Previous pointers and lose nodes.
Instead: Protect every operation with one lock, or use a concurrent collection designed for the access pattern.
Mistake: Building a custom LRU for production caching.
Why it is wrong: It also needs thread safety, size limits, expiry and metrics, which are easy to get wrong.
Instead: Use MemoryCache, HybridCache or a proven library, and keep the hand-written version for understanding and interviews.
Mistake: Losing the rest of the list when reversing or relinking.
Why it is wrong: Overwriting Next before saving it drops the tail.
Instead: Store next in a temporary variable before changing pointers.
Big-O says linked lists win for inserts and removals, but hardware says arrays usually win: contiguous memory, prefetching and fewer allocations beat pointer chasing for most real sizes. So my default is List<T>, and I reach for a linked list when I hold node handles and need O(1) relinking, the LRU pattern being the canonical example.
| Workload | Best choice | Why |
|---|---|---|
| Mostly reads and appends | List<T> |
Locality, one allocation |
| FIFO queue | Queue<T> |
Ring buffer: O(1) both ends without per-item allocation |
| Move known items to front, evict from back | Dictionary + LinkedList<T> |
O(1) relinking via node handles |
| Many producers and consumers | Channel<T> or ConcurrentQueue<T> |
Thread safety built in |
| Sorted with inserts | SortedSet<T> or SortedDictionary |
O(log n) balanced tree |
The interesting senior detail is that the best real-world structures are hybrids. ConcurrentQueue<T> uses linked segments of arrays, and Dictionary<TKey, TValue> stores collision chains as indices in arrays. They keep the O(1) relinking idea and still get cache locality. That is the trade-off thinking I want to show.
An array is a fixed-size block of contiguous elements of one type. Element i lives at start + i × elementSize, so indexing is O(1) and iteration is very cache-friendly. Its size cannot change.
List<T> wraps an internal array plus a Count. When the array is full, Add allocates a new array about twice as large and copies the elements, so Add is amortised O(1) but occasionally O(n). Inserting or removing in the middle is O(n) because elements shift.
Use an array for fixed-size data and hot paths, List<T> when the size changes, set Capacity when you know the size, and Span<T>/Memory<T> to work on slices without copying.
A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).
.NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.
Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.
Last updated 23 Oct 2026.