Challenge: tries
A search box suggests words as you type: after "ca", it offers "cab", "cake", "car" and "card". With the words in a list, every question scans all of them with startswith: O(n·m) for n words and a prefix of m letters, again for each letter typed. A trie (from retrieval, said "try") stores the words letter by letter in a tree, so that words with the same beginning share it. Each node stands for a prefix. Its children are a dict from a letter to the next node, and a flag marks the nodes where a whole word ends. Here are "cab", "cake", "car", "card" and "dog", with a * where a word ends:
(root)
+-- c
| +-- a
| +-- b *
| +-- k
| | +-- e *
| +-- r *
| +-- d *
+-- d
+-- o
+-- g *
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.is_word = True
def find(self, prefix):
node = self.root
for letter in prefix:
if letter not in node.children:
return None
node = node.children[letter]
return node
def contains(self, word):
node = self.find(word)
return node is not None and node.is_word
def complete(self, prefix):
words = []
node = self.find(prefix)
if node is not None:
self.collect(node, prefix, words)
return words
def collect(self, node, text, words):
if node.is_word:
words.append(text)
for letter in sorted(node.children):
self.collect(node.children[letter], text + letter, words)
trie = Trie()
for word in ["car", "dog", "card", "cake", "cab"]:
trie.insert(word)
print(trie.contains("car"), trie.contains("ca"), trie.contains("cart"))
print(trie.complete("ca"))
print(trie.complete("do"), trie.complete("x"))
True False False
['cab', 'cake', 'car', 'card']
['dog'] []insert walks down the word, making the nodes that are missing, and marks the last one. find walks down a prefix one letter at a time and returns its node, or None when the path breaks off. "ca" is in the trie as a prefix, but no word ends there, which is why contains checks the flag.
Autocomplete in sorted order
Every word that starts with a prefix lies below the prefix's node. So complete finds that node, and collect visits everything under it in pre-order: a node's own word first, then its children. Two things make the words come out sorted, with no sorting of the words at the end. A word comes before the longer words that start with it ("car" before "card"), and the children are visited in alphabetical order.
That sorted matters. A dict keeps its keys in the order they were added, so a collect that loops over node.children as it is gives the words in an order that depends on the inserts:
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
def insert(root, word):
node = root
for letter in word:
if letter not in node.children:
node.children[letter] = TrieNode()
node = node.children[letter]
node.is_word = True
def collect(node, text, words):
if node.is_word:
words.append(text)
for letter in node.children:
collect(node.children[letter], text + letter, words)
root = TrieNode()
for word in ["car", "dog", "card", "cake", "cab"]:
insert(root, word)
words = []
collect(root, "", words)
print(words)
['car', 'card', 'cake', 'cab', 'dog']Below "ca", the letters were added in the order r, k, b, because "car" came before "cake" and "cab", so "car" and "card" come out first. The same five words inserted in another order would come out in another order again. A test with words inserted in alphabetical order hides this mistake, which is why good tests mix them up.
The recursion in collect goes one level deeper per letter, so its depth is the length of the longest word: no danger of a RecursionError.
What it costs
insert, find and contains take one dict step per letter: O(m) for a word or a prefix of m letters, whether the trie holds ten words or a million. complete costs O(m) to find the node, then a visit to each node below it. Each visit builds its node's text with text + letter, which copies every letter so far. So with k nodes below the prefix and words of at most L letters, collect costs O(k·L): close to O(k) for short words, but in a trie of one word of 500 letters, complete("") copies over 125,000 letters. A version that keeps one list of letters, appends a letter on the way down, pops it on the way back up and calls "".join only where a word ends, costs O(k) plus the letters of the words it returns. The memory is one node per different prefix: at most the total number of letters in all the words, and less where words share their beginnings.
A trie can count, too. If every node keeps a count of the words that pass through it (insert adds 1 to the root and to each node on its way down), the number of words that start with a prefix is the count of the prefix's node: one walk of m letters instead of a scan of every word. The root is the node of the empty prefix "", which every word starts with, so its count is the number of words.