diff --git a/ipynb/Anigrams.ipynb b/ipynb/Anigrams.ipynb new file mode 100644 index 0000000..5eaff22 --- /dev/null +++ b/ipynb/Anigrams.ipynb @@ -0,0 +1,356 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
Peter Norvig, Sept 2022
\n", + "\n", + "# Anigrams Puzzle\n", + "\n", + "From 538's [**The Riddler** on Sept 16, 2022](https://fivethirtyeight.com/features/can-you-build-the-biggest-anigram/) comes this puzzle:\n", + " \n", + "> In the game of [**Anigrams**](http://anigrams.us), you **unscramble** successively larger, nested collections of letters to create a valid **chain** of six English **words** between four and nine letters in length. For example, a **chain** of five words (sadly, less than the six needed for a valid game of Anigrams) can be constructed using the following sequence, with each **term** after the first including **one additional letter** than the previous **term**:\n", + ">\n", + ">- DEIR (which unscramble to make the words DIRE, IRED or RIDE)\n", + ">- DEIR**D** (DRIED or REDID)\n", + ">- DEIRD**L** (DIRLED, DREIDL or RIDDLE)\n", + ">- DEIRDL**R** (RIDDLER)\n", + ">- DEIRDLR**S** (RIDDLERS)\n", + ">\n", + ">\n", + "> What is the **longest chain** of such nested anagrams you can create, **starting** with four letters?\n", + ">\n", + "> *Extra credit:* **How many possible games** of Anigrams are there? That is, how many valid sets are there of four initial letters, and then five more **letters added** one at a time in an ordered sequence, that result in a sequence of six valid anagrams? (Note: Swapping the order of the first four letters does not result in a distinct game.)\n", + "\n", + "I'll solve this puzzle with a Python program. First some generally useful imports:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Set, List\n", + "import functools " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Concepts\n", + "\n", + "The puzzle description mentions a bunch of concepts: **word**, **chain**, **term**, **starting**, **unscramble**, **longest chain**, and **one additional letter**. To solve the puzzle, I'll carefully define each concept, in prose and in Python. Once I have a collection of concepts, I can see how they fit together to solve the puzzle. First the data types:\n", + "\n", + "- **Word**: an English language word, e.g. `'ride'`.\n", + "- **Chain**: a list of words, each one adding a letter, e.g. `['ride', 'redid', 'riddle']`. \n", + "- **Term**: a scrambled word, e.g. `'deir'`. " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "Word = str # A word in the English word list, e.g. 'ride'.\n", + "Chain = List[Word] # A list of words, each one adding a letter, e.g. ['ride', 'redid', 'riddle']. \n", + "Term = str # A scrambled word, e.g. 'deir'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then some functions and constants:\n", + "\n", + "- **scramble**: I'll define `scramble(word)` to return one of the *n*! ways to scramble an *n*-letter word. I'll arbitrarily choose alphabetical order.\n", + "- **terms**: I'll define `all_terms` as a dict of `{Term: Word}` pairs, e.g. `{'deir': 'ride', 'ddeir': 'redid', ...}`.\n", + "- **starting**: I'll define `starting_terms` to be the set of 4-letter terms from `all_terms`, e.g. `{'deir', 'acef', 'eirt', ...]`.\n", + "- **unscramble**: I'll define `unscramble(term)` to return the corresponding word. A term might unscramble to several words; I'll arbitrarily pick one.\n", + "- **longest chain**: I'll define`longest(chains)` to return the chain with the most words.\n", + "- **one additional letter**: I'll define `add_one_letter(term)` to return the set of valid terms that can be formed by adding one letter to `term`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def scramble(word: Word) -> Term: \n", + " \"\"\"A canonical scrambling of the letters in `word` (into alphabetical order).\"\"\"\n", + " return ''.join(sorted(word))\n", + "\n", + "all_terms = {scramble(w): w for w in open('enable1.txt').read().split()} # {Term: Word}\n", + "\n", + "starting_terms = {term for term in all_terms if len(term) == 4} # 4-letter terms\n", + "\n", + "unscramble = all_terms.get # e.g., unscramble('deir') = 'ride'\n", + "\n", + "def longest(chains) -> Chain: return max(chains, key=len, default=[])\n", + "\n", + "def add_one_letter(term: Term) -> Set[Term]:\n", + " \"\"\"The set of valid Terms (from `all_terms`) that can be formed by adding one letter to `term`.\"\"\"\n", + " terms = (scramble(term + L) for L in 'abcdefghijklmnopqrstuvwxyz')\n", + " return {t for t in terms if t in all_terms} " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following assertions serve as unit tests and also as examples of usage:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "assert scramble('ride') == 'deir'\n", + "assert unscramble('deir') == 'ride'\n", + "assert scramble('dire') == scramble('ired') == scramble('ride')\n", + "assert unscramble('billowy') == scramble('billowy') == 'billowy'\n", + "assert longest(([1, 2, 3], [1, 2], [1, 2, 3, 4], [3, 2, 1])) == [1, 2, 3, 4]\n", + "assert longest([]) == [] # The longest of no chains is the empty chain\n", + "assert add_one_letter('abck') == {'aabck', 'abckl', 'abcks'}\n", + "assert add_one_letter(scramble('back')) == {scramble('aback'), scramble('black'), scramble('backs')}\n", + "assert add_one_letter(scramble('riddler')) == {scramble('riddlers')}\n", + "assert add_one_letter(scramble('riddlers')) == set()\n", + "assert len(all_terms) == 156473\n", + "assert len(starting_terms) == 2674" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Solution\n", + "\n", + "We are now ready to compute \"the longest chain ... starting with four letters.\" First we define `longest_starting_from(term)` to compute the longest chain starting from a term, then we apply that to every `starting_term`, and take the `longest`. To compute `longest_starting_from(term)`, recursively apply `longest_starting_from` to every one-letter addition to `term`, and then return the longest of those chains, with the unscrambling of `term` prepended. " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 1.35 s, sys: 9.73 ms, total: 1.36 s\n", + "Wall time: 1.36 s\n" + ] + }, + { + "data": { + "text/plain": [ + "['nine',\n", + " 'inane',\n", + " 'eonian',\n", + " 'enation',\n", + " 'nominate',\n", + " 'nominates',\n", + " 'antinomies',\n", + " 'nitrosamine',\n", + " 'terminations',\n", + " 'antimodernist',\n", + " 'determinations',\n", + " 'underestimation',\n", + " 'underestimations']" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cache = functools.lru_cache(None) # Used as a function decorator to avoid re-computation\n", + "\n", + "@cache\n", + "def longest_starting_from(term: Term) -> Chain:\n", + " \"\"\"The longest chain starting with `term`. Equal to the unscrambling of `term`,\n", + " followed by the longest chain starting from a one-letter addition to `term` (if there are any).\"\"\"\n", + " chains = map(longest_starting_from, add_one_letter(term))\n", + " return [unscramble(term), *longest(chains)]\n", + "\n", + "%time longest(map(longest_starting_from, starting_terms)) # A solution" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We see that the longest chain gets up to a 16-letter word. There are other chains that are equally long, and it turns out that all of them end with either `indeterminations` or `underestimations`. \n", + "\n", + "*Note*: Typically a recursive function has the form \"if terminal_condition return something else recurse.\" The function `longest_starting_from` does not need an explicit check for a terminal condition, because when `add_one_letter(term)` returns the empty set, the function `longest` deals with it (via the `default=[]` keyword)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Extra Credit\n", + "\n", + "How many possible games of Anigrams are there? A game is a chain that starts with a 4-letter word and ends with a 9-letter word. Swapping the order of letters in any word \"does not result in a distinct game.\" (I don't have to worry about that because my `unscramble(term)` always picks the same word, given a term.)\n", + "\n", + "The number of games starting from a given term is exactly 1 if the term has 9 letters, and is otherwise the sum of the number of games starting from each one-letter addition to term. To find the total number of possible games, just sum up the `games_starting_from` for each 4-letter term:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "@cache\n", + "def games_starting_from(term: Term, goal_length=9) -> int:\n", + " \"\"\"How many games start with `term` and end with a word of `goal_length` letters?\"\"\"\n", + " if len(term) == goal_length:\n", + " return 1\n", + " else:\n", + " return sum(games_starting_from(t, goal_length) for t in add_one_letter(term))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 816 ms, sys: 4.69 ms, total: 821 ms\n", + "Wall time: 820 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "4510515" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time sum(map(games_starting_from, starting_terms))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are 4,510,515 total possible games. \n", + "\n", + "## More Tests, Examples, and Notes\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "assert games_starting_from('beep') == 1\n", + "assert longest_starting_from('beep') == ['beep', 'plebe', 'beleap', 'beleapt', 'bedplate', 'bedplates']\n", + "assert max([games_starting_from(term), term] for term in starting_terms) == [64422, 'aers']\n", + "for word in 'alerts alters artels estral laster ratels salter slater staler stelar talers'.split():\n", + " assert scramble(word) == 'aelrst' \n", + "for (term, word) in all_terms.items():\n", + " assert scramble(word) == term\n", + " assert unscramble(term) == word\n", + " assert scramble(unscramble(term)) == term\n", + " assert unscramble(scramble(word)) == word" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The words `'a'` and `'I'` are not in `enable1.txt`, because one-letter words are not valid Scrabble™️ words. We could add them, and then find the longest chain of all:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['a',\n", + " 'as',\n", + " 'sea',\n", + " 'teas',\n", + " 'teams',\n", + " 'stamen',\n", + " 'tameins',\n", + " 'mannites',\n", + " 'nominates',\n", + " 'antinomies',\n", + " 'nitrosamine',\n", + " 'terminations',\n", + " 'antimodernist',\n", + " 'determinations',\n", + " 'underestimation',\n", + " 'underestimations']" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "all_terms.update({'a': 'a', 'i': 'i'})\n", + "longest(map(longest_starting_from, all_terms))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*Note*: The puzzle description uses the phrase \"collection of letters\" and the word \"anagram\" as synonyms for \"term\". I chose to use \"term\" throughout because \"collection of letters\" is too long, and \"anagram\" (although more descriptive than the bland \"term\") is not quite right: \"ride\" and \"dire\" are indeed anagrams of each other, but \"deir\" is not an anagram, because it is not an English word. Rather, \"deir\" is a *scrambled collection of letters that correspond to a word*. Also, I like the fact that \"term\" and \"word\" have the same number of letters.\n", + "\n", + "*Note*: This whole notebook only takes about 3 seconds to run. The use of the `@cache` decorator means that the complexity of the whole algorithm is *O*(*T*), where *T* is the number of terms. Without the `@cache`, many chains would have to be re-computed multiple times. For example, starting at `'ride'` you can add one letter to get to either `'riced'` or `'rimed'`. From either of those you can add one letter to get to `'dermic'`, and from there there's a whole subtree of possibilities to explore. With `@cache`, that subtree is only computed once. Without `@cache` it is computed twice, and lots of other subtrees are computed multiple times, making the overall run time about ten-fold slower.\n", + "\n", + "*Note*: I could have represented a chain as a list of scrambled terms, rather than a list of words, but I figure if you want the solution, you'd rather see words, not scrambles. I also could have represented a chain as an initial set of four letters plus the letters that get added, in order. For example, `'deir' + 'd' + 'l' + 'r' + 's' == 'deirdlrs'`. That would have been a more compact representation than a list, but messier to deal with. For example, the word `'dermic'` would be reached by two paths, `'deircm'` and `'deirmc'`, and we would lose the advantage of `@cache` unless we took some convoluted steps to translate back and forth among the chain format, the scrambled word format, and the unscrambled word format.\n", + "\n", + "*Note*: Because the order of iteration over sets is not fixed in Python, different runs of this notebook can produce different results.\n", + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}