Trie Time & Space Complexity
Space Complexity — Potentially O(n × m × Alphabet) Worst Case
Read aloud is unavailable in this browser.
The Space Trade-off
While tries offer elegant O(m) time complexity, this efficiency comes at a potential cost: space. Unlike hash tables or balanced trees that store each key once, tries distribute keys across a tree of nodes, each node potentially containing space for an entire alphabet of children.
In the worst case, a trie storing n strings of average length m with alphabet size Σ can consume O(n × m × Σ) space—a potentially enormous overhead compared to the raw data size.
Understanding this space complexity is essential for making informed decisions about when tries are appropriate. In this page, we'll dissect exactly where this space goes, when the worst case occurs, and how to reason about trie memory consumption in practice.
By the end of this page, you will understand the complete space complexity picture for tries: the theoretical worst case, the formula O(n × m × Σ), how different node representations affect space, and when tries actually hit their worst-case bounds versus when they achieve much better space efficiency through prefix sharing.
Understanding the O(n × m × Σ) Formula
The space complexity formula O(n × m × Σ) looks intimidating. Let's break it down component by component:
The Variables:
- n = number of strings stored in the trie
- m = average (or maximum) length of the strings
- Σ = alphabet size (e.g., 26 for lowercase English, 128 for ASCII, 256 for extended ASCII, or 65,536 for Unicode BMP)
How the Formula Arises:
In the worst case:
- Each of the n strings is completely unique (no shared prefixes)
- Each string has length m, requiring m nodes per string
- Each node allocates space for Σ child pointers (when using array-based storage)
Therefore:
- Total nodes: n × m (no sharing)
- Space per node: Σ pointers + metadata (isEndOfWord, etc.)
- Total space: n × m × Σ
| Parameter | Value | Meaning |
|---|---|---|
| n | 100,000 | Number of words in dictionary |
| m | 10 | Average word length |
| Σ | 26 | Lowercase English alphabet |
| Nodes (worst case) | 1,000,000 | n × m = 100K × 10 |
| Pointers per node | 26 | One pointer per character |
| Bytes per pointer | 8 | 64-bit system |
| Pointer space | 208 MB | 1M × 26 × 8 bytes |
| Raw data size | 1 MB | 100K × 10 bytes for characters |
| Overhead ratio | 208x | Trie uses 208× more memory! |
In the worst case with array-based children, a trie storing 1 MB of raw string data could require 208 MB of memory—a 208x overhead. This is why understanding space complexity is critical before choosing a trie.
Where Does All This Space Go?
Let's trace the memory allocation for a single word "cat" in an array-based trie with 26-character alphabet:
| Node | Character | What's Allocated | Space (64-bit) |
|---|---|---|---|
| Root → 'c' | c | 26 pointers + isEndOfWord | 26 × 8 + 1 = 209 bytes |
| 'c' → 'a' | a | 26 pointers + isEndOfWord | 209 bytes |
| 'a' → 't' | t | 26 pointers + isEndOfWord | 209 bytes |
| Total | 3 nodes | 627 bytes | |
| Raw data | "cat" | 3 bytes | |
| Overhead | 209x |
Each node allocates space for 26 children even though it typically uses only 1 or 2. This is the fundamental source of trie space inefficiency.
Node Representation & Space Trade-offs
The choice of how to store children within each node dramatically affects space consumption. Let's analyze the three primary approaches:
Approach 1: Fixed-Size Array (O(Σ) per node)
Each node contains an array of Σ pointers, one for each possible character:
class TrieNode {
children: (TrieNode | null)[] = new Array(26).fill(null);
isEndOfWord: boolean = false;
}
Approach 2: Hash Map (O(k) per node where k = actual children)
Each node contains a hash map storing only existing children:
class TrieNode {
children: Map<string, TrieNode> = new Map();
isEndOfWord: boolean = false;
}
Approach 3: Sorted Array with Binary Search (O(k) space, O(log k) lookup)
Each node contains a sorted array of (character, node) pairs:
class TrieNode {
children: { char: string; node: TrieNode }[] = [];
isEndOfWord: boolean = false;
}
| Representation | Space per Node | Lookup Time | Insert Time | Best For |
|---|---|---|---|---|
| Fixed Array | O(Σ) | O(1) | O(1) | Small alphabet, dense nodes |
| Hash Map | O(k) | O(1) average | O(1) average | Large alphabet, sparse nodes |
| Sorted Array | O(k) | O(log k) | O(k) | Read-heavy, space-critical |
| Linked List | O(k) | O(k) | O(k) | Very sparse, memory-critical |
Detailed Space Analysis by Representation:
Fixed Array (26 lowercase letters):
- Per node: 26 pointers × 8 bytes + 1 byte flag = 209 bytes
- For 1 million nodes: 209 MB
- Regardless of how many children each node actually has
Hash Map (JavaScript/TypeScript Map):
- Per node: Map overhead (~48 bytes) + entries
- Per entry: String key + pointer + hash overhead ≈ 50-80 bytes
- Node with 3 children: ~200-300 bytes (still substantial, but scales with actual children)
- Empty node: ~50 bytes
When Each Matters:
For a trie with 26-character alphabet storing English words:
- Average children per node: ~2-4 (most nodes are sparse)
- Array representation wastes 22-24 null pointers per node
- Hash map representation pays overhead but only for actual children
For typical English word tries (26-character alphabet), hash maps often use less memory than fixed arrays because nodes are sparse. For tries with very small alphabets (e.g., binary tries for bits) or very dense nodes, fixed arrays are more efficient. Profile your specific use case to decide.
The Best Case — Prefix Sharing Magic
The O(n × m × Σ) worst case assumes no prefix sharing. In practice, real-world data often has significant prefix overlap, dramatically reducing actual space usage.
When Prefixes Are Shared:
Consider storing these words: "cat", "car", "card", "care", "careful".
Without sharing (separate storage): 3 + 3 + 4 + 4 + 7 = 21 characters = 21 nodes
With trie prefix sharing:
root
|
c (1)
|
a (2)
/
t r (3)
(4) /|
d e ful
(5)(6)(7,8,9)
Nodes needed: 9 (not 21!)
Space savings: 57% reduction in nodes
- •URL paths: /api/users, /api/users/profile, /api/posts
- •File paths: /home/user/docs/, /home/user/downloads/
- •Domain names: mail.google.com, docs.google.com
- •Phone numbers with area codes: 555-000-xxxx
- •IP addresses: 192.168.1.x subnet
- •Dictionary words with common prefixes: un-, pre-, re-
- •Random UUIDs: a7f3..., b2c4..., d8e9...
- •Cryptographic hashes: completely random
- •Shuffled data: intentionally decorrelated
- •Diverse natural languages: different scripts
- •Arbitrary binary data: uniform distribution
- •Primary keys with random generation
Quantifying Prefix Sharing:
Let's define a prefix sharing ratio (PSR):
PSR = (Total characters in all strings) / (Actual nodes in trie)
- PSR = 1.0: No sharing (worst case, every string is completely unique)
- PSR > 1.0: Some sharing (higher is better)
- PSR = n × m / Σ: Extreme sharing (all strings share all-but-one character)
Real-World Examples:
| Dataset | Strings | Total Chars | Trie Nodes | PSR | Space Savings |
|---|---|---|---|---|---|
| English dictionary (170K words) | 171,476 | 1,514,230 | ~450,000 | 3.4x | 70% |
| URL paths (web API) | 10,000 | 350,000 | ~12,000 | 29x | 96% |
| Random UUIDs | 100,000 | 3,600,000 | ~3,600,000 | 1.0x | 0% |
| Phone numbers (same area) | 100,000 | 1,000,000 | ~250,000 | 4.0x | 75% |
| IP addresses (same /16) | 65,536 | 917,504 | ~100,000 | 9.2x | 89% |
Tries shine when your data has natural prefix structure. For random or hash-like data, tries provide no space benefit and significant overhead. Always analyze your data's prefix characteristics before choosing a trie.
Calculating Actual Space Usage
Let's develop a practical framework for estimating trie space requirements before implementation.
The General Formula:
Total Space = (Number of Nodes) × (Space per Node)
Where:
- Number of Nodes depends on prefix sharing
- Space per Node depends on representation
Step-by-Step Estimation Process:
- Estimate total characters: n × average_length
- Estimate prefix sharing ratio: Based on data characteristics
- Calculate expected nodes: total_characters / PSR
- Calculate space per node: Based on alphabet and representation
- Multiply for total: nodes × space_per_node
Example Calculation:
Scenario: 100,000 English words, average length 8, 26-character alphabet
| Step | Calculation | Result |
|---|---|---|
| Total characters | 100,000 × 8 | 800,000 |
| Estimate PSR (English) | ~3.0 (empirical) | 3.0 |
| Expected nodes | 800,000 / 3.0 | ~267,000 |
| Space per node (array) | 26 × 8 + 4 | 212 bytes |
| Space per node (hash map) | ~80 bytes avg | 80 bytes |
| Total (array) | 267,000 × 212 | ~54 MB |
| Total (hash map) | 267,000 × 80 | ~21 MB |
| Raw data | 800,000 | 0.8 MB |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
/** * Trie Space Estimator * * Calculates expected memory usage for a trie given dataset characteristics. */ interface TrieSpaceEstimate { rawDataBytes: number; estimatedNodes: number; arrayBasedBytes: number; hashMapBasedBytes: number; arrayOverhead: number; // ratio vs raw data hashMapOverhead: number;} /** * Estimate trie space requirements. * * @param stringCount - Number of strings (n) * @param avgLength - Average string length (m) * @param alphabetSize - Size of alphabet (Σ) * @param prefixSharingRatio - Estimated PSR (1.0 = no sharing) * @param pointerSize - Size of pointer in bytes (default 8 for 64-bit) */function estimateTrieSpace( stringCount: number, avgLength: number, alphabetSize: number, prefixSharingRatio: number = 1.0, pointerSize: number = 8): TrieSpaceEstimate { // Raw data size (just the strings themselves) const rawDataBytes = stringCount * avgLength; // Total characters across all strings const totalCharacters = stringCount * avgLength; // Estimated nodes after prefix sharing const estimatedNodes = Math.ceil(totalCharacters / prefixSharingRatio); // Array-based node: Σ pointers + 1 byte for isEndOfWord + object overhead const arrayNodeSize = (alphabetSize * pointerSize) + 1 + 16; // 16 for object header const arrayBasedBytes = estimatedNodes * arrayNodeSize; // Hash map node: base Map overhead + average 3 children // Map overhead ~48 bytes + per-entry ~32 bytes const avgChildrenPerNode = 2.5; // typical for natural language const hashMapNodeSize = 48 + (avgChildrenPerNode * 32) + 16; const hashMapBasedBytes = estimatedNodes * hashMapNodeSize; return { rawDataBytes, estimatedNodes, arrayBasedBytes, hashMapBasedBytes, arrayOverhead: arrayBasedBytes / rawDataBytes, hashMapOverhead: hashMapBasedBytes / rawDataBytes, };} // Example usageconst estimates = [ { name: "English Dictionary (170K words)", ...estimateTrieSpace(170000, 9, 26, 3.4) }, { name: "URL Paths (API routes)", ...estimateTrieSpace(10000, 35, 64, 15) }, { name: "Random UUIDs (worst case)", ...estimateTrieSpace(100000, 36, 16, 1.0) },]; console.log("Trie Space Estimates:");console.log("====================");for (const e of estimates) { console.log(`${e.name}:`); console.log(` Raw data: ${(e.rawDataBytes / 1024 / 1024).toFixed(2)} MB`); console.log(` Estimated nodes: ${e.estimatedNodes.toLocaleString()}`); console.log(` Array-based: ${(e.arrayBasedBytes / 1024 / 1024).toFixed(2)} MB (${e.arrayOverhead.toFixed(1)}x overhead)`); console.log(` Hash map: ${(e.hashMapBasedBytes / 1024 / 1024).toFixed(2)} MB (${e.hashMapOverhead.toFixed(1)}x overhead)`);}Memory Layout & Cache Considerations
Beyond raw byte counts, how memory is organized affects performance significantly. Tries present unique challenges for modern CPU caches.
The Cache Problem:
Modern CPUs rely on cache hierarchies to hide memory latency. Caches work best with:
- Sequential access patterns
- Spatial locality (accessing nearby memory)
- Temporal locality (accessing the same memory repeatedly)
Tries violate all three principles:
- Random Access: Each node traversal follows a pointer to arbitrary memory
- No Spatial Locality: A node's children are separate allocations
- Limited Temporal Locality: Unless searching similar strings repeatedly
Cache Miss Analysis:
| Operation | Trie | Array | Hash Table |
|---|---|---|---|
| Search pattern | Sequential pointer chase | Index calculation | Hash + probe |
| Cache misses (typical) | 1 per character | 0-1 total | 1-2 total |
| Prefetch effectiveness | None | Excellent | Poor |
| Memory bandwidth | Low utilization | High utilization | Medium |
| Latency per char | ~100 cycles (cache miss) | ~4 cycles | N/A (single lookup) |
Why This Matters for Space:
Cache-unfriendly access patterns mean:
- Larger working sets: More memory pages touched per operation
- TLB pressure: Translation Lookaside Buffer misses for scattered pages
- Memory bandwidth waste: Fetching 64-byte cache lines for 8-byte pointers
Optimization Strategies:
- Memory Pooling: Allocate nodes from contiguous pools to improve locality
- Cache-Conscious Layout: Group frequently-accessed nodes together
- Path Compression: Reduce pointer chains (radix trees)
- Array-of-Structures → Structure-of-Arrays: Sometimes helps vectorization
The Trade-off:
Optimizing for cache can increase implementation complexity significantly. For many applications, the simpler implementation is sufficient. Profile before optimizing.
Cache optimization matters for tries in hot paths (autocomplete serving millions of QPS) but not for batch processing or infrequent access. Measure first, optimize second. A simple trie with poor cache behavior may still be faster than alternatives for your workload.
Comparing Space with Alternative Data Structures
To make informed decisions, we need to compare trie space usage against alternatives. Each data structure makes different trade-offs.
The Contenders:
- Trie (Hash Map children): Our focus structure
- Hash Set: Standard string collection
- Sorted Array: Simple, compact storage
- Balanced BST (TreeSet): O(log n) operations
- Suffix Array: Specialized string structure
| Data Structure | Space | Prefix Search? | Notes |
|---|---|---|---|
| Raw strings (baseline) | 1 MB | No | Just the data, no structure |
| Hash Set (strings) | ~3-5 MB | O(n × m) | Hash table overhead + strings |
| Sorted Array (strings) | ~2 MB | O(log n × m) | Compact but requires sorting |
| Balanced BST (strings) | ~5-8 MB | O(log n × m) | Tree overhead + strings |
| Trie (hash map nodes) | ~20-50 MB | O(m) | Prefix operations are fast |
| Trie (array nodes) | ~50-100 MB | O(m) | Fastest but most memory |
The Memory-Performance Trade-off Matrix:
| Need | Best Choice | Memory | Performance |
|---|---|---|---|
| Just store and check membership | Hash Set | Low | O(m) average |
| Sorted iteration | Sorted Array/BST | Low-Medium | O(n) / O(n) |
| Prefix matching | Trie | High | O(m) |
| Substring matching | Suffix Array/Tree | Very High | O(m log n) |
| All of the above | Depends on priority | Trade-off | Trade-off |
Key Insight:
Tries use more memory to enable O(m) prefix operations. If you don't need prefix operations, you're paying for features you don't use. But if you do need prefix operations, the memory cost is often justified by the dramatic performance improvement.
• Prefix operations are critical to functionality • Data has high prefix sharing (URLs, paths, IPs) • Memory budget can accommodate overhead • Latency matters more than throughput • Lexicographic ordering is needed
• Only exact match is needed • Data is random/hash-like • Memory is severely constrained • Strings are very long (paths stack up) • Insertion is rare, bulk loading possible
Practical Space Optimization Techniques
When you need a trie but space is a concern, several techniques can dramatically reduce memory consumption.
Technique 1: Path Compression (Radix Trees)
Compress chains of single-child nodes into single edges labeled with multiple characters.
Before (standard trie for "romane", "romanus", "romulus"):
r → o → m → a → n → e
↘ u → s
↘ u → l → u → s
After (radix tree):
rom
/
an ulus
/
e us
Space saving: From 16 nodes to 6 nodes (62% reduction)
Technique 2: Alphabet Reduction
Map large alphabets to smaller ones when exact characters aren't needed.
Example: For prefix matching only, Unicode → ASCII → categories
- 65,536 codepoints → 128 ASCII → 5 categories (vowel, consonant, digit, space, other)
Technique 3: Double-Array Trie
Compact representation using two arrays (base, check) instead of pointers. Achieves near-optimal space but complex to implement.
Technique 4: HAT-trie
Hybrid structure: Trie at top levels, hash tables at leaves. Balances prefix operations with compact storage.
- •Use hash maps instead of arrays for children when alphabet is large (>50 characters)
- •Intern strings if storing full words in nodes (share string instances)
- •Lazily allocate isEndOfWord — use a Set of end-nodes instead of boolean in every node
- •Consider compact string representations — byte arrays instead of JavaScript strings
- •Prune unused branches — if trie is built once and only searched, remove dead paths
- •Use typed arrays in JavaScript for children indices instead of object pointers
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
/** * Space-Optimized Trie using a compact representation * * Instead of objects per node, uses parallel arrays: * - children: Uint32Array for child indices * - isEndOfWord: BitSet for end markers * * This is more cache-friendly and memory-efficient. */class CompactTrie { // Flat array of all children: node i's children start at childStart[i] private children: Uint32Array; private childChars: Uint8Array; // Character labels for children private childOffsets: Uint32Array; // Start index of each node's children private childCounts: Uint8Array; // Number of children per node private isEndOfWord: Uint32Array; // Bit array private nodeCount: number = 0; // ... implementation details omitted for brevity /** * Space comparison for 100K words: * * Standard trie (array children): * ~300K nodes × 212 bytes/node = ~63 MB * * Standard trie (hash map children): * ~300K nodes × 80 bytes/node = ~24 MB * * Compact trie (this implementation): * - childStart: 300K × 4 bytes = 1.2 MB * - children: ~450K × 4 bytes = 1.8 MB (average 1.5 children/node) * - childChars: ~450K × 1 byte = 0.45 MB * - isEndOfWord: 300K / 32 × 4 = 0.04 MB * - Total: ~3.5 MB * * That's 18x less memory than array-based, 7x less than hash map! */} // Alternative: Use standard Map but with space-conscious patternsclass SpaceEfficientTrieNode { // Only allocate map when first child is added (lazy initialization) children?: Map<string, SpaceEfficientTrieNode>; isEnd: boolean = false; getOrCreateChild(char: string): SpaceEfficientTrieNode { if (!this.children) { this.children = new Map(); } if (!this.children.has(char)) { this.children.set(char, new SpaceEfficientTrieNode()); } return this.children.get(char)!; } getChild(char: string): SpaceEfficientTrieNode | undefined { return this.children?.get(char); } hasChildren(): boolean { return this.children !== undefined && this.children.size > 0; }}KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.