diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c515479 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Debugger Python: Main with arguments", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/main.py", + "console": "integratedTerminal", + "args": "${command:pickArgs}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4b5a294 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/README.md b/README.md index 4b759b2..05322ae 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,15 @@ A few graph instances are available in a folder named `instances`. Good luck! + + +# Comment on the solution + +The solution is based on the construction of a semi-eulerian graph, by doubling +the edges starting from odd-degree vertices with the smallest weights. + +In the current version, it is still non-optimal for the following reasons: +- the minimum weight perfect matching relies on a greedy solution +- large graphs take a long time to be processed because of the repetition of +dijkstra executions +- separated graphs (as in the islands example) are not handled. \ No newline at end of file diff --git a/dijkstra.py b/dijkstra.py new file mode 100644 index 0000000..d46864e --- /dev/null +++ b/dijkstra.py @@ -0,0 +1,34 @@ +import numpy as np +from graph import Graph + +def dijkstra(graph: Graph, start_vertex: int) -> tuple[np.ndarray, np.ndarray]: + """Implement Dijkstra's algorithm to find the shortest paths from a starting + vertex to all other vertices in the graph.""" + num_vertices = len(graph.vertices) + # Initialize result matrices + distances = np.full(num_vertices, np.inf) + previous_vertices = np.full(num_vertices, -1, dtype=int) + # Set the distance to the starting vertex to 0 + distances[start_vertex] = 0 + # Utility matrix to keep track of visited vertices + visited = np.zeros(num_vertices, dtype=bool) + + for _ in range(num_vertices): + # Select the unvisited vertex with the smallest distance + current_vertex = np.argmin(np.where(visited, np.inf, distances)) + visited[current_vertex] = True + + # Update distances to neighboring vertices + for neighbor in graph.adjacency_matrix[current_vertex].nonzero()[0]: + # Avoid recomputing distances for visited vertices + if not visited[neighbor]: + # Compute the distance passing through the current vertex + edge_weight = graph.edges[ + graph.edge_index_map[(current_vertex, neighbor)]][2] + new_distance = distances[current_vertex] + edge_weight + # Compare with the previously known distance and update if smaller + if new_distance < distances[neighbor]: + distances[neighbor] = new_distance + previous_vertices[neighbor] = current_vertex + + return distances, previous_vertices \ No newline at end of file diff --git a/eulerian_path.py b/eulerian_path.py new file mode 100644 index 0000000..5cdca86 --- /dev/null +++ b/eulerian_path.py @@ -0,0 +1,61 @@ +import numpy as np +from graph import Graph + +def find_eulerian_path(adjacency_matrix: np.ndarray) -> list: + """Find an eulerian path in a graph represented by its adjacency matrix. + + We use the Hierholzer's algorithm. + + Return : + path : list[int] + List of vertex ids representing the eulerian path.""" + # Step 1: Check if an eulerian path exists + odd_degree_vertices = \ + np.nonzero(adjacency_matrix.sum(axis=0) % 2 == 1)[0] + num_odd_degree_vertices = len(odd_degree_vertices) + if num_odd_degree_vertices > 2: + raise RuntimeError( + "The graph must have at most 2 vertices with odd degree to have an \ + eulerian path.") + # Step 2: Find the starting vertex for the path + if num_odd_degree_vertices == 2: + # The graph is semi-eulerian. Start from one of the odd degree vertices + start_vertex = odd_degree_vertices[0] + else: + # The graph is eulerian. Pick any vertex as the starting point + start_vertex = 0 + # Step 3: initialize the path, the current subtour and the uncovered edges + path = np.array([start_vertex], dtype=int) + subtour = [start_vertex] + uncovered_edges = adjacency_matrix.copy() + # Step 4: Perform subtours through the graph until we have covered all edges + while uncovered_edges.sum() > 0: + current_vertex = subtour[-1] + # Find a neighbor of the current vertex with an uncovered edge + uncovered_edges_idx = np.nonzero(uncovered_edges[current_vertex, :]) + if len(uncovered_edges_idx[0]) == 0: + # We reached the second vertex with an odd degree, + # the subtour is inserted at the end of the path + path = np.append(path, subtour[1:]) + # Start a new subtour from any vertex with uncovered edges + if uncovered_edges.sum() > 0: + subtour = [path[np.nonzero(uncovered_edges[path].sum(axis=1))[0][0]]] + continue + next_vertex = uncovered_edges_idx[0][0] + # Add the edge to the subtour and mark it as covered + subtour.append(next_vertex) + uncovered_edges[current_vertex, next_vertex] -= 1 + uncovered_edges[next_vertex, current_vertex] -= 1 + # If we have returned to the starting vertex, we have completed a subtour + if next_vertex == subtour[0]: + # insert the subtour into the main path + insertion_index = np.where(path == subtour[0])[0][0] + path = np.insert(path, insertion_index + 1, subtour[1:]) + # Start a new subtour from any vertex with uncovered edges + if uncovered_edges[path].sum() > 0: + subtour = [path[np.nonzero(uncovered_edges[path].sum(axis=1))[0][0]]] + elif uncovered_edges.sum() > 0: + # TODO: manage the case of two graphs disconnected + raise RuntimeError( + "The remaining of the graph is not connected to the path, no eulerian path exists.") + return path \ No newline at end of file diff --git a/eulerian_solution.py b/eulerian_solution.py new file mode 100644 index 0000000..24fe820 --- /dev/null +++ b/eulerian_solution.py @@ -0,0 +1,58 @@ +import numpy as np +from graph import Graph +from dijkstra import dijkstra +from min_weight_perfect_matching import min_weight_perfect_matching +from graph_utils import double_edges +from eulerian_path import find_eulerian_path + + +def eulerian_solution(graph: Graph) -> list: + """Implement a eulerian solution to find a path in the graph which travels + all edges. + This solution is based on the construction of an semi-eulerian graph, by + doubling edges with the smallest weights.""" + # Step 1: Find vertices with odd degree + odd_degree_vertices = \ + np.nonzero(graph.adjacency_matrix.sum(axis=0) % 2 == 1)[0] + # If the graph is already semi-eulerian, we can directly find an eulerian path + if len(odd_degree_vertices) <= 2: + adjacency_matrix = graph.adjacency_matrix + # Otherwise, we need to double some edges to make the graph semi-eulerian + else: + # Step 2: Compute the shortest paths between each odd degree vertices + distances = \ + np.zeros((len(odd_degree_vertices), len(odd_degree_vertices))) + shortest_paths = \ + -1 * np.ones((len(graph.vertices), len(graph.vertices)), dtype=int) + for i in range(len(odd_degree_vertices)): + # TODO: this for loop is very big in cases of large graphs + # we could optimize it by only computing the shortest paths between + # odd degree vertices, or by using a more efficient algorithm + distances_to_i, shortest_paths_from_i = \ + dijkstra(graph, odd_degree_vertices[i]) + distances[i] = distances_to_i[odd_degree_vertices] + shortest_paths[odd_degree_vertices[i]] = shortest_paths_from_i + # Step 3: Find the minimum weight perfect matching between those vertices + matching = min_weight_perfect_matching(distances, odd_degree_vertices) + # Step 4: Double the edges along the shortest paths of each matching + # (except the last one, as only a semi-eulerian graph is needed) + adjacency_matrix = double_edges( + graph.adjacency_matrix, matching[:-1], shortest_paths) + # Step 5: Find an eulerian path in the virtual graph + eulerian_path = find_eulerian_path(adjacency_matrix) + # Step 6: Convert the path in a correct format + path = [] + for i, current_vertex in enumerate(eulerian_path[:-1]): + next_vertex = eulerian_path[i + 1] + edge = graph.edges[graph.edge_index_map[(current_vertex, next_vertex)]] + path.append( + ( + current_vertex, + next_vertex, + edge[2], + graph.vertices[current_vertex], + graph.vertices[next_vertex], + ) + ) + + return path diff --git a/graph.py b/graph.py index 5ce4d4a..0e8077c 100644 --- a/graph.py +++ b/graph.py @@ -1,5 +1,6 @@ from __future__ import annotations +import numpy as np import matplotlib.pyplot as plt from matplotlib.collections import LineCollection @@ -22,10 +23,26 @@ def __init__( edges : list[Edge] List of edges as tuple (id 1, id 2, weight, coordinates 1, coordinates 2). + + adjacency_matrix : np.ndarray + Adjacency matrix of the graph, with 0 if there is no edge between two vertices, + and the number of edges otherwise. + + edge_index_map : dict[tuple[int, int], int] + A hashmap to easily access the index of an edge given its vertices ids. """ self.vertices = vertices self.edges = edges + # Adjacency matrix of the graph, to simplify further algorithms. + self.adjacency_matrix = self._compute_adjacency_matrix() + # edge hashmap to easily access the index of an edge + self.edge_index_map = { + (edge[0], edge[1]): i for i, edge in enumerate(self.edges)} + self.edge_index_map.update( + {(edge[1], edge[0]): i for i, edge in enumerate(self.edges)} + ) + def plot(self): """ Plot the graph. @@ -67,3 +84,10 @@ def display_paths(*, paths: list[list[Edge]]): arrowprops=dict(arrowstyle="->", color=colors(path_index)), ) plt.show() + + def _compute_adjacency_matrix(self) -> np.ndarray: + adjacency_matrix = np.zeros((len(self.vertices), len(self.vertices)), dtype=int) + for edge in self.edges: + adjacency_matrix[edge[0], edge[1]] += 1 + adjacency_matrix[edge[1], edge[0]] += 1 + return adjacency_matrix diff --git a/graph_utils.py b/graph_utils.py new file mode 100644 index 0000000..9d05395 --- /dev/null +++ b/graph_utils.py @@ -0,0 +1,15 @@ +import numpy as np +from graph import Graph + +def double_edges(adjacency_matrix: np.ndarray, matching: list, shortest_paths: np.ndarray) -> np.ndarray: + """Double the edges along paths between two matched vertices.""" + new_adjacency_matrix = adjacency_matrix.copy() + for i, j in matching: + # Travel along the shortest path between i and j and double the edges + current_vertex = j + while current_vertex != i: + previous_vertex = shortest_paths[i, current_vertex] + new_adjacency_matrix[current_vertex, previous_vertex] += 1 + new_adjacency_matrix[previous_vertex, current_vertex] += 1 + current_vertex = previous_vertex + return new_adjacency_matrix \ No newline at end of file diff --git a/instances/simple_map.txt b/instances/simple_map.txt new file mode 100644 index 0000000..cf5fb77 --- /dev/null +++ b/instances/simple_map.txt @@ -0,0 +1,14 @@ +6 7 +0 1 +2 0 +2 4 +4 1 +3 4 +6 4 +0 1 1 +0 2 3 +1 2 6 +2 4 1 +1 3 2 +3 4 5 +3 5 10 \ No newline at end of file diff --git a/main.py b/main.py index 0248ba1..21ac828 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from input import parse_cmd_line, parse_file from graph import Graph from example_solution import example_solution +from eulerian_solution import eulerian_solution def main(): @@ -9,7 +10,7 @@ def main(): print(f"#E={len(edges)}, #V={len(vertices)}") graph = Graph(vertices, edges) - path = example_solution(graph) + path = eulerian_solution(graph) print("Length of path found:", len(path) + 1) print("Value of path found:", sum(edge[2] for edge in path)) diff --git a/min_weight_perfect_matching.py b/min_weight_perfect_matching.py new file mode 100644 index 0000000..d36c166 --- /dev/null +++ b/min_weight_perfect_matching.py @@ -0,0 +1,44 @@ +import numpy as np +from graph import Graph + + +def _greedy_matching(distances: np.ndarray, vertices_index: list) -> list: + """Find a greedy perfect matching given the distances between vertices.""" + # Initialize matching list + matching = [] + # Create a distances matrix that keeps track of edges between unmatched vertices + remaining_edges = distances.copy() + # Add infinite weights to the diagonal to avoid matching a vertex with itself + remaining_edges += np.diag([np.inf] * len(remaining_edges)) + while len(matching) < len(distances) // 2: + # Find the edge with the smallest weight + i, j = np.unravel_index(np.argmin(remaining_edges), remaining_edges.shape) + # Add the edge to the matching + matching.append((vertices_index[i], vertices_index[j])) + # Remove the matched vertices from the remaining edges + remaining_edges[i, :] = np.inf + remaining_edges[:, i] = np.inf + remaining_edges[j, :] = np.inf + remaining_edges[:, j] = np.inf + return matching + + +def min_weight_perfect_matching(distances: np.ndarray, vertices_index: list) -> list: + """Find the minimum weight perfect matching in a complete graph given the + distances between vertices. + + Parameters + ---------- + distances : np.ndarray + A matrix of distances between vertices. + vertices_index : list + A list of vertex indices. + + Return : + matching : list[tuple[int, int]] + List of matched vertices as tuples (vertex 1, vertex 2). + """ + # For now we use a greedy solution, which is not optimal + matching = _greedy_matching(distances, vertices_index) + # TODO: Implement a more efficient algorithm, such as the Blossom algorithm + return matching \ No newline at end of file