Skip to content

Commit e23b08e

Browse files
authored
Add TravelingSalesman search problem with an MST heuristic (#1194)
Add TravelingSalesman(Problem) that finds optimal Hamiltonian-cycle tours via A* using a minimum-spanning-tree (Kruskal) heuristic over the unvisited cities. DisjointSets fixed to per-instance state; test added verifying the optimal tour.
1 parent 88813ad commit e23b08e

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

search.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,119 @@ def h(self, node):
532532

533533
# ______________________________________________________________________________
534534

535+
class TravelingSalesman(Problem):
536+
""" The problem of finding the shortest Hamiltonian cycle (tour) that visits
537+
every city exactly once and returns to the start."""
538+
539+
def __init__(self, all_cities, initial, goal=None):
540+
# initial = (0), goal = some state that its first and last values are 0
541+
""" Define goal state and initialize a problem """
542+
super().__init__(initial, goal)
543+
self.cities_matrix = self.init_distance_matrix(all_cities)
544+
self.cities_identifiers = set(all_cities.keys())
545+
self.cities_amount = len(self.cities_identifiers)
546+
self.trees_evaluation_hash = {}
547+
548+
def value(self, state):
549+
""" Define state value """
550+
overall_distance = 0.0
551+
state_max_ind = len(state) - 1
552+
for ind, city in enumerate(state):
553+
if ind != state_max_ind:
554+
overall_distance += self.cities_matrix[state[ind]][state[ind + 1]]
555+
return overall_distance
556+
557+
def actions(self, state):
558+
""" Return the actions that can be executed in the given state.
559+
The result would be a list"""
560+
if len(state) == self.cities_amount:
561+
return [0]
562+
return self.get_unvisited_cities(state)
563+
564+
def result(self, state, action):
565+
""" Given state and action, return a new state that is the result of the action.
566+
Action is assumed to be a valid action in the state """
567+
568+
# blank is the index of the blank square
569+
new_state = list(state)
570+
new_state.append(action)
571+
return tuple(new_state)
572+
573+
def goal_test(self, state):
574+
""" Given a state, return True if state is a goal state or False, otherwise"""
575+
return len(state) == (self.cities_amount + 1) and state[0] == state[self.cities_amount]
576+
577+
def get_unvisited_cities(self, state):
578+
""" Returns unused actions(cities) """
579+
return self.cities_identifiers.difference(state)
580+
581+
def path_cost(self, c, state1, action, state2):
582+
""" Return next state cost """
583+
return c + self.cities_matrix[state1[-1]][state2[-1]]
584+
585+
def h(self, node):
586+
h = 0.0
587+
unvisited_cities = self.get_unvisited_cities(node.state)
588+
mst_evaluation = self.eval_unvisited_mst_edges_sum(unvisited_cities)
589+
h += mst_evaluation
590+
return h
591+
592+
def eval_unvisited_mst_edges_sum(self, unvisited_nodes):
593+
if not unvisited_nodes:
594+
return 0.0
595+
return self.tsp_kruskal(unvisited_nodes)
596+
597+
def tsp_kruskal(self, unvisited_nodes):
598+
""" Generate MST of the graph and return sum of edges """
599+
forest_value = 0.0
600+
disjoint_sets = DisjointSets()
601+
non_sorted_edges_list = []
602+
for vertex_x in unvisited_nodes:
603+
disjoint_sets.make_set(vertex_x)
604+
for vertex_y in unvisited_nodes:
605+
if vertex_x < vertex_y:
606+
non_sorted_edges_list.append((vertex_x, vertex_y))
607+
edges_list = sorted(non_sorted_edges_list, key=lambda edge: self.cities_matrix[edge[0]][edge[1]], reverse=False)
608+
for vertex_u, vertex_v in edges_list:
609+
if disjoint_sets.find(vertex_v) != disjoint_sets.find(vertex_u):
610+
forest_value += self.cities_matrix[vertex_u][vertex_v]
611+
disjoint_sets.union(vertex_v, vertex_u)
612+
return forest_value
613+
614+
def init_distance_matrix(self, all_cities):
615+
""" Generate matrix of distance between all cities """
616+
return {row_ind: {col_ind: distance(row_coords, col_coords) for col_ind, col_coords in all_cities.items()}
617+
for row_ind, row_coords in all_cities.items()}
618+
619+
620+
class DisjointSets:
621+
def __init__(self):
622+
self.parent = {}
623+
self.rank = {}
624+
625+
def make_set(self, vertex):
626+
self.parent[vertex] = vertex
627+
self.rank[vertex] = 0
628+
629+
def find(self, vertex):
630+
if self.parent[vertex] == vertex:
631+
return vertex
632+
return self.find(self.parent[vertex])
633+
634+
def union(self, vertex_u, vertex_v):
635+
vertex_u_root = self.find(vertex_u)
636+
vertex_v_root = self.find(vertex_v)
637+
638+
if self.rank[vertex_u_root] > self.rank[vertex_v_root]:
639+
self.parent[vertex_v_root] = vertex_u_root
640+
elif self.rank[vertex_u_root] < self.rank[vertex_v_root]:
641+
self.parent[vertex_u_root] = vertex_v_root
642+
else:
643+
self.parent[vertex_u_root] = vertex_v_root
644+
self.rank[vertex_v_root] += 1
645+
646+
647+
# ______________________________________________________________________________
535648

536649
class PlanRoute(Problem):
537650
""" The problem of moving the Hybrid Wumpus Agent from one place to other """

tests/test_search.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ def test_iterative_deepening_astar_search():
9191
assert iterative_deepening_astar_search(EightPuzzle((1, 2, 3, 4, 5, 6, 0, 7, 8))).solution() == ['RIGHT', 'RIGHT']
9292

9393

94+
def test_traveling_salesman():
95+
cities = {0: (0, 0), 1: (0, 1), 2: (1, 1), 3: (1, 0), 4: (0.5, 2)}
96+
tsp = TravelingSalesman(cities, initial=(0,))
97+
solution = astar_search(tsp).state
98+
# a valid tour starts and ends at the start city and visits every city once
99+
assert solution[0] == solution[-1] == 0
100+
assert set(solution) == set(cities)
101+
# the MST heuristic is admissible, so A* finds the optimal tour cost
102+
assert tsp.value(solution) == pytest.approx(3 + 5 ** 0.5)
103+
104+
94105
def test_find_blank_square():
95106
assert eight_puzzle.find_blank_square((0, 1, 2, 3, 4, 5, 6, 7, 8)) == 0
96107
assert eight_puzzle.find_blank_square((6, 3, 5, 1, 8, 4, 2, 0, 7)) == 7

0 commit comments

Comments
 (0)