"""Classic puzzles""" # TODO: add tags from puzzle_generator import PuzzleGenerator, Tags from typing import List # See https://github.com/microsoft/PythonProgrammingPuzzles/wiki/How-to-add-a-puzzle to learn about adding puzzles class TowersOfHanoi(PuzzleGenerator): """[Towers of Hanoi](https://en.wikipedia.org/w/index.php?title=Tower_of_Hanoi) In this classic version one must move all 8 disks from the first to third peg.""" @staticmethod def sat(moves: List[List[int]]): """ Eight disks of sizes 1-8 are stacked on three towers, with each tower having disks in order of largest to smallest. Move [i, j] corresponds to taking the smallest disk off tower i and putting it on tower j, and it is legal as long as the towers remain in sorted order. Find a sequence of moves that moves all the disks from the first to last towers. """ rods = ([8, 7, 6, 5, 4, 3, 2, 1], [], []) for [i, j] in moves: rods[j].append(rods[i].pop()) assert rods[j][-1] == min(rods[j]), "larger disk on top of smaller disk" return rods[0] == rods[1] == [] @staticmethod def sol(): def helper(m, i, j): if m == 0: return [] k = 3 - i - j return helper(m - 1, i, k) + [[i, j]] + helper(m - 1, k, j) return helper(8, 0, 2) class TowersOfHanoiArbitrary(PuzzleGenerator): """[Towers of Hanoi](https://en.wikipedia.org/w/index.php?title=Tower_of_Hanoi) In this version one must transform a given source state to a target state.""" @staticmethod def sat(moves: List[List[int]], source=[[0, 7], [4, 5, 6], [1, 2, 3, 8]], target=[[0, 1, 2, 3, 8], [4, 5], [6, 7]]): """ A state is a partition of the integers 0-8 into three increasing lists. A move is pair of integers i, j in {0, 1, 2} corresponding to moving the largest number from the end of list i to list j, while preserving the order of list j. Find a sequence of moves that transform the given source to target states. """ state = [s[:] for s in source] for [i, j] in moves: state[j].append(state[i].pop()) assert state[j] == sorted(state[j]) return state == target @staticmethod def sol(source, target): state = {d: i for i, tower in enumerate(source) for d in tower} final = {d: i for i, tower in enumerate(target) for d in tower} disks = set(state) assert disks == set(final) and all(isinstance(i, int) for i in state) and len(source) == len(target) >= 3 ans = [] def move(d, i): # move disk d to tower i if state[d] == i: return for t in range(3): # first tower besides i, state[d] if t != i and t != state[d]: break for d2 in range(d + 1, max(disks) + 1): if d2 in disks: move(d2, t) ans.append([state[d], i]) state[d] = i for d in range(min(disks), max(disks) + 1): if d in disks: move(d, final[d]) return ans def gen_random(self): n = self.random.randrange(4, 18) source, target = [[[] for _ in range(3)] for _ in range(2)] for d in range(n): self.random.choice(source).append(d) self.random.choice(target).append(d) self.add(dict(source=source, target=target)) class LongestMonotonicSubstring(PuzzleGenerator): """This is a form of the classic [Longest increasing subsequence](https://en.wikipedia.org/wiki/Longest_increasing_subsequence) problem where the goal is to find a substring with characters in sorted order.""" @staticmethod def sat(x: List[int], length=13, s="Dynamic programming solves this puzzle!!!"): """ Remove as few characters as possible from s so that the characters of the remaining string are alphebetical. Here x is the list of string indices that have not been deleted. """ return all(s[x[i]] <= s[x[i + 1]] and x[i + 1] > x[i] >= 0 for i in range(length - 1)) @staticmethod def sol(length, s): # O(N^2) method. Todo: add binary search solution which is O(n log n) if s == "": return [] n = len(s) dyn = [] # list of (seq length, seq end, prev index) for i in range(n): try: dyn.append(max((length + 1, i, e) for length, e, _ in dyn if s[e] <= s[i])) except ValueError: dyn.append((1, i, -1)) # sequence ends at i _length, i, _ = max(dyn) backwards = [i] while dyn[i][2] != -1: i = dyn[i][2] backwards.append(i) return backwards[::-1] def gen_random(self): n = self.random.randrange(self.random.choice([10, 100, 1000])) # a length between 1-10 or 1-100 or 1-1000 length = self.random.randrange(n + 1) rand_chars = [chr(self.random.randrange(32, 124)) for _ in range(n)] li = sorted(rand_chars[:length]) for i in range(length, n): li.insert(self.random.randrange(i + 1), rand_chars[i]) s = "".join(li) self.add(dict(length=length, s=s)) class LongestMonotonicSubstringTricky(PuzzleGenerator): """The same as the above problem, but with a twist!""" @staticmethod def sat(x: List[int], length=20, s="Dynamic programming solves this classic job-interview puzzle!!!"): """Find the indices of the longest substring with characters in sorted order""" return all(s[x[i]] <= s[x[i + 1]] and x[i + 1] > x[i] for i in range(length - 1)) @staticmethod def sol(length, s): # O(N^2) method. Todo: add binary search solution which is O(n log n) if s == "": return [] n = len(s) dyn = [] # list of (seq length, seq end, prev index) for i in range(-n, n): try: dyn.append(max((length + 1, i, e) for length, e, _ in dyn if s[e] <= s[i])) except ValueError: dyn.append((1, i, None)) # sequence ends at i _length, i, _ = max(dyn) backwards = [i] while dyn[n + i][2] is not None: i = dyn[n + i][2] backwards.append(i) return backwards[::-1] def gen_random(self): n = self.random.randrange(self.random.choice([10, 100, 1000])) # a length between 1-10 or 1-100 or 1-1000 length = self.random.randrange(n + 1) rand_chars = [chr(self.random.randrange(32, 124)) for _ in range(n)] li = sorted(rand_chars[:length]) a, b = li[:length // 2][::-1], li[length // 2:][::-1] li = [] while a or b: li.append(self.random.choice([c for c in (a, b) if c]).pop()) for i in range(length, n): li.insert(self.random.randrange(i + 1), rand_chars[i]) s = "".join(li) self.add(dict(length=length, s=s)) class Quine(PuzzleGenerator): """[Quine](https://en.wikipedia.org/wiki/Quine_%28computing%29)""" @staticmethod def sat(quine: str): """Find a string that when evaluated as a Python expression is that string itself.""" return eval(quine) == quine @staticmethod def sol(): return "(lambda x: f'({x})({chr(34)}{x}{chr(34)})')(\"lambda x: f'({x})({chr(34)}{x}{chr(34)})'\")" class RevQuine(PuzzleGenerator): """Reverse [Quine](https://en.wikipedia.org/wiki/Quine_%28computing%29). The solution we give is from GPT3.""" @staticmethod def sat(rev_quine: str): """Find a string that, when reversed and evaluated gives you back that same string.""" return eval(rev_quine[::-1]) == rev_quine @staticmethod def sol(): return "rev_quine"[::-1] # thanks GPT-3! class BooleanPythagoreanTriples(PuzzleGenerator): """[Boolean Pythagorean Triples Problem](https://en.wikipedia.org/wiki/Boolean_Pythagorean_triples_problem)""" @staticmethod def sat(colors: List[int], n=100): """ Color the first n integers with one of two colors so that there is no monochromatic Pythagorean triple. A monochromatic Pythagorean triple is a triple of numbers i, j, k such that i^2 + j^2 = k^2 that are all assigned the same color. The input, colors, is a list of 0/1 colors of length >= n. """ assert set(colors) <= {0, 1} and len(colors) >= n squares = {i ** 2: colors[i] for i in range(1, len(colors))} return not any(c == d == squares.get(i + j) for i, c in squares.items() for j, d in squares.items()) @staticmethod def sol(n): sqrt = {i * i: i for i in range(1, n)} trips = [(sqrt[i], sqrt[j], sqrt[i + j]) for i in sqrt for j in sqrt if i < j and i + j in sqrt] import random random.seed(0) sol = [random.randrange(2) for _ in range(n)] done = False while not done: done = True random.shuffle(trips) for i, j, k in trips: if sol[i] == sol[j] == sol[k]: done = False sol[random.choice([i, j, k])] = 1 - sol[i] return sol def gen(self, target_num_instances): for n in [7824] + list(range(target_num_instances)): if self.num_generated_so_far() == target_num_instances: return self.add(dict(n=n), test=n <= 100) class ClockAngle(PuzzleGenerator): """[Clock Angle Problem](https://en.wikipedia.org/wiki/Clock_angle_problem), easy variant""" @staticmethod def sat(hands: List[int], target_angle=45): """Find clock hands = [hour, min] such that the angle is target_angle degrees.""" h, m = hands assert 0 < h <= 12 and 0 <= m < 60 hour_angle = 30 * h + m / 2 minute_angle = 6 * m return abs(hour_angle - minute_angle) in [target_angle, 360 - target_angle] @staticmethod def sol(target_angle): for h in range(1, 13): for m in range(60): hour_angle = 30 * h + m / 2 minute_angle = 6 * m if abs(hour_angle - minute_angle) % 360 in [target_angle, 360 - target_angle]: return [h, m] def gen_random(self): target_angle = self.random.randrange(0, 360) if self.sol(target_angle): self.add(dict(target_angle=target_angle)) class Kirkman(PuzzleGenerator): """[Kirkman's problem](https://en.wikipedia.org/wiki/Kirkman%27s_schoolgirl_problem)""" @staticmethod def sat(daygroups: List[List[List[int]]]): """ Arrange 15 people into groups of 3 each day for seven days so that no two people are in the same group twice. """ assert len(daygroups) == 7 assert all(len(groups) == 5 and {i for g in groups for i in g} == set(range(15)) for groups in daygroups) assert all(len(g) == 3 for groups in daygroups for g in groups) return len({(i, j) for groups in daygroups for g in groups for i in g for j in g}) == 15 * 15 @staticmethod def sol(): from itertools import combinations import random rand = random.Random(0) days = [[list(range(15)) for _2 in range(2)] for _ in range(7)] # each day is pi, inv counts = {(i, j): (7 if j in range(k, k + 3) else 0) for k in range(0, 15, 3) for i in range(k, k + 3) for j in range(15) if j != i } todos = [pair for pair, count in counts.items() if count == 0] while True: pair = rand.choice(todos) # choose i and j to make next to each other on some day if rand.randrange(2): pair = pair[::-1] a, u = pair pi, inv = rand.choice(days) assert pi[inv[a]] == a and pi[inv[u]] == u bases = [3 * (inv[i] // 3) for i in pair] (b, c), (v, w) = [[x for x in pi[b: b + 3] if x != i] for i, b in zip(pair, bases)] if rand.randrange(2): b, c, = c, b # current (a, b, c) (u, v, w). consider swap of u with b to make (a, u, c) (b, v, w) new_pairs = [(a, u), (c, u), (b, v), (b, w)] old_pairs = [(u, v), (u, w), (b, a), (b, c)] gained = sum(counts[p] == 0 for p in new_pairs) lost = sum(counts[p] == 1 for p in old_pairs) if rand.random() <= 100 ** (gained - lost): for p in new_pairs: counts[p] += 1 counts[p[::-1]] += 1 for p in old_pairs: counts[p] -= 1 counts[p[::-1]] -= 1 pi[inv[b]], pi[inv[u]], inv[b], inv[u] = u, b, inv[u], inv[b] todos = [pair for pair, count in counts.items() if count == 0] if len(todos) == 0: return [[pi[k:k + 3] for k in range(0, 15, 3)] for pi, _inv in days] # return [[[0, 5, 10], [1, 6, 11], [2, 7, 12], [3, 8, 13], [4, 9, 14]], # wikipedia solution # [[0, 1, 4], [2, 3, 6], [7, 8, 11], [9, 10, 13], [12, 14, 5]], # [[1, 2, 5], [3, 4, 7], [8, 9, 12], [10, 11, 14], [13, 0, 6]], # [[4, 5, 8], [6, 7, 10], [11, 12, 0], [13, 14, 2], [1, 3, 9]], # [[2, 4, 10], [3, 5, 11], [6, 8, 14], [7, 9, 0], [12, 13, 1]], # [[4, 6, 12], [5, 7, 13], [8, 10, 1], [9, 11, 2], [14, 0, 3]], # [[10, 12, 3], [11, 13, 4], [14, 1, 7], [0, 2, 8], [5, 6, 9]]] class MonkeyAndCoconuts(PuzzleGenerator): """[The Monkey and the Coconuts](https://en.wikipedia.org/wiki/The_monkey_and_the_coconuts)""" @staticmethod def sat(n: int): """ Find the number of coconuts to solve the following riddle: There is a pile of coconuts, owned by five men. One man divides the pile into five equal piles, giving the one left over coconut to a passing monkey, and takes away his own share. The second man then repeats the procedure, dividing the remaining pile into five and taking away his share, as do the third, fourth, and fifth, each of them finding one coconut left over when dividing the pile by five, and giving it to a monkey. Finally, the group divide the remaining coconuts into five equal piles: this time no coconuts are left over. How many coconuts were there in the original pile? Quoted from https://en.wikipedia.org/wiki/The_monkey_and_the_coconuts """ for i in range(5): assert n % 5 == 1 n -= 1 + (n - 1) // 5 return n > 0 and n % 5 == 1 @staticmethod def sol(): m = 1 while True: n = m for i in range(5): if n % 5 != 1: break n -= 1 + (n - 1) // 5 if n > 0 and n % 5 == 1: return m m += 5 class No3Colinear(PuzzleGenerator): """[No three-in-a-line](https://en.wikipedia.org/wiki/No-three-in-line_problem)""" @staticmethod def sat(coords: List[List[int]], side=10, num_points=20): """Find num_points points in an side x side grid such that no three points are collinear.""" for i1 in range(len(coords)): x1, y1 = coords[i1] assert 0 <= x1 < side and 0 <= y1 < side for i2 in range(i1): x2, y2 = coords[i2] for i3 in range(i2): x3, y3 = coords[i3] assert x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2) != 0 return len({(a, b) for a, b in coords}) == len(coords) >= num_points @staticmethod def sol(side, num_points): from itertools import combinations assert side <= 5 or side == 10, "Don't know how to solve other sides" def test(coords): return all(p[0] * (q[1] - r[1]) + q[0] * (r[1] - p[1]) + r[0] * (p[1] - q[1]) for p, q, r in combinations(coords, 3)) if side <= 5: grid = [[i, j] for i in range(side) for j in range(side)] return next(list(coords) for coords in combinations(grid, num_points) if test(coords)) if side == 10: def mirror(coords): # rotate to all four corners return [[a, b] for x, y in coords for a in [x, side - 1 - x] for b in [y, side - 1 - y]] grid = [[i, j] for i in range(side // 2) for j in range(side // 2)] return next(list(mirror(coords)) for coords in combinations(grid, side // 2) if test(coords) and test(mirror(coords))) def gen(self, target_num_instances): for easy in range(47): for side in range(47): if self.num_generated_so_far() == target_num_instances: return test = side < 5 or side == 10 num_points = 1 if side == 1 else 2 * side if num_points >= easy: num_points -= easy self.add(dict(side=side, num_points=num_points), test=test) class PostageStamp(PuzzleGenerator): """[Postage stamp problem](https://en.wikipedia.org/wiki/Postage_stamp_problem)""" @staticmethod def sat(stamps: List[int], target=80, max_stamps=4, options=[10, 32, 8]): """Find a selection of at most max_stamps stamps whose total worth is the target value.""" for s in stamps: assert s in options return len(stamps) <= max_stamps and sum(stamps) == target @staticmethod def sol(target, max_stamps, options): from itertools import combinations_with_replacement for n in range(max_stamps + 1): for c in combinations_with_replacement(options, n): if sum(c) == target: return list(c) def gen_random(self): max_stamps = self.random.randrange(1, 10) options = [self.random.randrange(1, 100) for _ in range(self.random.randrange(1, 10))] target = sum(self.random.choices(options, k=self.random.randrange(1, max_stamps + 1))) self.add(dict(target=target, max_stamps=max_stamps, options=options)) class Sudoku(PuzzleGenerator): """The classic game of [Sudoku](https://en.wikipedia.org/wiki/Sudoku)""" @staticmethod def sat(x: str, puz='____9_2___7__________1_8_4____2_78____4_____1____69____2_8___5__6__3_7___49______'): """Find the unique valid solution to the Sudoku puzzle""" assert all(c == "_" or c == s for (c, s) in zip(puz, x)) full = set('123456789') for i in range(9): assert {x[i] for i in range(9 * i, 9 * i + 9)} == full, "invalid row" assert {x[i] for i in range(i, i + 81, 9)} == full, "invalid column" assert {x[9 * a + b + i + 26 * (i % 3)] for a in range(3) for b in range(3)} == full, "invalid square" return True @staticmethod def solve(puz): """Simple depth-first backtracking solver that branches at the square with fewest possibilities""" sets = [{int(c)} if c != '_' else set(range(1, 10)) for c in puz] groups = [] for i in range(9): groups.append(list(range(9 * i, 9 * i + 9))) groups.append(list(range(i, i + 81, 9))) groups.append([9 * a + b + i + 26 * (i % 3) for a in range(3) for b in range(3)]) inv = [[] for i in range(81)] for g in groups: for i in g: inv[i].append(g) def reduce(): """Reduce possibilities and return False if it's clearly impossible to solve, True otherwise. Repeatedly applies two types of logic: * When an entry has a single possibility, remove that value from all 20 neighbors * When a row/col/square has only one entry with k as a possibility, fill in that possibility """ done = False while not done: done = True for i in range(81): new = sets[i] - {k for g in inv[i] for j in g if j != i and len(sets[j]) == 1 for k in sets[j]} if not new: return False if len(sets[i]) != len(new): sets[i] = new done = False for g in groups: for k in range(1, 10): possibilities = [i for i in g if k in sets[i]] if not possibilities: return False if len(possibilities) == 1: i = possibilities[0] if len(sets[i]) > 1: done = False sets[i] = {k} return True ans = [] counter = 0 def solve_helper(): nonlocal sets, ans, counter counter += 1 assert len(ans) <= 1, "Sudoku puzzle should have a unique solution" old_sets = sets[:] if reduce(): if all(len(s) == 1 for s in sets): ans.append("".join(str(list(s)[0]) for s in sets)) else: smallest_set = min(range(81), key=lambda i: len(sets[i]) if len(sets[i]) > 1 else 10) for v in sorted(sets[smallest_set]): sets[smallest_set] = {v} solve_helper() sets = old_sets solve_helper() assert ans, "No solution found" return ans[0] @staticmethod def print_board(board): """Helpful method used for development""" for i in range(9): for j in range(9): print(board[9 * i + j], end=" " if j == 2 or j == 5 else "") print() if i == 2 or i == 5: print() @staticmethod def print_sets(sets): """Helpful method used for development""" ans = "" for i in range(9): for j in range(9): ans += " " + "".join(str(k) if k in sets[9 * i + j] else "_" for k in range(1, 10)) if j == 2 or j == 5: ans += " | " if i == 8: print(ans) return ans += "\n" if i == 2 or i == 5: ans += "\n" @staticmethod def gen_sudoku_puzzle(rand): groups = [] for i in range(9): groups.append(list(range(9 * i, 9 * i + 9))) groups.append(list(range(i, i + 81, 9))) groups.append([9 * a + b + i + 26 * (i % 3) for a in range(3) for b in range(3)]) inv = [[] for i in range(81)] for g in groups: for i in g: inv[i].append(g) def solve(puz): """Basically the same as our solver above except that it returns a list of (up to 2) solutions.""" sets = [{int(c)} if c != '_' else set(range(1, 10)) for c in puz] def reduce(): """Reduce possibilities and return False if it's clearly impossible to solve, True otherwise. Repeatedly applies two types of logic: * When an entry has a single possibility, remove that value from all 20 neighbors * When a row/col/square has only one entry with k as a possibility, fill in that possibility """ done = False while not done: done = True for i in range(81): new = sets[i] - {k for g in inv[i] for j in g if j != i and len(sets[j]) == 1 for k in sets[j]} if not new: return False if len(sets[i]) != len(new): sets[i] = new done = False for g in groups: for k in range(1, 10): possibilities = [i for i in g if k in sets[i]] if not possibilities: return False if len(possibilities) == 1: i = possibilities[0] if len(sets[i]) > 1: done = False sets[i] = {k} return True ans = [] counter = 0 def solve_helper(): nonlocal sets, ans, counter counter += 1 if len(ans) > 1: return old_sets = sets[:] if reduce(): if all(len(s) == 1 for s in sets): ans.append("".join(str(list(s)[0]) for s in sets)) else: smallest_set = min(range(81), key=lambda i: len(sets[i]) if len(sets[i]) > 1 else 10) pi = sorted(sets[smallest_set]) rand.shuffle(pi) for v in pi: sets[smallest_set] = {v} solve_helper() sets = old_sets solve_helper() return ans x = ["_"] * 81 perm = list("123456789") rand.shuffle(perm) x[:9] == perm x = list(solve(x)[0]) done = False while not done: done = True pi = list([i for i in range(81) if x[i] != "_"]) rand.shuffle(pi) for i in pi: old = x[i] x[i] = "_" ans = solve("".join(x)) assert ans if len(ans) > 1: x[i] = old else: done = False # print() # Sudoku.print_board(x) # print(" ", 81-x.count("_")) return "".join(x) def gen_random(self): puz = None for attempt in range(10 if self.num_generated_so_far() < 10 else 1): puz2 = Sudoku.gen_sudoku_puzzle(self.random) if puz is None or puz2.count("_") > puz.count("_"): puz = puz2 self.add(dict(puz=puz)) class SquaringTheSquare(PuzzleGenerator): """[Squaring the square](https://en.wikipedia.org/wiki/Squaring_the_square) Wikipedia gives a minimal [solution with 21 squares](https://en.wikipedia.org/wiki/Squaring_the_square) due to Duijvestijn (1978). """ @staticmethod def sat(xy_sides: List[List[int]]): """ Partition a square into smaller squares with unique side lengths. A perfect squared path has distinct sides. xy_sides is a List of (x, y, side) """ n = max(x + side for x, y, side in xy_sides) assert len({side for x, y, side in xy_sides}) == len(xy_sides) > 1 for x, y, s in xy_sides: assert 0 <= y < y + s <= n and 0 <= x for x2, y2, s2 in xy_sides: assert s2 <= s or x2 >= x + s or x2 + s2 <= x or y2 >= y + s or y2 + s2 <= y return sum(side ** 2 for x, y, side in xy_sides) == n ** 2 @staticmethod def sol(): return [[0, 0, 50], [0, 50, 29], [0, 79, 33], [29, 50, 25], [29, 75, 4], [33, 75, 37], [50, 0, 35], [50, 35, 15], [54, 50, 9], [54, 59, 16], [63, 50, 2], [63, 52, 7], [65, 35, 17], [70, 52, 18], [70, 70, 42], [82, 35, 11], [82, 46, 6], [85, 0, 27], [85, 27, 8], [88, 46, 24], [93, 27, 19]] class NecklaceSplit(PuzzleGenerator): """[Necklace Splitting Problem](https://en.wikipedia.org/wiki/Necklace_splitting_problem)""" @staticmethod def sat(n: int, lace="bbrbrbbbbbbrrrrrrrbrrrrbbbrbrrbbbrbrrrbrrbrrbrbbrrrrrbrbbbrrrbbbrbbrbbbrbrbb"): """ Find a split dividing the given red/blue necklace in half at n so that each piece has an equal number of reds and blues. """ sub = lace[n: n + len(lace) // 2] return n >= 0 and lace.count("r") == 2 * sub.count("r") and lace.count("b") == 2 * sub.count("b") @staticmethod def sol(lace): if lace == "": return 0 return next(n for n in range(len(lace) // 2) if lace[n: n + len(lace) // 2].count("r") == len(lace) // 4) def gen_random(self): m = 2 * self.random.randrange(self.random.choice([10, 100, 1000])) lace = ["r", "b"] * m self.random.shuffle(lace) lace = "".join(lace) self.add(dict(lace=lace)) class PandigitalSquare(PuzzleGenerator): """[Pandigital](https://en.wikipedia.org/wiki/Pandigital_number) Square""" @staticmethod def sat(n: int): """Find an integer whose square has all digits 0-9 once.""" s = str(n * n) for i in "0123456789": assert s.count(i) == 1 return True @staticmethod def sol(): for n in range(10 ** 5): if sorted([int(s) for s in str(n * n)]) == list(range(10)): return n class AllPandigitalSquares(PuzzleGenerator): """All [Pandigital](https://en.wikipedia.org/wiki/Pandigital_number) Squares""" @staticmethod def sat(nums: List[int]): """Find all 174 integers whose 10-digit square has all digits 0-9 just once.""" return [sorted([int(s) for s in str(n * n)]) for n in set(nums)] == [list(range(10))] * 174 @staticmethod def sol(): return [i for i in range(-10 ** 5, 10 ** 5) if sorted([int(s) for s in str(i * i)]) == list(range(10))] # MAYBE: add a version of TowersOfHanoiArbitrary where one has to find the fewest moves (maybe with more than 3 pegs) class CardGame24(PuzzleGenerator): """[24 Game](https://en.wikipedia.org/wiki/24_Game) In this game one is given four numbers from the range 1-13 (Ace-King) and one needs to combine them with + - * / (and parentheses) to make the number 24. The solution to this tricky example is `7 * (3 + 3 / 7)` """ @staticmethod def sat(expr: str, nums=[3, 7, 3, 7]): """Find a formula with two 3's and two 7's and + - * / (and parentheses) that evaluates to 24.""" assert len(nums) == 4 and 1 <= min(nums) and max(nums) <= 13, "hint: nums is a list of four ints in 1..13" expr = expr.replace(" ", "") # ignore whitespace digits = "" for i in range(len(expr)): if i == 0 or expr[i - 1] in "+*-/(": assert expr[i] in "123456789(", "Expr cannot contain **, //, or unary -" assert expr[i] in "1234567890()+-*/", "Expr can only contain `0123456789()+-*/`" digits += expr[i] if expr[i] in "0123456789" else " " assert sorted(int(s) for s in digits.split()) == sorted(nums), "Each number must occur exactly once" return abs(eval(expr) - 24.0) < 1e-6 @staticmethod def sol(nums): def helper(pairs): if len(pairs) == 2: (x, s), (y, t) = pairs ans = { x + y: f"{s}+{t}", x - y: f"{s}-({t})", y - x: f"{t}-({s})", x * y: f"({s})*({t})" } if y != 0: ans[x / y] = f"({s})/({t})" if x != 0: ans[y / x] = f"({t})/({s})" return ans ans = {y: t for i in range(len(pairs)) for x_s in helper(pairs[:i] + pairs[i + 1:]).items() for y, t in helper([x_s, pairs[i]]).items()} if len(pairs) == 3: return ans ans.update({z: u for i in range(1, 4) for x_s in helper([pairs[0], pairs[i]]).items() for y_t in helper(pairs[1:i] + pairs[i + 1:]).items() for z, u in helper([x_s, y_t]).items() }) return ans derivations = helper([(n, str(n)) for n in nums]) for x in derivations: if abs(x - 24.0) < 1e-6: return derivations[x] def gen_random(self): nums = [self.random.randint(1, 13) for _ in range(4)] if self.sol(nums): self.add({"nums": nums}) class Easy63(PuzzleGenerator): '''An easy puzzle to make 63 using two 8's and one 1's.''' @staticmethod def sat(s: str): """Find a formula using two 8s and two 1's and -+*/ that evaluates to 1.""" return set(s) <= set("18-+*/") and s.count("8") == 2 and s.count("1") == 1 and eval(s) == 63 @staticmethod def sol(): return "8*8-1" class Harder63(PuzzleGenerator): '''An harder puzzle to make 63 using three 8's and one 1's.''' @staticmethod def sat(s: str): """Find an expression using two 8s and two 1's and -+*/ that evaluates to 1.""" return set(s) <= set("18-+*/") and s.count("8") == 3 and s.count("1") == 1 and eval(s) == 63 @staticmethod def sol(): return "8*8-1**8" class WaterPouring(PuzzleGenerator): """[Water pouring puzzle](https://en.wikipedia.org/w/index.php?title=Water_pouring_puzzle&oldid=985741928)""" @staticmethod def sat(moves: List[List[int]], capacities=[8, 5, 3], init=[8, 0, 0], goal=[4, 4, 0]): """ Given an initial state of water quantities in jugs and jug capacities, find a sequence of moves (pouring one jug into another until it is full or the first is empty) to reaches the given goal state. moves is list of [from, to] pairs """ state = init.copy() for [i, j] in moves: assert min(i, j) >= 0, "Indices must be non-negative" assert i != j, "Cannot pour from same state to itself" n = min(capacities[j], state[i] + state[j]) state[i], state[j] = state[i] + state[j] - n, n return state == goal @staticmethod def sol(capacities, init, goal): from collections import deque num_jugs = len(capacities) start = tuple(init) target = tuple(goal) trails = {start: ([], start)} queue = deque([tuple(init)]) while target not in trails: state = queue.popleft() for i in range(num_jugs): for j in range(num_jugs): if i != j: n = min(capacities[j], state[i] + state[j]) new_state = list(state) new_state[i], new_state[j] = state[i] + state[j] - n, n new_state = tuple(new_state) if new_state not in trails: queue.append(new_state) trails[new_state] = ([i, j], state) ans = [] state = target while state != start: move, state = trails[state] ans.append(move) return ans[::-1] def gen_random(self): def random_reachable(capacities: List[int], init: List[int]): num_jugs = len(capacities) reachables = set() queue = {tuple(init)} while queue: state = queue.pop() if state not in reachables: reachables.add(state) for i in range(num_jugs): for j in range(num_jugs): if i != j: n = min(capacities[j], state[i] + state[j]) new_state = list(state) new_state[i], new_state[j] = state[i] + state[j] - n, n new_state = tuple(new_state) queue.add(new_state) return list(self.random.choice(sorted(reachables))) # sorted ensures same result if run twice despite use of sets capacities = [self.random.randrange(1, 1000) for _ in range(3)] init = [self.random.randrange(1, c + 1) for c in capacities] goal = random_reachable(capacities, init) self.add(dict(init=init, goal=goal, capacities=capacities)) class VerbalArithmetic(PuzzleGenerator): # updated because the answer was given away in the docstring! OMG """ Find a substitution of digits for characters to make the numbers add up in a sum like this: SEND + MORE = MONEY The first digit in any number cannot be 0. In this example the solution is `9567 + 1085 = 10652`. See [Wikipedia article](https://en.wikipedia.org/wiki/Verbal_arithmetic) """ @staticmethod def sat(li: List[int], words=["SEND", "MORE", "MONEY"]): """ Find a list of integers corresponding to the given list of strings substituting a different digit for each character, so that the last string corresponds to the sum of the previous numbers. """ assert len(li) == len(words) and all(i > 0 and len(str(i)) == len(w) for i, w in zip(li, words)) assert len({c for w in words for c in w}) == len({(d, c) for i, w in zip(li, words) for d, c in zip(str(i), w)}) return sum(li[:-1]) == li[-1] @staticmethod def sol(words): print("solving", words) pi = list(range(10)) # permutation letters = [] order = {} steps = [] tens = 1 for col in range(1, 1 + max(len(w) for w in words)): for w in words: is_tot = (w is words[-1]) if len(w) >= col: c = w[-col] if c in order: if is_tot: kind = "check" else: kind = "seen" else: if is_tot: kind = "derive" else: kind = "add" order[c] = len(letters) letters.append(c) steps.append((kind, order[c], tens)) tens *= 10 inits = [any(w[0] == c for w in words) for c in letters] def helper(pos, delta): # on success, returns True and pi has the correct values if pos == len(steps): return delta == 0 kind, i, tens = steps[pos] if kind == "seen": return helper(pos + 1, delta + tens * pi[i]) if kind == "add": for j in range(i, 10): if pi[j] != 0 or not inits[i]: # not adding a leading 0 pi[i], pi[j] = pi[j], pi[i] if helper(pos + 1, delta + tens * pi[i]): return True pi[i], pi[j] = pi[j], pi[i] return False if kind == "check": delta -= tens * pi[i] return (delta % (10 * tens)) == 0 and helper(pos + 1, delta) assert kind == "derive" digit = (delta % (10 * tens)) // tens if digit == 0 and inits[i]: return False # would be a leading 0 j = pi.index(digit) if j < i: return False # already used pi[i], pi[j] = pi[j], pi[i] if helper(pos + 1, delta - tens * digit): return True pi[i], pi[j] = pi[j], pi[i] return False assert helper(0, 0) return [int("".join(str(pi[order[c]]) for c in w)) for w in words] _fixed = [ ["FORTY", "TEN", "TEN", "SIXTY"], ["GREEN", "ORANGE", "COLORS"] ] def gen(self, target_num_instances): for words in self._fixed: self.add(dict(words=words)) def gen_random(self): alpha = list("abcdefghijklmnopqrstuvwxyz") n = self.random.randrange(2, 10) nums = [self.random.randrange(10000) for _ in range(n)] nums.append(sum(nums)) self.random.shuffle(alpha) words = ["".join(alpha[int(d)] for d in str(i)) for i in nums] self.add(dict(words=words)) # , test=False) class SlidingPuzzle(PuzzleGenerator): """ [Sliding puzzle](https://en.wikipedia.org/wiki/15_puzzle) The 3-, 8-, and 15-sliding puzzles are classic examples of A* search. The problem is NP-hard but the puzzles can all be solved with A* and an efficient representation. """ @staticmethod def sat(moves: List[int], start=[[5, 0, 2, 3], [1, 9, 6, 7], [4, 14, 8, 11], [12, 13, 10, 15]]): """ In this puzzle, you are given a board like: 1 2 5 3 4 0 6 7 8 and your goal is to transform it to: 0 1 2 3 4 5 6 7 8 by a sequence of swaps with the 0 square (0 indicates blank). The starting configuration is given by a 2d list of lists and the answer is represented by a list of integers indicating which number you swap with 0. In the above example, an answer would be [1, 2, 5] """ locs = {i: [x, y] for y, row in enumerate(start) for x, i in enumerate(row)} # locations, 0 stands for blank for i in moves: assert abs(locs[0][0] - locs[i][0]) + abs(locs[0][1] - locs[i][1]) == 1 locs[0], locs[i] = locs[i], locs[0] return all(locs[i] == [i % len(start[0]), i // len(start)] for i in locs) @staticmethod def sol(start): from collections import defaultdict import math d = len(start) N = d * d assert all(len(row) == d for row in start) def get_state( li): # state is an integer with 4 bits for each slot and the last 4 bits indicate where the blank is ans = 0 for i in li[::-1] + [li.index(0)]: ans = (ans << 4) + i return ans start = get_state([i for row in start for i in row]) target = get_state(list(range(N))) def h(state): # manhattan distance ans = 0 for i in range(N): state = (state >> 4) n = state & 15 if n != 0: ans += abs(i % d - n % d) + abs(i // d - n // d) return ans g = defaultdict(lambda: math.inf) g[start] = 0 # shortest p ath lengths f = {start: h(start)} # f[s] = g[s] + h(s) backtrack = {} todo = {start} import heapq heap = [(f[start], start)] neighbors = [[i for i in [b - 1, b + 1, b + d, b - d] if i in range(N) and (b // d == i // d or b % d == i % d)] for b in range(N)] def next_state(s, blank, i): assert blank == (s & 15) v = (s >> (4 * i + 4)) & 15 return s + (i - blank) + (v << (4 * blank + 4)) - (v << (4 * i + 4)) while todo: (dist, s) = heapq.heappop(heap) if f[s] < dist: continue if s == target: # compute path ans = [] while s != start: s, i = backtrack[s] ans.append((s >> (4 * i + 4)) & 15) return ans[::-1] todo.remove(s) blank = s & 15 score = g[s] + 1 for i in neighbors[blank]: s2 = next_state(s, blank, i) if score < g[s2]: # paths[s2] = paths[s] + [s[i]] g[s2] = score backtrack[s2] = (s, i) score2 = score + h(s2) f[s2] = score2 todo.add(s2) heapq.heappush(heap, (score2, s2)) def gen_random(self): d = self.random.randint(2, 4) N = d * d state = list(range(N)) num_moves = self.random.randrange(100) for _ in range(num_moves): blank = state.index(0) delta = self.random.choice([-1, 1, d, -d]) i = blank + delta if i not in range(N) or delta == 1 and i % d == 0 or delta == -1 and blank % d == 0: continue state[i], state[blank] = state[blank], state[i] start = [list(state[i:i + d]) for i in range(0, N, d)] self.add(dict(start=start)) if __name__ == "__main__": PuzzleGenerator.debug_problems()