Why std::vector Linear Search Beats Binary Trees
In theoretical computer science, a binary search tree's \(O(\log n)\) lookup complexity easily
outperforms an array's \(O(n)\) linear
scan. However, in modern C++ systems programming, a linear search
through a contiguous std::vector routinely outperforms a
node-based binary search tree (such as std::set or
std::map) for small to medium-sized datasets. This article
breaks down why modern hardware architecture—specifically cache
locality, hardware prefetching, and memory overhead—causes real-world
hardware realities to invert theoretical algorithmic expectations.
Cache Locality and Memory Architecture
Modern CPU performance is heavily constrained by memory latency. While a CPU core can process instructions in fractions of a nanosecond, fetching data from main memory (DRAM) requires tens to hundreds of nanoseconds. To mitigate this discrepancy, modern processors rely on multi-tier cache systems (L1, L2, and L3).
When a CPU reads an address from memory, it does not fetch a single byte or element; it retrieves an entire cache line, which is typically 64 bytes wide.
std::vector: Elements are stored contiguously in memory. Loading the first element automatically pulls neighboring elements into the L1 cache. If you are searching through an array of 4-byte integers, a single 64-byte cache line fetch loads 16 consecutive integers at once. Subsequent comparisons occur almost instantly against the L1 cache.- Binary Search Tree (e.g.,
std::set): Trees are node-based data structures. Each element is individually allocated on the heap, accompanied by child and parent pointers. These nodes are scattered across non-contiguous addresses in memory. Every step down a branch typically results in a cache miss, forcing the CPU to stall while waiting for main memory to retrieve the next node.
Hardware Prefetching vs. Pointer Chasing
Modern processors incorporate hardware prefetchers that analyze
memory access patterns. When the CPU detects a linear scan through
memory, it proactively fetches upcoming cache lines from DRAM into the
cache before the execution thread even requests them. A linear search
over a std::vector creates a completely predictable access
pattern, allowing the CPU to read elements with near-zero memory-stall
cycles.
In contrast, navigating a tree requires pointer chasing. The address of the next node is unknown until the current node's pointer is read and evaluated. Because the hardware prefetcher cannot predict where the next node resides in physical memory, it cannot prefetch the data. The CPU must repeatedly stall on every traversal level.
Memory Overhead and Cache Efficiency
A binary search tree introduces significant memory bloat compared to
a vector. In a standard 64-bit implementation of a Red-Black tree (such
as std::set):
- Three 64-bit pointers (parent, left child, right child) = 24 bytes.
- Node color/metadata and padding = 8 bytes.
- The actual data payload = \(M\) bytes.
A single 4-byte integer in a std::set can consume 32 to
48 bytes of memory due to allocator alignment and pointer overhead.
Because the data density is so low, CPU caches fill up primarily with
tree navigation metadata rather than payload data. A
std::vector has zero per-element memory overhead,
maximizing the amount of actual payload stored in the processor's
fastest cache levels.
Instruction Pipelining and Branch Prediction
A linear search over contiguous data consists of tight, repetitive loops. Modern processors unroll and vectorize (via SIMD instructions) these operations, comparing multiple elements in a single CPU cycle.
Binary search tree lookups involve unpredictable branch directions
(left vs. right) depending on the key
compared. If the CPU's branch predictor guesses incorrectly, the
instruction pipeline is flushed, resulting in a penalty of 10 to 20
clock cycles per misprediction.
Where the Crossover Occurs
Theoretical Big-O notation measures operations as \(N\) approaches infinity, completely
omitting hardware-dependent constant factors. Because the constant
factor for an in-cache linear scan is orders of magnitude smaller than a
main-memory pointer dereference, a linear scan through
std::vector is typically faster than a tree lookup for
collections containing tens, hundreds, or even thousands of elements,
depending on the payload size.
For sorted data where lookup performance is critical, a sorted
std::vector queried using std::binary_search
or std::lower_bound combines the algorithmic efficiency of
\(O(\log n)\) with the contiguous
memory benefits of arrays, outperforming both linear scans and
traditional node-based binary trees.