Last active
August 15, 2020 05:51
-
-
Save petertseng/f21f4f509b367c6e648ab2b1387a7ca9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/signpost.html | |
# This should work on every single puzzle generated, | |
# because this solver uses the same reasoning as that used by the generator to check for solvability: | |
# check for cells with only a single possible successor or a single possible predecessor. | |
# | |
# There are other techniques that can be used: | |
# * Search with distance > 1, for example check which cell will eventually allow 24 to connect to 27. | |
# * Naked groups and hidden groups: | |
# * If N preds have N succs as their only possible succs, discard all other possible preds from those succs | |
# * If N succs have N preds as their only possible preds, discard all other possible succs from those preds | |
# However, since they have not proven to be necessary, will not write them at this time. | |
# | |
# https://www.janko.at/Raetsel/Pfeilpfad/index.htm | |
require 'set' | |
class Grid | |
ARROW = { | |
[-1, 0] => ?↑, | |
[-1, 1] => ?↗, | |
[0, 1] => ?→, | |
[1, 1] => ?↘, | |
[1, 0] => ?↓, | |
[1, -1] => ?↙, | |
[0, -1] => ?←, | |
[-1, -1] => ?↖, | |
}.each_key(&:freeze).each_value(&:freeze).freeze | |
def self.from_link(link) | |
link.include?('janko.at') ? from_janko(link) : from_sgtatham(link) | |
end | |
def self.from_sgtatham(link) | |
# Example string: | |
# (url)#5x5:1cee8egccfgh20ccbehechgaach9b25a | |
# since each grid cell must have an arrow, | |
# there must be one letter for each cell. | |
# It's prefixed by a number if there is one. | |
l, str = link.split(?#, 2) | |
str ||= l | |
size_str, grid_str = str.split(?:, 2) | |
w_str, h_str = size_str.split(?x, 2) | |
width = Integer(w_str) | |
height = Integer(h_str) | |
size = width * height | |
letters = grid_str.scan(/(\d+)?([a-h])/) | |
raise "bad letters #{letters.size}, need #{size} "if letters.size != size | |
# a is up, subsequent letters go clockwise around | |
# expressed as dy, dx | |
dir = { | |
?a => [-1, 0], | |
?b => [-1, 1], | |
?c => [0, 1], | |
?d => [1, 1], | |
?e => [1, 0], | |
?f => [1, -1], | |
?g => [0, -1], | |
?h => [-1, -1], | |
}.each_value(&:freeze).freeze | |
Grid.new(letters.map.with_index { |(number, letter), i| | |
number = number && Integer(number) | |
Cell.new(number && Integer(number), dir.fetch(letter), i.divmod(width), last: number == size) | |
}.each_slice(width).to_a.each(&:freeze).freeze) | |
end | |
def self.from_janko(link) | |
lines = `curl #{link}`.lines | |
problem = false | |
side_length = nil | |
grid = [] | |
dir = { | |
?n => [-1, 0], | |
'ne' => [-1, 1], | |
?e => [0, 1], | |
'se' => [1, 1], | |
?s => [1, 0], | |
'sw' => [1, -1], | |
?w => [0, -1], | |
'nw' => [-1, -1], | |
}.each_value(&:freeze).freeze | |
regexp = /(\d+)?(#{Regexp.union(*dir.keys)})?\b/ | |
lines.each { |l| | |
if l.start_with?('size') | |
raise "already know side_length #{side_length}" if side_length | |
side_length = Integer(l.split.last) | |
next | |
elsif l.chomp == 'problem' | |
problem = true | |
next | |
elsif l.chomp == 'solution' | |
break | |
end | |
grid << l.scan(regexp).select { _1.any? }.map.with_index { |(number, letter), x| | |
n = number && Integer(number) | |
# Ending square has no direction, just choose any one | |
direction = n == side_length * side_length ? [-1, 0] : dir.fetch(letter) | |
Cell.new(n, direction, [grid.size, x], last: n == side_length * side_length) | |
}.freeze if problem | |
} | |
Grid.new(grid.freeze) | |
end | |
def initialize(grid) | |
@grid = grid.each(&:freeze).freeze | |
@height = grid.size | |
@width = grid[0].size | |
@size = @height * @width | |
@grid.each_with_index { |row, y| | |
row.each_with_index { |cell, x| | |
dy, dx = cell.direction | |
ny = y + dy | |
nx = x + dx | |
while (0...@height).cover?(ny) && (0...@width).cover?(nx) | |
neigh = @grid[ny][nx] | |
cell.possible_succ(neigh) | |
# OPT: Can simply not make these links, | |
# but doesn't seem necessary; let Cell#legal_succ? handle it. | |
#if (na = cell.number) && (nb = neigh.number) && na + 1 != nb | |
# puts "debug: #{na} + 1 isn't #{nb}, no link" | |
#else | |
# cell.possible_succ(neigh) | |
#end | |
ny += dy | |
nx += dx | |
end | |
} | |
} | |
@flat_cells = @grid.flatten | |
@flat_cells.each(&:freeze_possibilities) | |
@by_number = {} | |
@flat_cells.each { |cell| | |
@by_number[cell.number] = cell if cell.number | |
} | |
(1..@size).each_cons(2) { |a, b| | |
next unless (ca = @by_number[a]) | |
next unless (cb = @by_number[b]) | |
ca.succ = cb | |
} | |
@by_group_name = {} | |
@highlight_cells = [] | |
@stat = Hash.new(0) | |
end | |
def stat | |
@stat.dup | |
end | |
def solved? | |
@flat_cells.all?(&:number) | |
end | |
def step(verbose: false) | |
@flat_cells.each { |cell| | |
next unless (succ = cell.deduce_succ(nil)) | |
puts "new succ #{cell.pos} -> #{succ.pos}" if verbose | |
@stat[:single_succ_1] += 1 | |
bookkeep_links(cell, succ) | |
return true | |
} | |
@flat_cells.each { |cell| | |
next unless (pred = cell.deduce_pred(nil)) | |
puts "new pred #{pred.pos} -> #{cell.pos}" if verbose | |
@stat[:single_pred_1] += 1 | |
bookkeep_links(pred, cell) | |
return true | |
} | |
@flat_cells.each { |cell| | |
next unless cell.number | |
next if cell.number == @size | |
next unless (succ = cell.deduce_succ(closest_succ(cell))) | |
puts "new succ #{cell.pos} -> #{succ.pos} (took closest into account)" if verbose | |
@stat[:single_succ_1_closest] += 1 | |
bookkeep_links(cell, succ) | |
return true | |
} | |
@flat_cells.each { |cell| | |
next unless cell.number | |
next if cell.number == 1 | |
next unless (pred = cell.deduce_pred(closest_pred(cell))) | |
puts "new pred #{pred.pos} -> #{cell.pos} (took closest into account)" if verbose | |
@stat[:single_pred_1_closest] += 1 | |
bookkeep_links(pred, cell) | |
return true | |
} | |
false | |
end | |
def closest_succ(cell) | |
num = ((cell.number + 1)..@size).find(&@by_number) | |
@by_number[num] | |
end | |
def closest_pred(cell) | |
num = (cell.number - 1).downto(1).find(&@by_number) | |
@by_number[num] | |
end | |
def bookkeep_links(pred, succ, highlight: true) | |
pred_had_number = !!pred.number | |
succ_had_number = !!succ.number | |
g1, g2 = [pred.group, succ.group] | |
@highlight_cells = [pred, succ] if highlight | |
pred.succ = succ | |
new_group = succ.group | |
@by_group_name.delete(g1.name) if g1.can_free_name? | |
@by_group_name.delete(g2.name) if g2.can_free_name? | |
new_group.name = unused_group_name if !new_group.number && !new_group.name | |
@by_group_name[new_group.name] = new_group if new_group.name | |
pred.succs.each { @by_number[_1.number] = _1 } if pred_had_number && !succ_had_number | |
succ.preds.each { @by_number[_1.number] = _1 } if succ_had_number && !pred_had_number | |
if new_group.number | |
if (succ = @by_number[new_group.last.number + 1]) && succ.group != new_group | |
bookkeep_links(new_group.last, succ, highlight: false) | |
end | |
if (pred = @by_number[new_group.first.number - 1]) && pred.group != new_group | |
bookkeep_links(pred, new_group.first, highlight: false) | |
end | |
end | |
end | |
def unused_group_name | |
(?a..).find { !@by_group_name[_1] } | |
end | |
GROUP_COLOUR = [ | |
'216;30', | |
'48;30', | |
'14;30', | |
'93', | |
'202;30', | |
'74;30', | |
'11;30', | |
'224;30', | |
'144;30', | |
# Really hard to tell the difference between j and b | |
'79;30', | |
# Really hard to tell the difference between k and f | |
'32', | |
'94', | |
# Colours past here (l) are mostly untested | |
# Really hard to tell the difference between m and i | |
'180;30', | |
'155;30', | |
'228;30', | |
# Starting with p, it loops back to a's colour | |
].each(&:freeze).freeze | |
def to_s | |
max_num_size = (@height * @width).to_s.size | |
# no support for > z letters, so just one letter | |
# x + n | |
cell_to_s_size = max_num_size + 4 | |
highlight_poses = Set.new(@highlight_cells.map(&:pos)) | |
lines = @grid.map.with_index { |row, y| | |
cell_strs = row.map.with_index { |cell| | |
colour = if cell.number | |
'30;47' | |
elsif cell.group.name | |
idx = cell.group.name.ord - ?a.ord | |
"48;5;#{GROUP_COLOUR[idx % GROUP_COLOUR.size]}" | |
else | |
40 | |
end | |
n = "\e[#{colour}m%#{cell_to_s_size}s\e[0m" % cell | |
"#{n} #{cell.need_pred? ? ?P : ' '}#{cell.need_succ? ? ?S : ' '}\e[1m#{cell.number == @size ? '★' : ARROW.fetch(cell.direction)}\e[0m" | |
} | |
# intersperse dividing bars, possibly highlighted | |
dividing_bars = (@width + 1).times.map { |x| | |
if highlight_poses.include?([y, x]) || highlight_poses.include?([y, x - 1]) | |
" \e[1;32m|\e[0m " | |
else | |
' | ' | |
end | |
} | |
lines = dividing_bars.zip(cell_strs).join | |
lines[1..] | |
} | |
# plus space, arrow, PS indicators | |
# plus one space of padding on each side, plus the vertical bar | |
cell_size = cell_to_s_size + 7 | |
# intersperse dividing lines, possibly highlighted | |
dividing_lines = (@height + 1).times.map { |y| | |
@width.times.map { |x| | |
div = ?- * (cell_size - 1) | |
if highlight_poses.include?([y, x]) || highlight_poses.include?([y - 1, x]) | |
"-\e[1;32m#{div}\e[0m" | |
else | |
?- + div | |
end | |
}.join + ?- | |
} | |
lines = dividing_lines.zip(lines).flatten | |
lines.pop | |
lines.join("\n") | |
end | |
end | |
class Cell | |
attr_reader :number, :direction, :pos | |
attr_reader :pred, :succ | |
attr_reader :group, :offset | |
def initialize(number, direction, pos, last: false) | |
@number = number | |
@direction = direction.freeze | |
@pos = pos.freeze | |
@group = Group.new(self) | |
@offset = nil | |
# pred and succ: | |
# if known, singular form non-nil. | |
# if not known, singular form nil, possible_Xs contains the possibilites | |
@pred = nil | |
@succ = nil | |
@possible_preds = Set.new | |
@possible_succs = Set.new | |
@possibilities_frozen = false | |
@last = last | |
end | |
def possibilities_frozen? | |
@possibilities_frozen | |
end | |
def need_pred? | |
@pred.nil? && @number != 1 | |
end | |
def need_succ? | |
@succ.nil? && !@last | |
end | |
def to_s | |
@number&.to_s || "#{@group.name}#{"+#{@offset}" if @offset &.> 0}" | |
end | |
def deduce_pred(closest_pred, dist = 1) | |
return unless need_pred? | |
raise "dist other than 1 not supported yet" if dist != 1 | |
# OPT: Can remember false answers to legal_succ? and remove them permanently. | |
legal_preds = @possible_preds.select { _1.legal_succ?(self, closest_pred: closest_pred) } | |
raise "no legal pred for #{self}" if legal_preds.empty? | |
return legal_preds.first if legal_preds.size == 1 | |
end | |
def deduce_succ(closest_succ, dist = 1) | |
return unless need_succ? | |
raise "dist other than 1 not supported yet" if dist != 1 | |
# OPT: Can remember false answers to legal_succ? and remove them permanently. | |
legal_succs = @possible_succs.select { legal_succ?(_1, closest_succ: closest_succ) } | |
raise "no legal succ for #{self}" if legal_succs.empty? | |
return legal_succs.first if legal_succs.size == 1 | |
end | |
def legal_succ?(succ, closest_pred: nil, closest_succ: nil) | |
return false unless need_succ? | |
return false unless succ.need_pred? | |
return false if (na = @number) && (nb = succ.number) && na + 1 != nb | |
# Creating a closed loop | |
return false if @group == succ.group | |
if closest_pred && succ.number | |
# Example: Evaluate whether 3 (succ) can connect to potential 2 (self) when 1 (closest_pred) is known. | |
# Example: Evaluate whether 4 (succ) can connect to chain of size two (self = potential 3) when 1 (closest_pred) is known. | |
potential_number = succ.number - @group.size | |
return false if potential_number <= closest_pred.number | |
return false if potential_number == closest_pred.number + 1 && !closest_pred.possible_succs.include?(@group.first) | |
end | |
if closest_succ && @number | |
# Example: Evaluate whether 1 (self) can connect to potential 2 (succ) when 3 (closest_succ) is known. | |
# Example: Evaluate whether 1 (succ) can connect to chain of size two (self = potential 2) when 4 (closest_succ) is known. | |
potential_number = @number + succ.group.size | |
return false if potential_number >= closest_succ.number | |
return false if potential_number + 1 == closest_succ.number && !closest_succ.possible_preds.include?(succ.group.last) | |
end | |
true | |
end | |
def succ=(x) | |
return if x && @succ == x | |
raise "#{self} already has succ #{@succ}, can't set to #{x}" if @succ | |
raise "#{self} doesn't have #{x} as possible succ" unless @possible_succs.include?(x) | |
@succ = x | |
x.pred = self | |
merged_group = @group.merge(x.group) | |
pred_had_number = !!@number | |
succ_had_number = !!succ.number | |
curr_num = @number | |
succs.each { |curr_cell| | |
curr_cell.number = curr_num += 1 if pred_had_number && !succ_had_number | |
curr_cell.group = merged_group | |
} | |
curr_num = x.number | |
x.preds.each { |curr_cell| | |
curr_cell.number = curr_num -= 1 if !pred_had_number && succ_had_number | |
curr_cell.group = merged_group | |
} | |
group.cells.with_index { |cell, offset| cell.offset = offset } unless group.number | |
end | |
def preds | |
curr_cell = self | |
Enumerator.new { |y| | |
while (curr_cell = curr_cell.pred) | |
y << curr_cell | |
end | |
} | |
end | |
def succs | |
curr_cell = self | |
Enumerator.new { |y| | |
while (curr_cell = curr_cell.succ) | |
y << curr_cell | |
end | |
} | |
end | |
def possible_succ(cell) | |
raise "possibilities of #{self} are frozen" if @possibilities_frozen | |
raise "possibilities of #{cell} are frozen" if cell.possibilities_frozen? | |
cell.possible_preds << self | |
@possible_succs << cell | |
end | |
def rm_succ(cell) | |
@possible_succs.delete(cell) | |
end | |
def rm_pred(cell) | |
@possible_preds.delete(cell) | |
end | |
def freeze_possibilities | |
@possibilities_frozen = true | |
end | |
protected | |
attr_reader :possible_succs, :possible_preds | |
attr_writer :number | |
attr_writer :group, :offset | |
def pred=(x) | |
return if x && @pred == x | |
raise "#{self} already has pred #{@pred}, can't set to #{x}" if @pred | |
raise "#{self} doesn't have #{x} as possible pred" unless @possible_preds.include?(x) | |
@pred = x | |
# Do not need to do the other steps of succ=, | |
# since external callers only get to call succ= | |
end | |
end | |
# A little unsatisfied with the visibility of group: | |
# Cell is exclusively responsible for managing groups, | |
# but it exposes the group to the outside. | |
# Maybe the cell should expose the appropriate readers that read the appropriate values of group. | |
class Group | |
attr_accessor :name | |
attr_reader :first, :last, :size | |
def initialize(first, last = nil, size = 1, name = nil) | |
@name = name.freeze | |
@first = first | |
@last = last || first | |
@size = size | |
@can_free_name = false | |
end | |
def can_free_name? | |
@can_free_name | |
end | |
def number | |
@first.number | |
end | |
def merge(succ) | |
return self if succ == self | |
# If either side has a number, no name needed | |
# If only one side has a name, use that one. | |
# If both sides have a name, take the name of the larger side. | |
new_name = if @first.number || succ.first.number | |
@can_free_name = true | |
succ.can_free_name = true | |
nil | |
elsif @name && !succ.name | |
@name | |
elsif succ.name && !@name | |
succ.name | |
elsif @size < succ.size | |
@can_free_name = true | |
succ.name | |
else | |
succ.can_free_name = true | |
name | |
end | |
self.class.new(@first, succ.last, @size + succ.size, new_name) | |
end | |
def to_s | |
"group size #{size} #{first.number} - #{last.number}" | |
end | |
def cells | |
Enumerator.new { |y| | |
curr_cell = @first | |
while curr_cell | |
y << curr_cell | |
curr_cell = curr_cell.succ | |
end | |
} | |
end | |
protected | |
attr_writer :can_free_name | |
end | |
verbose = ARGV.delete('-v') | |
interactive = ARGV.delete('-i') | |
link = ARGV[0] | |
grid = Grid.from_link(link) | |
puts grid | |
while grid.step(verbose: verbose) | |
puts grid if verbose | |
end | |
# If verbose, we've already printed out the final state. | |
puts grid if !verbose && !interactive | |
puts grid.solved? ? 'solvable' : 'unsolvable' | |
if interactive | |
grid = Grid.from_link(link) | |
while grid.step(verbose: true) | |
puts grid | |
_ = STDIN.gets | |
end | |
end | |
puts grid.stat |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment