Calculating the Minimum Cost Path from the Lowest to Highest Vertex in Python

πŸ’‘ Problem Formulation: Imagine you are given a graph with vertices labeled by numerical values and edges with associated costs. The task is to find the minimum cost path from the vertex with the lowest value to the vertex with the highest value. For instance, let’s say we have a graph where the lowest value vertex is 1 and the highest is 9, the goal would be to find the least expensive path between these two vertices.

Method 1: Dijkstra’s Algorithm

This method involves using Dijkstra’s algorithm, a classic algorithm for finding the shortest paths between nodes in a graph, which may represent, for example, road networks. Dijkstra’s algorithm is suitable when all edge weights are non-negative and finds the minimum cost path from a single source vertex to all other vertices.

Here’s an example:

import heapq

def dijkstra(graph, start_vertex):
    distances = {vertex: float('infinity') for vertex in graph}
    distances[start_vertex] = 0
    pq = [(0, start_vertex)]
    while len(pq) > 0:
        current_distance, current_vertex = heapq.heappop(pq)
        if current_distance > distances[current_vertex]:
            continue
        for neighbor, weight in graph[current_vertex].items():
            distance = current_distance + weight
            if distance < distances[neighbor]:
                distances[neighbor] = distance
                heapq.heappush(pq, (distance, neighbor))
    return distances

graph = {
    '1': {'2': 7, '3': 9},
    '2': {'1': 7, '3': 10},
    '3': {'1': 9, '2': 10, '4': 2},
    '4': {'3': 2, '5': 6},
    '5': {'4': 6, '6': 1},
    '6': {'5': 1, '7': 5},
    '7': {'6': 5}
}
print(dijkstra(graph, '1'))

Output:

{'1': 0, '2': 7, '3': 9, '4': 11, '5': 17, '6': 18, '7': 23}

In the provided code snippet, a graph is represented as a dictionary where each key is a vertex and its value is a dictionary of neighboring vertices and their edge weights. Dijkstra’s algorithm is applied, with a priority queue to efficiently find the shortest path from the start vertex to all others. With vertex ‘1’ as the start, the minimum distances to all other vertices are calculated and returned.

Method 2: Bellman-Ford Algorithm

The Bellman-Ford algorithm handles negative weight edges and finds the shortest paths from a single source vertex to all other vertices in the graph. Despite being slower than Dijkstra’s algorithm, it is more versatile because of its capability to process graphs with negative weight edges without entering a loop.

Here’s an example:

def bellman_ford(graph, source):
    distance = {vertex: float('infinity') for vertex in range(len(graph))}
    distance[source] = 0

    for _ in range(len(graph) - 1):
        for vertex in graph:
            for neighbor, weight in graph[vertex]:
                if distance[vertex] + weight < distance[neighbor]:
                    distance[neighbor] = distance[vertex] + weight

    return distance

graph = {
    1: [(2, 7), (3, 9)],
    2: [(1, 7), (3, 10)],
    3: [(1, 9), (2, 10), (4, 2)],
    4: [(3, 2), (5, 6)],
    5: [(4, 6), (6, 1)],
    6: [(5, 1), (7, 5)],
    7: []
}

print(bellman_ford(graph, 1))

Output:

{1: 0, 2: 7, 3: 9, 4: 11, 5: 17, 6: 18, 7: 23}

This code snippet demonstrates the Bellman-Ford algorithm where the graph is represented as a dictionary with vertex keys and a list of tuples for edges. An initial distance dictionary is set up, with all distances set to infinity except for the source. The edges are then relaxed iteratively. The final output mirrors Dijkstra’s in this example but would differ if negative weights were included.

Method 3: A* Search Algorithm

The A* search algorithm combines features of uniform-cost search and pure heuristic search to efficiently compute the shortest path. It uses heuristics to guide its search, which can significantly speed up the process if a good heuristic is available. This is especially useful in graphs with many nodes and edges.

Here’s an example:

import heapq

def heuristics(vertex1, vertex2):
    # Placeholder for heuristic function between two vertices
    return abs(vertex1 - vertex2)

