diff --git a/exercises/alphametics/.version b/exercises/alphametics/.version index e440e5c842..bf0d87ab1b 100644 --- a/exercises/alphametics/.version +++ b/exercises/alphametics/.version @@ -1 +1 @@ -3 \ No newline at end of file +4 \ No newline at end of file diff --git a/exercises/alphametics/alphametics_test.rb b/exercises/alphametics/alphametics_test.rb index e9990c06c9..60ebd17754 100755 --- a/exercises/alphametics/alphametics_test.rb +++ b/exercises/alphametics/alphametics_test.rb @@ -3,64 +3,75 @@ require 'minitest/autorun' require_relative 'alphametics' -# Test data version: -# 8d8589f +# Test data version: 9dab356 class AlphameticsTest < Minitest::Test - def test_solve_short_puzzle + + def test_puzzle_with_three_letters # skip - expect = { - 'I' => 1, 'B' => 9, 'L' => 0 - } - actual = Alphametics.new.solve('I + BB == ILL') - assert_equal(expect, actual) + input = 'I + BB == ILL' + expected = { 'B' => 9, 'I' => 1, 'L' => 0 } + assert_equal expected, Alphametics.solve(input) end - # This test has been commented out due its long runtime. - # def test_solve_long_puzzle - # skip - # expect = { - # 'S' => 9, 'E' => 5, 'N' => 6, 'D' => 7, - # 'M' => 1, 'O' => 0, 'R' => 8, 'Y' => 2 - # } - # actual = Alphametics.new.solve('SEND + MORE == MONEY') - # assert_equal(expect, actual) - # end - def test_solution_must_have_unique_value_for_each_letter skip - expect = nil - actual = Alphametics.new.solve('A == B') - assert_equal(expect, actual) + input = 'A == B' + expected = {} + assert_equal expected, Alphametics.solve(input) end def test_leading_zero_solution_is_invalid skip - expect = nil - actual = Alphametics.new.solve('ACA + DD == BD') - assert_equal(expect, actual) + input = 'ACA + DD == BD' + expected = {} + assert_equal expected, Alphametics.solve(input) end - def test_solve_puzzle_with_four_words + def test_puzzle_with_four_letters skip - expect = { - 'E' => 4, 'G' => 2, 'H' => 5, 'I' => 0, - 'L' => 1, 'S' => 9, 'T' => 7 - } - actual = Alphametics.new.solve('HE + SEES + THE == LIGHT') - assert_equal(expect, actual) + input = 'AS + A == MOM' + expected = { 'A' => 9, 'M' => 1, 'O' => 0, 'S' => 2 } + assert_equal expected, Alphametics.solve(input) end - # This test has been commented out due its long runtime. - # def test_solve_puzzle_with_many_words - # skip - # expect = { - # 'A' => 5, 'D' => 3, 'E' => 4, 'F' => 7, - # 'G' => 8, 'N' => 0, 'O' => 2, 'R' => 1, - # 'S' => 6, 'T' => 9 - # } - # actual = Alphametics.new.solve('AND + A + STRONG + OFFENSE + AS + A + GOOD = DEFENSE') - # assert_equal(expect, actual) - # end + def test_puzzle_with_six_letters + skip + input = 'NO + NO + TOO == LATE' + expected = { 'A' => 0, 'E' => 2, 'L' => 1, 'N' => 7, + 'O' => 4, 'T' => 9 } + assert_equal expected, Alphametics.solve(input) + end + + def test_puzzle_with_seven_letters + skip + input = 'HE + SEES + THE == LIGHT' + expected = { 'E' => 4, 'G' => 2, 'H' => 5, 'I' => 0, + 'L' => 1, 'S' => 9, 'T' => 7 } + assert_equal expected, Alphametics.solve(input) + end + + # The obvious algorithm can take a long time to solve this puzzle, + # but an optimised solution can solve it fairly quickly. + # (It's OK to submit your solution without getting this test to pass.) + def test_puzzle_with_eight_letters + skip + input = 'SEND + MORE == MONEY' + expected = { 'D' => 7, 'E' => 5, 'M' => 1, 'N' => 6, + 'O' => 0, 'R' => 8, 'S' => 9, 'Y' => 2 } + assert_equal expected, Alphametics.solve(input) + end + + # The obvious algorithm can take a long time to solve this puzzle, + # but an optimised solution can solve it fairly quickly. + # (It's OK to submit your solution without getting this test to pass.) + def test_puzzle_with_ten_letters + skip + input = 'AND + A + STRONG + OFFENSE + AS + A + GOOD == DEFENSE' + expected = { 'A' => 5, 'D' => 3, 'E' => 4, 'F' => 7, + 'G' => 8, 'N' => 0, 'O' => 2, 'R' => 1, + 'S' => 6, 'T' => 9 } + assert_equal expected, Alphametics.solve(input) + end # Problems in exercism evolve over time, as we find better ways to ask # questions. @@ -78,9 +89,8 @@ def test_solve_puzzle_with_four_words # # If you are curious, read more about constants on RubyDoc: # http://ruby-doc.org/docs/ruby-doc-bundle/UsersGuide/rg/constants.html - def test_bookkeeping skip - assert_equal 3, BookKeeping::VERSION + assert_equal 4, BookKeeping::VERSION end end diff --git a/exercises/alphametics/example.rb b/exercises/alphametics/example.rb index 2a20e14b26..49446d4326 100644 --- a/exercises/alphametics/example.rb +++ b/exercises/alphametics/example.rb @@ -1,98 +1,135 @@ module BookKeeping - VERSION = 3 + VERSION = 4 end class Alphametics - def solve(puzzle) - letters = Hash.new(0) - puzzle.scan(/[a-zA-Z]/) { |w| letters[w] += 1 } - possible_values(letters.keys) do |letters_values| - return letters_values if valid?(puzzle, letters_values) - end + def self.solve(equation) + new.solve(equation) + end - nil + def solve(equation) + @prime_solver = AlphaSolver.new(equation) + solve_using_partials end private - def possible_values(letters) - (0..9).to_a.combination(letters.length) do |combined_integers| - combined_integers.permutation do |permutated_integers| - yield permutated_integers.map.with_index { |integer, index| - [letters[index], integer] - }.to_h - end + attr_accessor :prime_solver + + def solve_using_partials + prime_solver.partial_solutions.each do |partial_solution| + sub_solver = AlphaSolver.new(prime_solver.partial_equation(partial_solution)) + sub_solution = sub_solver.first_solution + return sub_solution.merge(partial_solution) if sub_solution end + {} end - def valid?(puzzle, letters_values) - equation = puzzle.gsub(/[a-zA-Z]/, letters_values) - Equation.new(equation).valid? - end end -class Equation - attr_reader :equation +class AlphaSolver - def initialize(equation) - @equation = equation + def initialize(input_equation) + @input_equation = input_equation.gsub('^', '**') + @puzzle = Puzzle.new(input_equation) end - def valid? - return false if has_leading_zeros? + def partial_solutions + AlphaSolver.new(puzzle.simplified).all_solutions + end - expression, result = equation.split('==') + def all_solutions + numeric_permutations.map { |values| result_table if solution?(values) }.compact + end - numbers = [] - operators = [] + def first_solution + numeric_permutations.each { |values| return result_table if solution?(values) } + nil + end - expression.scan(/\d+|\+|\-|\*|\/|\^/).each do |token| - case token - when /^\d+$/ - numbers.push(token.to_i) - when '+', '-', '*', '/', '^' - calculate_last(numbers, operators) if has_precedence?(operators, token) - operators.push(token) - end - end + def partial_equation(partial_solution) + input_equation.tr(partial_solution.keys.join, partial_solution.values.join) + end - until operators.empty? - calculate_last(numbers, operators) - end + private + + attr_reader :input_equation, :puzzle + attr_accessor :proposed_values - numbers.last == result.to_i + def solution?(values) + self.proposed_values = values.join + proposed_equation_qualified? && proposed_equation_evaluates? end - private + def proposed_equation + input_equation.tr(puzzle_letters, proposed_values) + end - def has_leading_zeros? - equation.match(/^0\d+|\D0\d+/) + def numeric_permutations + puzzle.numeric_permutations end - def has_precedence?(operators, token) - return false if operators.empty? - prev_operator = operators.last + def puzzle_letters + puzzle.letters + end - case token - when '+', '-' - prev_operator == '*' || prev_operator == '/' || prev_operator == '^' - when '*', '/' - prev_operator == '^' - else - false - end + def proposed_equation_qualified? + (proposed_equation =~ /\b0\d+/).nil? end - def calculate_last(numbers, operators) - right = numbers.pop - left = numbers.pop - operator = as_ruby_operator(operators.pop) - result = left.send(operator, right) - numbers.push(result) + def proposed_equation_evaluates? + eval(proposed_equation) end - def as_ruby_operator(operator) - operator == '^' ? '**' : operator + def result_table + Hash[puzzle_letters.chars.zip(result_numbers)] end + + def result_numbers + proposed_values.chars.map(&:to_i) + end + end + +class Puzzle + + PATTERNS = {mod_10: ' % 10', + adjacent_letters: /(\b)([A-Z]{1,})([A-Z])/, + equation_left_side: /(.*)( == )/} + + def initialize(string_equation) + @string_equation = string_equation + end + + def letters + @letters ||= string_equation.scan(/[A-Z]/).uniq.join + end + + def numeric_permutations + @numeric_permutations ||= unused_numbers.to_a.permutation(letter_count) + end + + def simplified + @simplified ||= string_equation + .gsub(PATTERNS[:adjacent_letters], "\\1\\3") + .gsub(PATTERNS[:equation_left_side], "(\\1)#{PATTERNS[:mod_10]}\\2") + end + + private + + attr_reader :string_equation + + def letter_count + @letter_count ||= letters.length + end + + def unused_numbers + @unused_numbers ||= (0..9).to_a.map(&:to_s) - used_numbers + end + + def used_numbers + @used_numbers ||= string_equation.gsub(PATTERNS[:mod_10], '').scan(/\d/).uniq + end + +end \ No newline at end of file diff --git a/exercises/alphametics/example.tt b/exercises/alphametics/example.tt index 03d126c39f..48cf2eabf7 100644 --- a/exercises/alphametics/example.tt +++ b/exercises/alphametics/example.tt @@ -3,16 +3,17 @@ gem 'minitest', '>= 5.0.0' require 'minitest/autorun' require_relative 'alphametics' -# Test data version: -# <%= sha1 %> -class AlphameticsTest < Minitest::Test<% test_cases.each do |test_case| %> +# Test data version: <%= sha1 %> +class AlphameticsTest < Minitest::Test +<% test_cases.each do |test_case| %> + +<%= test_case.runtime_comment %> def <%= test_case.test_name %> <%= test_case.skipped %> - expect = <%= test_case.expect %> - actual = <%= test_case.work_load %> - assert_equal(expect, actual) + <%= test_case.workload %> end <% end %> + <%= IO.read(XRUBY_LIB + '/bookkeeping.md') %> def test_bookkeeping skip diff --git a/lib/alphametics_cases.rb b/lib/alphametics_cases.rb index 44cc5a664e..6ead800010 100644 --- a/lib/alphametics_cases.rb +++ b/lib/alphametics_cases.rb @@ -1,30 +1,66 @@ class AlphameticsCase < OpenStruct - PAIRS_PER_LINE = 4 - SPACE = ->(num) { ' ' * num } - def test_name "test_#{description.tr(' ', '_')}" end - def work_load - "Alphametics.new.solve('#{puzzle}')" + def skipped + index.zero? ? '# skip' : 'skip' + end + + def input + "'#{puzzle}'" end def expect - return 'nil' if expected.nil? - expected_values + expected.nil? ? {} : expected_values + end + + def workload + body = + "input = %s\n" % input, + "expected = %s\n" % expect, + "assert_equal expected, Alphametics.solve(input)" + indent(body,4) + end + + def runtime_comment + if slow? + comments = + '', + "# The obvious algorithm can take a long time to solve this puzzle,\n", + "# but an optimised solution can solve it fairly quickly.\n", + "# (It's OK to submit your solution without getting this test to pass.)\n" + indent(comments,2) + end + end + + private + + def slow? + (expected||{}).size > 7 end def expected_values - "{\n" << expected.each_slice(PAIRS_PER_LINE).map do |pairs| - '%s'.prepend(SPACE[6]) % - pairs.map { |k, v| "'#{k}' => #{v}" }.join(', ') - end.join(",\n") << "\n }" + "{ #{indent(expected_values_as_lines,17)} }" end - def skipped - index.zero? ? '# skip' : 'skip' + def expected_values_as_lines + lines = expected_values_as_strings.each_slice(4).map { |line| line.join(', ') } + add_trailing_comma_and_newline(lines) + end + + def expected_values_as_strings + expected.sort.map { |(key,value)| "'#{key}' => #{value}" } end + + def add_trailing_comma_and_newline(lines) + lines[0...-1].map { |line| "#{line},\n" }.push(lines.last) + end + + def indent(lines, spaces) + lines.join(' ' * spaces) + end + end AlphameticsCases = proc do |data|