What Letter-Pair Tileset Forms the Most Words?
While building a word game, Daniel Feldman ran into a problem that nerdsniped me instantly: what choice of twenty letter-pair tiles generates the most words?
A number of approaches were proposed in the ensuing thread, with some folks even wondering if the problem might be NP-complete. In this post I'll present a greedy algorithm that's linear in the dictionary size and quadratic in the squared alphabet size. I believe this finds an optimal solution, but haven't proven that formally.
Let's first define the problem.
The Problem Definition
There are 26 alphabet letters, and each tile has two letters on it, so that works out to a total of 26 * 26 = 676 possible tiles. We only get to choose a meager 20 of these 676 to form our tileset. Like in Scrabble, you can then rearrange subsets of the tileset to form dictionary words. The problem: find the tileset of size 20 that lets you form the most dictionary words.
A Much Smaller Example Tileset
Here's all possible tiles, with a specific size-3 tileset ed,pi,ti
highlighted:
aa | ab | ac | ad | ae | af | ag | ah | ai | aj | ak | al | am | an | ao | ap | aq | ar | as | at | au | av | aw | ax | ay | az |
ba | bb | bc | bd | be | bf | bg | bh | bi | bj | bk | bl | bm | bn | bo | bp | bq | br | bs | bt | bu | bv | bw | bx | by | bz |
ca | cb | cc | cd | ce | cf | cg | ch | ci | cj | ck | cl | cm | cn | co | cp | cq | cr | cs | ct | cu | cv | cw | cx | cy | cz |
da | db | dc | dd | de | df | dg | dh | di | dj | dk | dl | dm | dn | do | dp | dq | dr | ds | dt | du | dv | dw | dx | dy | dz |
ea | eb | ec | ed | ee | ef | eg | eh | ei | ej | ek | el | em | en | eo | ep | eq | er | es | et | eu | ev | ew | ex | ey | ez |
fa | fb | fc | fd | fe | ff | fg | fh | fi | fj | fk | fl | fm | fn | fo | fp | fq | fr | fs | ft | fu | fv | fw | fx | fy | fz |
ga | gb | gc | gd | ge | gf | gg | gh | gi | gj | gk | gl | gm | gn | go | gp | gq | gr | gs | gt | gu | gv | gw | gx | gy | gz |
ha | hb | hc | hd | he | hf | hg | hh | hi | hj | hk | hl | hm | hn | ho | hp | hq | hr | hs | ht | hu | hv | hw | hx | hy | hz |
ia | ib | ic | id | ie | if | ig | ih | ii | ij | ik | il | im | in | io | ip | iq | ir | is | it | iu | iv | iw | ix | iy | iz |
ja | jb | jc | jd | je | jf | jg | jh | ji | jj | jk | jl | jm | jn | jo | jp | jq | jr | js | jt | ju | jv | jw | jx | jy | jz |
ka | kb | kc | kd | ke | kf | kg | kh | ki | kj | kk | kl | km | kn | ko | kp | kq | kr | ks | kt | ku | kv | kw | kx | ky | kz |
la | lb | lc | ld | le | lf | lg | lh | li | lj | lk | ll | lm | ln | lo | lp | lq | lr | ls | lt | lu | lv | lw | lx | ly | lz |
ma | mb | mc | md | me | mf | mg | mh | mi | mj | mk | ml | mm | mn | mo | mp | mq | mr | ms | mt | mu | mv | mw | mx | my | mz |
na | nb | nc | nd | ne | nf | ng | nh | ni | nj | nk | nl | nm | nn | no | np | nq | nr | ns | nt | nu | nv | nw | nx | ny | nz |
oa | ob | oc | od | oe | of | og | oh | oi | oj | ok | ol | om | on | oo | op | oq | or | os | ot | ou | ov | ow | ox | oy | oz |
pa | pb | pc | pd | pe | pf | pg | ph | pi | pj | pk | pl | pm | pn | po | pp | pq | pr | ps | pt | pu | pv | pw | px | py | pz |
qa | qb | qc | qd | qe | qf | qg | qh | qi | qj | qk | ql | qm | qn | qo | qp | qr | qs | qt | qu | qv | qw | qx | qy | qz | |
ra | rb | rc | rd | re | rf | rg | rh | ri | rj | rk | rl | rm | rn | ro | rp | rq | rr | rs | rt | ru | rv | rw | rx | ry | rz |
sa | sb | sc | sd | se | sf | sg | sh | si | sj | sk | sl | sm | sn | so | sp | sq | sr | ss | st | su | sv | sw | sx | sy | sz |
ta | tb | tc | td | te | tf | tg | th | ti | tj | tk | tl | tm | tn | to | tp | tq | tr | ts | tt | tu | tv | tw | tx | ty | tz |
ua | ub | uc | ud | ue | uf | ug | uh | ui | uj | uk | ul | um | un | uo | up | uq | ur | us | ut | uu | uv | uw | ux | uy | uz |
va | vb | vc | vd | ve | vf | vg | vh | vi | vj | vk | vl | vm | vn | vo | vp | vq | vr | vs | vt | vu | vv | vw | vx | vy | vz |
wa | wb | wc | wd | we | wf | wg | wh | wi | wj | wk | wl | wm | wn | wo | wp | wq | wr | ws | wt | wu | wv | ww | wx | wy | wz |
xa | xb | xc | xd | xe | xf | xg | xh | xi | xj | xk | xl | xm | xn | xo | xp | xq | xr | xs | xt | xu | xv | xw | xx | xy | xz |
ya | yb | yc | yd | ye | yf | yg | yh | yi | yj | yk | yl | ym | yn | yo | yp | yq | yr | ys | yt | yu | yv | yw | yx | yy | yz |
za | zb | zc | zd | ze | zf | zg | zh | zi | zj | zk | zl | zm | zn | zo | zp | zq | zr | zs | zt | zu | zv | zw | zx | zy | zz |
The ed,pi,ti
tileset generates these 6 words:
pi
pied
pitied
ti
tied
tipi
Note that throughout this post I'll be using american-english
under /usr/share/dict/
as the dictionary.
Initial Approach: Maximum Letter Pair Frequency
Before we get into the final greedy approach, let's try something more straightforward.
When Alfred Butts designed the Scrabble tileset, he looked at the front page of the New York Times and hand-tabulated letter frequencies. He then added more copies of frequently occurring letters.
While our problem is different in a couple ways – unlike Scrabble, duplicate tiles aren't allowed, and also unlike Scrabble, we can only hope to generate a small fraction of all dictionary words — this approach feels intuitively promising.
We'll iterate through the dictionary, split each word into letter-pairs, and count pair occurrences. But note:
- Words of odd length get thrown out, because they can't be formed by a sequence of pairs.
- Words that use the same letter pair twice also get thrown out, since, per the problem definition, our tileset doesn't contain repeats.
Among the words that remain, we'll pick the most frequently occurring pairs as our tileset.
Here's the Python code:
from collections import defaultdict
import sys
import re
args = sys.argv[1:]
NTILES = int(args.pop(0))
RE_VALID_WORD = re.compile(r'^([a-z][a-z]){1,%s}$' % NTILES)
RE_REPEATED_PAIR = re.compile(r'''
^(..)*
(?P<letter1>.)(?P<letter2>.)
(?P=letter1)(?P=letter2)
''', re.X)
freqs = defaultdict(lambda: 0)
words = []
for word in open(args.pop(0)) if args else sys.stdin:
word = word.rstrip('\n')
# discard words with odd length, capitals, apostrophes.
if not RE_VALID_WORD.match(word):
continue
# split the word into letter pairs.
pairs = re.findall(r'.{2}', word)
# discard words with repeated pairs.
if RE_REPEATED_PAIR.match(''.join(sorted(pairs))):
continue
# add to valid word list. update letter pair statistics.
words.append(word)
for pair in pairs:
freqs[pair] += 1
# our tileset is the top N most frequently occurring pairs.
metric = lambda pair: freqs[pair]
tileset = set(sorted(freqs.keys(), key=metric)[-NTILES:])
# report the tileset.
print(','.join(sorted(tileset)))
# report all formable dictionary words.
for word in words:
if not set(re.findall(r'.{2}', word)).difference(tileset):
print(word)
You'll notice the program takes the tileset size, NTILES
, as a command line argument. We can use this to run a sanity check on much smaller tilesets. When we do, we immediately see a problem:
./lettergen2 3 /usr/share/dict/american-english
ed,er,es
es
It's no surprise that ed,er,es
occur most frequently, since they're common English word endings. However, word endings by themselves don't play nice together. They rely on word beginnings and middles to form complete words. And we already know from the small example above that ed,pi,ti
generates 6 words. Generating 1 word, es
, is suboptimal.
This maximum letter pair frequency approach was a greedy algorithm, and its failure makes one despair of finding any optimal greedy (read: simple) algorithm. To construct the optimal tileset from scratch, perhaps you need to perform search on a graph of successively longer words, so you're passing from word beginnings to word middles to word endings? I pursued this approach myself before giving up. Ruminate long enough, and your thoughts may even turn to dark subjects like the set cover problem, which is NP-Complete.
That level of despair didn't sit right with me though. Our problem isn't the same as the set cover problem, which seeks a set-of-sets that union together to form a bigger set. Let's accept a set-theoretic framing for a moment to see why.
Thinking in Terms of Sets
Suppose we're working with 676 sets, one for each letter pair. Each set contains all the words a particular letter pair occurs in. I'll denote these word sets Waa, Wab, … Wzz.
The tileset we seek is a subset of these 676 word sets. But it isn't a set-of-sets we get to union together like in the set cover problem. Consider: just because our tileset includes Wed doesn't mean we can form the word need
– our tileset must also contain Wne for that. The "and" logic here feels more like set intersection – Wne ∩ Wed – than set union.
But set intersection isn't the right operation either. For example, just because our tileset contains Wne and Wed, and both contain the word needle
, doesn't mean we can actually form the word needle
. We'd also need Wle for that.
So it looks like constructing the list of formable words isn't a basic set operation over the tileset.
However, hidden in the negative space here, there is a basic set operation at work. We've seen that if our tileset doesn't include Wne and doesn't contain Wed, we have no hope of forming need
, needle
, nerd
, edit
or any of the words in either word set. When we omit Wne and Wed from the tileset, we lose all the words in the union Wne ∪ Wed.
Another name for "the set of all word sets omitted from our tileset" is the tileset's complement. The unformable words are the union of those omitted word sets. So what we're really seeking is a tileset whose complement has minimal union size. Now we're dealing with basic set operations!
At this point it occurred to me to try a new greedy approach, but working backwards this time. Instead of constructing the tileset from scratch by greedily picking the next best tile to add, what if we started with the full size 676 tileset, and greedily picked the least-bad tile to remove, until only 20 tiles remained?
Final Approach: Subtractive Minimum Damage
Without further ado, here's Python code for this new approach:
from collections import defaultdict
import sys
import re
args = sys.argv[1:]
NTILES = int(args.pop(0))
RE_VALID_WORD = re.compile(r'^([a-z][a-z]){1,%s}$' % NTILES)
RE_REPEATED_PAIR = re.compile(r'''
^(..)*
(?P<letter1>.)(?P<letter2>.)
(?P=letter1)(?P=letter2)
''', re.X)
words = defaultdict(set)
for word in open(args.pop(0)) if args else sys.stdin:
word = word.rstrip('\n')
# discard words with odd length, capitals, apostrophes.
if not RE_VALID_WORD.match(word):
continue
# split the word into letter pairs.
pairs = re.findall(r'.{2}', word)
# discard words with repeated pairs.
if RE_REPEATED_PAIR.match(''.join(sorted(pairs))):
continue
# add the word to the wordsets for its letter pairs.
for pair in pairs:
words[pair].add(word)
# work backwards from 676 to NTILES.
# remove the least-damaging letter pair at each step.
damage = lambda pair: len(words[pair])
while len(words) > NTILES:
least_damaging_pair = min(words.keys(), key=damage)
lost_words = words.pop(least_damaging_pair)
for wordset in words.values():
wordset.difference_update(lost_words)
# report the tileset.
print(','.join(sorted(words.keys())))
# report all formable dictionary words.
for word in sorted(set().union(*words.values())):
print(word)
As you can see, the setup is substantially the same. This time, instead of counting appearances of each letter pair, we're populating the Waa … Wzz word sets for the 676 letter pairs. If a word uses a letter pair, it appears in that pair's word set.
This gives us a direct and O(1)
way of measuring the damage incurred by removing a letter pair: it is simply the size of its word set.
After the dictionary has been scanned, all letter pairs are in play, and we can start greedy removal. At each step we pick the letter that inflicts the least amount of damage in terms of formable words. Note that any word longer than 2 letters will appear in multiple word sets, and we have to remember to remove these extra copies of every "lost" word. Python's set
data type is doing a fair bit of work here.
Results
You can use acg/lettergen to compare the results of the two approaches, which I'm calling maxfreq
and subtractive
. Simply type make
and wait a few seconds:
make
running maxfreq/results1 ...
running maxfreq/results2 ...
running subtractive/results1 ...
running subtractive/results2 ...
maxfreq/results1: 12392 words
a,b,c,d,e,f,g,h,i,k,l,m,n,o,p,r,s,t,u,y
maxfreq/results2: 172 words
al,at,co,de,ed,en,er,es,in,le,li,ly,ng,on,re,ri,rs,st,te,ti
subtractive/results1: 12392 words
a,b,c,d,e,f,g,h,i,k,l,m,n,o,p,r,s,t,u,y
subtractive/results2: 292 words
ar,ca,co,de,di,ed,er,es,in,li,ng,nt,ra,re,ri,si,st,te,ti,ve
For completeness, I wrote Python scripts that handle the 1-letter tile case (lettergen1
). There are only 26 tiles to pick the 20 from, and you can see that both approaches arrive at the same result of 12,302 formable words.
The 2-letter tile case (lettergen2
) is another story. Maximum Letter Pair Frequency comes up with a tileset that generates 172 words, but Subtractive Minimum Damage does substantially better by finding a 292-word-generating tileset – a 1.7x improvement. You'll find the full lists of formable words at */results2
.
So according to Subtractive Minimum Damage, the optimal tileset is the following:
ar,ca,co,de,di,ed,er,es,in,li,ng,nt,ra,re,ri,si,st,te,ti,ve
It's also interesting to experiment with different tileset sizes. For instance, try make -B NTILES=100
. You'll notice as NTILES
gets larger, the maxfreq
approach converges on the subtractive
approach. This makes sense: they should agree for NTILES=676
because there are no letter pair decisions to make. And in fact they should agree even earlier than that, since English doesn't use all possible letter pairs.
Open Questions & Further Research
Yes, but is Subtractive Minimum Damage optimal? The answer is I don't know! I vaguely remember proving greedy optimality once in undergrad computer science, but that was two decades ago. Pointers welcome.
What if there's a tie for least damaging letter pair? If there's no path dependence here, you should be able to pick either one at random, and the greedy subtractive approach should still arrive at an optimal solution. To explore this idea, I decided to pick from the top 2 least damaging tiles at random. Then I ran the script thousands of times. To my surprise, it did find a couple tilesets that produced 293 and 294 words – slightly better than the thought-to-be-optimal tileset! A revolting development. But this gap (1-2 tiles) is suspiciously small, and I'm just gonna go to press with what I've got.
What happens if the tileset can have repeats? I haven't thought about this too deeply, but it seems like it would spell trouble for a greedy approach, which can no longer make stepwise progress towards optimal subproblems.
What about words of odd length? Yeah it's awkward we have to exclude those, and it makes even less sense when you look at what motivated this problem (Daniel's Ambigame). One approach would be to pad all odd-lettered dictionary words with a trailing period, and then add a.
, b.
, c.
, and so on to the possible letter tiles. A similar trick with leading padding might let you split words after the 1st, 3rd, 5th etc character instead of always splitting at even indexes.
Is this a known problem? I mean surely Knuth solved this like 50 years ago? I found many related problems, but not this specific one. I worry this means it's considered too easy / too obvious, and I should feel embarrassed for writing a whole blog post about it. Anyway, please reach out if you know.
Is Scrabble's tileset optimal? I dabble in Scrabble myself, and I'd always heard it wasn't. In researching this, I learned that Peter Norvig has calculated more accurate English letter frequencies than Alfred Butt's. Norvig has a couple proposals for a better Scrabble tileset at the link. TL;DR no.
The Formable Word List
I buried the lede to avoid a wall of text. Here's the complete list of 292 formable words found by Subtractive Minimum Damage:
arcade
, ardent
, ares
, arranged
, arranger
, arranges
, arrant
, arrest
, arrested
, arrive
, arteries
, artier
, artist
, artistes
, calico
, calicoes
, cant
, canted
, canter
, cantered
, care
, career
, careered
, caries
, caring
, casing
, cast
, caster
, castes
, castling
, castrate
, castrating
, catering
, cave
, code
, coding
, coed
, coin
, coined
, congestive
, contesting
, contraries
, contrast
, contrasted
, contrite
, contrive
, core
, coring
, cosier
, cosies
, cost
, costar
, costarring
, costed
, costlier
, cote
, coteries
, cove
, covering
, coveting
, dear
, dearer
, decant
, decanted
, decanter
, decoding
, decorate
, decorating
, decorative
, dedicate
, dedicating
, deed
, deer
, deli
, delicate
, deliveries
, delivering
, dent
, dented
, dentin
, deranged
, deranges
, deriding
, derisive
, derive
, desire
, desiring
, desist
, desisted
, destined
, destines
, detentes
, detest
, detested
, died
, dies
, ding
, dinged
, dint
, dire
, direst
, disinter
, disinterring
, dive
, divest
, divested
, eddies
, errant
, erring
, es
, in
, indeed
, indelicate
, indent
, indented
, indicate
, indicating
, indicative
, inside
, insist
, insisted
, intent
, interest
, interested
, invent
, invented
, invest
, invested
, liar
, lied
, lies
, linger
, lingered
, lint
, lira
, lire
, list
, listed
, lite
, literati
, live
, liveried
, liveries
, livest
, rain
, rained
, rang
, ranged
, ranger
, ranges
, rant
, ranted
, ranter
, rare
, rarest
, raring
, rarities
, raster
, rate
, rating
, rave
, raveling
, re
, rear
, reared
, rearranged
, rearranges
, recant
, recanted
, recast
, recoveries
, recovering
, redecorate
, redecorating
, rededicate
, rededicating
, reed
, rein
, reindeer
, reined
, reinvent
, reinvented
, reinvest
, reinvested
, relied
, relies
, relive
, rent
, rented
, renter
, reside
, resident
, residing
, resist
, resisted
, resister
, rest
, restarting
, rested
, restrain
, restrained
, retiring
, reveling
, revenged
, revenges
, reveries
, revering
, ride
, riding
, riling
, ring
, ringed
, ringer
, ringside
, rising
, rite
, riveting
, side
, siding
, sierra
, silica
, silicate
, sing
, singed
, singer
, singes
, sire
, siring
, sister
, site
, siting
, star
, stared
, stares
, starling
, starrier
, starring
, starting
, starve
, sterling
, stinting
, strain
, strained
, strainer
, stranger
, stride
, strident
, striding
, string
, stringed
, stringer
, strive
, tear
, teared
, teed
, tees
, tent
, tented
, test
, tested
, tester
, testes
, ti
, tide
, tidied
, tidier
, tidies
, tiding
, tied
, tier
, ties
, tiling
, ting
, tinged
, tinges
, tint
, tinted
, tirade
, tire
, tiredest
, tiring
, veer
, veered
, vein
, veined
, vent
, vented
, verier
, verities
, vest
, vested
, vestries