Searching algorithms: linear search, binary search (with a correct implementation and edge cases), hash lookup, and choosing between them in .NET.
IndexOf, Contains, First.Array.BinarySearch, List<T>.BinarySearch, span BinarySearch. Returns the bitwise complement of the insertion point when not found.HashSet/Dictionary built first, only for exact matches.Use binary search for sorted data, range queries ("first item ≥ x") and when you cannot afford a hash index.
Check every element until you find it. O(n) time, O(1) space, no preconditions. .NET's Array.IndexOf, Span.IndexOf and string.IndexOf are vectorised with SIMD, so for small or medium arrays of primitives linear search is very fast.
Requires the data to be sorted by the same comparison you search with. Each step compares the target with the middle element and discards half:
search 23 in [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
lo=0 hi=9 mid=4 -> 16 < 23, go right
lo=5 hi=9 mid=7 -> 56 > 23, go left
lo=5 hi=6 mid=5 -> 23 found (3 comparisons instead of 6)
O(log n) time: a million items need at most about 20 comparisons.
HashSet<T>.Contains / Dictionary.TryGetValue: O(1) average. Costs O(n) to build and extra memory, and only answers exact match questions. It cannot answer "the nearest price below 50" or "all timestamps between 10:00 and 10:05".
| Need | Best choice |
|---|---|
| Tiny collection (< ~32 items) or searched once | Linear (Contains, IndexOf) |
| Many exact lookups | HashSet / Dictionary / FrozenDictionary |
| Sorted data, range or nearest queries | Binary search, SortedSet.GetViewBetween |
| Data already sorted in a file or array, no memory for an index | Binary search |
| Searching for any of a set of chars/bytes/strings | SearchValues<T> with IndexOfAny |
| Database | an index (B+tree), which is binary-search-like on disk |
A rate-card service looked up tariffs by effective date with tariffs.Where(t => t.From <= date).OrderByDescending(t => t.From).First(): O(n log n) per request. Tariffs change rarely, so we sorted them once into an array and used a binary search for the last tariff with From <= date, which made each lookup O(log n) with no allocations. A dictionary could not do this because the query is "nearest at or below", not an exact key.
Checks the candidate can implement binary search correctly and choose the right search for the job.
Green flags
Array.BinarySearch returns ~insertionPoint.Red flags
Dictionary, even for range queries.Binary search is famous for off-by-one bugs. Use a half-open or closed interval consistently.
// Closed interval [lo, hi]. Returns index, or ~insertionPoint when not found (same contract as Array.BinarySearch).
static int BinarySearch(ReadOnlySpan<int> a, int target)
{
int lo = 0, hi = a.Length - 1;
while (lo <= hi)
{
int mid = lo + ((hi - lo) >> 1); // avoids int overflow of (lo + hi)
if (a[mid] == target) return mid;
if (a[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return ~lo; // lo is where target would be inserted
}
Why lo + (hi - lo) / 2? With arrays near int.MaxValue elements, lo + hi can overflow to a negative number. This exact bug sat in the JDK's binary search for about nine years.
Array.BinarySearch returns a negative number when the item is missing: the bitwise complement of the index where it would go.
int[] a = { 10, 20, 30 };
int i = Array.BinarySearch(a, 25); // returns ~2 == -3
int insertAt = ~i; // 2
This makes "insert in sorted order" and "find nearest" cheap.
Most real problems are not "is x here?" but "where does x start?":
index: 0 1 2 3 4 5
value: [1, 3, 3, 3, 7, 9]
lowerBound(3) = 1 (first >= 3)
upperBound(3) = 4 (first > 3)
count of 3s = upper - lower = 3
static int LowerBound(ReadOnlySpan<int> a, int x)
{
int lo = 0, hi = a.Length; // half-open [lo, hi)
while (lo < hi)
{
int mid = lo + ((hi - lo) >> 1);
if (a[mid] < x) lo = mid + 1; else hi = mid;
}
return lo;
}
Array.BinarySearch returns some matching index when duplicates exist, not necessarily the first. Use a lower bound when you need the first.
Binary search works on any monotonic predicate, not just arrays. "What is the smallest batch size that finishes within the time limit?" If ok(size) is false then true as size grows, binary-search the size. The same trick finds the first bad build in a range (that is what git bisect does).
| Pitfall | Result |
|---|---|
| Data not sorted | wrong answers, no exception |
Sorted with one comparer, searched with another (e.g. OrdinalIgnoreCase vs default culture) |
misses items |
| Sorting strings with culture rules, then searching ordinally | misses items |
| Mutating keys after sorting | silently wrong |
Always pass the same comparer to Sort and BinarySearch.
Modern CPUs prefetch sequential memory and .NET vectorises IndexOf for primitives with Vector128/256/512. Binary search jumps around memory and mispredicts branches. For arrays smaller than roughly 32–64 ints, a linear scan often wins. Measure before "optimising" a tiny list.
var data = Enumerable.Range(0, 1_000_000).Select(i => i * 2).ToArray(); // sorted even numbers
Console.WriteLine(Array.BinarySearch(data, 500_000)); // 250000
int miss = Array.BinarySearch(data, 7);
Console.WriteLine($"{miss} -> insert at {~miss}"); // -5 -> insert at 4
Console.WriteLine(LowerBound(data, 7)); // 4
static int LowerBound(ReadOnlySpan<int> a, int x)
{
int lo = 0, hi = a.Length;
while (lo < hi) { int mid = lo + ((hi - lo) >> 1); if (a[mid] < x) lo = mid + 1; else hi = mid; }
return lo;
}
Then break it on purpose: shuffle data and see BinarySearch return nonsense without throwing.
// Effective-dated lookup: last tariff whose From <= date
public sealed class TariffTable
{
private readonly Tariff[] _byFrom; // sorted once
private readonly DateOnly[] _from;
public TariffTable(IEnumerable<Tariff> tariffs)
{
_byFrom = tariffs.OrderBy(t => t.From).ToArray();
_from = Array.ConvertAll(_byFrom, t => t.From);
}
public Tariff? For(DateOnly date)
{
int i = Array.BinarySearch(_from, date);
if (i < 0) i = ~i - 1; // insertion point minus one = last From <= date
return i >= 0 ? _byFrom[i] : null;
}
}
// Exact lookups: build once, O(1) each
var blocked = new[] { "[email protected]", "[email protected]" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
bool isBlocked = blocked.Contains(email);
// Searching for any of several characters: SearchValues (vectorised)
private static readonly SearchValues<char> Separators = SearchValues.Create(",;|\t");
int pos = line.AsSpan().IndexOfAny(Separators);
// Same comparer for sort and search
var names = new List<string> { "bob", "Alice", "carol" };
names.Sort(StringComparer.OrdinalIgnoreCase);
int idx = names.BinarySearch("ALICE", StringComparer.OrdinalIgnoreCase); // 0
Mistake: Binary searching unsorted data. Why it is wrong: The algorithm discards half the range based on order; on unsorted data it returns wrong results without throwing. Instead: Sort once (or keep data in a sorted structure) and assert sortedness in tests.
Mistake: Mixing interval conventions, for example hi = a.Length with while (lo <= hi).
Why it is wrong: The loop reads past the end or never terminates when hi = mid meets a closed interval.
Instead: Use closed [lo, hi] with hi = mid - 1, or half-open [lo, hi) with hi = mid, and test empty, one-element, first, last and missing cases.
Mistake: Computing mid = (lo + hi) / 2.
Why it is wrong: For very large indexes lo + hi overflows int and becomes negative.
Instead: Use lo + (hi - lo) / 2.
Mistake: Treating a negative BinarySearch result as just "not found".
Why it is wrong: It throws away the insertion point, which is exactly what nearest-value and sorted-insert need.
Instead: Use ~result to get the insertion index.
Mistake: Sorting with one comparer and searching with another.
Why it is wrong: Culture-sensitive and ordinal orderings differ, so the search walks the wrong way and misses items.
Instead: Pass the same IComparer<T> or StringComparer to both.
Mistake: Assuming the result is the first duplicate.
Why it is wrong: Array.BinarySearch returns any matching index.
Instead: Write a lower-bound search when you need the first occurrence or a count.
Mistake: Replacing every Contains with a hash set or binary search.
Why it is wrong: For a handful of items a vectorised linear scan is faster, and building the index costs more than it saves.
Instead: Pick by size and number of lookups, and measure.
Seniors pick the search by the question being asked: exact match → hash; ordered, range or nearest → sorted structure plus binary search; one-off or tiny → linear. Databases make the same choice: a B+ tree index supports WHERE CreatedAt BETWEEN ..., while a hash index cannot.
| Situation | Choice | Why, beyond Big O |
|---|---|---|
| Under ~32–64 primitives, or searched once | Linear (IndexOf, Contains) |
Vectorised, sequential memory, no build cost |
| Thousands of exact lookups | Dictionary, HashSet, FrozenDictionary |
O(1) average, build cost amortised |
| Sorted data with range, nearest or insert-position queries | Binary search, SortedSet.GetViewBetween |
Exact-match structures cannot answer these |
| Huge sorted file or memory-mapped data | Binary search in place | No extra memory for an index |
| Searching for any of several characters | SearchValues<T> with IndexOfAny |
SIMD across the whole set |
They respect constants: binary search jumps around memory and its branches are hard to predict, so for small arrays a linear scan can win even though it is O(n). They also treat binary search as a general technique for monotonic predicates (capacity planning, git bisect). The senior answer names the crossover point and says "we measured".
Array.Sort, List<T>.Sort and Span.Sort use introsort: quicksort, switching to heapsort if recursion gets too deep (guarantees O(n log n)) and insertion sort for small partitions (16 items or fewer). It is in place and not stable.
Enumerable.OrderBy / ThenBy (and Order) are stable and allocate a copy.
Comparison sorts cannot beat O(n log n). For small integer keys, counting or radix sort is O(n + k). Most of the time the real choice is: sort in the database, sort in place, or use a stable LINQ sort.
A tree is a hierarchy of nodes with one root, where each node has children and no cycles. A binary tree limits each node to two children (left, right). A binary search tree (BST) adds an ordering rule: left subtree < node < right subtree, so search, insert and delete take O(height).
If the tree is balanced, height is O(log n); if you insert sorted data into a plain BST it degenerates into a linked list with O(n) height. Self-balancing trees (red-black, AVL) keep O(log n); .NET's SortedSet and SortedDictionary are red-black trees.
Traversals: in-order (sorted output for a BST), pre-order (copy/serialise), post-order (delete/evaluate), level-order (BFS).
Trees are everywhere: database B+ tree indexes, file systems, JSON/XML/DOM, Roslyn syntax trees, expression trees, org charts.
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.