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

📚 Interview question

What is the difference between SortedDictionary<TKey,TValue> and SortedList<TKey,TValue> in C#? How is each implemented, what are the costs of insert, lookup and enumeration, and how do you choose between them (and versus Dictionary)?

Compares the two sorted key-value collections in .NET: red-black tree versus sorted arrays, costs, memory and use cases.

Mid-Level Theory Intermediate binary-search collections red-black-tree sorteddictionary sortedlist

🎯 Key points

  • Both keep unique keys in sorted order and accept an `IComparer<TKey>`
  • `SortedDictionary` is a red-black tree: O(log n) insert, remove and lookup
  • `SortedList` is two sorted arrays: O(log n) lookup, O(n) insert and remove
  • Appending keys in ascending order to a `SortedList` is cheap
  • `SortedList` uses less memory and is more cache-friendly; it supports `Keys[i]` and `IndexOfKey`
  • Choose `SortedList` for load-once read-often data, `SortedDictionary` for frequent random changes
  • Use `Dictionary` and sort at output when order is only needed for display
  • Neither has a range API; use `SortedSet<T>.GetViewBetween` or binary search the keys

⏱ 30-second answer

Both keep keys sorted and give O(log n) lookup, but they are built differently. SortedDictionary is a red-black tree: O(log n) insert and remove anywhere, but one node object per entry, so more memory and worse cache locality. SortedList is two sorted arrays (keys and values) with binary search: lookup is O(log n) and memory is compact, but inserting or removing in the middle is O(n) because elements shift. Appending keys in ascending order is cheap. SortedList also allows access by index (Keys[i], Values[i]). Choose SortedList for data loaded once or in order and read often; SortedDictionary for frequent random inserts and removes; plain Dictionary when you do not need order.

🗣 Full answer (senior)

How I would answer this in an interview

Both keep keys sorted by a comparer, but they use different data structures. SortedDictionary<TKey,TValue> is a red-black tree: O(log n) insert, remove and lookup, with one heap node per entry. SortedList<TKey,TValue> is two parallel sorted arrays (keys and values): O(log n) lookup by binary search, but O(n) insert and remove because elements shift. Then there is plain Dictionary, which is faster than both when you don't need order.

1. Implementations

SortedDictionary<TKey,TValue> wraps a SortedSet<KeyValuePair<TKey,TValue>> — a red-black tree of nodes, each holding a key/value pair, left/right child references and a colour bit.

SortedList<TKey,TValue> holds TKey[] keys and TValue[] values of the same length, kept sorted by key. Lookup is Array.BinarySearch on the keys array; inserting at position i shifts everything after i in both arrays.

2. Costs

Operation SortedDictionary SortedList Dictionary
Lookup by key O(log n) O(log n) O(1) avg
Insert random key O(log n) O(n) (shift) O(1) avg
Insert in ascending order O(log n) O(1) amortised (append) O(1) avg
Remove O(log n) O(n) O(1) avg
Enumerate in key order O(n), pointer-chasing O(n), sequential arrays (fast) Unordered
Access by index (k-th key) Not supported (O(n) via enumeration) O(1) (Keys[i], GetKeyAtIndex(i), IndexOfKey) Not supported
Memory per entry High (node object, ~40+ bytes overhead) Low (just array slots) Medium (entry struct + bucket)
Build from data O(n log n) O(n log n) via sorted input; O(n²) if inserted randomly one by one O(n)

3. How I choose

  • Don't need ordering → Dictionary (or FrozenDictionary for read-only). Sort only when you output: dict.OrderBy(kv => kv.Key).
  • Need ordering + frequent random inserts/removes (e.g. a live order book, a time-ordered index of pending jobs, a leaderboard with continuous updates) → SortedDictionary.
  • Need ordering + mostly read, built once or appended in key order (e.g. lookup tables loaded at start-up, time-series appended chronologically, small configuration maps) → SortedList — less memory, faster enumeration, and index-based access for "k-th smallest" or paging.
  • Read-only after build → a sorted array + BinarySearch, or SortedList populated from sorted data; or FrozenDictionary if ordering isn't needed at lookup time.
  • Range queries ("all keys between A and B") → SortedList (binary search for the bounds, then index range) or SortedSet<T>.GetViewBetween for tree-based sets. SortedDictionary doesn't expose a range view directly.

