Files
Python/graphs/graph_peripherality.py
d182474c4f Graph Peripherality (#11898)
* Initial Commit: Add algorithms to determine peripheral and farthest nodes in a weighted graph

- Implemented Floyd-Warshall algorithm to calculate all-pairs shortest paths,
  supporting positive edge weights for weighted graphs.
- Added functions to identify peripheral and far nodes:
  - `find_peripheral_node`: Determines the node with maximal eccentricity.
  - `find_far_node`: Finds the node with maximal farness, summing shortest-path distances.
- Included error handling for non-positive edge weights, with appropriate ValueError raising.
- Developed extensive test cases using doctests:
  - Covered single-node, fully connected, sparse, cyclic, directed acyclic, and
    disconnected graphs.
  - Validated handling of edge cases including zero and negative weights.
- Ensured code meets PEP 8 standards and passed all lint checks with `ruff`.
- Provided detailed module-level docstring with explanations of algorithms,
  complexity, and example applications.

This commit provides foundational functionality for graph-theoretical analysis
and sets up for future expansions or integrations.

* Refactor main function and update docstring formatting

* updating DIRECTORY.md

---------

Co-authored-by: Christian Clauss <cclauss@me.com>
Co-authored-by: cclauss <cclauss@users.noreply.github.com>
2026-09-23 14:32:23 +02:00

394 lines
12 KiB
Python

"""
Graph Peripheral and Farness Algorithms for Determining Peripheral and Farthest Nodes
in a Graph.
This module provides functions to compute the peripheral and farthest nodes in a
weighted graph based on graph-theoretical distance measures. The peripheral node
maximizes the maximum shortest-path distance to all other reachable nodes
(eccentricity), while the far node maximizes the sum of shortest-path distances to
all other reachable nodes (farness).
Problem Description:
Given a weighted graph G = (V, E), where V is the set of vertices, and E is the set
of edges with positive weights representing distances between nodes, determine:
- Peripheral Node: The node with maximal eccentricity. The eccentricity of a node v
is defined as the greatest distance between v and any other node reachable from v.
- Far Node: The node with maximal farness. Farness of a node v is the sum of the
shortest-path distances from v to all other reachable nodes.
Algorithms Implemented:
- Floyd-Warshall Algorithm for All-Pairs Shortest Paths.
- Calculation of Eccentricity and Farness for identifying Peripheral and Far nodes.
Algorithm Descriptions:
Floyd-Warshall Algorithm (Pseudo-code):
---------------------------------------
for k from 1 to N:
for i from 1 to N:
for j from 1 to N:
if distance[i][j] > distance[i][k] + distance[k][j]:
distance[i][j] = distance[i][k] + distance[k][j]
Peripheral and Far Node Calculation:
------------------------------------
For each node i:
- Eccentricity[i] = maximum distance from node i to any other reachable node.
- Farness[i] = sum of distances from node i to all reachable nodes.
Select:
- Peripheral Node: node with maximal eccentricity.
- Far Node: node with maximal farness.
References:
- Floyd-Warshall Algorithm: https://en.wikipedia.org/wiki/Floyd-Warshall_algorithm
- Eccentricity and Farness: https://en.wikipedia.org/wiki/Distance_(graph_theory)
Example Application:
These algorithms can be applied to network analysis, such as identifying the most
distant nodes within a network, analyzing communication delays, or planning
infrastructure at the boundaries of a network.
"""
import numpy as np
def initialize_distance_matrix(
graph: dict[int, list[tuple[int, float]]], number_of_nodes: int
) -> np.ndarray:
"""Initialize the distance matrix and validate edge weights.
Args:
graph: The graph represented as an adjacency list.
number_of_nodes: The total number of nodes in the graph.
Returns:
A numpy.ndarray representing the initialized distance matrix.
Raises:
ValueError: If any edge has a non-positive weight.
"""
distance_matrix: np.ndarray = np.full((number_of_nodes, number_of_nodes), np.inf)
np.fill_diagonal(distance_matrix, 0)
for node_index, edges in graph.items():
for neighbor_index, edge_weight in edges:
if edge_weight <= 0:
error_message: str = (
f"Edge weight must be positive. Found {edge_weight} between "
f"nodes {node_index} and {neighbor_index}."
)
raise ValueError(error_message)
distance_matrix[node_index, neighbor_index] = edge_weight
return distance_matrix
def floyd_warshall_algorithm(graph: dict[int, list[tuple[int, float]]]) -> np.ndarray:
"""Compute all-pairs shortest paths using the Floyd-Warshall algorithm.
Floyd-Warshall Complexity:
--------------------------
Time Complexity: O(N^3), where N is the number of nodes.
Space Complexity: O(N^2), for storing the distance matrix.
Args:
graph: The graph represented as an adjacency list.
Returns:
The distance matrix with the shortest paths between all pairs of nodes.
"""
number_of_nodes: int = len(graph)
distance_matrix: np.ndarray = initialize_distance_matrix(graph, number_of_nodes)
for k in range(number_of_nodes):
# Use broadcasting to update the distance matrix in place
distance_matrix[:] = np.minimum(
distance_matrix,
distance_matrix[:, k][:, np.newaxis] + distance_matrix[k, :],
)
return distance_matrix
def get_reachable_distances(distances: np.ndarray) -> np.ndarray:
"""Filter reachable distances, excluding infinite values (unreachable nodes).
Args:
distances: Array of shortest-path distances from a specific node.
Returns:
An array of distances to reachable nodes only (finite values).
"""
return distances[np.isfinite(distances) & (distances != 0)]
def find_peripheral_node(
eccentricities: list[tuple[int, float]],
) -> tuple[int, float]:
"""Identify the node with maximal eccentricity among reachable nodes.
Args:
eccentricities: List of tuples (node index, eccentricity).
Returns:
The node with maximal eccentricity and its value. Returns (-1, inf) if
no valid nodes are found.
"""
return max(
eccentricities,
key=lambda node_eccentricity: (
node_eccentricity[1], # Prioritize highest eccentricity
-node_eccentricity[0], # For equal values, prefer nodes with higher index
),
default=(-1, float("inf")),
)
def find_far_node(farnesses: list[tuple[int, float]]) -> tuple[int, float]:
"""Identify the node with maximal farness among reachable nodes.
Args:
farnesses: List of tuples (node index, farness).
Returns:
The node with maximal farness and its value. Returns (-1, inf) if
no valid nodes are found.
"""
return max(
farnesses,
key=lambda node_farness: (
node_farness[1], # Prioritize highest farness
-node_farness[0], # For equal values, prefer nodes with higher index
),
default=(-1, float("inf")),
)
def find_peripheral_and_far_node(
distance_matrix: np.ndarray,
) -> tuple[tuple[int, float], tuple[int, float]]:
"""Determine the peripheral and far nodes based on shortest-path distances.
For each node, calculates its eccentricity and farness (sum of distances to all
reachable nodes). Identifies the peripheral node (maximal eccentricity) and
farthest node (maximal farness).
Args:
distance_matrix: A numpy.ndarray representing shortest-path distances
between all pairs of nodes.
Returns:
A tuple containing:
- peripheral_node: A tuple (node index, eccentricity) for the node with
maximal eccentricity.
- far_node: A tuple (node index, farness) for the node with maximal
farness (sum of shortest-path distances).
"""
num_nodes: int = len(distance_matrix)
# Handle single-node graphs
if num_nodes == 1:
return (0, 0.0), (0, 0.0)
eccentricities: list[tuple[int, float]] = []
farnesses: list[tuple[int, float]] = []
all_disconnected = True # Assume all are disconnected until proven otherwise
for i in range(num_nodes):
reachable_distances = get_reachable_distances(distance_matrix[i])
if reachable_distances.size == 0:
# Fully disconnected node
eccentricity = float("inf")
farness = float("inf")
else:
all_disconnected = False # If any node has reachable nodes, we update this
eccentricity = float(np.max(reachable_distances))
farness = float(np.sum(reachable_distances))
eccentricities.append((i, eccentricity))
farnesses.append((i, farness))
# If all nodes are disconnected, we return (-1, inf)
if all_disconnected:
return (-1, float("inf")), (-1, float("inf"))
peripheral_node = find_peripheral_node(eccentricities)
far_node = find_far_node(farnesses)
return peripheral_node, far_node
# Test cases included as doctests
def test_single_node() -> None:
"""
Test a graph with a single node.
>>> graph = {0: []}
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(0, 0.0)
>>> far_node
(0, 0.0)
"""
def test_two_nodes_positive_weight() -> None:
"""
Test a graph with two nodes connected by a positive weight.
>>> graph = {0: [(1, 5.0)], 1: [(0, 5.0)]}
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(0, 5.0)
>>> far_node
(0, 5.0)
"""
def test_fully_connected_graph() -> None:
"""
Test a fully connected graph.
>>> graph = {
... 0: [(1, 1.0), (2, 1.0)],
... 1: [(0, 1.0), (2, 1.0)],
... 2: [(0, 1.0), (1, 1.0)],
... }
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(0, 1.0)
>>> far_node
(0, 2.0)
"""
def test_graph_with_zero_weight() -> None:
"""
Test a graph with zero weight, which should raise a ValueError.
>>> graph = {0: [(1, 0.0)], 1: []}
>>> floyd_warshall_algorithm(graph)
Traceback (most recent call last):
...
ValueError: Edge weight must be positive. Found 0.0 between nodes 0 and 1.
"""
def test_graph_with_negative_weight() -> None:
"""
Test a graph with negative weight, which should raise a ValueError.
>>> graph = {0: [(1, -2.0)], 1: []}
>>> floyd_warshall_algorithm(graph)
Traceback (most recent call last):
...
ValueError: Edge weight must be positive. Found -2.0 between nodes 0 and 1.
"""
def test_sparse_graph() -> None:
"""
Test a larger sparse graph.
>>> graph = {
... 0: [(1, 2.0)],
... 1: [(2, 3.0)],
... 2: [(3, 4.0)],
... 3: [(4, 5.0)],
... 4: []
... }
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(4, inf)
>>> far_node
(4, inf)
"""
def test_cyclic_graph() -> None:
"""
Test a cyclic graph where there is a cycle between nodes.
>>> graph = {
... 0: [(1, 1.0)],
... 1: [(2, 1.0)],
... 2: [(0, 1.0)]
... }
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(0, 2.0)
>>> far_node
(0, 3.0)
"""
def test_directed_acyclic_graph() -> None:
"""
Test a directed acyclic graph (DAG).
>>> graph = {
... 0: [(1, 1.0), (2, 2.0)],
... 1: [(3, 3.0)],
... 2: [(3, 1.0)],
... 3: []
... }
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(3, inf)
>>> far_node
(3, inf)
"""
def test_disconnected_graph() -> None:
"""
Test a disconnected graph with nodes that cannot reach each other.
>>> graph = {
... 0: [],
... 1: [],
... 2: []
... }
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node
(-1, inf)
>>> far_node
(-1, inf)
"""
def test_large_fully_connected_graph() -> None:
"""
Test a larger fully connected graph with random weights.
>>> import random
>>> random.seed(42)
>>> number_of_nodes = 10
>>> graph = {i: [(j, random.uniform(1, 10)) for j in
... range(number_of_nodes) if i != j]
... for i in range(number_of_nodes)}
>>> distance_matrix = floyd_warshall_algorithm(graph)
>>> peripheral_node, far_node = find_peripheral_and_far_node(distance_matrix)
>>> peripheral_node[0] is not None # Ensure it found a peripheral node
True
>>> far_node[0] is not None # Ensure it found a far node
True
"""
if __name__ == "__main__":
import doctest
doctest.testmod()