diff --git a/ipynb/CrossProduct.ipynb b/ipynb/CrossProduct.ipynb index e6a808c..12517fa 100644 --- a/ipynb/CrossProduct.ipynb +++ b/ipynb/CrossProduct.ipynb @@ -16,7 +16,7 @@ "Sample puzzle:\n", "\n", " \n", - "| 6615 | 15552 |   420 | |\n", + "| 6615 | 15552 |   420 | [6x3] |\n", "|-------|-------|-------|---|\n", "| ? | ? | ? |**210**|\n", "| ? | ? | ? |**144**|\n", @@ -28,7 +28,7 @@ "\n", "Solution:\n", "\n", - "|6615|15552|  420||\n", + "|6615|15552|  420| [6x3]|\n", "|---|---|---|---|\n", "|7|6|5|**210**|\n", "|9|8|2|**144**|\n", @@ -50,7 +50,9 @@ "- `Digit`: a single digit, from 1 to 9 (but not 0).\n", "- `Row`: a sequence of digits that forms a row in the table, e.g. `(7, 6, 5)`.\n", "- `Table`: a table of digits that fill in for the \"?\"s; a list of rows, e.g. `[(7, 6, 5), (9, 8, 2), ...]`.\n", - "- `Products`: a list of the numbers that corresponding digits must multiply to, e.g. in the puzzle above, `[6615, 15552, 420]` for the column products, and `[210, 144, 54, 135, 4, 49]` for the row products.\n", + "- `Products`: a list of the numbers that corresponding digits must multiply to, e.g. in the puzzle above:\n", + " - `[6615, 15552, 420]` for the column products;\n", + " - `[210, 144, 54, 135, 4, 49]` for the row products.\n", "- `Puzzle`: a puzzle to be solved, as defined by the row products and column products." ] }, @@ -67,8 +69,9 @@ "\n", "Digit = int\n", "Row = Tuple[Digit, ...] \n", - "Table = List[Row] \n", - "Products = List[int] \n", + "Table = List[Row] \n", + "Product = int\n", + "Products = List[Product] \n", "Puzzle = namedtuple('Puzzle', 'row_prods, col_prods')" ] }, @@ -101,7 +104,6 @@ "\n", "- A first step in solving the puzzle is filling in a single row of the table.\n", "- We will need to respect the row- and column-product constraints.\n", - "- The strategy is: for every feasible way of filling the first digit, try all ways of recursively filling the rest of the row.\n", "- `fill_one_row(row_prod=210, col_prods=[6615, 15552, 420])` will return a set of 3-digit tuples where each tuple multiplies to 210, and each digit of the tuple evenly divides the corresponding number in `col_prods`.\n", " - If `col_prods` is `[]`, then there is one solution (the 0-length tuple) if `row_prod` is 1, and no solution otherwise.\n", " - Otherwise, try each digit `d` that divides both the `row_prod` and the first `col_prods`, and then try all ways to fill the rest of the row." @@ -113,7 +115,7 @@ "metadata": {}, "outputs": [], "source": [ - "def fill_one_row(row_prod, col_prods) -> Set[Row]:\n", + "def fill_one_row(row_prod: Product, col_prods: Products) -> Set[Row]:\n", " \"All permutations of digits that multiply to `row_prod` and evenly divide `col_prods`.\"\n", " return ({()} if not col_prods and row_prod == 1 else\n", " set() if not col_prods and row_prod != 1 else\n", @@ -252,7 +254,7 @@ "source": [ "# Solving a whole puzzle\n", "\n", - "- We can solve a whole puzzle with a similar strategy.\n", + "- We can solve a whole puzzle with a similar strategy to filling a row.\n", "- For every possible way of filling the first row, try every way of recursively solving the rest of the puzzle. \n", "- `solve` finds the first solution to a puzzle. (A well-formed puzzle has exactly one solution, but some might have more or less.)\n", "- `solutions` yields all possible solutions to a puzzle. There are three main cases to consider:\n", @@ -334,7 +336,7 @@ " \"\"\"A puzzle and the filled-in table as a str that will be pretty in Markdown.\"\"\"\n", " row_prods, col_prods = puzzle\n", " table = table or solve(puzzle)\n", - " head = surround(col_prods + [''])\n", + " head = surround(col_prods + [f'[{len(row_prods)}x{len(col_prods)}]'])\n", " dash = surround(['---'] * (1 + len(col_prods)))\n", " rest = [surround(row + (f'**{rp}**',))\n", " for row, rp in zip(table, row_prods)]\n", @@ -353,7 +355,7 @@ { "data": { "text/markdown": [ - "|3000|3969|640||\n", + "|3000|3969|640|[5x3]|\n", "|---|---|---|---|\n", "|3|9|5|**135**|\n", "|5|9|1|**45**|\n", @@ -361,7 +363,7 @@ "|5|7|8|**280**|\n", "|5|7|2|**70**|\n", "\n", - "|6615|15552|420||\n", + "|6615|15552|420|[6x3]|\n", "|---|---|---|---|\n", "|7|6|5|**210**|\n", "|9|8|2|**144**|\n", @@ -370,7 +372,7 @@ "|1|4|1|**4**|\n", "|7|1|7|**49**|\n", "\n", - "|183708|245760|117600||\n", + "|183708|245760|117600|[7x3]|\n", "|---|---|---|---|\n", "|7|8|5|**280**|\n", "|3|8|7|**168**|\n", @@ -441,7 +443,7 @@ { "data": { "text/plain": [ - "[(5, 3, 8), (7, 2, 1)]" + "[(9, 4, 9), (7, 4, 9), (3, 8, 3), (8, 6, 1), (3, 4, 5)]" ] }, "execution_count": 14, @@ -450,27 +452,16 @@ } ], "source": [ - "random_table(nrows=2, ncols=3)" + "random_table(nrows=5, ncols=3)" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Puzzle(row_prods=[120, 14], col_prods=[35, 6, 8])" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "table_puzzle(_)" + "puz = table_puzzle(_)" ] }, { @@ -490,7 +481,36 @@ } ], "source": [ - "well_formed(_)" + "well_formed(puz)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "|4536|3072|1215|[5x3]|\n", + "|---|---|---|---|\n", + "|6|6|9|**324**|\n", + "|7|4|9|**252**|\n", + "|9|8|1|**72**|\n", + "|2|8|3|**48**|\n", + "|6|2|5|**60**|" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "Markdown(pretty(puz))" ] }, { @@ -502,7 +522,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -511,18 +531,17 @@ "text": [ "38% of random puzzles with 3 rows and 3 cols ( 9 cells) are well-formed\n", "15% of random puzzles with 3 rows and 4 cols (12 cells) are well-formed\n", - "14% of random puzzles with 4 rows and 3 cols (12 cells) are well-formed\n", - " 6% of random puzzles with 3 rows and 5 cols (15 cells) are well-formed\n", - " 5% of random puzzles with 5 rows and 3 cols (15 cells) are well-formed\n", - " 3% of random puzzles with 4 rows and 4 cols (16 cells) are well-formed\n", - " 1% of random puzzles with 6 rows and 3 cols (18 cells) are well-formed\n", - " 0% of random puzzles with 5 rows and 4 cols (20 cells) are well-formed\n" + "15% of random puzzles with 4 rows and 3 cols (12 cells) are well-formed\n", + " 4% of random puzzles with 3 rows and 5 cols (15 cells) are well-formed\n", + " 4% of random puzzles with 5 rows and 3 cols (15 cells) are well-formed\n", + " 4% of random puzzles with 4 rows and 4 cols (16 cells) are well-formed\n", + " 2% of random puzzles with 6 rows and 3 cols (18 cells) are well-formed\n" ] } ], "source": [ - "N = 300\n", - "for r, c in [(3, 3), (3, 4), (4, 3), (3, 5), (5, 3), (4, 4), (6, 3), (5, 4)]:\n", + "N = 200\n", + "for r, c in [(3, 3), (3, 4), (4, 3), (3, 5), (5, 3), (4, 4), (6, 3)]:\n", " w = sum(map(well_formed, random_puzzles(N, r, c))) / N\n", " print(f'{w:3.0%} of random puzzles with {r} rows and {c} cols ({r * c:2} cells) are well-formed')" ] @@ -535,42 +554,7 @@ "\n", "# Speed\n", "\n", - "How fast is it to solve a random puzzle? We can do a hundred small (5x3) puzzles in under a second:" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 179 ms, sys: 7.47 ms, total: 187 ms\n", - "Wall time: 181 ms\n" - ] - }, - { - "data": { - "text/plain": [ - "100" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "%time len([solve(p) for p in random_puzzles(100, 5, 3)])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Puzzles that are even a little bit larger can be a lot slower. For example, a 10 x 4 puzzle can take from 5 milliseconds to 5 seconds or so:" + "How long does it take to solve a random puzzle? We can do a thousand small (5x3) puzzles in about two seconds:" ] }, { @@ -582,28 +566,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 5.94 ms, sys: 237 µs, total: 6.18 ms\n", - "Wall time: 6 ms\n" + "CPU times: user 2 s, sys: 76.7 ms, total: 2.08 s\n", + "Wall time: 2.02 s\n" ] }, { "data": { - "text/markdown": [ - "|645120|201600|1382400|1290240||\n", - "|---|---|---|---|---|\n", - "|2|3|5|5|**150**|\n", - "|3|4|4|4|**192**|\n", - "|6|5|2|3|**180**|\n", - "|8|4|4|8|**1024**|\n", - "|8|4|4|8|**1024**|\n", - "|1|5|2|4|**40**|\n", - "|1|2|9|6|**108**|\n", - "|5|1|3|1|**15**|\n", - "|7|3|8|7|**1176**|\n", - "|8|7|5|2|**560**|" - ], "text/plain": [ - "" + "1000" ] }, "execution_count": 19, @@ -612,7 +582,56 @@ } ], "source": [ - "puzz = random_puzzles(1, 10, 4)[0]\n", + "%time len([solve(p) for p in random_puzzles(1000, 5, 3)])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Puzzles that are even a little bit larger can be a lot slower, and there is huge variability in the time to solve. For example, a single 10 x 4 puzzle can take from a few milliseconds to several seconds:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 2.83 s, sys: 38.1 ms, total: 2.87 s\n", + "Wall time: 2.87 s\n" + ] + }, + { + "data": { + "text/markdown": [ + "|470400|53760|8294400|226800|[10x4]|\n", + "|---|---|---|---|---|\n", + "|7|1|5|4|**140**|\n", + "|5|1|5|4|**100**|\n", + "|4|5|4|7|**560**|\n", + "|5|8|2|5|**400**|\n", + "|7|7|2|1|**98**|\n", + "|3|8|4|3|**288**|\n", + "|1|1|9|1|**9**|\n", + "|8|6|8|5|**1920**|\n", + "|4|4|8|3|**384**|\n", + "|1|1|9|9|**81**|" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[puzz] = random_puzzles(1, 10, 4)\n", "%time Markdown(pretty(puzz))" ] }, @@ -620,9 +639,37 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "What could we do to speed that up?\n", - "- We could treat it as a constraint satisfaction problem (CSP), and use a highly-optimized [CSP solver](https://developers.google.com/optimization/cp/cp_solver).\n", - "- We could use some of the lessons that CSP solvers use, such as trying the most constrained variables first. If a row- (or column-) product is divisible by 5 (or 7), then one of the digits in the row (or column) must be 5 (or 7). (That's not true of any other digit. For example, if a product is divisible by 2 and 3, it might be that the digits 2 and 3 appear, but perhaps 6 appears instead.) So instead of filling in the table row by row, top to bottom, try filling in the 5s and 7s first, then fill other rows that have a small number of possible fillers." + "In general, the time to solve a puzzle can grow exponentially in the number of cells. Consider this one row in a six-column puzzle, with 3,960 possibilities:" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3960" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "n = 5 * 7 * 8 * 9\n", + "len(fill_one_row(2 * n, [n] * 6))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If the first three rows all had a similar number of possibilities, that would be tens of billions of combinations to try. What could we do to speed things up?\n", + "- We could treat it as a constraint satisfaction problem (CSP), and use a highly-optimized [CSP solver](https://developers.google.com/optimization/cp/cp_solver). A good CSP representation would be to make each cell be a variable with range {1, ... 9}, and with the constraints being that the digit in each cell must evenly divide both the row- and column- constraint for the cell, and the product of the row (or column) must equal the corresponding value.\n", + "- Even without using a professional CSP solver, we could borrow the heuristics they use. In `solve`, we fill in cells in strict top-to-bottom, left-to-right order. It is better to fill in first the cell with the minimum number of possible values. For each cell, find the greatest common divisor of the row- and column-products. For a cell whose gcd is 72, the possible digits are {2, 3, 4, 6, 9}. For a cell whose gcd is 21, the possible digits are {3, 7}. Thus, it is better to fill in the 21 cell first, because you have a 1/2 chance of guessing right, not a 1/5 chance." ] }, { @@ -636,7 +683,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -645,7 +692,7 @@ "True" ] }, - "execution_count": 20, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" }