Filling the grid
Answer "does any filed entry fit this half-crossed slot?" by fanning a dotted pattern out over a prefix tree instead of scanning the word list.
A crossword setter has a six-cell slot with two crossing letters fixed and four cells blank. The question is not which entries exist, but whether any fits.
The problem
A setter keeps a word list — the entries they are willing to put in a grid — and adds to it as the puzzle grows. When a slot has some cells settled by crossing entries and the rest blank, the setter asks whether the list holds anything that fits.
A slot is written as a pattern: a lowercase letter where the cell is fixed, a dot where it is blank. A dot stands for exactly one letter, so an entry fits only if it is exactly as long as the pattern and agrees at every fixed cell.
Input. moves — a stream of (move, text) pairs. ("file", entry) adds an
entry, lowercase letters. ("fits", pattern) asks whether any entry filed so
far matches that pattern, lowercase letters and dots.
Output. A list of booleans, one per fits move, in the order asked.
Example.
moves = [("file", "dagger"), ("file", "danger"), ("file", "ledger"),
("fits", "danger"), ("fits", "dodger"), ("fits", "d..ger"),
("fits", "..dger"), ("fits", "dagge")]
-> [True, False, True, True, False]
d..ger fits dagger and ..dger fits ledger. dagge is a five-cell slot,
and nothing five letters long has been filed.
A second example, where the first branch a dot takes is the wrong one:
moves = [("file", "batten"), ("file", "bitten"), ("file", "bit"),
("fits", "bat"), ("fits", "b.t"), ("fits", "b.tten"), ("fits", "bitte")]
-> [False, True, True, False]
b.t sends the dot down the a branch and lands on bat — a real prefix of
batten, never filed. The search has to climb back and try i, where bit is.
Constraints.
1 <= len(moves) <= 2 * 10^41 <= len(text) <= 20- entries are lowercase a-z; patterns are lowercase a-z and
. - total characters filed at most
3 * 10^5
Hints
Hint 1
Comparing a pattern against every entry repeats the same first-cell test thousands of times. Entries that start alike should be tested once.
Hint 2
With no dots the query is an exact lookup. What does one dot cost, if you know which letters can follow the prefix in front of it?
Hint 3
A dot turns one search into one search per child of the node you stand on. A branch that dies has to leave the others untouched.
Approach
Brute force
Keep the entries in a list and walk the pattern against every entry, letter by letter. With 10⁴ entries of 20 letters and 10⁴ queries that is 2 · 10⁹ comparisons, and every query pays again for entries that fail on cell one.
The insight
Entries that share a prefix should be walked once rather than once each, and inside a prefix tree a blank cell costs a branch over the children that exist, not over all 26 letters.
Every distinct prefix is a single node, so a fixed cell is one lookup however many entries lie below it. The precondition the fan-out needs is that matching is positional and left to right: the pattern's j-th character is decided by the j-th letter of the entry alone, so a choice at depth j cannot invalidate one made above it. That is what makes it safe to recurse into a child and, on failure, return to the parent unchanged.
Algorithm
- Keep a trie: one node per distinct prefix, one child per letter, an end marker on nodes where a filed entry stops.
file: walk the entry, creating missing children, then set the marker.fits: walk from the root, stepping to the child named by each fixed letter and reporting no match when that child is missing.- On a dot, recurse on the rest of the pattern once per child; a match if any child succeeds.
- When the pattern runs out, match only if the node carries the marker.
Complexity
Time O(L) per dotless query, L the pattern length, and O(26^d · L) with d dots — bounded by the node count, so no query costs more than one sweep of the tree. Space O(S) for S filed characters, plus L stack frames.
Solution
"""Filling the grid — a prefix tree searched with a dot that fans out over the children."""
# Entries are lowercase letters only, so "#" can never be a real edge and is
# safe as the marker for "a filed entry ends at this node".
END = "#"
def file_entry(root, entry):
"""Add one entry, creating a node for each prefix that does not exist yet."""
node = root
for ch in entry:
node = node.setdefault(ch, {})
node[END] = True
def fits(node, pattern, i):
"""True when some path down from `node` spells pattern[i:] and stops there."""
for j in range(i, len(pattern)):
if pattern[j] == ".":
# A blank cell matches any single filed letter, so each child is a
# separate search and a dead branch must leave the others untouched.
return any(
fits(child, pattern, j + 1)
for edge, child in node.items()
if edge != END
)
node = node.get(pattern[j])
if node is None:
return False
return END in node # reaching a node is not the same as fitting it
def solve(moves):
root = {}
answers = []
for move, text in moves:
if move == "file":
file_entry(root, text)
else: # ("fits", pattern), answered against the list so far
answers.append(fits(root, text, 0))
return answersThe cases that ran
TESTS = [
# Dots land on either side of a fixed letter; a five-cell slot has nothing to fit.
(
(
[
("file", "dagger"), ("file", "danger"), ("file", "ledger"),
("fits", "danger"), ("fits", "dodger"), ("fits", "d..ger"),
("fits", "..dger"), ("fits", "dagge"),
],
),
[True, False, True, True, False],
),
# "b.t" tries the a-branch first, reaches "bat" — a prefix of "batten" that was
# never filed — and has to come back up and try the i-branch.
(
(
[
("file", "batten"), ("file", "bitten"), ("file", "bit"),
("fits", "bat"), ("fits", "b.t"), ("fits", "b.tten"), ("fits", "bitte"),
],
),
[False, True, True, False],
),
# Nothing filed yet: every slot, blank or not, fits nothing.
(([("fits", "a"), ("fits", "."), ("fits", "....")],), [False, False, False]),
# A filed entry that is a prefix of another filed entry stays findable, and a
# dot past the end of every path fits nothing.
(
(
[
("file", "arc"), ("file", "arch"),
("fits", "arc"), ("fits", "arc."), ("fits", "a.c"),
("fits", "ar.."), ("fits", "arch."),
],
),
[True, True, True, True, False],
),
# Filing the same entry twice changes nothing; length still has to match.
(
(
[
("file", "zip"), ("file", "zip"),
("fits", "zip"), ("fits", "..."), ("fits", "...."),
],
),
[True, True, False],
),
# Single-cell slot.
(([("file", "q"), ("fits", "q"), ("fits", "."), ("fits", "qq")],), [True, True, False]),
]Pitfalls
- Treating "the walk reached a node" as a match. The node for
batexists becausebattenwas filed. Without the end-marker test,fits("bat")answers True and a non-word goes into the grid. - Returning from the first dot branch that fails.
b.tmust tryiafteradead-ends; a search that returns False out of the first child answers False even thoughbitis filed. - Letting the end marker into the fan-out. The marker sits in the same dict
as the child letters, so a dot that loops over every key recurses into the
marker's value and raises an
AttributeError.
Variants
- The shade chart — the same tree walked from many starting positions, to split a name into other names.
- The dispatch console — no wildcards, but each node carries a ranked shortlist so a keystroke is one step.