Skip to content
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

Add TSP Graph and Nearest Neighborhood Heuristic #12418

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DIRECTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@
* Tests
* [Test Min Spanning Tree Kruskal](graphs/tests/test_min_spanning_tree_kruskal.py)
* [Test Min Spanning Tree Prim](graphs/tests/test_min_spanning_tree_prim.py)
* [Travelling Salesman Problem](graphs/travelling_salesman_problem.py)

## Greedy Methods
* [Best Time To Buy And Sell Stock](greedy_methods/best_time_to_buy_and_sell_stock.py)
Expand Down
371 changes: 371 additions & 0 deletions graphs/travelling_salesman_problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,371 @@
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
CedricAnover marked this conversation as resolved.
Show resolved Hide resolved
"""
Examples:
>>> tsp_edge = TSPEdge.from_3_tuple(1, 2, 0.5)
>>> str(tsp_edge)
'(frozenset({1, 2}), 0.5)'
"""
return f"({self.vertices}, {self.weight})"

def __post_init__(self) -> None:
# 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, vertex_1: T, vertex_2: T, weight: float) -> "TSPEdge":
"""
Construct TSPEdge from a 3-tuple (x, y, w).
x & y are vertices and w is the weight.

Examples:
>>> tsp_edge = TSPEdge.from_3_tuple(1, 2, 0.5)
>>> tsp_edge.vertices
frozenset({1, 2})
>>> tsp_edge.weight
0.5
"""
return cls(frozenset([vertex_1, vertex_2]), weight)

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
"""
Examples:
>>> tsp_edge_1 = TSPEdge.from_3_tuple(1, 2, 0.5)
>>> tsp_edge_2 = TSPEdge.from_3_tuple(2, 1, 0.7)
>>> tsp_edge_1 == tsp_edge_2
True
"""
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
"""
Examples:
>>> tsp_edge_1 = TSPEdge.from_3_tuple(1, 2, 1.0)
>>> tsp_edge_2 = TSPEdge.from_3_tuple(2, 1, 2.5)
>>> tsp_edge_1 + tsp_edge_2
3.5
"""
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):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide return type hint for the function: __init__. If the function does not return a value, please provide the type hint as: def function() -> None:

CedricAnover marked this conversation as resolved.
Show resolved Hide resolved
self._edges = edges or frozenset()

def __str__(self) -> str:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function __str__

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":

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function from_3_tuples

Please provide type hint for the parameter: edges

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":

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function from_weights

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]:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function vertices

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]:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function edges

CedricAnover marked this conversation as resolved.
Show resolved Hide resolved
return self._edges

@property
def weight(self) -> float:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function weight

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function __contains__

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function is_edge_in_graph

Please provide descriptive name for the parameter: x

Please provide descriptive name for the parameter: y

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":

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function add_edge

Please provide descriptive name for the parameter: x

Please provide descriptive name for the parameter: y

Please provide descriptive name for the parameter: w

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]]:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_edges

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_edge_weight

Please provide descriptive name for the parameter: x

Please provide descriptive name for the parameter: y

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]:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_vertex_neighbors

Please provide descriptive name for the parameter: x

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_vertex_degree

Please provide descriptive name for the parameter: x

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_vertex_argmin

Please provide descriptive name for the parameter: x

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_vertex_argmax

Please provide descriptive name for the parameter: x

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]]:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function get_vertex_neighbor_weights

Please provide descriptive name for the parameter: x

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:

Choose a reason for hiding this comment

The 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 graphs/travelling_salesman_problem.py, please provide doctest for the function path_weight

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.

Examples:
>>> graph = TSPGraph.from_3_tuples((1, 2, 2), (2, 3, 4), (3, 4, 2), (4, 5, 1))
>>> path_weight([1, 2, 3], graph)
6
>>> path_weight([1, 2, 3, 4], graph)
8
>>> path_weight([1, 2, 3, 4, 5], graph)
9
"""
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.

Examples:
>>> graph = TSPGraph.from_3_tuples((1, 2, 2), (2, 3, 4), (3, 1, 2))
>>> graph_generator = generate_paths(1, 3, graph)
>>> next(graph_generator)
[1, 2, 3]
>>> next(graph_generator)
[1, 3]
"""

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, current_vertex: T, visited_: list[T] | None = None
) -> list[T] | None:
"""
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.

Examples:
>>> 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)
... ]
>>> graph = TSPGraph.from_3_tuples(*edges)
>>> import random
>>> init_v = random.choice(list(graph.vertices))
>>> result = nearest_neighborhood(graph, init_v)
>>> assert result in [
... ['A', 'D', 'C', 'E', 'B', 'A'],
... ['E', 'A', 'D', 'C', 'B', 'E'],
... None
... ]
>>> path_1 = ['A', 'D', 'C', 'E', 'B', 'A']
>>> path_2 = ['E', 'A', 'D', 'C', 'B', 'E']
>>> assert path_weight(path_1, graph) == 24 if result == path_1 else 19 or None
>>> assert path_weight(path_2, graph) == 19 if result == path_2 else 24 or None
"""
# Initialize visited list on first call
visited = visited_ or [current_vertex]

# 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(current_vertex)
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, current_vertex=next_v, visited_=[*visited, next_v]
)
else:
# No more neighbors, return None (cannot form a complete tour)
return None


if __name__ == "__main__":
import doctest

doctest.testmod()
Loading