Skip to content

Commit c085617

Browse files
committed
added implementation for solving two satisfiability problem using Kosaraju's algorithm
1 parent e2a78d4 commit c085617

File tree

2 files changed

+358
-0
lines changed

2 files changed

+358
-0
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from graphs.two_satisfiability import TwoSatisfiability
2+
3+
4+
def test_satisfiable_basic_case() -> None:
5+
"""Case 1: Basic satisfiable case."""
6+
two_sat = TwoSatisfiability(5)
7+
8+
two_sat.add_clause(1, False, 2, False) # (x1 v x2)
9+
two_sat.add_clause(3, True, 2, False) # (¬x3 v x2)
10+
two_sat.add_clause(4, False, 5, True) # (x4 v ¬x5)
11+
12+
two_sat.solve()
13+
14+
assert two_sat.is_solution_exists(), "Expected solution to exist"
15+
expected: list[bool] = [False, True, True, True, True, True]
16+
assert two_sat.get_solutions() == expected
17+
18+
19+
def test_unsatisfiable_contradiction() -> None:
20+
"""Case 2: Unsatisfiable due to direct contradiction."""
21+
two_sat = TwoSatisfiability(1)
22+
23+
two_sat.add_clause(1, False, 1, False) # (x1 v x1)
24+
two_sat.add_clause(1, True, 1, True) # (¬x1 v ¬x1)
25+
26+
two_sat.solve()
27+
28+
assert not two_sat.is_solution_exists(), "Expected no solution (contradiction)"
29+
30+
31+
def test_single_variable_trivial_satisfiable() -> None:
32+
"""Case 3: Single variable, trivially satisfiable."""
33+
two_sat = TwoSatisfiability(1)
34+
35+
two_sat.add_clause(1, False, 1, False) # (x1 v x1)
36+
37+
two_sat.solve()
38+
39+
assert two_sat.is_solution_exists(), "Expected solution to exist"
40+
expected: list[bool] = [False, True]
41+
assert two_sat.get_solutions() == expected
42+
43+
44+
def test_chained_dependencies_satisfiable() -> None:
45+
"""Case 4: Larger satisfiable system with dependencies."""
46+
two_sat = TwoSatisfiability(5)
47+
48+
two_sat.add_clause(1, False, 2, False)
49+
two_sat.add_clause(2, True, 3, False)
50+
two_sat.add_clause(3, True, 4, False)
51+
two_sat.add_clause(4, True, 5, False)
52+
53+
two_sat.solve()
54+
55+
assert two_sat.is_solution_exists(), "Expected solution to exist"
56+
solution = two_sat.get_solutions()
57+
for i in range(1, 6):
58+
assert solution[i], f"Expected x{i} to be True"
59+
60+
61+
def test_unsatisfiable_cycle() -> None:
62+
"""Case 5: Contradiction due to dependency cycle."""
63+
two_sat = TwoSatisfiability(2)
64+
65+
two_sat.add_clause(1, False, 2, False)
66+
two_sat.add_clause(1, True, 2, True)
67+
two_sat.add_clause(1, False, 2, True)
68+
two_sat.add_clause(1, True, 2, False)
69+
70+
two_sat.solve()
71+
72+
assert not two_sat.is_solution_exists(), (
73+
"Expected no solution due to contradictory cycle"
74+
)
75+
76+
77+
def test_cses_case() -> None:
78+
"""Case 6: CSES testcase."""
79+
two_sat = TwoSatisfiability(2)
80+
81+
two_sat.add_clause(1, True, 2, False)
82+
two_sat.add_clause(2, True, 1, False)
83+
two_sat.add_clause(1, True, 1, True)
84+
two_sat.add_clause(2, False, 2, False)
85+
86+
two_sat.solve()
87+
88+
assert not two_sat.is_solution_exists(), "Expected no solution."

