#!/usr/bin/env python
# coding: utf-8
#
Peter Norvig 2014
#
# # Cryptarithmetic (Alphametic) Problems
#
# On April 28, Gary Antonik had another [Numberplay column](http://wordplay.blogs.nytimes.com/2014/04/28/num/) that quotes my friend Bill Gosper. (Gosper often presents more advanced puzzles in the [math-fun](http://mailman.xmission.com/cgi-bin/mailman/listinfo/math-fun) mailing list.) This puzzle was:
#
# > For the expression `N U M + B E R = P L A Y`,
# > - Which distinct numerals (each different) can be substituted for letters to make a valid expression?
# > - How many solutions are there?
#
# I [tackled](https://www.udacity.com/wiki/cs212/unit-2#rethinking-eval) this type of problem (known as a [cryptarithmetic](http://mathworld.wolfram.com/Cryptarithmetic.html) or [alphametic](http://mathworld.wolfram.com/Alphametic.html) problem) in my Udacity class [CS 212](https://www.udacity.com/wiki/cs212/unit-2#cryptarithmetic).
#
# My initial approach was simple: [when in doubt, use brute force](https://www.brainyquote.com/quotes/ken_thompson_185574). I try all permutations of digits replacing letters (that should be quick and easy—there can be at most 10 factorial or 3.6 million permutations), then for each one, I use Python's `eval` function to see if the resulting string is a valid expression. The basic idea is simple, but there are a few complications to worry about:
#
# 1. Math uses `=` and Python uses `==` for equality; we'll allow either one in formulas.
# 2. A number with a leading zero (like `012`) is illegal (but `0` by itself is ok).
# 4. If we try to eval an expression like 1/0, we'll have to handle the divide-by-zero error.
# 5. Only uppercase letters are replaced by digits. So in `2 * BE or not 2`, the `or` and `not` are Python keywords.
# 6. Should I find one solution (faster) or all solutions (more complete)? I'll handle both use cases by
# implementing my function `solve` to return an iterator, which yields solutions one at a time; you can get the first one with `next` or all of them with `set`.
#
# # The solution: `solve`
#
# Below we see that `solve` works by generating every way to replace letters in the formula with numbers,
# and then filtering them to keep only valid strings (ones that evaluate to true and have no leading zero).
# The `str.translate` method is used to do the replacements.
# In[1]:
import itertools
import re
def solve(formula):
"""Given a formula like 'NUM + BER = PLAY', fill in digits to solve it.
Generate all valid digit-filled-in strings."""
return filter(valid, letter_replacements(formula))
def letter_replacements(formula):
"""All possible replacements of letters with digits in formula."""
formula = formula.replace(' = ', ' == ') # Allow = or ==
letters = cat(set(re.findall('[A-Z]', formula)))
for digits in itertools.permutations('1234567890', len(letters)):
yield formula.translate(str.maketrans(letters, cat(digits)))
def valid(exp):
"""Expression is valid iff it has no leading zero, and evaluates to true."""
try:
return not leading_zero(exp) and eval(exp) is True
except ArithmeticError:
return False
cat = ''.join # Function to concatenate strings
leading_zero = re.compile(r'\b0[0-9]').search # Function to check for illegal number
# In[2]:
next(solve('NUM + BER = PLAY'))
# In[3]:
get_ipython().run_line_magic('time', "len(set(solve('NUM + BER = PLAY')))")
# # A faster solution: `faster_solve`
#
# Depending on your computer, that probably took 15 or 20 seconds. Can we make it faster? To answer the question, I start by profiling to see where the time is spent. I can use the magic function `%prun` to profile:
# In[4]:
get_ipython().run_line_magic('prun', "next(solve('NUM + BER = PLAY'))")
# We see that about 2/3 of the time is spent in `eval`. So let's eliminate the calls to `eval`. That should be doable, because the expression we are evaluating is basically the same each time, but with different permutations of digits filled in. We could save a lot of work if we convert the expression into a Python function, compile that function just once, and then call the function for each of the 3.6 million permutations of digits. We want to take an expression such as:
#
# "NUM + BER == PLAY"
#
# and transform it into the Python function:
#
# (lambda A,B,E,L,M,N,P,R,U,Y:
# (100*N+10*U+M) + (100*B+10*E+R) == (1000*P+100*L+10*A+Y))
#
# Actually that's not quite right. The rules say that "N", "B", and "P" cannot be zero. So the function should be:
#
# (lambda A,B,E,L,M,N,P,R,U,Y:
# B and N and P and ((100*N+10*U+M) + (100*B+10*E+R) == (1000*P+100*L+10*A+Y)))
#
# Here is the code to compile a formula into a Python function:
# In[5]:
def compile_formula(formula, verbose=False):
"""Compile formula into a function. Also return letters found, as a str,
in same order as parms of function. For example, 'YOU == ME**2' returns
(lambda E,M,O,U,Y: M and Y and ((100*Y+10*O+U) == (10*M+E)**2), 'YMEUO'"""
formula = formula.replace(' = ', ' == ')
letters = cat(sorted(set(re.findall('[A-Z]', formula))))
firstletters = sorted(set(re.findall(r'\b([A-Z])[A-Z]', formula)))
body = re.sub('[A-Z]+', compile_word, formula)
body = ' and '.join(firstletters + [body])
fn = 'lambda {}: {}'.format(','.join(letters), body)
if verbose: print(fn)
assert len(letters) <= 10
return eval(fn), letters
def compile_word(matchobj):
"Compile the word 'YOU' as '(100*Y + 10*O + U)'."
word = matchobj.group()
terms = reversed([mult(10**i, L) for (i, L) in enumerate(reversed(word))])
return '(' + '+'.join(terms) + ')'
def mult(factor, var): return var if factor == 1 else str(factor) + '*' + var
# In[6]:
compile_formula("YOU == ME**2", verbose=True)
# In[7]:
compile_formula("NUM + BER = PLAY", verbose=True)
# Now we're ready for the faster version of `solve`:
# In[8]:
def faster_solve(formula):
"""Given a formula like 'NUM + BER == PLAY', fill in digits to solve it.
This version precompiles the formula and generates all digit-filled-in strings."""
fn, letters = compile_formula(formula)
for digits in itertools.permutations((1,2,3,4,5,6,7,8,9,0), len(letters)):
try:
if fn(*digits):
yield formula.translate(str.maketrans(letters, cat(map(str, digits))))
except ArithmeticError:
pass
# In[9]:
next(faster_solve('NUM + BER = PLAY'))
# In[10]:
get_ipython().run_line_magic('time', "len(list(faster_solve('NUM + BER = PLAY')))")
# We get the same answer, but `faster_solve` is 15 times faster than `solve`.
# # More Examples
# In[11]:
examples = """
NUM + BER = PLAY
YOU = ME ** 2
SEND + MORE = MONEY
PI * R**2 = AREA
WRONG + WRONG = RIGHT
WRIGHT + WRIGHT = TO * FLY + FLIGHT
TWO + TWENTY = TWELVE + TEN
A**2 + B**2 = C**2
AYH**2 + BEE**2 = SEE**2
RAMN = R**3 + RM**3 = N**3 + RX**3
MON-EY = EVIL**(1/2)
ALPHABET + LETTERS = SCRABBLE
SIN**2 + COS**2 = UNITY
POTATO + TOMATO = PUMPKIN
ODD * ODD = FREAKY
DOUBLE + DOUBLE + TOIL = TROUBLE
WASH + YOUR = HANDS
SPEED + LIMIT = FIFTY
TERRIBLE + NUMBER = THIRTEEN
SEVEN + SEVEN + SIX = TWENTY
EIGHT + EIGHT + TWO + ONE + ONE = TWENTY
ELEVEN + NINE + FIVE + FIVE = THIRTY
NINE + SEVEN + SEVEN + SEVEN = THIRTY
FOURTEEN + TEN + TEN + SEVEN = FORTYONE
HAWAII + IDAHO + IOWA + OHIO = STATES
VIOLIN * 2 + VIOLA = TRIO + SONATA
SEND + A + TAD + MORE = MONEY
ZEROES + ONES = BINARY
DCLIZ + DLXVI = MCCXXV
COUPLE + COUPLE = QUARTET
FISH + N + CHIPS = SUPPER
SATURN + URANUS + NEPTUNE + PLUTO = PLANETS
PLUTO not in {PLANETS}
EARTH + AIR + FIRE + WATER = NATURE
TWO * TWO = SQUARE
HIP * HIP = HURRAY
NORTH / SOUTH = EAST / WEST
NAUGHT ** 2 = ZERO ** 3
I + THINK + IT + BE + THINE = INDEED
DO + YOU + FEEL = LUCKY
WELL - DO + YOU = PUNK
NOW + WE + KNOW + THE = TRUTH
SORRY + TO + BE + A + PARTY = POOPER
SORRY + TO + BUST + YOUR = BUBBLE
STEEL + BELTED = RADIALS
ABRA + CADABRA + ABRA + CADABRA = HOUDINI
I + GUESS + THE + TRUTH = HURTS
LETS + CUT + TO + THE = CHASE
THATS + THE + THEORY = ANYWAY
TWO + TWO = FOUR
X / X = X
A**N + B**N = C**N and N > 1
ATOM**0.5 = A + TO + M
GLITTERS is not GOLD
ONE < TWO and FOUR < FIVE
ONE < TWO < THREE < TWO * TWO
(2 * BE or not 2 * BE) = THE + QUES-TION
sum(range(AA)) = BB
sum(range(POP)) = BOBO
ODD + ODD = EVEN
ROMANS+ALSO+MORE+OR+LESS+ADDED = LETTERS
FOUR+ONE==FIVE and ONE+ONE+ONE+ONE+ONE==FIVE
TEN+SEVEN+SEVEN+SEVEN+FOUR+FOUR+ONE = FORTY
NINETEEN+THIRTEEN+THREE+2*TWO+3*ONE = FORTYTWO
IN + ARCTIC + TERRAIN + AN + ANCIENT + EERIE + ICE + TRACT + I + ENTER + A + TRANCE = FLATIANA
ONE < TWO < THREE < SEVEN - THREE < THREE + TWO < THREE + THREE < SEVEN < SEVEN + ONE < THREE * THREE
AN + ACCELERATING + INFERENTIAL + ENGINEERING + TALE + ELITE + GRANT + FEE + ET + CETERA = ARTIFICIAL + INTELLIGENCE
""".strip().splitlines()
def run(examples):
for e in examples:
print('{:45}| {}'.format(e, next(faster_solve(e))))
return len(examples)
get_ipython().run_line_magic('time', 'run(examples)')