4. A concrete example

A market-data component maintained price levels (price → quantity) for an order book. Levels change constantly at random positions, so SortedDictionary<decimal, decimal> (with a descending comparer for bids) gives O(log n) updates and in-order iteration for the top levels.

By contrast, a holiday calendar (date → holiday name) for Australian states is loaded once at start-up and queried thousands of times: SortedList<DateOnly, string> built from sorted data uses less memory and lets me find "the next public holiday after date X" by binary searching the keys (IndexOfKey/BinarySearch on Keys) and taking the next index.

5. A production example

A team used SortedList<DateTime, Reading> to store sensor readings, inserting as messages arrived. Messages came out of order from multiple devices, so most inserts landed in the middle and shifted large arrays; with ~500,000 entries per device, CPU spiked and ingestion fell behind. Switching to SortedDictionary fixed ingestion (O(log n) inserts), and for the reporting path we copied into arrays once per batch for fast sequential reads. Profiling with dotnet-trace showed Array.Copy inside SortedList.Insert as the smoking gun.

🔍 What the interviewer is checking

Checks understanding of the implementation behind each type and the ability to choose based on workload.

  • Green flags: names red-black tree versus sorted arrays, explains O(n) inserts for SortedList, memory and cache trade-offs, index access, and when a plain Dictionary is better.
  • Red flags: says they are the same, thinks SortedList allows duplicates, or uses sorted collections when order is not needed.

📚 Learn in depth

1. Red-black trees in brief

A red-black tree is a binary search tree with extra rules that keep it approximately balanced:

  1. Every node is red or black; the root is black.
  2. Red nodes can't have red children.
  3. Every path from a node to its descendant leaves has the same number of black nodes.

These rules guarantee the longest path is at most twice the shortest, so height is O(log n). Inserts and deletes restore the rules with recolouring and at most a few rotations — O(log n) worst case.

.NET's implementation (SortedSet<T>, used by SortedDictionary) uses top-down insertion and deletion, splitting 4-nodes on the way down, and each node is a class:

internal sealed class Node { public T Item; public Node? Left; public Node? Right; public NodeColor Color; }

For SortedDictionary<int,int>, each entry costs a node object (~48 bytes on 64-bit including header and the KeyValuePair), and traversal follows references scattered across the heap — more cache misses than array-based structures.

2. SortedList internals

private TKey[] keys;
private TValue[] values;
private int _size;
private readonly IComparer<TKey> comparer;
  • TryGetValue: Array.BinarySearch(keys, 0, _size, key, comparer) — O(log n) comparisons, and binary search over contiguous memory is cache-friendly for small-to-medium sizes.
  • Add(key, value): binary search for the insertion point; if the key exists, throw; otherwise Array.Copy both arrays one slot to the right from that point, then write. Inserting at the end (keys arriving in ascending order) has nothing to shift.
  • Capacity doubles when full, like List<T>.
  • Keys and Values return IList<T> views supporting index access; GetKeyAtIndex(i) / GetValueAtIndex(i) / SetValueAtIndex(i, v) (.NET 7+) give direct access.

3. Comparers

Both types take an IComparer<TKey>:

  • Default: Comparer<TKey>.Default, which uses IComparable<T>. For strings this is culture-sensitive — sorted order and even key equality (Compare == 0) depend on the current culture. Pass StringComparer.Ordinal (or OrdinalIgnoreCase) for identifiers.
  • Equality is defined by the comparer returning 0, not by Equals. A comparer that ignores part of the key will merge distinct keys.
  • Descending order: Comparer<T>.Create((a, b) => b.CompareTo(a)).

4. Performance intuition

For n = 100,000 random integer keys (rough relative numbers on a modern CPU):

Operation Dictionary SortedDictionary SortedList
Build by random inserts 1× ~10× ~100×+ (shifting)
Build from sorted input 1× ~8× ~2×
Lookup 1× ~5–10× ~3–5×
Full ordered enumeration n/a ~3–5× slower than SortedList fastest
Memory 1× ~2–3× ~0.5–0.7×

