├── .gitignore ├── constraint ├── __init__.py ├── variable.py ├── domain_set.py ├── domain.py ├── solver.py └── constraint.py ├── test_domain.py ├── test_constraint.py ├── readme.md ├── test.py └── test_alphametics.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /constraint/__init__.py: -------------------------------------------------------------------------------- 1 | from .variable import Variable 2 | from .constraint import LessThan, SumUp, AllUnique, Equal 3 | from .solver import Solver, BTSolver 4 | from .domain import Domain 5 | -------------------------------------------------------------------------------- /constraint/variable.py: -------------------------------------------------------------------------------- 1 | from .domain import Domain 2 | 3 | 4 | class Variable: 5 | def __init__(self, name: str, values: list[int]): 6 | self.name = name 7 | self.domain = Domain(values) 8 | self.affected_constraints = set() 9 | 10 | def __repr__(self): 11 | return f"{self.name} = {self.domain.values()}" 12 | -------------------------------------------------------------------------------- /constraint/domain_set.py: -------------------------------------------------------------------------------- 1 | # It uses a set for pushing and popping values, 2 | # slower but more intuitive than using indices(see below). 3 | class DomainSet: 4 | def __init__(self, values: list[int]): 5 | self.values = set(values) 6 | self.snapshots = [] 7 | 8 | def __repr__(self): 9 | return f"{self.values}, snapshots = {self.snapshots}" 10 | 11 | def assign(self, values: set[int]): 12 | to_remove = self.values - values 13 | for v in to_remove: 14 | self.remove(v) 15 | to_add = values - self.values 16 | for v in to_add: 17 | self.add(v) 18 | 19 | def add(self, value): 20 | if value not in self.values: 21 | self.values.add(value) 22 | self.snapshots[-1].remove(value) 23 | 24 | def remove(self, value): 25 | self.values.remove(value) 26 | self.snapshots[-1].add(value) 27 | 28 | def snapshot(self): 29 | self.snapshots.append(set[int]()) 30 | 31 | def rollback(self): 32 | removed = self.snapshots.pop() 33 | for v in removed: 34 | self.values.add(v) 35 | 36 | def discard(self): 37 | self.snapshots = [] 38 | -------------------------------------------------------------------------------- /test_domain.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from random import shuffle 3 | from itertools import combinations 4 | from domain import Domain 5 | 6 | 7 | class TestDomain(unittest.TestCase): 8 | # def test_DomainSet(self): 9 | # d = DomainSet(list(range(10))) 10 | # self.assertSetEqual(d.values, set(list(range(10)))) 11 | # self.assertListEqual(d.snapshots, []) 12 | 13 | # # snapshot 14 | # d.snapshot() 15 | # self.assertListEqual(d.snapshots, [[]]) 16 | 17 | # # remove 18 | # d.remove(0) 19 | # self.assertSetEqual(d.values, set(list(range(1, 10)))) 20 | # d.remove(5) 21 | # self.assertSetEqual(d.values, set([1, 2, 3, 4, 6, 7, 8, 9])) 22 | 23 | # # restore 24 | # d.rollback() 25 | # self.assertSetEqual(d.values, set(list(range(10)))) 26 | 27 | def test_Domain(self): 28 | COMBINATION_SIZE = 8 29 | 30 | for comb_size in range(1, COMBINATION_SIZE): 31 | values = list(range(0, 10)) 32 | d = Domain(values) 33 | copy = values.copy() 34 | 35 | comb = combinations(copy, comb_size) 36 | 37 | for c in comb: 38 | d.snapshot() 39 | removing = list(c) 40 | shuffle(removing) 41 | 42 | for i, v in enumerate(removing): 43 | d.remove_at(i) 44 | 45 | d.rollback() 46 | self.assertListEqual(sorted(list(d.values())), copy) 47 | self.assertEqual(d.barrier, len(copy)) 48 | self.assertEqual(len(d.snapshots), 0) 49 | -------------------------------------------------------------------------------- /test_constraint.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from random import shuffle 3 | from itertools import combinations 4 | from constraint import NotEqual, Equal, SumUp 5 | from variable import Variable 6 | 7 | 8 | class TestConstraint(unittest.TestCase): 9 | def test_equal(self): 10 | print("Testing Equal Constraint") 11 | a = Variable("A", [1, 2, 3]) 12 | b = Variable("B", [2, 3, 4, 5]) 13 | a.vid = 0 14 | b.vid = 1 15 | variables = [a, b] 16 | 17 | cs = Equal(a, b) 18 | cs.cid = 0 19 | 20 | cs.prune(variables) 21 | 22 | self.assertListEqual(sorted(a.domain.values()), [2, 3]) 23 | self.assertListEqual(sorted(b.domain.values()), [2, 3]) 24 | 25 | def test_not_equal(self): 26 | print("Testing NotEqual Constraint") 27 | a = Variable("A", [3]) 28 | b = Variable("B", [2, 3, 4, 5]) 29 | a.vid = 0 30 | b.vid = 1 31 | variables = [a, b] 32 | 33 | cs = NotEqual(a, b) 34 | cs.cid = 0 35 | 36 | cs.prune(variables) 37 | 38 | self.assertListEqual(sorted(a.domain.values()), [3]) 39 | self.assertListEqual(sorted(b.domain.values()), [2, 4, 5]) 40 | 41 | def test_sum_up(self): 42 | print("Testing SumUp Constraint") 43 | a = Variable("A", [1, 2, 3]) 44 | b = Variable("B", [1, 2, 3]) 45 | c = Variable("C", [1, 2, 3]) 46 | a.vid = 0 47 | b.vid = 1 48 | c.vid = 2 49 | variables = [a, b, c] 50 | 51 | cs = SumUp([a, b], [1, 1], [c], [1]) 52 | cs.cid = 0 53 | 54 | cs.prune(variables) 55 | 56 | # print("After pruning:") 57 | # print(f"A: {a.domain.values()}") 58 | # print(f"B: {b.domain.values()}") 59 | # print(f"C: {c.domain.values()}") 60 | 61 | self.assertListEqual(sorted(a.domain.values()), [1, 2]) 62 | self.assertListEqual(sorted(b.domain.values()), [1, 2]) 63 | self.assertListEqual(sorted(c.domain.values()), [2, 3]) 64 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | A python constraint solver. 2 | 3 | # Domain value optimization 4 | Inspired by this: 5 | https://opensourc.es/blog/constraint-solver-data-structure/ 6 | 7 | It uses one extra array for storing value positions. 8 | I added another array for swapped indices. See "domain.py" for details. 9 | This approach is 20% faster than using a `set` domain. 10 | 11 | # Benchmarks 12 | > SEND + MORE = MONEY 13 | 14 | 0.0009 second 15 | 16 | > TEN + HERONS + REST + NEAR + NORTH + SEA + SHORE + AS + TAN + TERNS + SOAR + TO + ENTER + THERE + AS + HERONS + NEST + ON + STONES + AT + SHORE + THREE + STARS + ARE + SEEN + TERN + SNORES + ARE + NEAR == SEVVOTH 17 | 18 | 0.28 sec(all) 0.02 sec(one) 19 | 20 | > SO + MANY + MORE + MEN + SEEM + TO + SAY + THAT + THEY + MAY + SOON + TRY + TO + STAY + AT + HOME + SO + AS + TO + SEE + OR + HEAR + THE + SAME + ONE + MAN + TRY + TO + MEET + THE + TEAM + ON + THE + MOON + AS + HE + HAS + AT + THE + OTHER + TEN == TESTS 21 | 22 | 0.04 sec(all) 0.01 sec(one) 23 | 24 | > THIS + A + FIRE + THEREFORE + FOR + ALL + HISTORIES + I + TELL + A + TALE + THAT + FALSIFIES + ITS + TITLE + TIS + A + LIE + THE + TALE + OF + THE + LAST + FIRE + HORSES + LATE + AFTER + THE + FIRST + FATHERS + FORESEE + THE + HORRORS + THE + LAST + FREE + TROLL + TERRIFIES + THE + HORSES + OF + FIRE + THE + TROLL + RESTS + AT + THE + HOLE + OF + LOSSES + IT + IS + THERE + THAT + SHE + STORES + ROLES + OF + LEATHERS + AFTER + SHE + SATISFIES + HER + HATE + OFF + THOSE + FEARS + A + TASTE + RISES + AS + SHE + HEARS + THE + LEAST + FAR + HORSE + THOSE + FAST + HORSES + THAT + FIRST + HEAR + THE + TROLL + FLEE + OFF + TO + THE + FOREST + THE + HORSES + THAT + ALERTS + RAISE + THE + STARES + OF + THE + OTHERS + AS + THE + TROLL + ASSAILS + AT + THE + TOTAL + SHIFT + HER + TEETH + TEAR + HOOF + OFF + TORSO + AS + THE + LAST + HORSE + FORFEITS + ITS + LIFE + THE + FIRST + FATHERS + HEAR + OF + THE + HORRORS + THEIR + FEARS + THAT + THE + FIRES + FOR + THEIR + FEASTS + ARREST + AS + THE + FIRST + FATHERS + RESETTLE + THE + LAST + OF + THE + FIRE + HORSES + THE + LAST + TROLL + HARASSES + THE + FOREST + HEART + FREE + AT + LAST + OF + THE + LAST + TROLL + ALL + OFFER + THEIR + FIRE + HEAT + TO + THE + ASSISTERS + FAR + OFF + THE + TROLL + FASTS + ITS + LIFE + SHORTER + AS + STARS + RISE + THE + HORSES + REST + SAFE + AFTER + ALL + SHARE + HOT + FISH + AS + THEIR + AFFILIATES + TAILOR + A + ROOFS + FOR + THEIR + SAFE == FORTRESSES 25 | 26 | 3 sec(all) 0.6 sec(one) 27 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | from datetime import datetime 3 | 4 | from test_alphametics import parse_question 5 | 6 | question = "SEND + MORE = MONEY" # 0.0009 sec(all, one) 7 | # question = "I + BB == ILL" 8 | # question = "AS + A == MOM" 9 | # question = "NO + NO + TOO == LATE" 10 | # question = "HE + SEES + THE == LIGHT" 11 | # question = "A + A + A + A + A + A + A + A + A + A + A + B == BCC" 12 | # question = "AND + A + STRONG + OFFENSE + AS + A + GOOD == DEFENSE" 13 | 14 | # slow 15 | # 0.28 sec(all) 0.02 sec(one) 16 | # question = "TEN + HERONS + REST + NEAR + NORTH + SEA + SHORE + AS + TAN + TERNS + SOAR + TO + ENTER + THERE + AS + HERONS + NEST + ON + STONES + AT + SHORE + THREE + STARS + ARE + SEEN + TERN + SNORES + ARE + NEAR == SEVVOTH" 17 | # 0.04 sec(all) 0.01 sec(one) 18 | # question = "SO + MANY + MORE + MEN + SEEM + TO + SAY + THAT + THEY + MAY + SOON + TRY + TO + STAY + AT + HOME + SO + AS + TO + SEE + OR + HEAR + THE + SAME + ONE + MAN + TRY + TO + MEET + THE + TEAM + ON + THE + MOON + AS + HE + HAS + AT + THE + OTHER + TEN == TESTS" 19 | # 3 sec(all) 0.6 sec(one) 20 | # question = "THIS + A + FIRE + THEREFORE + FOR + ALL + HISTORIES + I + TELL + A + TALE + THAT + FALSIFIES + ITS + TITLE + TIS + A + LIE + THE + TALE + OF + THE + LAST + FIRE + HORSES + LATE + AFTER + THE + FIRST + FATHERS + FORESEE + THE + HORRORS + THE + LAST + FREE + TROLL + TERRIFIES + THE + HORSES + OF + FIRE + THE + TROLL + RESTS + AT + THE + HOLE + OF + LOSSES + IT + IS + THERE + THAT + SHE + STORES + ROLES + OF + LEATHERS + AFTER + SHE + SATISFIES + HER + HATE + OFF + THOSE + FEARS + A + TASTE + RISES + AS + SHE + HEARS + THE + LEAST + FAR + HORSE + THOSE + FAST + HORSES + THAT + FIRST + HEAR + THE + TROLL + FLEE + OFF + TO + THE + FOREST + THE + HORSES + THAT + ALERTS + RAISE + THE + STARES + OF + THE + OTHERS + AS + THE + TROLL + ASSAILS + AT + THE + TOTAL + SHIFT + HER + TEETH + TEAR + HOOF + OFF + TORSO + AS + THE + LAST + HORSE + FORFEITS + ITS + LIFE + THE + FIRST + FATHERS + HEAR + OF + THE + HORRORS + THEIR + FEARS + THAT + THE + FIRES + FOR + THEIR + FEASTS + ARREST + AS + THE + FIRST + FATHERS + RESETTLE + THE + LAST + OF + THE + FIRE + HORSES + THE + LAST + TROLL + HARASSES + THE + FOREST + HEART + FREE + AT + LAST + OF + THE + LAST + TROLL + ALL + OFFER + THEIR + FIRE + HEAT + TO + THE + ASSISTERS + FAR + OFF + THE + TROLL + FASTS + ITS + LIFE + SHORTER + AS + STARS + RISE + THE + HORSES + REST + SAFE + AFTER + ALL + SHARE + HOT + FISH + AS + THEIR + AFFILIATES + TAILOR + A + ROOFS + FOR + THEIR + SAFE == FORTRESSES" 21 | 22 | # impossible 23 | # question = "A == B" 24 | # question = "ACA + DD == BD" 25 | 26 | solver = parse_question(question) 27 | # solver.find_all = True 28 | solver.print() 29 | 30 | st = datetime.now() 31 | solver.solve() 32 | et = datetime.now() 33 | 34 | print("time cost:", et - st) 35 | print("All solutions:") 36 | 37 | for s in solver.solutions: 38 | print(s) 39 | -------------------------------------------------------------------------------- /constraint/domain.py: -------------------------------------------------------------------------------- 1 | from itertools import islice 2 | 3 | # Inspired by this: 4 | # https://opensourc.es/blog/constraint-solver-data-structure/ 5 | # It uses 2 extra arrays to track 6 | # - indices[]: the indices of the values 7 | # - recovery[]: it stores which index was removed 8 | 9 | # A B C D E F| 10 | # 0 1 2 3 4 5| 11 | # 0 0 0 0 0 0| 12 | # 13 | # 1. remove_at(2) as c 14 | # 15 | # A B F D E|C 16 | # 0 1 5 3 4|2 17 | # 0 0 0 0 0|2 18 | # 19 | # 2. remove_at(3) as D 20 | # 21 | # A B F E|D C 22 | # 0 1 5 4|3 2 23 | # 0 0 0 0|3 2 24 | # 25 | # 3. remove_at(2, 3) as F, E 26 | # 27 | # - remove_at(2) as F 28 | # 29 | # A B E|F D C 30 | # 0 1 5|4 2 3 31 | # 0 0 0|2 3 2 32 | # 33 | # - remove_at(3) (should be E, but now it's F) 34 | # 35 | # A B|E F D C 36 | # 0 1|5 4 2 3 37 | # 0 0|2 2 3 2 38 | 39 | 40 | class Domain: 41 | def __init__(self, values: list[int]): 42 | self._values = values.copy() 43 | self.indices = [i for i in range(len(values))] 44 | self.recovery = [-1 for _ in range(len(values))] 45 | self.barrier = len(values) 46 | self.snapshots = [] 47 | 48 | def __str__(self): 49 | return f"values: {self._values}\nbarrier: {self.barrier}\nindices: {self.indices}\nrecovery: {self.recovery}" 50 | 51 | def values(self): 52 | return islice(self._values, self.barrier) 53 | 54 | def len(self): 55 | return self.barrier 56 | 57 | def snapshot(self): 58 | self.snapshots.append(self.barrier) 59 | 60 | def rollback(self): 61 | b = self.snapshots.pop() 62 | while self.barrier < b: 63 | self.recover_1() 64 | 65 | def remove(self, to_rm: list[int]): 66 | for i in to_rm: 67 | self.remove_at(i) 68 | 69 | # A B C D E F| 70 | # 0 1 2 3 4 5| 71 | # 72 | # 1. remove_at(2) as c 73 | # 74 | # A B F D E|C 75 | # 0 1 5 3 4|2 76 | # 0 0 0 0 0|2 77 | # 78 | # 2. remove_at(3) as D 79 | # 80 | # A B F E|D C 81 | # 0 1 5 4|3 2 82 | # 0 0 0 0|3 2 83 | # 84 | # 3. remove_at(2, 3) as F, E 85 | # 86 | # - remove_at(2) as F 87 | # 88 | # A B E|F D C 89 | # 0 1 5|4 2 3 90 | # 0 0 0|2 3 2 91 | # 92 | # - remove_at(3) (should be E, but now it's F) 93 | # 94 | # A B|E F D C 95 | # 0 1|5 4 2 3 96 | # 0 0|2 2 3 2 97 | def remove_at(self, i: int): 98 | self.barrier -= 1 99 | b = self.barrier 100 | 101 | if i >= b: 102 | new_i = self.recovery[i] 103 | if new_i != -1: 104 | i = new_i 105 | 106 | self.swap_value(i, b) 107 | self.swap_index(self.indices[i], self.indices[b]) 108 | self.recovery[b] = i 109 | 110 | def recover_1(self): 111 | b = self.barrier 112 | i = self.recovery[b] 113 | self.swap_value(i, b) 114 | self.swap_index(self.indices[i], self.indices[b]) 115 | self.recovery[b] = -1 116 | self.barrier += 1 117 | 118 | def swap_value(self, i: int, j: int): 119 | self._values[i], self._values[j] = self._values[j], self._values[i] 120 | 121 | def swap_index(self, i: int, j: int): 122 | self.indices[i], self.indices[j] = self.indices[j], self.indices[i] 123 | 124 | def temp_assign(self, value): 125 | v0 = self._values[0] 126 | self._values[0] = value 127 | barr = self.barrier 128 | self.barrier = 1 129 | return (v0, barr) 130 | 131 | def temp_restore(self, tup): 132 | value, barrier = tup 133 | self._values[0] = value 134 | self.barrier = barrier 135 | -------------------------------------------------------------------------------- /constraint/solver.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from functools import cmp_to_key 3 | 4 | from .variable import Variable 5 | 6 | type VarId = int 7 | type Val = int 8 | 9 | 10 | def Degree_MRV( 11 | unassigned: set[int], 12 | variables: list[Variable], 13 | ) -> Variable: 14 | def fn(a: int, b: int) -> int: 15 | va = variables[a] 16 | vb = variables[b] 17 | 18 | if len(va.affected_constraints) < len(vb.affected_constraints): 19 | return 1 20 | if va.domain.len() < vb.domain.len(): 21 | return -1 22 | return 0 23 | 24 | sorted_ = sorted(unassigned, key=cmp_to_key(fn)) 25 | first_vid = sorted_[0] 26 | return variables[first_vid] 27 | 28 | 29 | def no_sorter(s): 30 | return list(s) 31 | 32 | 33 | class Solver(ABC): 34 | def __init__( 35 | self, 36 | ): 37 | self.variables = [] 38 | self.constraints = [] 39 | self.solutions = [] 40 | 41 | def print(self): 42 | print("Solver:") 43 | for var in self.variables: 44 | print(f"\t{var.name} ({var.vid}): {list(var.domain.values())}") 45 | for c in self.constraints: 46 | print(f"\t{c.cid}: {c}") 47 | 48 | def add_variable(self, variable): 49 | variable.vid = len(self.variables) # Assign an ID to the variable 50 | self.variables.append(variable) 51 | 52 | def add_variables(self, variables): 53 | for v in variables: 54 | self.add_variable(v) 55 | 56 | def add_constraint(self, constraint): 57 | constraint.cid = len(self.constraints) 58 | self.constraints.append(constraint) 59 | 60 | # Initialize affected constraints for each variable 61 | for v in self.variables: 62 | if v.vid in constraint.affected_variables(): 63 | v.affected_constraints.add(constraint.cid) 64 | 65 | def add_constraints(self, constraints): 66 | for c in constraints: 67 | self.add_constraint(c) 68 | 69 | @abstractmethod 70 | def solve(self, all: bool = False): 71 | pass 72 | 73 | 74 | class BTSolver(Solver): 75 | def __init__(self): 76 | super().__init__() 77 | self.next_variable_picker = Degree_MRV 78 | self.values_orderer = no_sorter 79 | self.find_all = False # find all solutions or just one 80 | 81 | # return: feasible or not 82 | def fix_point( 83 | self, 84 | forward_checkers: set[int], 85 | ) -> bool: 86 | while len(forward_checkers) > 0: 87 | cid = forward_checkers.pop() 88 | c = self.constraints[cid] 89 | feasible, changed_vars = c.prune(self.variables) 90 | if not feasible: 91 | return False # infeasible 92 | 93 | # add affected constraints to forward checkers 94 | for vid in changed_vars: 95 | v = self.variables[vid] 96 | for cid in v.affected_constraints: 97 | if cid != c.cid: 98 | forward_checkers.add(cid) 99 | 100 | return True # feasible 101 | 102 | def solve(self): 103 | unassigned = set(v.vid for v in self.variables) 104 | 105 | if not self.pre_check(unassigned): 106 | return 107 | 108 | self.dfs(unassigned) 109 | 110 | def pre_check(self, unassigned: set[int]) -> bool: 111 | # all constraints 112 | forward_checkers = {c.cid for c in self.constraints} 113 | 114 | for vid in unassigned: 115 | self.variables[vid].domain.snapshot() 116 | 117 | if not self.fix_point(forward_checkers): # infeasible 118 | return False 119 | 120 | return True # feasible 121 | 122 | def dfs(self, unassigned: set[int]) -> bool: 123 | if len(unassigned) == 0: 124 | self.solutions.append( # 125 | {v.name: v.domain._values[0] for v in self.variables} 126 | ) 127 | return True 128 | 129 | var = self.next_variable_picker(unassigned, self.variables) 130 | 131 | unassigned.remove(var.vid) 132 | 133 | ordered_values = self.values_orderer(var.domain.values()) 134 | 135 | prev = var.domain.temp_assign(0) # snapshot before assigning 136 | 137 | for val in ordered_values: 138 | var.domain._values[0] = val 139 | 140 | for vid in unassigned: 141 | self.variables[vid].domain.snapshot() 142 | 143 | if self.fix_point(var.affected_constraints.copy()): # if feasible 144 | found_solution = self.dfs(unassigned) 145 | if found_solution and not self.find_all: 146 | return True 147 | 148 | for vid in unassigned: 149 | self.variables[vid].domain.rollback() 150 | 151 | unassigned.add(var.vid) 152 | var.domain.temp_restore(prev) # restore the snapshot 153 | 154 | return False 155 | -------------------------------------------------------------------------------- /test_alphametics.py: -------------------------------------------------------------------------------- 1 | from rich import print 2 | from datetime import datetime 3 | import unittest 4 | 5 | from constraint import Variable 6 | from constraint import LessThan, SumUp, AllUnique 7 | from constraint import Solver, BTSolver 8 | 9 | 10 | def parse_question(s: str) -> Solver: 11 | solver = BTSolver() 12 | 13 | # Split input string by '+' or '=' and clean up 14 | lines = [x.strip() for x in s.replace("+", "=").split("=") if x.strip()] 15 | 16 | # Get all unique characters 17 | all_chars = [x for x in set("".join(lines))] 18 | 19 | # --------------- Variables --------------- 20 | variables = dict[str, Variable]() 21 | 22 | # All characters are 0-9 23 | non_0_chars = set(line[0] for line in lines) 24 | for ch in all_chars: 25 | if ch in non_0_chars: 26 | variables[ch] = Variable(ch, list(range(1, 10))) 27 | else: 28 | variables[ch] = Variable(ch, list(range(0, 10))) 29 | 30 | # Carries 31 | max_column = len(lines[-1]) 32 | max_carry = 0 33 | for col in range(max_column - 1): 34 | char_count_at_col = sum(1 for line in lines[:-1] if len(line) > col) 35 | max_carry = (9 * char_count_at_col + max_carry) // 10 36 | 37 | variables[f"c{col}"] = Variable(f"c{col}", list(range(max_carry + 1))) 38 | 39 | for v in variables.values(): 40 | solver.add_variable(v) 41 | 42 | # -------------- Constraints ------------ 43 | # 1. Letters not equal to each other 44 | non_carry_variables = [v for v in variables.values() if not v.name.startswith("c")] 45 | solver.add_constraints(AllUnique(non_carry_variables)) 46 | 47 | # 2. All chars on the left most column must be < the char at last_line[0] 48 | # e.g.: for SEND+MORE=GOLD, this means S 0: 83 | laddends.append(variables[f"c{col - 1}"]) 84 | lcoeffs.append(1) # carry is always 1 85 | 86 | # Sub carry for this column 87 | if col != max_column - 1: 88 | raddends.append(variables[f"c{col}"]) 89 | rcoeffs.append(10) # always -10 90 | 91 | solver.add_constraint(SumUp(laddends, lcoeffs, raddends, rcoeffs)) 92 | 93 | return solver 94 | 95 | 96 | class TestAlphametics(unittest.TestCase): 97 | def setUp(self): 98 | self.tick = datetime.now() 99 | 100 | def tearDown(self): 101 | self.tock = datetime.now() 102 | print(self.tock - self.tick) 103 | 104 | def solve(self, question: str, solution: bool, expected: dict[str, int]): 105 | solver = parse_question(question) 106 | # solver.find_all = True 107 | solver.solve() 108 | 109 | if len(solver.solutions) == 0: 110 | self.assertFalse(solution, f"No solution found for: {question}") 111 | else: 112 | self.assertDictEqual(solver.solutions[0], expected) 113 | 114 | def test_send_more_money(self): 115 | question = "SEND + MORE = MONEY" 116 | expected = { 'S': 9, 'E': 5, 'N': 6, 'M': 1, 'Y': 2, 'D': 7, 'R': 8, 'O': 0, 'c0': 1, 'c1': 1, 'c2': 0, 'c3': 1 } # fmt: off 117 | self.solve(question, True, expected) 118 | 119 | def test_i_bb_ill(self): 120 | question = "I + BB == ILL" 121 | expected = {'L': 0, 'B': 9, 'I': 1, 'c0': 1, 'c1': 1} # fmt: off 122 | self.solve(question, True, expected) 123 | 124 | def test_as_a_mom(self): 125 | question = "AS + A == MOM" 126 | expected = {'O': 0, 'S': 2, 'A': 9, 'M': 1, 'c0': 1, 'c1': 1} # fmt: off 127 | self.solve(question, True, expected) 128 | 129 | def test_no_no_too_late(self): 130 | question = "NO + NO + TOO == LATE" 131 | expected = {'A': 0, 'E': 2, 'N': 7, 'L': 1, 'T': 9, 'O': 4, 'c0': 1, 'c1': 1, 'c2': 1} # fmt: off 132 | self.solve(question, True, expected) 133 | 134 | def test_he_sees_the_light(self): 135 | question = "HE + SEES + THE == LIGHT" 136 | expected = { 'S': 9, 'G': 2, 'E': 4, 'H': 5, 'L': 1, 'I': 0, 'T': 7, 'c0': 1, 'c1': 1, 'c2': 1, 'c3': 1 } # fmt: off 137 | self.solve(question, True, expected) 138 | 139 | def test_a_a_a_a_a_a_a_a_a_a_b_bcc(self): 140 | question = "A + A + A + A + A + A + A + A + A + A + A + B == BCC" 141 | expected = {'B': 1, 'C': 0, 'A': 9, 'c0': 10, 'c1': 1} # fmt: off 142 | self.solve(question, True, expected) 143 | 144 | def test_and_a_strong_offense_as_a_good_defense(self): 145 | question = "AND + A + STRONG + OFFENSE + AS + A + GOOD == DEFENSE" 146 | expected = { "S": 6, "G": 8, "A": 5, "E": 4, "N": 0, "F": 7, "D": 3, "R": 1, "T": 9, "O": 2, "c0": 3, "c1": 1, "c2": 1, "c3": 1, "c4": 1, "c5": 1, } # fmt: off 147 | self.solve(question, True, expected) 148 | 149 | def test_long_1(self): 150 | question = "TEN + HERONS + REST + NEAR + NORTH + SEA + SHORE + AS + TAN + TERNS + SOAR + TO + ENTER + THERE + AS + HERONS + NEST + ON + STONES + AT + SHORE + THREE + STARS + ARE + SEEN + TERN + SNORES + ARE + NEAR == SEVVOTH" 151 | expected = { "S": 1, "A": 2, "E": 5, "H": 3, "N": 7, "V": 8, "R": 6, "T": 9, "O": 4, "c0": 13, "c1": 14, "c2": 13, "c3": 10, "c4": 7, "c5": 1 } # fmt: off 152 | self.solve(question, True, expected) 153 | 154 | def test_long_2(self): 155 | question = "SO + MANY + MORE + MEN + SEEM + TO + SAY + THAT + THEY + MAY + SOON + TRY + TO + STAY + AT + HOME + SO + AS + TO + SEE + OR + HEAR + THE + SAME + ONE + MAN + TRY + TO + MEET + THE + TEAM + ON + THE + MOON + AS + HE + HAS + AT + THE + OTHER + TEN == TESTS" 156 | expected = { "S": 3, "A": 7, "E": 0, "N": 6, "M": 2, "Y": 4, "H": 5, "R": 8, "T": 9, "O": 1, "c0": 14, "c1": 20, "c2": 14, "c3": 8 } # fmt: off 157 | self.solve(question, True, expected) 158 | 159 | def test_long_3(self): 160 | question = "THIS + A + FIRE + THEREFORE + FOR + ALL + HISTORIES + I + TELL + A + TALE + THAT + FALSIFIES + ITS + TITLE + TIS + A + LIE + THE + TALE + OF + THE + LAST + FIRE + HORSES + LATE + AFTER + THE + FIRST + FATHERS + FORESEE + THE + HORRORS + THE + LAST + FREE + TROLL + TERRIFIES + THE + HORSES + OF + FIRE + THE + TROLL + RESTS + AT + THE + HOLE + OF + LOSSES + IT + IS + THERE + THAT + SHE + STORES + ROLES + OF + LEATHERS + AFTER + SHE + SATISFIES + HER + HATE + OFF + THOSE + FEARS + A + TASTE + RISES + AS + SHE + HEARS + THE + LEAST + FAR + HORSE + THOSE + FAST + HORSES + THAT + FIRST + HEAR + THE + TROLL + FLEE + OFF + TO + THE + FOREST + THE + HORSES + THAT + ALERTS + RAISE + THE + STARES + OF + THE + OTHERS + AS + THE + TROLL + ASSAILS + AT + THE + TOTAL + SHIFT + HER + TEETH + TEAR + HOOF + OFF + TORSO + AS + THE + LAST + HORSE + FORFEITS + ITS + LIFE + THE + FIRST + FATHERS + HEAR + OF + THE + HORRORS + THEIR + FEARS + THAT + THE + FIRES + FOR + THEIR + FEASTS + ARREST + AS + THE + FIRST + FATHERS + RESETTLE + THE + LAST + OF + THE + FIRE + HORSES + THE + LAST + TROLL + HARASSES + THE + FOREST + HEART + FREE + AT + LAST + OF + THE + LAST + TROLL + ALL + OFFER + THEIR + FIRE + HEAT + TO + THE + ASSISTERS + FAR + OFF + THE + TROLL + FASTS + ITS + LIFE + SHORTER + AS + STARS + RISE + THE + HORSES + REST + SAFE + AFTER + ALL + SHARE + HOT + FISH + AS + THEIR + AFFILIATES + TAILOR + A + ROOFS + FOR + THEIR + SAFE == FORTRESSES" 161 | expected = { "S": 4, "A": 1, "E": 0, "H": 8, "L": 2, "F": 5, "I": 7, "T": 9, "R": 3, "O": 6, "c0": 66, "c1": 87, "c2": 92, "c3": 67, "c4": 54, "c5": 22, "c6": 9, "c7": 5, "c8": 4 } # fmt: off 162 | self.solve(question, True, expected) 163 | -------------------------------------------------------------------------------- /constraint/constraint.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import itertools 3 | 4 | from .variable import Variable 5 | 6 | # The very tiny constraint solver: 7 | # https://choco-solver.org/tinytiny/ 8 | 9 | 10 | class Constraint(ABC): 11 | @abstractmethod 12 | def affected_variables(self) -> set[int]: 13 | pass 14 | 15 | @abstractmethod 16 | def prune(self) -> (bool, list[int]): 17 | """ 18 | return value: 19 | 20 | - feasible or not 21 | - all changed variables 22 | """ 23 | pass 24 | 25 | @abstractmethod 26 | def __repr__(self): 27 | pass 28 | 29 | 30 | class LessThan(Constraint): 31 | def __init__(self, v1, v2, include_equal: bool = False): 32 | self.vid1 = v1.vid 33 | self.vid2 = v2.vid 34 | self.name_map = { 35 | v1.vid: v1.name, 36 | v2.vid: v2.name, 37 | } 38 | self.include_equal = include_equal 39 | 40 | def affected_variables(self) -> set[int]: 41 | return {self.vid1, self.vid2} 42 | 43 | def __repr__(self): 44 | n1 = self.name_map[self.vid1] 45 | n2 = self.name_map[self.vid2] 46 | return f"{n1} < {n2}" 47 | 48 | def prune(self, variables: list[Variable]) -> (bool, list[int]): 49 | vid1 = self.vid1 50 | vid2 = self.vid2 51 | v1 = variables[vid1] 52 | v2 = variables[vid2] 53 | d1 = v1.domain 54 | d2 = v2.domain 55 | len1 = d1.len() 56 | len2 = d2.len() 57 | if len1 == 0 or len2 == 0: 58 | return False, None 59 | 60 | min_d1 = min(d1.values()) 61 | max_d2 = max(d2.values()) 62 | 63 | to_rm_1 = ( 64 | [i for i, v in enumerate(d1.values()) if v >= max_d2] 65 | if self.include_equal 66 | else [i for i, v in enumerate(d1.values()) if v > max_d2] 67 | ) 68 | if len(to_rm_1) == len1: # "domain 1" becomes empty... 69 | return False, None 70 | to_rm_2 = ( 71 | [i for i, v in enumerate(d2.values()) if v <= min_d1] 72 | if self.include_equal 73 | else [i for i, v in enumerate(d2.values()) if v < min_d1] 74 | ) 75 | if len(to_rm_2) == len2: # "domain 2" becomes empty... 76 | return False, None 77 | 78 | # update variable domains 79 | v1.domain.remove(to_rm_1) 80 | v2.domain.remove(to_rm_2) 81 | 82 | # return all changed variables 83 | changed = [] 84 | if len(to_rm_1) > 0: 85 | changed.append(vid1) 86 | if len(to_rm_2) > 0: 87 | changed.append(vid2) 88 | return True, changed 89 | 90 | 91 | class Equal(Constraint): 92 | def __init__(self, v1, v2): 93 | self.vid1 = v1.vid 94 | self.vid2 = v2.vid 95 | self.name_map = { 96 | v1.vid: v1.name, 97 | v2.vid: v2.name, 98 | } 99 | 100 | def affected_variables(self) -> set[int]: 101 | return {self.vid1, self.vid2} 102 | 103 | def __repr__(self): 104 | n1 = self.name_map[self.vid1] 105 | n2 = self.name_map[self.vid2] 106 | return f"{n1} == {n2}" 107 | 108 | def prune(self, variables: list[Variable]) -> (bool, list[int]): 109 | vid1 = self.vid1 110 | vid2 = self.vid2 111 | v1 = variables[vid1] 112 | v2 = variables[vid2] 113 | d1 = v1.domain 114 | d2 = v2.domain 115 | len1 = d1.len() 116 | len2 = d2.len() 117 | if len1 == 0 or len2 == 0: 118 | return False, None 119 | 120 | changed = [] 121 | 122 | set2 = set(d2.values()) 123 | to_rm_1 = [i for i, v in enumerate(d1.values()) if v not in set2] 124 | if len(to_rm_1) == len1: # "domain 1" becomes empty... 125 | return False, None 126 | # update variable domains 127 | v1.domain.remove(to_rm_1) 128 | if len(to_rm_1) > 0: 129 | changed.append(vid1) 130 | 131 | set1 = set(d1.values()) 132 | to_rm_2 = [i for i, v in enumerate(d2.values()) if v not in set1] 133 | if len(to_rm_2) == len2: # "domain 2" becomes empty... 134 | return False, None 135 | # update variable domains 136 | v2.domain.remove(to_rm_2) 137 | if len(to_rm_2) > 0: 138 | changed.append(vid2) 139 | 140 | return True, changed 141 | 142 | 143 | class NotEqual(Constraint): 144 | def __init__(self, v1, v2): 145 | self.vid1 = v1.vid 146 | self.vid2 = v2.vid 147 | self.name_map = { 148 | v1.vid: v1.name, 149 | v2.vid: v2.name, 150 | } 151 | 152 | def affected_variables(self) -> set[int]: 153 | return {self.vid1, self.vid2} 154 | 155 | def __repr__(self): 156 | n1 = self.name_map[self.vid1] 157 | n2 = self.name_map[self.vid2] 158 | return f"{n1} != {n2}" 159 | 160 | def prune(self, variables: list[Variable]) -> (bool, list[int]): 161 | vid1 = self.vid1 162 | vid2 = self.vid2 163 | v1 = variables[vid1] 164 | v2 = variables[vid2] 165 | d1 = v1.domain 166 | d2 = v2.domain 167 | len1 = d1.len() 168 | len2 = d2.len() 169 | if len1 == 0 or len2 == 0: 170 | return False, None 171 | 172 | # return all changed variables 173 | changed = [] 174 | 175 | if len2 == 1: 176 | to_rm_1 = [i for i, v in enumerate(d1.values()) if v == d2._values[0]] 177 | if len(to_rm_1) == len1: # "domain 1" becomes empty... 178 | return False, None 179 | # update variable domains 180 | v1.domain.remove(to_rm_1) 181 | 182 | if len(to_rm_1) > 0: 183 | changed.append(vid1) 184 | if len1 == 1: 185 | to_rm_2 = [i for i, v in enumerate(d2.values()) if v == d1._values[0]] 186 | if len(to_rm_2) == len2: # "domain 2" becomes empty... 187 | return False, None 188 | # update variable domains 189 | v2.domain.remove(to_rm_2) 190 | 191 | if len(to_rm_2) > 0: 192 | changed.append(vid2) 193 | 194 | return True, changed 195 | 196 | 197 | def AllUnique(variables: list[Variable]) -> list[NotEqual]: 198 | ret = [] 199 | for i in range(len(variables)): 200 | for j in range(i + 1, len(variables)): 201 | ret.append(NotEqual(variables[i], variables[j])) 202 | return ret 203 | 204 | 205 | # 3*x + 2*y + 5*z + ... == 4*a + 6*b + 7*c + ... 206 | class SumUp(Constraint): 207 | # 1. Remove all repeated variables, e.g.: 208 | # 3x + ... = x + ... 209 | # -> 2x + ... = ... 210 | # 2. Move negative coeff to the other side, e.g.: 211 | # 3x = -5y + z 212 | # -> 3x + 5y = z 213 | def __init__( 214 | self, 215 | lvars: list[Variable], 216 | lcoeffs: list[int], 217 | rvars: list[Variable], 218 | rcoeffs: list[int], 219 | ): 220 | all = dict[int, int]() 221 | 222 | # 1. add left side to `all` 223 | for i in range(len(lvars)): 224 | vid = lvars[i].vid 225 | co = lcoeffs[i] 226 | if vid in all: 227 | all[vid] += co 228 | else: 229 | all[vid] = co 230 | # 2. add right side to `all` 231 | for i in range(len(rvars)): 232 | vid = rvars[i].vid 233 | co = rcoeffs[i] 234 | if vid in all: 235 | all[vid] -= co 236 | else: 237 | all[vid] = -co 238 | 239 | # 3. split into left and right side 240 | self.lvids = list[int]() 241 | self.lcoeffs = list[int]() 242 | self.rvids = list[int]() 243 | self.rcoeffs = list[int]() 244 | for vid, co in all.items(): 245 | if co == 0: 246 | continue 247 | if co < 0: 248 | self.rvids.append(vid) 249 | self.rcoeffs.append(-co) 250 | else: 251 | self.lvids.append(vid) 252 | self.lcoeffs.append(co) 253 | 254 | self.name_map = {v.vid: v.name for v in itertools.chain(lvars, rvars)} 255 | 256 | def affected_variables(self) -> set[int]: 257 | return set[int](self.lvids + self.rvids) 258 | 259 | def __repr__(self): 260 | lterms = " + ".join( 261 | list( 262 | map( 263 | lambda vid, coeff: f"{self.name_map[vid]}*{coeff}", 264 | self.lvids, 265 | self.lcoeffs, 266 | ) 267 | ) 268 | ) 269 | rterms = " + ".join( 270 | list( 271 | map( 272 | lambda vid, scoeff: f"{self.name_map[vid]}*{scoeff}", 273 | self.rvids, 274 | self.rcoeffs, 275 | ) 276 | ) 277 | ) 278 | 279 | return f"Sum: {lterms} == {rterms}" 280 | 281 | def min_max_of_each_variable( 282 | self, variables: list[Variable], vids: list[int] 283 | ) -> list[tuple[int, int]]: 284 | ret = [] # the min, max value of domain Xi 285 | for vid in vids: 286 | var = variables[vid] 287 | min_v = 0 288 | max_v = 0 289 | for i, v in enumerate(var.domain.values()): 290 | if i == 0 or v < min_v: 291 | min_v = v 292 | if i == 0 or v > max_v: 293 | max_v = v 294 | ret.append((min_v, max_v)) 295 | 296 | return ret 297 | 298 | # https://youtu.be/SCcOrHzdHxI?t=1446 299 | def prune(self, variables: list[Variable]) -> (bool, list[int]): 300 | # 1. Get the intersection of left part and right part 301 | l_min_max = self.min_max_of_each_variable(variables, self.lvids) 302 | r_min_max = self.min_max_of_each_variable(variables, self.rvids) 303 | 304 | LMIN = 0 # the minimum possible summary of left side 305 | LMAX = 0 306 | for n in range(len(self.lvids)): 307 | LMIN += l_min_max[n][0] * self.lcoeffs[n] 308 | LMAX += l_min_max[n][1] * self.lcoeffs[n] 309 | 310 | RMIN = 0 # the minimum possible summary of right side 311 | RMAX = 0 312 | for n in range(len(self.rvids)): 313 | RMIN += r_min_max[n][0] * self.rcoeffs[n] 314 | RMAX += r_min_max[n][1] * self.rcoeffs[n] 315 | 316 | # the intersection of left and right side: 317 | MIN = max(LMIN, RMIN) 318 | MAX = min(LMAX, RMAX) 319 | 320 | if MIN > MAX: 321 | return False, None 322 | 323 | # Both Left and Right sides should be in range of [MIN, MAX] 324 | # MIN <= Left <= MAX 325 | # MIN <= Right <= MAX 326 | 327 | changed_vids = set[int]() 328 | 329 | # 2. Prune left_side: 330 | for i, vid in enumerate(self.lvids): 331 | var = variables[vid] 332 | coeff = self.lcoeffs[i] 333 | 334 | # For min: 335 | # co1*X1 + co2*X2 + ... >= MIN 336 | # -> with: Left = co1*X1 + co2*X2 + ..., if max(Left) if still less than MIN, then it's infeasible 337 | # -> max(Left) >= MIN 338 | # -> co1*X1 >= MIN - (max(Left) - co1*Max(X1)) # this applies to all X 339 | # For max: 340 | # co1*X1 + co2*X2 + ... <= MAX 341 | # -> ... 342 | # -> co1*X1 <= MAX - (min(Left) - co1*Min(X1)) 343 | to_rm = [ 344 | rm 345 | for rm, v in enumerate(var.domain.values()) 346 | if coeff * v < (MIN - (LMAX - coeff * l_min_max[i][1])) 347 | or coeff * v > (MAX - (LMIN - coeff * l_min_max[i][0])) 348 | ] 349 | if len(to_rm) == var.domain.len(): # "domain" becomes empty... 350 | return False, None 351 | 352 | if len(to_rm) > 0: 353 | changed_vids.add(vid) 354 | 355 | var.domain.remove(to_rm) 356 | 357 | # 2.2 Prune right_side: 358 | for i, vid in enumerate(self.rvids): 359 | var = variables[vid] 360 | coeff = self.rcoeffs[i] 361 | 362 | to_rm = [ 363 | rm 364 | for rm, v in enumerate(var.domain.values()) 365 | if coeff * v < (MIN - (RMAX - coeff * r_min_max[i][1])) 366 | or coeff * v > (MAX - (RMIN - coeff * r_min_max[i][0])) 367 | ] 368 | 369 | if len(to_rm) == var.domain.len(): # "domain" becomes empty... 370 | return False, None 371 | 372 | if len(to_rm) > 0: 373 | changed_vids.add(vid) 374 | 375 | var.domain.remove(to_rm) 376 | 377 | # TODO: 378 | # More accurate pruning 379 | 380 | return True, list(changed_vids) 381 | --------------------------------------------------------------------------------