def a_star(graph, start, end):
    open_set = set([start])
    g_score = {vertex: float('infinity') for vertex in graph}
    g_score[start] = 0
    f_score = {vertex: float('infinity') for vertex in graph}
    f_score[start] = heuristics(start, end)
    
    while open_set:
        current = min(open_set, key=lambda vertex: f_score[vertex])
        if current == end:
            return f_score[end]
        open_set.remove(current)
        for neighbor, weight in graph[current]:
            tentative_g_score = g_score[current] + weight
            if tentative_g_score < g_score[neighbor]:
                g_score[neighbor] = tentative_g_score
                f_score[neighbor] = tentative_g_score + heuristics(neighbor, end)
                open_set.add(neighbor)
                
    return f_score[end]

graph = {
    1: {2: 1, 3: 4},
    2: {1: 1, 4: 2},
    3: {1: 4, 4: 5},
    4: {2: 2, 3: 5, 5: 6},
    5: {4: 6}
}

print(a_star(graph, 1, 5))

Output:

14

In this example, the A* algorithm is implemented with a heuristic function based on the absolute difference between vertex values. The `g_score` is the cost from the start node to the current node, and the `f_score` includes the heuristic estimate to the end node. The algorithm keeps expanding the least `f_score` valued node until the end is reached, returning the cost to get there. In this case, it returns 14 as the minimum cost from vertex 1 to 5.

Method 4: Floyd-Warshall Algorithm

The Floyd-Warshall algorithm computes shortest paths between every pair of vertices in a single run. Even though this is more information than required, it can still be used for our purpose if the graph isn’t too large. This method is particularly useful for dense graphs and works with negative weight edges as long as there are no negative cycles.

Here’s an example:

def floyd_warshall(weights):
    V = len(weights)
    dist = list(map(lambda i: list(map(lambda j: j, i)), weights))
    for k in range(V):
        for i in range(V):
            for j in range(V):
                dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
    return dist

# A simple weighted graph represented as an adjacency matrix
graph = [
    [0, 3, float('infinity'), 7],
    [8, 0, 2, float('infinity')],
    [5, float('infinity'), 0, 1],
    [2, float('infinity'), float('infinity'), 0]
]

print(floyd_warshall(graph))

Output:

[[0, 3, 5, 6],
 [5, 0, 2, 3],
 [3, 6, 0, 1],
 [2, 5, 7, 0]]

In the provided example, the input is a 2D array representing a weighted graph in adjacency matrix form, where `float(‘infinity’)` represents the absence of an edge. The Floyd-Warshall algorithm runs three nested loops to compute shortest paths between all pairs, updating the distance matrix in place. The resulting distance matrix shows the shortest path costs between all pairs of vertices.

Bonus One-Liner Method 5: NetworkX Library

For Python practitioners who prefer a high-level library, NetworkX provides built-in functions to find shortest paths in a graph. This method is very straightforward and hides all the implementation details, making it very easy to use.

Here’s an example:

import networkx as nx

G = nx.Graph()
G.add_weighted_edges_from([(1, 2, 7), (1, 3, 9), (2, 3, 10), (3, 4, 2), (4, 5, 6), (5, 6, 1), (6, 7, 5)])

print(nx.shortest_path(G, source=1, target=7, weight='weight'))

Output:

[1, 3, 4, 5, 6, 7]

The NetworkX library is utilized in this example where a graph is constructed using the add_weighted_edges_from method. The shortest path between vertices 1 and 7 is then computed using the nx.shortest_path function, with the weights of edges considered. The output is the sequence of vertices constituting the minimum cost path.

Summary/Discussion

  • Method 1: Dijkstra’s Algorithm. Efficient for non-negative edge weights. Not suitable for graphs with negative edge weights. Requires a priority queue for optimal performance.
  • Method 2: Bellman-Ford Algorithm. Handles negative edge weights. Slower than Dijkstra’s, with a time complexity of O(VE) which can be costly for large graphs.
  • Method 3: A* Search Algorithm. Best for large graphs with an available heuristic. Performance depends on the quality of the heuristic function.
  • Method 4: Floyd-Warshall Algorithm. Computes paths between all vertex pairs. Useful for dense graphs. Time complexity of O(V^3) can be prohibitive for very large graphs.
  • Method 5: NetworkX Library. Simple and easy to use. Good for quick implementations without the need for detailed understanding of the underlying algorithm. Relies on external libraries.