Skip to content
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
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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}"
}
]
}
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"python-envs.defaultEnvManager": "ms-python.python:conda",
"python-envs.defaultPackageManager": "ms-python.python:conda"
}
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions dijkstra.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions eulerian_path.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions eulerian_solution.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions graph.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection

Expand All @@ -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.
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions graph_utils.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions instances/simple_map.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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))
Expand Down
44 changes: 44 additions & 0 deletions min_weight_perfect_matching.py
Original file line number Diff line number Diff line change
@@ -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