Skip to content

Commit 6698da3

Browse files
committed
Add basic SAT solver
1 parent 1a4b1cb commit 6698da3

File tree

2 files changed

+519
-0
lines changed

2 files changed

+519
-0
lines changed

src/sat.cr

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Crystal port of simple-sat by Sahand Saba distributed under the MIT License.
2+
# - https://github.com/sahands/simple-sat/
3+
# - https://sahandsaba.com/understanding-sat-by-implementing-a-simple-sat-solver-in-python.html
4+
5+
module Shards
6+
class SAT
7+
alias Literal = Int32
8+
alias Clause = Array(Literal)
9+
10+
def self.from_io(io : IO) : SAT
11+
new.tap do |sat|
12+
while line = io.gets
13+
next if line.empty? || line.starts_with?('#')
14+
sat.add_clause(line)
15+
end
16+
end
17+
end
18+
19+
def self.from_file(path : String) : SAT
20+
File.open(path) { |f| from_io(f) }
21+
end
22+
23+
def initialize
24+
@clauses = Array(Clause).new
25+
@table = Hash(String, Literal).new
26+
@variables = Array(String).new
27+
end
28+
29+
def add_clause(str : String) : Nil
30+
add_clause(str.split)
31+
end
32+
33+
def add_clause(ary : Array(String)) : Nil
34+
clause = ary.map do |literal|
35+
negated = literal.starts_with?('~') ? 1 : 0
36+
variable = literal[negated..-1]
37+
38+
unless @table.has_key?(variable)
39+
@table[variable] = @variables.size
40+
@variables << variable
41+
end
42+
43+
@table[variable] << 1 | negated
44+
end
45+
46+
clause.uniq!
47+
@clauses << clause
48+
end
49+
50+
protected def literal_to_s(literal : Literal) : String
51+
String.build { |str| literal_to_s(str, literal) }
52+
end
53+
54+
protected def literal_to_s(io : IO, literal : Literal) : Nil
55+
io << '~' unless literal & 1 == 0
56+
io << @variables[literal >> 1]
57+
end
58+
59+
protected def clause_to_s(clause : Clause) : String
60+
String.build do |str|
61+
clause.each_with_index do |literal, index|
62+
str << ' ' unless index == 0
63+
literal_to_s(str, literal)
64+
end
65+
end
66+
end
67+
68+
protected def assignment_to_s(assignment : Array(Literal?), brief = false)
69+
String.build do |str|
70+
assignment.zip(@variables).compact_map do |(a, v)|
71+
if a == 0
72+
next if brief
73+
str << '~'
74+
end
75+
str << v << ' '
76+
end
77+
end
78+
end
79+
80+
protected def to_assigment(assignment : Array(Literal?), brief = false)
81+
aa = Array(String).new(assignment.size)
82+
83+
assignment.zip(@variables).each do |(a, v)|
84+
if a == 0
85+
aa << "~#{v}" unless brief
86+
else
87+
aa << v
88+
end
89+
end
90+
91+
aa
92+
end
93+
94+
# Solves SAT and yields solutions.
95+
def solve(brief = true, verbose = false) : Nil
96+
watchlist = setup_watchlist
97+
assignment = Array(Literal?).new(@variables.size) { nil }
98+
99+
solve(watchlist, assignment, 0, verbose) do |solution|
100+
yield to_assigment(solution, brief)
101+
end
102+
end
103+
104+
# Iteratively solve SAT by assigning to variables d, d+1, ..., n-1.
105+
# Assumes variables 0, ..., d-1 are assigned so far.
106+
private def solve(watchlist, assignment, d, verbose)
107+
# The state list wil keep track of what values for which variables
108+
# we have tried so far. A value of 0 means nothing has been tried yet,
109+
# a value of 1 means False has been tried but not True, 2 means True
110+
# but not False, and 3 means both have been tried.
111+
n = @variables.size
112+
state = Array(Literal).new(n) { Literal.new(0) }
113+
114+
loop do
115+
if d == n
116+
yield assignment
117+
d -= 1
118+
next
119+
end
120+
121+
# Let's try assigning a value to v. Here would be the place to insert
122+
# heuristics of which value to try first.
123+
tried_something = false
124+
125+
{0, 1}.each do |a|
126+
if (state[d] >> a) & 1 == 0
127+
STDERR.puts "Trying #{@variables[d]} = #{a}" if verbose
128+
129+
tried_something = true
130+
131+
# set the bit indicating a has been tried for d:
132+
state[d] |= 1 << a
133+
assignment[d] = a
134+
135+
if !update_watchlist(watchlist, (d << 1) | a, assignment, verbose)
136+
assignment[d] = nil
137+
else
138+
d += 1
139+
break
140+
end
141+
end
142+
end
143+
144+
unless tried_something
145+
if d == 0
146+
# can't backtrack further, no solutions:
147+
return
148+
end
149+
150+
# backtrack:
151+
state[d] = 0
152+
assignment[d] = nil
153+
d -= 1
154+
end
155+
end
156+
end
157+
158+
private def setup_watchlist
159+
watchlist = Array.new(@variables.size * 2) { Deque(Clause).new }
160+
@clauses.each { |clause| watchlist[clause[0]] << clause }
161+
watchlist
162+
end
163+
164+
private def dump_watchlist(watchlist)
165+
STDERR.puts "Current watchlist:"
166+
167+
watchlist.each_with_index do |w, l|
168+
STDERR << literal_to_s(l) << ": "
169+
STDERR.puts w.map { |c| clause_to_s(c) }.join(", ")
170+
end
171+
end
172+
173+
# Updates the watch list after literal 'false_literal' was just assigned
174+
# `false`, by making any clause watching false_literal watch something else.
175+
# Returns False it is impossible to do so, meaning a clause is contradicted by
176+
# the current assignment.
177+
private def update_watchlist(watchlist, false_literal, assignment, verbose)
178+
while clause = watchlist[false_literal].first?
179+
found_alternative = false
180+
181+
clause.each do |alternative|
182+
v = alternative >> 1
183+
a = alternative & 1
184+
av = assignment[v]
185+
186+
if av.nil? || av == a ^ 1
187+
found_alternative = true
188+
watchlist[false_literal].shift?
189+
watchlist[alternative] << clause
190+
break
191+
end
192+
end
193+
194+
unless found_alternative
195+
if verbose
196+
dump_watchlist(watchlist)
197+
STDERR.puts "Current assignment: #{assignment_to_s(assignment)}"
198+
STDERR.puts "Clause #{clause_to_s(clause)} contradicted."
199+
end
200+
return false
201+
end
202+
end
203+
204+
return true
205+
end
206+
end
207+
end

0 commit comments

Comments
 (0)