Always benchmark with your key type and comparer — string comparisons with culture rules can dominate everything.

5. Alternatives worth knowing

  • Sorted array + BinarySearch: smallest and fastest for read-only data; ~index gives the insertion point for range queries.
  • ImmutableSortedDictionary<TKey,TValue>: AVL-tree-based persistent map for sharing snapshots across threads.
  • PriorityQueue when you only need the minimum/maximum repeatedly, not full ordering.
  • Database indexes: for large or persistent ordered data, let SQL Server do the sorting and range scanning.

Try it yourself

  1. Benchmark inserting 100,000 random vs ascending keys into SortedList and SortedDictionary.
  2. Measure memory for each with GC.GetTotalMemory or a memory profiler.
  3. Implement "next key greater than X" with SortedList using binary search on Keys.
  4. Create a SortedDictionary<string, int> with the default comparer under tr-TR culture and insert "I" and "ı".

💻 Code

// Tiered rates loaded once in ascending order: SortedList
var tiers = new SortedList<decimal, decimal>
{
    [0m] = 0.10m,
    [1_000m] = 0.08m,
    [10_000m] = 0.05m,
};

decimal RateFor(decimal amount)
{
    // floor lookup: largest key <= amount, using binary search on Keys
    IList<decimal> keys = tiers.Keys;
    int lo = 0, hi = keys.Count - 1, best = 0;
    while (lo <= hi)
    {
        int mid = lo + (hi - lo) / 2;
        if (keys[mid] <= amount) { best = mid; lo = mid + 1; }
        else hi = mid - 1;
    }
    return tiers.Values[best];
}

// Frequent random inserts and removes: SortedDictionary
var orderBook = new SortedDictionary<decimal, int>(
    Comparer<decimal>.Create((a, b) => b.CompareTo(a)));   // highest price first
orderBook[101.5m] = 300;
orderBook[99.0m] = 150;
orderBook.Remove(101.5m);

foreach (var (price, qty) in orderBook)
    Console.WriteLine($"{price}: {qty}");                  // key order

// Index access only on SortedList
Console.WriteLine(tiers.Keys[^1]);                        // highest threshold
Console.WriteLine(tiers.IndexOfKey(1_000m));              // 1

⚠ Common mistakes

1. Using a sorted collection when order isn't needed

What happens: SortedDictionary chosen "because sorted is nicer". Why it is wrong: O(log n) operations and more memory instead of O(1) Dictionary. Do instead: use Dictionary and sort only when producing output.

2. Inserting random keys into SortedList

What happens: a SortedList filled from unordered data or live, out-of-order events. Why it is wrong: each insert shifts both arrays — O(n) per insert, O(n²) overall. Do instead: use SortedDictionary for random inserts, or sort the data first and build once.

3. Using the default comparer for string keys

What happens: new SortedDictionary<string, T>(). Why it is wrong: the default comparer is culture-sensitive: ordering and key equality vary by server culture. Do instead: pass StringComparer.Ordinal or OrdinalIgnoreCase.

4. Comparers that return 0 for distinct keys

What happens: keys compared by a partial field (e.g. only the date part of a timestamp). Why it is wrong: distinct keys are treated as duplicates — Add throws or values overwrite. Do instead: make comparers total over the key, adding tie-breakers.

5. Expecting index access on SortedDictionary

What happens: ElementAt(i) used for paging through a SortedDictionary. Why it is wrong: O(i) per call because it enumerates from the start — O(n²) for full paging. Do instead: use SortedList (O(1) index access) or enumerate once.

6. Ignoring memory overhead of tree nodes

What happens: millions of entries in SortedDictionary in a memory-constrained container. Why it is wrong: node objects add tens of bytes per entry and GC tracing cost. Do instead: consider sorted arrays, SortedList (if inserts are ordered), or pushing ordering to the database.

7. Using OrderBy repeatedly instead of a sorted structure

What happens: a dictionary is sorted with LINQ on every request. Why it is wrong: O(n log n) work and allocations per request. Do instead: maintain a sorted structure or cache the sorted result if data changes rarely.

