Loading content...
If search answers "Is this word in the dictionary?" then startsWith answers a fundamentally different question: "Are there any words that begin with this prefix?"
This seemingly simple distinction is what elevates tries from a mere alternative to hash sets into a category of their own. Hash tables excel at exact match—given a key, retrieve a value. But they are helpless for prefix-based queries. To find all words starting with "auto", a hash set would need to iterate through every word, checking each one. That's O(n × m) where n is the dictionary size.
A trie answers the same question in O(m) time—just the length of the prefix. The existence of a path for "a-u-t-o" tells us instantly whether any words with that prefix exist. This capability powers:
This page explores startsWith in depth—its semantics, implementation, relationship to search, and the powerful applications it enables.
By the end of this page, you will: (1) Understand exactly what startsWith queries and why it differs from search, (2) Implement startsWith and explain why it's simpler than search, (3) Trace through prefix queries and predict results, (4) Leverage startsWith as a foundation for autocomplete, (5) Recognize the computational advantage tries provide for prefix operations.
Before examining the algorithm, we must be crystal clear about what startsWith actually asks.
startsWith(prefix) returns true if and only if there exists at least one word in the trie such that word.startsWith(prefix).
Equivalently: The path corresponding to prefix exists in the trie.
The existence of a path for prefix p means one of two things (or both):
p was inserted, so there's an end-of-word marker at the path's terminus.p as their prefix, so nodes exist beyond p's terminus.Either case makes startsWith(p) return true. The key insight: startsWith doesn't care about end-of-word markers at all. It only cares whether the path exists.
If search(word) is true, then startsWith(word) is also true. A complete word is always a valid prefix of itself.
But the converse is not true: startsWith(prefix) being true does not imply search(prefix) is true. The prefix might exist only as a path segment toward longer words.
In set notation:
For any word w: w ∈ W ⟹ w starts with w ⟹ startsWith(w) is true.
But startsWith(p) true does not mean p ∈ W.
startsWith("") is always true for a non-empty trie. Every word starts with the empty prefix. The empty string is the universal prefix. If your trie has at least one word, startsWith("") returns true. Even for an empty trie, you might consider returning true since there's no word that doesn't start with ""—though this is a philosophical edge case most implementations handle by returning true.
The startsWith algorithm is remarkably simple—simpler than search, in fact. We walk the trie following the prefix characters. If we reach the end of the prefix without hitting a missing edge, we return true.
Given a trie with root root and prefix prefix to check:
current = root.c in prefix:current corresponding to c.current to that child.Notice what's absent: there's no end-of-word check at the end. The algorithm just returns true after successfully walking the path. We don't care whether the node we land on marks a complete word—we only care that we got there.
This single difference encapsulates the semantic distinction between search and startsWith.
1234567891011121314151617181920212223
// Search: path must exist AND node must be end-of-wordsearch(word: string): boolean { const node = this.walkPath(word); return node !== null && node.isEndOfWord; // <-- End-of-word check} // StartsWith: path must exist, that's itstartsWith(prefix: string): boolean { const node = this.walkPath(prefix); return node !== null; // <-- No end-of-word check} // Shared path-walking logicprivate walkPath(str: string): TrieNode | null { let current = this.root; for (const char of str) { if (!current.children.has(char)) { return null; } current = current.children.get(char)!; } return current;}By factoring out the path-walking into a helper method, we see that search and startsWith are identical except for one line. This isn't just DRY code—it's a conceptual revelation. Both operations are fundamentally about path existence; they differ only in what additional property they require at the terminal node.
Let's provide complete, production-ready implementations for both array-based and hash map-based tries.
123456789101112131415161718192021222324252627282930313233343536373839404142
class TrieNode { children: (TrieNode | null)[]; isEndOfWord: boolean; constructor() { this.children = new Array(26).fill(null); this.isEndOfWord = false; }} class Trie { private root: TrieNode; constructor() { this.root = new TrieNode(); } /** * Returns true if there is any word in the trie * that starts with the given prefix. * * Time: O(m) where m is prefix length * Space: O(1) */ startsWith(prefix: string): boolean { let current = this.root; for (const char of prefix) { const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // Path breaks - no words with this prefix if (current.children[index] === null) { return false; } current = current.children[index]!; } // We walked the entire prefix - words exist return true; }}12345678910111213141516171819202122232425262728293031323334353637383940
class TrieNode { children: Map<string, TrieNode>; isEndOfWord: boolean; constructor() { this.children = new Map(); this.isEndOfWord = false; }} class Trie { private root: TrieNode; constructor() { this.root = new TrieNode(); } /** * Returns true if there is any word in the trie * that starts with the given prefix. * * Time: O(m) where m is prefix length * Space: O(1) */ startsWith(prefix: string): boolean { let current = this.root; for (const char of prefix) { // Path breaks - no words with this prefix if (!current.children.has(char)) { return false; } current = current.children.get(char)!; } // We walked the entire prefix - words exist return true; }}Here's a cohesive implementation combining insert, search, and startsWith with shared logic:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
class TrieNode { children: Map<string, TrieNode>; isEndOfWord: boolean; constructor() { this.children = new Map(); this.isEndOfWord = false; }} class Trie { private root: TrieNode; constructor() { this.root = new TrieNode(); } /** * Inserts a word into the trie. * Time: O(m), Space: O(m) worst case */ insert(word: string): void { let current = this.root; for (const char of word) { if (!current.children.has(char)) { current.children.set(char, new TrieNode()); } current = current.children.get(char)!; } current.isEndOfWord = true; } /** * Returns true if the word is in the trie (exact match). * Time: O(m), Space: O(1) */ search(word: string): boolean { const node = this.findNode(word); return node !== null && node.isEndOfWord; } /** * Returns true if any word starts with the given prefix. * Time: O(m), Space: O(1) */ startsWith(prefix: string): boolean { return this.findNode(prefix) !== null; } /** * Private helper: walks the trie following the string. * Returns the terminal node or null if path breaks. */ private findNode(str: string): TrieNode | null { let current = this.root; for (const char of str) { if (!current.children.has(char)) { return null; } current = current.children.get(char)!; } return current; }}Let's trace through startsWith queries on our familiar trie containing: "cat", "car", "card", "care", "careful".
root
└── c
└── a
├── t [END]
└── r [END]
├── d [END]
└── e [END]
└── f
└── u
└── l [END]
| Step | Current | Char | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move to 'c' |
| 2 | c | 'a' | Yes | Move to 'a' |
| 3 | a | 'r' | Yes | Move to 'r' |
| End | - | - | - | Return true |
Result: True. The path exists. Words starting with "car": "car", "card", "care", "careful".
| Step | Current | Char | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move |
| 2 | c | 'a' | Yes | Move |
| 3 | a | 'r' | Yes | Move |
| 4 | r | 'e' | Yes | Move |
| 5 | e | 'f' | Yes | Move to 'f' |
| End | - | - | - | Return true |
Result: True. Even though "caref" itself is not a word (search("caref") = false), words starting with "caref" exist ("careful").
This is the crucial difference: startsWith("caref") is true because the path exists and leads to "careful". search("caref") is false because "caref" was never inserted.
| Step | Current | Char | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move |
| 2 | c | 'a' | Yes | Move |
| 3 | a | 'b' | No | Return false |
Result: False. No words start with "cab". The 'a' node has children 't' and 'r', but not 'b'.
Full path c→a→r→e→f→u→l exists. "careful" is both a prefix that exists and a complete word.
Path c→a→r→e→f→u→l exists, but there's no 'x' child at 'l' node. No words start with "carefulx".
The loop executes zero times. We return true immediately. Every word starts with the empty prefix.
Think of startsWith as asking: "If I start typing this prefix, is autocomplete going to show me anything?" If the path exists, there are completions. If not, the prefix is a dead end—no word in the dictionary begins with those characters in that order.
Where m is the length of the prefix.
Identical to search. We iterate through each character at most once, performing O(1) work per character (child lookup). The final return is O(1).
Key insight: The time is independent of:
We're just checking if the path exists, not counting or listing matches.
Suppose we have a dictionary of n words and want to check if any word starts with a prefix of length m.
| Data Structure | Time Complexity | Notes |
|---|---|---|
| Trie | O(m) | Just walk the prefix path |
| Hash Set | O(n × m) | Must check every word |
| Sorted Array + Binary Search | O(m × log n) | Binary search, then verify prefix |
| Unsorted Array | O(n × m) | Linear scan, prefix check each |
Example with n=1,000,000 words, m=5 prefix:
For prefix queries, tries outperform hash sets by orders of magnitude. Even sorted arrays with binary search still depend on n.
The O(m) complexity shines for interactive applications where prefix queries happen frequently (every keystroke in autocomplete). Reducing from O(log n) to O(m) might seem minor, but when m is typically 1-3 characters and n is millions, the difference is significant. And compared to O(n) operations, it's transformative.
Like search, startsWith only needs a pointer to walk the trie. No additional data structures are created.
The basic startsWith returns a boolean. But the real power lies in the next step: retrieving all words that match the prefix.
This is what powers autocomplete. When you type "prog", the system doesn't just confirm words exist—it lists them: "program", "programming", "progress", "programmer", etc.
Phase 1: Use startsWith logic to navigate to the node at the end of the prefix. If it doesn't exist, return an empty list.
Phase 2: From that node, perform a DFS (depth-first search) to collect all words in the subtrie rooted at that node.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
class Trie { private root: TrieNode; // ... constructor, insert, search, startsWith ... /** * Returns all words in the trie that start with the given prefix. * Time: O(m + k) where m is prefix length, k is total characters * in all matching words after the prefix * Space: O(max word length) for recursion stack, plus output array */ getWordsWithPrefix(prefix: string): string[] { // Phase 1: Navigate to prefix node let current = this.root; for (const char of prefix) { if (!current.children.has(char)) { return []; // No words with this prefix } current = current.children.get(char)!; } // Phase 2: Collect all words from this subtrie const results: string[] = []; this.collectWords(current, prefix, results); return results; } /** * DFS helper to collect all words rooted at the given node. * Uses currentWord as the accumulated string so far. */ private collectWords( node: TrieNode, currentWord: string, results: string[] ): void { // If this node marks a complete word, add it if (node.isEndOfWord) { results.push(currentWord); } // Recursively explore all children for (const [char, childNode] of node.children) { this.collectWords(childNode, currentWord + char, results); } }}Time Complexity: O(m + k)
Space Complexity: O(L + r × L)
Why this is efficient: We only visit nodes that are actually in the subtrie. For a prefix with few matches, this is fast. The traversal is proportional to the output size—can't do better than that when you need to enumerate results.
In practice, autocomplete shows only the top 5-10 suggestions. Modify collectWords to stop after finding k words, or use a priority queue to return most frequent/relevant matches. For production systems, combine with frequency data stored in nodes.
Let's examine real-world systems where startsWith (and its extensions) provide critical functionality.
As users type, the search box predicts completions.
Challenge: Millions of possible queries; must respond in <100ms.
Solution: Trie stores popular queries. Each keystroke triggers startsWith + getWordsWithPrefix. Frequency counts at nodes enable ranking.
When you type str., the IDE suggests methods like startsWith, substring, split.
Challenge: Symbol tables can be huge; language servers must be fast.
Solution: Tries index method names. Typing filters instantly via prefix matching.
Typing "Jo" shows "John", "Jonathan", "Joanna".
Challenge: Must update results after each keystroke.
Solution: Names stored in trie. Single-character prefixes return quickly even with thousands of contacts.
| Application | Prefix Query Use | Why Tries Excel |
|---|---|---|
| Search autocomplete | Suggest queries as user types | O(m) regardless of dictionary size |
| Code completion | Filter symbols by typed prefix | Instant updates per keystroke |
| Spell check | Suggest corrections starting with partial word | Efficient prefix enumeration |
| IP routing | Match address to longest prefix | Packet routing in microseconds |
| Dictionary browser | Show words starting with selection | Smooth scroll through filtered list |
| File path completion | Complete paths in terminal/file dialog | Fast even with deep directory trees |
Many applications share this pattern:
Each step is O(m), and m is incrementing by 1 each time. The trie naturally supports this incremental refinement without preprocessing or caching—though caching the "current node" across keystrokes can eliminate re-walking the path.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
/** * Optimized autocomplete that maintains cursor position. * Avoids re-walking the trie from root on each keystroke. */class IncrementalAutocomplete { private trie: Trie; private currentNode: TrieNode | null; private currentPrefix: string; constructor(words: string[]) { this.trie = new Trie(); words.forEach(word => this.trie.insert(word)); this.reset(); } /** * User typed a character. Advance the cursor if possible. */ type(char: string): string[] { if (this.currentNode === null) { return []; // Already in dead-end state } if (!this.currentNode.children.has(char)) { this.currentNode = null; // Dead end return []; } this.currentNode = this.currentNode.children.get(char)!; this.currentPrefix += char; return this.getSuggestions(); } /** * User deleted a character. Walk from root to new prefix. */ backspace(): string[] { if (this.currentPrefix.length === 0) { return this.getSuggestions(); } this.currentPrefix = this.currentPrefix.slice(0, -1); // Re-walk to new prefix (could optimize with parent pointers) this.currentNode = this.trie.getNode(this.currentPrefix); return this.getSuggestions(); } private getSuggestions(): string[] { if (this.currentNode === null) return []; return this.trie.getWordsFromNode(this.currentNode, this.currentPrefix); } reset(): void { this.currentNode = this.trie.getRoot(); this.currentPrefix = ""; }}The startsWith operation encapsulates why tries exist. Let's consolidate what we've learned:
What's Next:
Now that we understand all three core operations—insert, search, and startsWith—the next page brings everything together with a comprehensive time complexity analysis. We'll rigorously verify the O(m) bound, explore how it compares to alternatives in different scenarios, and understand when tries are the optimal choice versus when other data structures might suffice.
You've mastered the startsWith operation—the capability that makes tries irreplaceable for prefix-based queries. You understand its semantics, implementation, O(m) complexity, and real-world applications. Combined with insert and search, you now have a complete mental model of trie operations. Next: formal complexity analysis.