graphs/two_satisfiability.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
class TwoSatisfiability:
2+
"""
3+
This class implements a solution to the 2-SAT (2-Satisfiability) problem
4+
using Kosaraju's algorithm to find strongly connected components (SCCs)
5+
in the implication graph.
6+
7+
Brief Idea:
8+
-----------
9+
1. From each clause (a v b), we can derive implications:
10+
(¬a → b) and (¬b → a)
11+
12+
2. We construct an implication graph using these implications.
13+
14+
3. For each variable x, its negation ¬x is also represented as a node.
15+
If x and ¬x belong to the same SCC, the expression is unsatisfiable.
16+
17+
4. Otherwise, we assign truth values based on the SCC order:
18+
If SCC(x) > SCC(¬x), then x = true; otherwise, x = false.
19+
20+
Complexities:
21+
-------------
22+
- Time Complexity: O(n + m)
23+
- Space Complexity: O(n + m)
24+
25+
where n is the number of variables and m is the number of clauses.
26+
27+
Examples:
28+
---------
29+
>>> # Example 1: Simple satisfiable formula
30+
>>> two_sat = TwoSatisfiability(3)
31+
>>> two_sat.add_clause(1, False, 2, False) # (x1 v x2)
32+
>>> two_sat.add_clause(2, True, 3, False) # (¬x2 v x3)
33+
>>> two_sat.solve()
34+
>>> two_sat.is_solution_exists()
35+
True
36+
>>> solution = two_sat.get_solutions()
37+
>>> solution
38+
[False, True, True, True]
39+
40+
>>> # Example 2: Unsatisfiable formula
41+
>>> two_sat = TwoSatisfiability(1)
42+
>>> two_sat.add_clause(1, False, 1, False) # (x1 v x1)
43+
>>> two_sat.add_clause(1, True, 1, True) # (¬x1 v ¬x1)
44+
>>> two_sat.solve()
45+
>>> two_sat.is_solution_exists()
46+
False
47+
48+
>>> # Example 3: Testing error handling
49+
>>> two_sat = TwoSatisfiability(2)
50+
>>> try:
51+
... two_sat.is_solution_exists()
52+
... except RuntimeError as e:
53+
... print("Error caught")
54+
Error caught
55+
56+
57+
Reference:
58+
----------
59+
- CP Algorithm: https://cp-algorithms.com/graph/2SAT.html
60+
- Wikipedia: https://en.wikipedia.org/wiki/2-satisfiability
61+
62+
Author: Shoyeb Ansari (https://github.com/Shoyeb45)
63+
64+
See Also:
65+
---------
66+
Kosaraju's algorithm for finding strongly connected components
67+
"""
68+
69+
def __init__(self, number_of_variables: int):
70+
"""
71+
Initializes the TwoSat solver with the given number of variables.
72+
73+
Args:
74+
number_of_variables (int): The number of boolean variables
75+
76+
Raises:
77+
ValueError: If the number of variables is negative
78+
"""
79+
if number_of_variables < 0:
80+
raise ValueError("Number of variables cannot be negative.")
81+
82+
self.__number_of_variables = number_of_variables
83+
n = 2 * number_of_variables + 1
84+
85+
# Implication graph built from the boolean clauses
86+
self.__graph: list[list[int]] = [[] for _ in range(n)]
87+
88+
# Transposed implication graph used in Kosaraju's algorithm
89+
self.__graph_transpose: list[list[int]] = [[] for _ in range(n)]
90+
91+
# Stores one valid truth assignment for all variables (1-indexed)
92+
self.__variable_assignments: list[bool] = [False] * (number_of_variables + 1)
93+
94+
# Indicates whether a valid solution exists
95+
self.__has_solution: bool = True
96+
97+
# Tracks whether the solve() method has been called
98+
self.__is_solved: bool = False
99+
100+
def add_clause(self, a: int, is_negate_a: bool, b: int, is_negate_b: bool) -> None:
101+
"""
102+
Adds a clause of the form (a v b) to the boolean expression.
103+
104+
Args:
105+
a (int): The first variable (1 ≤ a ≤ number_of_variables)
106+
is_negate_a (bool): True if variable a is negated
107+
b (int): The second variable (1 ≤ b ≤ number_of_variables)
108+
is_negate_b (bool): True if variable b is negated
109+
110+
For example::
111+
112+
# To add (¬x₁ v x₂), call:
113+
two_sat.add_clause(1, True, 2, False)
114+
115+
Raises:
116+
ValueError: If a or b are out of range
117+
"""
118+
exception_message = f"Variable number must be \
119+
between 1 and {self.__number_of_variables}"
120+
if a <= 0 or a > self.__number_of_variables:
121+
raise ValueError(exception_message)
122+
if b <= 0 or b > self.__number_of_variables:
123+
raise ValueError(exception_message)
124+
125+
a = self.__negate(a) if is_negate_a else a
126+
b = self.__negate(b) if is_negate_b else b
127+
not_a = self.__negate(a)
128+
not_b = self.__negate(b)
129+
130+
# Add implications: (¬a → b) and (¬b → a)
131+
self.__graph[not_a].append(b)
132+
self.__graph[not_b].append(a)
133+
134+
# Build transpose graph
135+
self.__graph_transpose[b].append(not_a)
136+
self.__graph_transpose[a].append(not_b)
137+
138+
def solve(self) -> None:
139+
"""
140+
Solves the 2-SAT problem using Kosaraju's algorithm to find SCCs
141+
and determines whether a satisfying assignment exists.
142+
"""
143+
self.__is_solved = True
144+
number_of_nodes = 2 * self.__number_of_variables + 1
145+
146+
visited = [False] * number_of_nodes
147+
component = [0] * number_of_nodes
148+
topological_order: list[int] = []
149+
150+
# Step 1: Perform DFS to get topological order
151+
for i in range(1, number_of_nodes):
152+
if not visited[i]:
153+
self.__depth_first_search_for_topological_order(
154+
i, visited, topological_order
155+
)
156+
157+
visited = [False] * number_of_nodes
158+
scc_id = 0
159+
160+
# Step 2: Find SCCs on transposed graph
161+
while topological_order:
162+
node = topological_order.pop()
163+
if not visited[node]:
164+
self.__depth_first_search_for_scc(node, visited, component, scc_id)
165+
scc_id += 1
166+
167+
# Step 3: Check for contradictions and assign values
168+
for i in range(1, self.__number_of_variables + 1):
169+
not_i = self.__negate(i)
170+
if component[i] == component[not_i]:
171+
self.__has_solution = False
172+
return
173+
# If SCC(i) > SCC(¬i), then variable i is true
174+
self.__variable_assignments[i] = component[i] > component[not_i]
175+
176+
def is_solution_exists(self) -> bool:
177+
"""
178+
Returns whether the given boolean formula is satisfiable.
179+
180+
Returns:
181+
bool: True if a solution exists; False otherwise
182+
183+
Raises:
184+
RuntimeError: If called before solve()
185+
"""
186+
if not self.__is_solved:
187+
raise RuntimeError("Please call solve() before checking for a solution.")
188+
return self.__has_solution
189+
190+
def get_solutions(self) -> list[bool]:
191+
"""
192+
Returns one valid assignment of variables that satisfies the boolean formula.
193+
194+
Returns:
195+
list[bool]: A boolean list where result[i] represents the truth value
196+
of variable xᵢ
197+
198+
Raises:
199+
RuntimeError: If called before solve() or if no solution exists
200+
"""
201+
if not self.__is_solved:
202+
raise RuntimeError("Please call solve() before fetching the solution.")
203+
if not self.__has_solution:
204+
raise RuntimeError(
205+
"No satisfying assignment exists for the given expression."
206+
)
207+
return self.__variable_assignments.copy()
208+
209+
def __depth_first_search_for_topological_order(
210+
self, u: int, visited: list[bool], topological_order: list[int]
211+
) -> None:
212+
"""
213+
Performs DFS to compute topological order.
214+
215+
Args:
216+
u (int): Current node
217+
visited (list[bool]): Visited array
218+
topological_order (list[int]): list to store topological order
219+
"""
220+
visited[u] = True
221+
for v in self.__graph[u]:
222+
if not visited[v]:
223+
self.__depth_first_search_for_topological_order(
224+
v, visited, topological_order
225+
)
226+
topological_order.append(u)
227+
228+
def __depth_first_search_for_scc(
229+
self, u: int, visited: list[bool], component: list[int], scc_id: int
230+
) -> None:
231+
"""
232+
Performs DFS on the transposed graph to identify SCCs.
233+
234+
Args:
235+
u (int): Current node
236+
visited (list[bool]): Visited array
237+
component (list[int]): Array to store component IDs
238+
scc_id (int): Current SCC identifier
239+
"""
240+
visited[u] = True
241+
component[u] = scc_id
242+
for v in self.__graph_transpose[u]:
243+
if not visited[v]:
244+
self.__depth_first_search_for_scc(v, visited, component, scc_id)
245+
246+
def __negate(self, a: int) -> int:
247+
"""
248+
Returns the index representing the negation of the given variable.
249+
250+
Args:
251+
a (int): The variable index
252+
253+
Mapping rule:
254+
-------------
255+
For a variable i:
256+
negate(i) = i + n
257+
For a negated variable (i + n):
258+
negate(i + n) = i
259+
where n = number_of_variables
260+
261+
262+
Returns:
263+
int:
264+
The index representing its negation
265+
"""
266+
return (
267+
a + self.__number_of_variables
268+
if a <= self.__number_of_variables
269+
else a - self.__number_of_variables
270+
)

0 commit comments

Comments
 (0)