Two Functions, One Difference

The trie API in LC 208 asks you to implement three methods: insert, search, and startsWith. You already know how insert works. Now for the other two.

Here's a claim that sounds too good to be true: search and startsWith share every single line of code except one. Not “similar logic.” Not “same general approach.” Literally the same loop, the same traversal, the same early-return conditions — differing by a single expression at the very end.

If that's true, it means you don't need to learn two algorithms. You need to learn one traversal and understand what question it asks at the finish line. But rather than just telling you the answer, let's make you feel the difference.

You're going to run queries against a trie — some using search, some using startsWith — and watch where their answers diverge. You won't see any code yet. First, build intuition from the behavior.

But before that — toggle between the two operations on this mini trie. The walk is identical. Watch what changes at the end.

Rapple

Walk: root → a → p → p ✓ path complete

Final check:node.isEnd → true

Both agree here because “app” was actually inserted. Try thinking about what happens with “ap” — a prefix that exists but was never stored as a word.

Find the Rule

You're about to run pairs of queries against a trie. Each pair uses the same input string — once with search, once with startsWith. Sometimes both functions agree. Sometimes they diverge, returning different results for the exact same input.

The divergences are the interesting part. When both functions say true, that's easy — the string is clearly in the trie in some capacity. When both say false, that's also straightforward — the path doesn't even exist. But when search says false and startsWith says true? That's the seam where the two functions fundamentally differ.

Rather than looking at each query in isolation, try to build a mental model across multiple pairs. What property does the input string have in every case where the two functions agree? What property does it have in the cases where they disagree? The answer isn't about the trie's shape or the number of children or anything structural. It's about a single fact about the input string relative to the words that were inserted.

Once you have a hypothesis, test it against the remaining queries. If it predicts every result correctly, you've found the one-line difference between search and startsWith — without ever seeing the code.

Trie contains: "apple", "app", "ape"

Rapeple

Function A

???

Function B

???

Tap a query to run it through both functions:

The One-Line Lesson

Did you catch it? Every time the results diverged, the query string was a prefix of an inserted word but was never inserted as a word itself. search("ap") returns false because "ap" was never stored — it's a ghost path. But startsWith("ap") returns true because the path a → p exists, and that's all it cares about.

The implementation difference is exactly one expression: return node.isEnd versus return true.

In practice, you'd write a shared helper — call it findNode(prefix) — that walks the trie character by character and returns the node at the end of the path (or null if any character is missing). Then:

  • search(word) calls findNode(word) and checks node !== null && node.isEnd
  • startsWith(prefix) calls findNode(prefix) and checks node !== null

That's it. The traversal logic — the loop, the child lookup, the null check — is written exactly once. The only question that differs is what you ask at the destination: "Was this path claimed as a word?“ versus ”Does this path exist at all?"

This pattern — factoring shared traversal into a helper and varying only the final check — shows up constantly in trie problems. LC 211 (wildcard search), LC 212 (word search II), and LC 676 (magic dictionary) all build on this same findNode skeleton.

See it in code. Toggle between search and startsWith — the shared findNode helper does all the work, and the final line is the only difference.

1
function findNode(prefix: string) {
2
  let node = this.root
3
  for (const ch of prefix) {
4
    if (!node.children.has(ch)) return null
5
    node = node.children.get(ch)!
6
  }
7
  return node
8
}
9
10
// search: was this path CLAIMED as a word?
11
search(word) → findNode(word)?.isEnd ?? false
12
13
// startsWith: does this path EXIST at all?
14
startsWith(prefix) → findNode(prefix) !== null