-
-
Notifications
You must be signed in to change notification settings - Fork 46.8k
Add TSP Graph and Nearest Neighborhood Heuristic #12418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,354 @@ | ||
import itertools | ||
from collections.abc import Generator, Hashable, Sequence | ||
from dataclasses import dataclass | ||
from typing import Generic, TypeVar | ||
|
||
T = TypeVar("T", bound=int | str | Hashable) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class TSPEdge(Generic[T]): | ||
""" | ||
Represents an edge in a graph for the Traveling Salesman Problem (TSP). | ||
|
||
Attributes: | ||
vertices (frozenset[T]): A pair of vertices representing the edge. | ||
weight (float): The weight (or cost) of the edge. | ||
""" | ||
|
||
vertices: frozenset[T] | ||
weight: float | ||
|
||
def __str__(self) -> str: | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return f"({self.vertices}, {self.weight})" | ||
|
||
def __post_init__(self): | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Ensures that there is no loop in a vertex | ||
if len(self.vertices) != 2: | ||
raise ValueError("frozenset must have exactly 2 elements") | ||
|
||
@classmethod | ||
def from_3_tuple(cls, x, y, w) -> "TSPEdge": | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Construct TSPEdge from a 3-tuple (x, y, w). | ||
x & y are vertices and w is the weight. | ||
""" | ||
return cls(frozenset([x, y]), w) | ||
|
||
def __eq__(self, other: object) -> bool: | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if not isinstance(other, TSPEdge): | ||
return NotImplemented | ||
return self.vertices == other.vertices | ||
|
||
def __add__(self, other: "TSPEdge") -> float: | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return self.weight + other.weight | ||
|
||
|
||
class TSPGraph(Generic[T]): | ||
""" | ||
Represents a graph for the Traveling Salesman Problem (TSP). | ||
The graph is: | ||
- Simple (no loops or multiple edges between vertices). | ||
- Undirected. | ||
- Connected. | ||
""" | ||
|
||
def __init__(self, edges: frozenset[TSPEdge] | None = None): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide return type hint for the function:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self._edges = edges or frozenset() | ||
|
||
def __str__(self) -> str: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return f"{[str(edge) for edge in self._edges]}" | ||
|
||
@classmethod | ||
def from_3_tuples(cls, *edges) -> "TSPGraph": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide type hint for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return cls(frozenset(TSPEdge.from_3_tuple(x, y, w) for x, y, w in edges)) | ||
|
||
@classmethod | ||
def from_weights(cls, weights: list) -> "TSPGraph": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Create TSPGraph from Weights (List of Lists) where the vertices | ||
are labeled with integers. | ||
""" | ||
triples = [ | ||
(x, y, weights[x][y]) | ||
for x, y in itertools.product(range(len(weights)), range(len(weights[0]))) | ||
if x != y # Filter out self-loops | ||
] | ||
# return cls.from_3_tuples(*cast(list[tuple[T, T, float]], triples)) | ||
return cls.from_3_tuples(*triples) | ||
|
||
@property | ||
def vertices(self) -> frozenset[T]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return frozenset(vertex for edge in self._edges for vertex in edge.vertices) | ||
|
||
@property | ||
def edges(self) -> frozenset[TSPEdge]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return self._edges | ||
|
||
@property | ||
def weight(self) -> float: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Total Weight of TSPGraph.""" | ||
return sum(edge.weight for edge in self._edges) | ||
|
||
def __contains__(self, obj: T | TSPEdge) -> bool: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if isinstance(obj, TSPEdge): | ||
return any(obj == edge_ for edge_ in self._edges) | ||
else: | ||
return obj in self.vertices | ||
|
||
def is_edge_in_graph(self, x: T, y: T) -> bool: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter: Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return frozenset([x, y]) in self.get_edges() | ||
|
||
def add_edge(self, x: T, y: T, w: float) -> "TSPGraph": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter: Please provide descriptive name for the parameter: Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Validator to check if either x or y is in the vertex set to ensure | ||
# that the graph would be connected | ||
# Only use this validator if there exist at least 1 edge in the edge set. | ||
if self._edges and x not in self and y not in self: | ||
error_message = f"Adding the edge ({x}, {y}) may form a disconnected graph." | ||
raise ValueError(error_message) | ||
|
||
new_edge = TSPEdge.from_3_tuple( | ||
x, y, w | ||
) # This would raise Vertex Loop error if x == y | ||
|
||
# Raise error if Multi-Edges | ||
if new_edge in self: | ||
error_message = f"({x}, {y}, {w}) is invalid." | ||
raise ValueError(error_message) | ||
|
||
return TSPGraph( | ||
edges=frozenset(self._edges | frozenset([TSPEdge.from_3_tuple(x, y, w)])) | ||
) | ||
|
||
def get_edges(self) -> list[frozenset[T]]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return [edge.vertices for edge in self.edges] | ||
|
||
def get_edge_weight(self, x: T, y: T) -> float: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter: Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (x not in self) or (y not in self): | ||
error_message = f"{x} or {y} does not belong to the graph vertices." | ||
raise ValueError(error_message) | ||
|
||
# Find the edge with vertices (x, y) | ||
edge = next( | ||
(edge for edge in self.edges if frozenset([x, y]) == edge.vertices), None | ||
) | ||
|
||
if edge is None: | ||
error_message = f"No edge exists between {x} and {y}." | ||
raise ValueError(error_message) | ||
|
||
return edge.weight | ||
|
||
def get_vertex_neighbors(self, x: T) -> frozenset[T]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if x not in self.vertices: | ||
error_message = f"{x} does not belong to the graph vertex set." | ||
raise ValueError(error_message) | ||
return frozenset( | ||
next(iter(edge.vertices - frozenset([x]))) | ||
for edge in self.edges | ||
if x in edge.vertices | ||
) | ||
|
||
def get_vertex_degree(self, x: T) -> int: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if x not in self.vertices: | ||
error_message = f"{x} does not belong to the graph vertices." | ||
raise ValueError(error_message) | ||
return sum(1 for edge in self.edges if x in edge.vertices) | ||
|
||
def get_vertex_argmin(self, x: T) -> T: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Returns the Neighbor of a Vertex with the Minimum Weight.""" | ||
return min( | ||
[(y, self.get_edge_weight(x, y)) for y in self.get_vertex_neighbors(x)], | ||
key=lambda tup: tup[1], | ||
)[0] | ||
|
||
def get_vertex_argmax(self, x: T) -> T: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Returns the Neighbor of a Vertex with the Maximum Weight.""" | ||
return max( | ||
[(y, self.get_edge_weight(x, y)) for y in self.get_vertex_neighbors(x)], | ||
key=lambda tup: tup[1], | ||
)[0] | ||
|
||
def get_vertex_neighbor_weights(self, x: T) -> Sequence[tuple[T, float]]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file Please provide descriptive name for the parameter:
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Sort by Smallest to Largest | ||
return sorted( | ||
[(y, self.get_edge_weight(x, y)) for y in self.get_vertex_neighbors(x)], | ||
key=lambda tup: tup[1], # pair[1] is the weight (float) | ||
) | ||
|
||
|
||
def adjacent_tuples(path: list[T]) -> zip: | ||
""" | ||
Generates adjacent pairs of elements from a path. | ||
|
||
Args: | ||
path (list[T]): A list of vertices representing a path. | ||
|
||
Returns: | ||
zip: A zip object containing tuples of adjacent vertices. | ||
|
||
Examples | ||
>>> list(adjacent_tuples([1, 2, 3, 4, 5])) | ||
[(1, 2), (2, 3), (3, 4), (4, 5)] | ||
|
||
>>> list(adjacent_tuples(["A", "B", "C", "D", "E"])) | ||
[('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'E')] | ||
""" | ||
iter1, iter2 = itertools.tee(path) | ||
next(iter2, None) | ||
return zip(iter1, iter2) | ||
|
||
|
||
def path_weight(path: list[T], tsp_graph: TSPGraph) -> float: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As there is no test file in this pull request nor any test function or class in the file
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Calculates the total weight of a given path in the graph. | ||
|
||
Args: | ||
path (list[T]): A list of vertices representing a path. | ||
tsp_graph (TSPGraph): The graph containing the edges and weights. | ||
|
||
Returns: | ||
float: The total weight of the path. | ||
""" | ||
return sum(tsp_graph.get_edge_weight(x, y) for x, y in adjacent_tuples(path)) | ||
|
||
|
||
def generate_paths(start: T, end: T, tsp_graph: TSPGraph) -> Generator[list[T]]: | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Generates all possible paths between two vertices in a | ||
TSPGraph using Depth-First Search (DFS). | ||
|
||
Args: | ||
start (T): The starting vertex. | ||
end (T): The target vertex. | ||
tsp_graph (TSPGraph): The graph to traverse. | ||
|
||
Yields: | ||
Generator[list[T]]: A generator yielding paths as lists of vertices. | ||
|
||
Raises: | ||
AssertionError: If start or end is not in the graph, or if they are the same. | ||
""" | ||
|
||
assert start in tsp_graph.vertices | ||
assert end in tsp_graph.vertices | ||
assert start != end | ||
|
||
def dfs( | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
current: T, target: T, visited: set[T], path: list[T] | ||
) -> Generator[list[T]]: | ||
visited.add(current) | ||
path.append(current) | ||
|
||
# If we reach the target, yield the current path | ||
if current == target: | ||
yield list(path) | ||
else: | ||
# Recur for all unvisited neighbors | ||
for neighbor in tsp_graph.get_vertex_neighbors(current): | ||
if neighbor not in visited: | ||
yield from dfs(neighbor, target, visited, path) | ||
|
||
# Backtrack | ||
path.pop() | ||
visited.remove(current) | ||
|
||
# Initialize DFS | ||
yield from dfs(start, end, set(), []) | ||
|
||
|
||
def nearest_neighborhood(tsp_graph: TSPGraph, v, visited_=None) -> list[T] | None: | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Approximates a solution to the Traveling Salesman Problem | ||
using the Nearest Neighbor heuristic. | ||
|
||
Args: | ||
tsp_graph (TSPGraph): The graph to traverse. | ||
v (T): The starting vertex. | ||
visited_ (list[T] | None): A list of already visited vertices. | ||
|
||
Returns: | ||
list[T] | None: A complete Hamiltonian cycle if possible, otherwise None. | ||
""" | ||
# Initialize visited list on first call | ||
visited = visited_ or [v] | ||
|
||
# Base case: if all vertices are visited | ||
if len(visited) == len(tsp_graph.vertices): | ||
# Check if there is an edge to return to the starting point | ||
if tsp_graph.is_edge_in_graph(visited[-1], visited[0]): | ||
return [*visited, visited[0]] | ||
else: | ||
return None | ||
|
||
# Get unvisited neighbors | ||
filtered_neighbors = [ | ||
tup for tup in tsp_graph.get_vertex_neighbor_weights(v) if tup[0] not in visited | ||
] | ||
|
||
# If there are unvisited neighbors, continue to the nearest one | ||
if filtered_neighbors: | ||
next_v = min(filtered_neighbors, key=lambda tup: tup[1])[0] | ||
return nearest_neighborhood(tsp_graph, v=next_v, visited_=[*visited, next_v]) | ||
else: | ||
# No more neighbors, return None (cannot form a complete tour) | ||
return None | ||
|
||
|
||
def sample_1(): | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Reference: https://graphicmaths.com/computer-science/graph-theory/travelling-salesman-problem/ | ||
|
||
edges = [ | ||
("A", "B", 7), | ||
("A", "D", 1), | ||
("A", "E", 1), | ||
("B", "C", 3), | ||
("B", "E", 8), | ||
("C", "E", 2), | ||
("C", "D", 6), | ||
("D", "E", 7), | ||
] | ||
|
||
# Create the graph | ||
graph = TSPGraph.from_3_tuples(*edges) | ||
|
||
import random | ||
|
||
init_v = random.choice(list(graph.vertices)) | ||
optim_path = nearest_neighborhood(graph, init_v) | ||
# optim_path = nearest_neighborhood(graph, 'A') | ||
print(f"Optimal Cycle: {optim_path}") | ||
if optim_path: | ||
print(f"Optimal Weight: {path_weight(optim_path, graph)}") | ||
|
||
|
||
def sample_2(): | ||
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
CedricAnover marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Example 8x8 weight matrix (symmetric, no self-loops) | ||
weights = [ | ||
[0, 1, 2, 3, 4, 5, 6, 7], | ||
[1, 0, 8, 9, 10, 11, 12, 13], | ||
[2, 8, 0, 14, 15, 16, 17, 18], | ||
[3, 9, 14, 0, 19, 20, 21, 22], | ||
[4, 10, 15, 19, 0, 23, 24, 25], | ||
[5, 11, 16, 20, 23, 0, 26, 27], | ||
[6, 12, 17, 21, 24, 26, 0, 28], | ||
[7, 13, 18, 22, 25, 27, 28, 0], | ||
] | ||
|
||
graph = TSPGraph.from_weights(weights) | ||
|
||
import random | ||
|
||
init_v = random.choice(list(graph.vertices)) | ||
optim_path = nearest_neighborhood(graph, init_v) | ||
print(f"Optimal Cycle: {optim_path}") | ||
if optim_path: | ||
print(f"Optimal Weight: {path_weight(optim_path, graph)}") | ||
|
||
|
||
if __name__ == "__main__": | ||
import doctest | ||
|
||
doctest.testmod() | ||
sample_1() | ||
sample_2() |
Uh oh!
There was an error while loading. Please reload this page.