8. Sharing sorted collections across threads

What happens: a static SortedDictionary updated from multiple threads. Why it is wrong: not thread-safe; tree rotations under concurrent writes corrupt the structure. Do instead: lock, or use immutable sorted collections with atomic swaps.

9. Using ContainsKey + indexer

What happens: double lookups on sorted collections. Why it is wrong: each lookup is O(log n), doubling cost. Do instead: use TryGetValue.

10. Misjudging "sorted" as "insertion ordered"

What happens: expecting a sorted collection to keep insertion order. Why it is wrong: sorted collections order by key; insertion order is lost. Do instead: use OrderedDictionary<TKey,TValue> (.NET 9) for insertion order.

🏛 Senior insight: trade-offs

Seniors answer with the access pattern first and back it with measurement, because constant factors and cache behaviour often beat big-O for small sizes.

Access pattern Choice Reason
Load once, read often, need order SortedList Compact, cache-friendly, index access
Random inserts and deletes, need order SortedDictionary O(log n) updates
Need ranges, floor or ceiling SortedSet<T>.GetViewBetween or binary search on SortedList.Keys Neither map has a range API
Order only for display Dictionary + sort at output O(1) lookups
Read-only table FrozenDictionary + pre-sorted key array Fastest reads

They also note that SortedList memory can be trimmed with TrimExcess after loading, while the tree's per-node overhead cannot be reduced.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares tree maps and hash maps Why: Checks map comparison theory
    How to answer

    A map (associative array, dictionary) stores key → value pairs with unique keys. Choose the implementation by access pattern:

    • Dictionary<K,V>: hash table, O(1) average, unordered. The default.
    • SortedDictionary<K,V>: red-black tree, O(log n), sorted by key, fast inserts/removes.
    • SortedList<K,V>: two sorted arrays, O(log n) lookup but O(n) insert/remove; compact, indexable. Good for small or build-once sorted maps.
    • OrderedDictionary<K,V> (.NET 9+): keeps insertion order with index access.
    • ConcurrentDictionary<K,V>: thread-safe, lock-free reads.
    • FrozenDictionary<K,V> (.NET 8+): built once, fastest reads.
    • ImmutableDictionary<K,V>: immutable snapshots with cheap modified copies.
    • ILookup<K,V> / Dictionary<K, List<V>>: multi-map (one key, many values).
  2. When: Candidate says order is not needed Why: Checks correct Dictionary usage
    How to answer

    Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.

🛠 Real-world problems: how you handle them

Rate table rebuilt nightly was slow and memory-heavy as a SortedDictionary
Situation
A pricing service held [several thousand] tiered rate tables, each with [up to a few hundred] breakpoints, rebuilt nightly and read [thousands] of times per second to find the tier for an amount.
Signals
High memory use on the pricing pods, many small objects in the heap dump, and the tier lookup showing up in CPU profiles.
Investigation
A heap dump with dotnet-gcdump showed millions of `SortedSet` node objects. The code only needed a floor lookup (largest breakpoint at or below the amount) but enumerated the `SortedDictionary` to find it, which was O(n) per call.
Resolution
Switched to `SortedList<decimal, decimal>` filled from data already sorted by the database, called `TrimExcess` after loading, and replaced the enumeration with a binary search over `Keys`.
Prevention
Added a benchmark for tier lookup, a memory budget alert on the pods, and a code review note on choosing sorted collections by access pattern.

How to tell it (STAR)

**Situation:** Our pricing service at [company] was using far more memory than expected and its tier lookup was hot in CPU profiles. **Task:** I was asked to reduce memory and latency without changing any prices. **Action:** A heap dump showed millions of tree nodes from `SortedDictionary`. The tables were rebuilt once a night and only read afterwards, and the lookup enumerated the whole tree to find the floor tier. I changed them to `SortedList` loaded from already-sorted query results, trimmed the capacity, and replaced the enumeration with a binary search over the keys. I verified identical prices by replaying [a day] of requests against both versions. **Result:** Memory on the pods dropped by [about X%] and p99 lookup time fell from [X] to [Y]. I added a benchmark and documented the decision so the next table type would be chosen by access pattern.

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me