Unit 25: Words and Patterns
Finding a needle in a very long haystack.
Unit 25 of 25 in Competitive programming for kids. Its 4 lessons are A String as a Number, The Prefix Function, A Tree of Letters and Three Ways to Find It — below is everything each one explains, and a question or two from it to try.
Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.
This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.
🔑 A String as a Number
Turn the letters into digits
Read the string as a number in base 31: each letter is a digit, and the whole thing is worked out under a big prime modulus so it stays small.
Same string, same number, always. Different strings, almost certainly different numbers.
Python
MOD = 1000000007
BASE = 31
def string_hash(text):
value = 0
for ch in text:
value = (value * BASE + ord(ch) - 96) % MOD
return value
print(string_hash("abc"))
print(string_hash("abc") == string_hash("abc"))
print(string_hash("abc") == string_hash("acb"))
It prints
1026 True False
Prefix hashes give you every substring at once
Store the hash of every prefix and the powers of the base. Then the hash of any slice is one subtraction and one multiply — the same trick as unit 6's prefix sums, with multiplication instead of addition.
So comparing two substrings of any length costs the same as comparing two numbers.
Python
MOD = 1000000007
BASE = 31
text = "abcabc"
n = len(text)
prefix = [0] * (n + 1)
power = [1] * (n + 1)
for i in range(n):
prefix[i + 1] = (prefix[i] * BASE + ord(text[i]) - 96) % MOD
power[i + 1] = power[i] * BASE % MOD
def slice_hash(start, length):
return (prefix[start + length] - prefix[start] * power[length]) % MOD
print(slice_hash(0, 3), slice_hash(3, 3))
print(slice_hash(0, 3) == slice_hash(3, 3))
print(slice_hash(0, 2) == slice_hash(1, 2))
It prints
1026 1026 True False
The honest warning
Two different strings can land on the same number. With a modulus near a thousand million it is vanishingly unlikely; with a small one it happens almost at once.
Under a modulus of 101 it takes only a few dozen two-letter words to find aa and di sharing the number 32. That is exactly what a problem setter does when they want to break hash solutions.
Python
SMALL = 101
BASE = 31
def small_hash(text):
value = 0
for ch in text:
value = (value * BASE + ord(ch) - 96) % SMALL
return value
seen = {}
for a in range(26):
for b in range(26):
word = chr(97 + a) + chr(97 + b)
h = small_hash(word)
if h in seen:
print(seen[h], word, h)
break
seen[h] = word
else:
continue
break
It prints
aa di 32
So what does a hash answer mean?
It means "these are equal, with a chance of being wrong of about one in a thousand million". For a contest that is fine, and it is what nearly everybody submits.
If a problem is famous for anti-hash tests, use two moduli at once — the chance of colliding under both is one in a very large number indeed.
Try it yourself
Two substrings have the same hash. What do you know?
- They are definitely equal
- They are almost certainly equal
- They are definitely different
- Nothing at all
What does this print?
Python
MOD = 1000000007
BASE = 31
def string_hash(text):
value = 0
for ch in text:
value = (value * BASE + ord(ch) - 96) % MOD
return value
print(string_hash("a"), string_hash("b"), string_hash("ab"))
Answer them in the app
🧵 The Prefix Function
How much of the start is also the end?
For each position, find the longest piece that is both a beginning and an ending of the string up to there — not counting the whole thing.
For abcabca the answer at the end is 4: abca starts it and finishes it. This list is called the prefix function.
Python
def prefix_function(text):
n = len(text)
result = [0] * n
k = 0
for i in range(1, n):
while k > 0 and text[i] != text[k]:
k = result[k - 1]
if text[i] == text[k]:
k += 1
result[i] = k
return result
print(prefix_function("abcabca"))
print(prefix_function("aaaa"))
print(prefix_function("abcdef"))
It prints
[0, 0, 0, 1, 2, 3, 4] [0, 1, 2, 3] [0, 0, 0, 0, 0, 0]
Why it is fast
The inner while looks alarming. But k goes up by at most one per letter and the while only ever pushes it down, so over the whole string it cannot fall more than it rose.
That is unit 4's two-pointer argument again: count the total moves, not the moves per round. The whole thing is O(n).
And now search: glue the two together
To find abc inside a long text, run the prefix function on abc#thetext — with a separator that appears in neither.
Wherever the value reaches the length of the pattern, the pattern ends there. This is KMP, and it never backs up over the text.
Python
def prefix_function(text):
n = len(text)
result = [0] * n
k = 0
for i in range(1, n):
while k > 0 and text[i] != text[k]:
k = result[k - 1]
if text[i] == text[k]:
k += 1
result[i] = k
return result
pattern = "abc"
text = "xabcyabc"
joined = pattern + "#" + text
values = prefix_function(joined)
found = []
for i in range(len(joined)):
if values[i] == len(pattern):
found.append(i - 2 * len(pattern))
print(values)
print(found)
It prints
[0, 0, 0, 0, 0, 1, 2, 3, 0, 1, 2, 3] [1, 5]
Why the separator has to be there
Without it, a match could straddle the join — the end of the pattern and the start of the text pretending to be one piece.
The separator must be a character that appears in neither string. If the input can contain anything, hash instead.
Try it yourself
What does a prefix function value of 4 at some position mean?
- There are 4 letters left
- The first 4 letters of the string also end at this position
- The letter appears 4 times
- The pattern was found 4 times
What does this print?
Python
def prefix_function(text):
n = len(text)
result = [0] * n
k = 0
for i in range(1, n):
while k > 0 and text[i] != text[k]:
k = result[k - 1]
if text[i] == text[k]:
k += 1
result[i] = k
return result
print(prefix_function("ababa"))
Answer them in the app
🌲 A Tree of Letters
One tree for a whole dictionary
A trie stores words by their letters: the root is empty, and every step down adds a letter. Words sharing a start share a branch.
Looking a word up costs its own length, whether the dictionary holds ten words or ten million.
Python
words = ["cat", "car", "cart", "dog"]
children = [{}]
ended = [False]
for word in words:
node = 0
for ch in word:
if ch not in children[node]:
children.append({})
ended.append(False)
children[node][ch] = len(children) - 1
node = children[node][ch]
ended[node] = True
print(len(children))
print(sorted(children[0].keys()))
It prints
9 ['c', 'd']
Nine nodes for four words
Sixteen letters, nine nodes: cat, car and cart share ca, and cart is car with one more step.
That sharing is the point. A dictionary of a hundred thousand words has far fewer nodes than letters.
Looking things up
Walk down one letter at a time. Fall off the tree and the word is not there; land on a node marked as an ending and it is.
And a node not marked as an ending still tells you something useful: car is a real word, ca is only a prefix.
Python
words = ["cat", "car", "cart", "dog"]
children = [{}]
ended = [False]
for word in words:
node = 0
for ch in word:
if ch not in children[node]:
children.append({})
ended.append(False)
children[node][ch] = len(children) - 1
node = children[node][ch]
ended[node] = True
def find(word):
node = 0
for ch in word:
if ch not in children[node]:
return "missing"
node = children[node][ch]
return "word" if ended[node] else "prefix only"
for word in ["car", "cart", "ca", "cab", "dog"]:
print(word, find(word))
It prints
car word cart word ca prefix only cab missing dog word
What tries are actually used for
Autocomplete, spell checking, and any problem shaped like "for each of these words, is it in that pile" — where hashing works too, but a trie also answers questions about prefixes that a hash cannot.
A trie of the binary digits of numbers also answers "which stored number gives the biggest xor with this one", which is a classic contest problem.
Try it yourself
How long does looking up a word of 8 letters take?
- As long as the dictionary is
- 8 steps, whatever the dictionary size
- The logarithm of the dictionary size
- 64 steps
What does this print?
Python
words = ["a", "ab", "abc"]
children = [{}]
for word in words:
node = 0
for ch in word:
if ch not in children[node]:
children.append({})
children[node][ch] = len(children) - 1
node = children[node][ch]
print(len(children))
Answer them in the app
🏆 Three Ways to Find It
The same search, three times over
Find every place a pattern appears in a text. Naively — compare at every position, letter by letter. By hashing — one number comparison per position. By KMP — one pass, never looking back.
All three give the same answer. Only the counting is different, and this is where the track finishes.
Counting the comparisons
On a text of aaaaaaaaaa and a pattern of aaa, the naive search compares 24 letters. Hashing compares 8 numbers. KMP walks the joined string once.
The naive one is fine here and dies on the case problem setters love: a long text and a pattern that nearly matches everywhere.
Python
text = "aaaaaaaaaa"
pattern = "aaa"
steps = 0
found = []
for start in range(len(text) - len(pattern) + 1):
ok = True
for i in range(len(pattern)):
steps += 1
if text[start + i] != pattern[i]:
ok = False
break
if ok:
found.append(start)
print(found)
print(steps)
It prints
[0, 1, 2, 3, 4, 5, 6, 7] 24
The hashed version
Work out the pattern's hash once and every window's hash from the prefix table. Eight comparisons, each of one number, whatever the pattern's length.
Same eight positions found.
Python
MOD = 1000000007
BASE = 31
text = "aaaaaaaaaa"
pattern = "aaa"
n = len(text)
m = len(pattern)
prefix = [0] * (n + 1)
power = [1] * (n + 1)
for i in range(n):
prefix[i + 1] = (prefix[i] * BASE + ord(text[i]) - 96) % MOD
power[i + 1] = power[i] * BASE % MOD
wanted = 0
for ch in pattern:
wanted = (wanted * BASE + ord(ch) - 96) % MOD
steps = 0
found = []
for start in range(n - m + 1):
steps += 1
value = (prefix[start + m] - prefix[start] * power[m]) % MOD
if value == wanted:
found.append(start)
print(found)
print(steps)
It prints
[0, 1, 2, 3, 4, 5, 6, 7] 8
And KMP, which never guesses
KMP is the only one of the three that is both fast and certain — hashing could in principle collide, and the naive version could in principle take n * m steps.
It is also the fiddliest to type from memory, which is why most contestants hash first and reach for this when the setter has clearly gone hunting.
Python
def matches(pattern, text):
joined = pattern + "#" + text
n = len(joined)
values = [0] * n
k = 0
for i in range(1, n):
while k > 0 and joined[i] != joined[k]:
k = values[k - 1]
if joined[i] == joined[k]:
k += 1
values[i] = k
out = []
for i in range(n):
if values[i] == len(pattern):
out.append(i - 2 * len(pattern))
return out
print(matches("aaa", "aaaaaaaaaa"))
print(matches("abc", "xabcyabc"))
print(matches("zz", "abcabc"))
It prints
[0, 1, 2, 3, 4, 5, 6, 7] [1, 5] []
Which to reach for
Hash it. It is short, it is fast, it handles "are these two substrings the same" as easily as searching, and it is what most people submit.
Use KMP when a wrong answer from a collision would be fatal, or when the problem is about the prefix function itself — "what is the shortest piece this string is made of by repeating" is a one-line answer from it.
Try it yourself
Which of the three can, in principle, give a wrong answer?
- The naive one
- The hashed one
- KMP
- None of them
What does this print?
Python
text = "abab"
pattern = "ab"
steps = 0
found = []
for start in range(len(text) - len(pattern) + 1):
ok = True
for i in range(len(pattern)):
steps += 1
if text[start + i] != pattern[i]:
ok = False
break
if ok:
found.append(start)
print(found, steps)
Answer them in the app