Current section

Files

Jump to
yog_ex lib yog pathfinding.ex
Raw

lib/yog/pathfinding.ex

defmodule Yog.Pathfinding do
@moduledoc """
Unified facade for pathfinding algorithms.
This module provides a single entry point for all pathfinding and distance
computation algorithms. Each function delegates to a specialized submodule.
## Submodules
- `Yog.Pathfinding.Dijkstra` — Single-source shortest paths (non-negative weights)
- `Yog.Pathfinding.AStar` — Heuristic-guided shortest paths
- `Yog.Pathfinding.BellmanFord` — Shortest paths with negative weights, cycle detection
- `Yog.Pathfinding.Bidirectional` — Bidirectional BFS/Dijkstra for faster convergence
- `Yog.Pathfinding.FloydWarshall` — All-pairs shortest paths (dense graphs)
- `Yog.Pathfinding.Johnson` — All-pairs shortest paths (sparse graphs, negative weights)
- `Yog.Pathfinding.Matrix` — Distance matrix computation
- `Yog.Pathfinding.Path` — Path struct for representing results
## All-Pairs Functions
- `all_pairs_shortest_paths_unweighted/1` — Parallel BFS for unweighted graphs
- `floyd_warshall/1` — Floyd-Warshall for weighted graphs
- `johnson/5` — Johnson's algorithm for sparse graphs with negative weights
- `distance_matrix/6` — Distance matrix between specific points of interest
## Algorithm Selection Guide
| Algorithm | Use When | Time Complexity |
|-----------|----------|-----------------|
| **Dijkstra** | Non-negative weights, single pair | O((V+E) log V) |
| **A*** | Non-negative weights + good heuristic | O((V+E) log V) |
| **Bellman-Ford** | Negative weights OR cycle detection | O(VE) |
| **Bidirectional** | Large graphs, unweighted or uniform weights | O((V+E) log V) |
| **All-Pairs Unweighted** | All-pairs, unweighted graphs (parallel) | O(V² + VE) |
| **Floyd-Warshall** | All-pairs, dense weighted graphs | O(V³) |
| **Johnson's** | All-pairs, sparse graphs, negative weights | O(V² log V + VE) |
"""
alias Yog.Pathfinding.AStar
alias Yog.Pathfinding.BellmanFord
alias Yog.Pathfinding.Bidirectional
alias Yog.Pathfinding.Dijkstra
alias Yog.Pathfinding.FloydWarshall
alias Yog.Pathfinding.Johnson
alias Yog.Pathfinding.Matrix
# =============================================================================
# Dijkstra
# =============================================================================
@doc """
Finds the shortest path between two nodes using Dijkstra's algorithm.
## Options
* `:in` - The graph to search
* `:from` - Starting node ID
* `:to` - Target node ID
* `:zero` - Zero value for weights (default: 0)
* `:add` - Addition function for weights (default: &Kernel.+/2)
* `:compare` - Comparison function for weights (:lt, :eq, :gt) (default: &Yog.Utils.compare/2)
## Example
iex> {:ok, graph} = Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_node(3, "C")
...> |> Yog.add_edges([{1, 2, 5}, {2, 3, 3}, {1, 3, 10}])
iex> {:ok, path} = Yog.Pathfinding.shortest_path(
...> in: graph, from: 1, to: 3
...> )
iex> path.weight
8
"""
defdelegate shortest_path(opts), to: Dijkstra
@doc """
Single-source distances from a node to all reachable nodes (Dijkstra).
## Options
* `:in` - The graph
* `:from` - Source node
* `:zero` - Zero value (default: 0)
* `:add` - Addition function (default: &Kernel.+/2)
* `:compare` - Comparison function (default: &Yog.Utils.compare/2)
"""
defdelegate single_source_distances(opts), to: Dijkstra
# =============================================================================
# A*
# =============================================================================
@doc """
Finds the shortest path using A* search with a heuristic.
## Options
* `:in` - The graph to search
* `:from` - Starting node ID
* `:to` - Target node ID
* `:zero` - Zero value for weights (default: 0)
* `:add` - Addition function for weights (default: &Kernel.+/2)
* `:compare` - Comparison function for weights (default: &Yog.Utils.compare/2)
* `:heuristic` - Heuristic function `(node, goal) -> weight` (Mandatory)
"""
defdelegate a_star(opts), to: AStar
@doc """
Alias for `a_star/1`.
"""
def astar(opts), do: a_star(opts)
# =============================================================================
# Bellman-Ford
# =============================================================================
@doc """
Finds the shortest path using Bellman-Ford (supports negative weights).
Returns `{:error, :negative_cycle}` if a negative cycle is reachable.
## Options
* `:in` - The graph to search
* `:from` - Starting node ID
* `:to` - Target node ID
* `:zero` - Identity value for weights (default: 0)
* `:add` - Addition function (default: &Kernel.+/2)
* `:compare` - Comparison function (default: &Yog.Utils.compare/2)
"""
defdelegate bellman_ford(opts), to: BellmanFord
# =============================================================================
# Bidirectional
# =============================================================================
@doc """
Finds the shortest path using bidirectional BFS (unweighted graphs).
## Options
* `:in` - The graph to search
* `:from` - Starting node ID
* `:to` - Target node ID
"""
defdelegate bidirectional_unweighted(opts), to: Bidirectional, as: :shortest_path_unweighted
@doc """
Finds the shortest path using bidirectional Dijkstra.
## Options
* `:in` - The graph to search
* `:from` - Starting node ID
* `:to` - Target node ID
* `:zero` - Zero value for weights (default: 0)
* `:add` - Addition function (default: &Kernel.+/2)
* `:compare` - Comparison function (default: &Yog.Utils.compare/2)
## Example
iex> {:ok, graph} = Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_node(3, "C")
...> |> Yog.add_edges([{1, 2, 5}, {2, 3, 3}, {1, 3, 10}])
iex> {:ok, path} = Yog.Pathfinding.bidirectional(
...> in: graph, from: 1, to: 3
...> )
iex> path.weight
8
"""
defdelegate bidirectional(opts), to: Bidirectional, as: :shortest_path
# =============================================================================
# Floyd-Warshall
# =============================================================================
@doc """
Computes all-pairs shortest paths using Floyd-Warshall.
## Options
* `:in` - The graph
* `:zero` - Identity element (default: 0)
* `:add` - Addition function (default: &Kernel.+/2)
* `:compare` - Comparison function (default: &Yog.Utils.compare/2)
"""
def floyd_warshall(opts) do
graph = Keyword.fetch!(opts, :in)
zero = opts[:zero] || 0
add = opts[:add] || (&Kernel.+/2)
compare = opts[:compare] || (&Yog.Utils.compare/2)
FloydWarshall.floyd_warshall(graph, zero, add, compare)
end
@doc """
Detects negative cycles in the graph via Floyd-Warshall.
"""
defdelegate detect_negative_cycle?(graph, zero, add, compare), to: FloydWarshall
# =============================================================================
# Johnson's
# =============================================================================
@doc """
Computes all-pairs shortest paths using Johnson's algorithm.
More efficient than Floyd-Warshall for sparse graphs. Supports negative
edge weights (but not negative cycles).
"""
def johnson(
graph,
zero \\ 0,
add \\ &Kernel.+/2,
subtract \\ &Kernel.-/2,
compare \\ &Yog.Utils.compare/2
) do
Johnson.johnson(graph, zero, add, subtract, compare)
end
# =============================================================================
# Distance Matrix
# =============================================================================
@doc """
Computes a distance matrix between specified points of interest.
Uses Dijkstra from each point to compute pairwise distances.
"""
def distance_matrix(
graph,
points,
zero \\ 0,
add \\ &Kernel.+/2,
compare \\ &Yog.Utils.compare/2,
subtract \\ nil
) do
Matrix.distance_matrix(graph, points, zero, add, compare, subtract)
end
@doc """
Computes all-pairs shortest paths in an unweighted graph using parallel BFS.
Returns a map of `%{source => %{target => distance}}` where distances are the
number of edges in the shortest path.
## Parameters
- `graph` - The unweighted graph to analyze
## Returns
- A map from source nodes to distance maps: `%{source => %{target => distance}}`
- Each inner map contains distances from the source to all reachable nodes
- Distance from a node to itself is always 0
- Unreachable nodes have `nil` distance (note: this differs from `floyd_warshall/1`
which omits unreachable pairs entirely)
## Complexity
- **Time:** O(V × (V + E)) = O(V² + VE) overall, but parallelized across CPU cores
- **Space:** O(V²) for the result matrix
## When to Use
- **Unweighted graphs only** - For weighted graphs, use `floyd_warshall/1` or `johnson/1`
- **All-pairs needed** - When you need distances between all node pairs
- **Large graphs** - Parallelization makes this efficient on multi-core systems
## Algorithm
1. For each node, run BFS to compute distances to all other nodes
2. BFS from each source is parallelized using `Task.async_stream`
3. Results are aggregated into a nested map structure
## Examples
# Simple path graph: 1-2-3-4
iex> graph = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_node(4, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
...> |> Yog.add_edge_ensure(from: 3, to: 4, with: 1)
iex> distances = Yog.Pathfinding.all_pairs_shortest_paths_unweighted(graph)
iex> distances[1][4]
3
iex> distances[2][4]
2
iex> distances[1][1]
0
# Directed graph with unreachable nodes
iex> graph = Yog.directed()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> distances = Yog.Pathfinding.all_pairs_shortest_paths_unweighted(graph)
iex> distances[1][2]
1
iex> distances[3][1]
nil
## See Also
- `floyd_warshall/1` - For weighted graphs (all-pairs)
- `johnson/5` - For sparse weighted graphs with negative weights
- `distance_matrix/6` - For distances between specific points of interest only
"""
@spec all_pairs_shortest_paths_unweighted(Yog.graph()) :: %{
Yog.node_id() => %{Yog.node_id() => non_neg_integer()}
}
def all_pairs_shortest_paths_unweighted(graph) do
nodes = Yog.Model.all_nodes(graph)
# 1. Determine concurrency based on hardware
# We use schedulers_online to ensure we hit every core without overloading
parallel_opts = [
max_concurrency: System.schedulers_online(),
timeout: :infinity,
ordered: false
]
# 2. Parallelize the BFS from every node
nodes
|> Task.async_stream(
fn source ->
{source, bfs_distances(graph, source)}
end,
parallel_opts
)
|> Enum.reduce(%{}, fn {:ok, {source, dist_map}}, acc ->
Map.put(acc, source, dist_map)
end)
end
# Standard BFS to find all distances from a single source: O(V + E)
defp bfs_distances(graph, source) do
# Handle edge case: isolated node (no edges at all)
case Yog.Model.successors(graph, source) do
[] ->
%{source => 0}
_ ->
q = :queue.in({source, 0}, :queue.new())
do_bfs_dist(graph, q, %{source => 0})
end
end
defp do_bfs_dist(graph, q, visited) do
case :queue.out(q) do
{:empty, _} ->
visited
{{:value, {curr, dist}}, rest_q} ->
# Use Model.successors for proper encapsulation
# Returns list of {neighbor, weight} tuples; we only need neighbor IDs for unweighted
neighbors = Yog.Model.successors(graph, curr) |> Enum.map(&elem(&1, 0))
{next_q, next_v} =
Enum.reduce(neighbors, {rest_q, visited}, fn nb, {q_acc, v_acc} ->
if Map.has_key?(v_acc, nb) do
{q_acc, v_acc}
else
{:queue.in({nb, dist + 1}, q_acc), Map.put(v_acc, nb, dist + 1)}
end
end)
do_bfs_dist(graph, next_q, next_v)
end
end
end