Loading learning content...
If insertion builds the trie, search answers the fundamental question every dictionary must answer: "Is this word in the collection?"
The search operation walks the same character-by-character path that insertion created. But whereas insertion creates nodes that don't exist, search must fail when it encounters a missing path. And even when the path exists completely, search must verify that the path represents a complete word, not merely a prefix of some longer word.
This page explores the search operation with the same rigor we applied to insertion. You'll understand the precise semantics of trie search, the two distinct failure modes, and how to implement bulletproof search logic that handles every edge case.
By the end of this page, you will: (1) Understand the exact semantics of trie search and how it differs from startsWith, (2) Identify the two failure modes and implement proper checks for both, (3) Trace through searches step-by-step to build intuition, (4) Analyze time complexity and understand why it's O(m), (5) Handle edge cases and implement robust search logic.
Before writing any code, we must precisely define what a successful search means. In a trie, search returns true if and only if the exact word was previously inserted.
This sounds simple, but the implications are subtle:
For search(word) to return true, both conditions must hold:
The complete path exists: Every character in word has a corresponding edge from its parent node. We can walk the entire string without hitting a dead end.
The final node is marked as end-of-word: The node we land on after processing the last character has its isEndOfWord flag set to true.
Missing either condition means the word was not inserted:
Failure Mode 2 is the subtle one. Just because you can walk a path through the trie doesn't mean that path represents an inserted word. The path might exist only because it's a prefix of some longer word. The end-of-word marker is the definitive answer to "was this specific string inserted?"
Think of the trie as a city with roads. Every word we insert is like establishing a named location at the end of a series of roads. The roads (edges) exist and you can travel them, but only some destinations have street addresses (end-of-word markers).
Searching for a word is like asking: "Can I reach this address?" Two things can prevent you:
Both mean the address doesn't exist—but for different reasons.
The search algorithm mirrors insertion's path-walking logic but with crucial differences in how we handle missing children and what we return.
Given a trie with root node root and a string word to search for:
current = root.c in word:current corresponding to character c.current to the child node.current.isEndOfWord.Steps 1-2: Identical to insertion. We're tracing the same path structure.
Steps 3-4 (Early Exit): This is where search diverges from insert. Insert creates missing children; search fails on missing children. If the path doesn't exist, the word was never inserted.
Step 5: Continue walking if the path exists.
Step 6 (Final Check): The crucial verification. We've confirmed the path exists; now we confirm it represents a complete word. Return the boolean directly—no need for if (isEndOfWord) return true else return false.
Notice that step 6 simply returns isEndOfWord. This handles both success (true) and failure mode 2 (false) in a single expression. Don't overcomplicate with if-else when the flag is already a boolean that represents exactly what you need to return.
Let's implement search for both children representations. The logic is identical; only the child lookup mechanism differs.
1234567891011121314151617181920212223242526272829303132333435363738394041
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 the word is in the trie. * Time: O(m) where m is the length of the word * Space: O(1) - no additional space needed */ search(word: string): boolean { let current = this.root; for (const char of word) { const index = char.charCodeAt(0) - 'a'.charCodeAt(0); // Failure Mode 1: Path doesn't exist if (current.children[index] === null) { return false; } current = current.children[index]!; } // Failure Mode 2 handled implicitly: // Returns true only if this node marks end of word return current.isEndOfWord; }}1234567891011121314151617181920212223242526272829303132333435363738
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 the word is in the trie. * Time: O(m) where m is the length of the word * Space: O(1) - no additional space needed */ search(word: string): boolean { let current = this.root; for (const char of word) { // Failure Mode 1: Path doesn't exist if (!current.children.has(char)) { return false; } current = current.children.get(char)!; } // Failure Mode 2 check: is this a complete word? return current.isEndOfWord; }}Notice that the algorithm structure is exactly the same. The only difference is:
current.children[index] === null vs current.children[index]!!current.children.has(char) vs current.children.get(char)!This illustrates a powerful abstraction: the trie's logical behavior is independent of its physical representation. The children lookup is an implementation detail that doesn't affect the algorithm's correctness or complexity.
Let's trace through searches on a trie containing: "cat", "car", "card", "care", "careful".
root
└── c
└── a
├── t [END]
└── r [END]
├── d [END]
└── e [END]
└── f
└── u
└── l [END]
| Step | Current Node | Character | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move to 'c' node |
| 2 | c | 'a' | Yes | Move to 'a' node |
| 3 | a | 'r' | Yes | Move to 'r' node |
| End | r | - | - | r.isEndOfWord = true → return true |
Result: Found! Both conditions satisfied.
| Step | Current Node | Character | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move to 'c' node |
| 2 | c | 'a' | Yes | Move to 'a' node |
| 3 | a | 'r' | Yes | Move to 'r' node |
| 4 | r | 'e' | Yes | Move to 'e' node |
| End | e | - | - | e.isEndOfWord = true → return true |
Result: Found! Note that 'e' node also has a child 'f' (for "careful"), but this doesn't affect the search—we stop at the end of our query string.
| Step | Current Node | Character | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move to 'c' node |
| 2 | c | 'a' | Yes | Move to 'a' node |
| 3 | a | 'r' | Yes | Move to 'r' node |
| 4 | r | 'e' | Yes | Move to 'e' node |
| 5 | e | 'f' | Yes | Move to 'f' node |
| End | f | - | - | f.isEndOfWord = false → return false |
Result: Not found! The path exists completely, but "caref" was never inserted—only "careful" was. The 'f' node exists only as a stepping stone to 'u'.
Search 3 demonstrates the critical importance of the end-of-word check. We successfully walked the entire path c→a→r→e→f, but that's not enough. The 'f' node was never marked as an end-of-word because no one inserted "caref". Always check isEndOfWord—the path existing is necessary but not sufficient.
| Step | Current Node | Character | Child Exists? | Action |
|---|---|---|---|---|
| 1 | root | 'c' | Yes | Move to 'c' node |
| 2 | c | 'a' | Yes | Move to 'a' node |
| 3 | a | 'b' | No | Return false immediately |
Result: Not found! The 'a' node has children 't' and 'r', but no 'b'. The path breaks before we finish processing the query string.
| Step | Current Node | Character | 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 |
| 6 | f | 'u' | Yes | Move |
| 7 | u | 'l' | Yes | Move |
| End | l | - | - | l.isEndOfWord = true → return true |
Result: Found! We traversed the longest path in the trie and confirmed "careful" was inserted.
For an empty string, the loop executes zero times, and we check root.isEndOfWord. If we allowed inserting empty strings, this would return true. Otherwise, it returns false.
Practical advice: Either reject empty strings at insertion time, or document that search("") returns whether the empty string was explicitly inserted.
Where m is the length of the search query.
The algorithm processes each character exactly once (or exits early if the path breaks). At each character, we perform O(1) work:
children[index] is O(1)children.has(char) is O(1) averageThe final isEndOfWord check is also O(1).
Total: O(m)
Key insight: Search time is independent of how many words are in the trie. Whether the trie contains 100 words or 100 million words, searching for "algorithm" (m=9) takes the same time. This is a major advantage over balanced BSTs, where search time depends on the number of elements.
| Data Structure | Search Time | Depends On |
|---|---|---|
| Trie | O(m) | Query length only |
| Hash Set | O(m) | Query length (for hashing) |
| Balanced BST | O(m log n) | Query length × log(# elements) |
| Sorted Array + Binary Search | O(m log n) | Query length × log(# elements) |
| Unsorted Array | O(n × m) | Linear scan, string comparison |
Search uses only a constant amount of extra space:
current) that walks down the triefor...of)We don't create any new nodes or allocate any data structures. The trie itself occupies space, but search doesn't add to it.
If the query has a prefix that doesn't exist in the trie, we terminate early:
In practice, early termination means many failed searches are faster than the full O(m).
Consider a dictionary with 1 million words. A balanced BST would require O(log 1,000,000) ≈ 20 string comparisons for each search. If strings average 10 characters, that's O(10 × 20) = O(200) character comparisons. A trie needs only O(10) = 10 character lookups. For large dictionaries with long strings, tries provide a significant constant-factor improvement.
A common source of confusion is the difference between search and startsWith. Let's clarify with precision.
search(word): Returns true if and only if word was previously inserted as a complete word.
startsWith(prefix): Returns true if any word in the trie starts with prefix. The prefix itself need not be a complete word.
| Aspect | search(word) | startsWith(prefix) |
|---|---|---|
| What it asks | "Was this exact word inserted?" | "Does any word begin with this?" |
| Path must exist | Yes | Yes |
| End-of-word required | Yes | No |
| Returns | current.isEndOfWord | true (if path exists) |
If we insert only "application":
| Query | search Result | startsWith Result | Why |
|---|---|---|---|
| "application" | true | true | Full word, path exists, isEndOfWord=true |
| "app" | false | true | Path exists to 'p', but isEndOfWord=false |
| "apple" | false | false | Path breaks at 'l' (no edge from second 'p' to 'l') |
| "" | false | true | Empty prefix matches everything |
Critical observation: For "app", the path exists and if we were doing startsWith, we'd return true. But search must verify that "app" was explicitly inserted—which it wasn't. The 'p' node (at depth 3) exists only as part of the path to "application", not as a word terminus.
Many trie implementations factor out the path-walking logic into a private helper method that returns the node at the end of the path (or null if the path doesn't exist). Then search returns node.isEndOfWord if node exists, while startsWith returns node !== null. This DRYs up the code and makes the distinction crystal clear.
123456789101112131415161718192021222324252627282930313233343536373839
class Trie { private root: TrieNode; constructor() { this.root = new TrieNode(); } /** * Private helper: walks the path and returns the terminal node. * Returns null if the path doesn't exist. */ private findNode(str: string): TrieNode | null { let current = this.root; for (const char of str) { if (!current.children.has(char)) { return null; // Path breaks } current = current.children.get(char)!; } return current; // Path exists, return terminal node } /** * Search: exact word match */ search(word: string): boolean { const node = this.findNode(word); return node !== null && node.isEndOfWord; } /** * StartsWith: prefix match */ startsWith(prefix: string): boolean { return this.findNode(prefix) !== null; }}This refactored implementation makes the semantic difference explicit:
search needs the node to exist AND be an end-of-wordstartsWith only needs the node to existThe shared path-walking logic is in findNode, eliminating duplication and potential for bugs.
Searching for any word in an empty trie (no insertions):
search("") returns root.isEndOfWord, which is false unless you explicitly allow empty string insertion.Behavior: Correctly returns false for all non-empty queries.
search("") executes zero loop iterations and returns root.isEndOfWord.
If you inserted "": Returns true. If you didn't insert "" (or disallow it): Returns false.
Best practice: Decide your API contract and document it. Most implementations either reject empty strings at insertion or return false for empty search queries.
If your trie supports deletion, be aware:
search("care") return false.Deletion is more complex than search and will be covered in advanced trie operations. The key insight here is that search depends on the current state of the trie, which may change if you implement deletion.
By default, tries are case-sensitive. "Apple" and "apple" are different words with different paths.
For case-insensitive search:
123456789101112131415
/** * Case-insensitive search. * Normalizes the query to lowercase before searching. */search(word: string): boolean { // Normalize to lowercase for case-insensitive matching return this.searchInternal(word.toLowerCase());} /** * Insert should also normalize to lowercase. */insert(word: string): void { this.insertInternal(word.toLowerCase());}If you normalize queries in search, you MUST normalize during insertion too. Otherwise, inserting "Apple" and searching for "apple" will fail because the trie stores 'A'→'p'→... but you're looking for 'a'→'p'→... Normalization must be applied consistently across all operations.
We've dissected the trie search operation with surgical precision. Here are the essential takeaways:
What's Next:
The next page explores startsWith—the prefix matching operation that distinguishes tries from hash sets. While the algorithm is nearly identical to search, the semantics and use cases differ profoundly. We'll see why startsWith is the operation that makes tries irreplaceable for autocomplete, spell check, and prefix-based filtering.
You now understand trie search at an expert level. You can explain the two failure modes, trace through searches step by step, implement search for both array and hash map representations, and articulate the precise difference between search and startsWith. Next: the prefix matching operation that makes tries special.