Current section
Files
Jump to
Current section
Files
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
## Special Path Types
- `widest_path/3` — Maximum capacity path (bottleneck routing)
- `k_shortest_paths/5` — Yen's algorithm for k shortest loopless paths
## 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) |
| **Widest Path** | Maximize minimum edge capacity | O((V+E) log V) |
"""
alias Yog.Pathfinding.AStar
alias Yog.Pathfinding.BellmanFord
alias Yog.Pathfinding.Bidirectional
alias Yog.Pathfinding.ChinesePostman
alias Yog.Pathfinding.Dijkstra
alias Yog.Pathfinding.Disjoint
alias Yog.Pathfinding.FloydWarshall
alias Yog.Pathfinding.Johnson
alias Yog.Pathfinding.LCA
alias Yog.Pathfinding.Matrix
alias Yog.Pathfinding.Yen
# =============================================================================
# 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
@doc """
Finds the widest path (maximum capacity path) between two nodes.
The widest path maximizes the minimum edge weight along the path (the bottleneck).
This is useful for network bandwidth routing, finding reliable paths, and
max-min fair allocation problems.
## Parameters
* `graph` - The graph to search
* `source` - Starting node ID
* `target` - Target node ID
## Returns
* `{:ok, path}` - A `Path.t()` struct with maximum bottleneck capacity
* `:error` - If no path exists between the nodes
## Example
# Network with bandwidths
graph = Yog.directed()
|> Yog.add_edges!([
{:a, :b, 100}, # 100 Mbps link
{:a, :c, 50}, # 50 Mbps link
{:b, :d, 80}, # 80 Mbps link
{:c, :d, 200} # 200 Mbps link
])
{:ok, path} = Yog.Pathfinding.widest_path(graph, :a, :d)
# Path via b has bottleneck min(100, 80) = 80
# Path via c has bottleneck min(50, 200) = 50
# So widest path is a->b->d with capacity 80
## Algorithm
This is a modification of Dijkstra's algorithm:
- Standard Dijkstra: `distance[v] = min(distance[u] + weight(u,v))`
- Widest Path: `capacity[v] = max(capacity[v], min(capacity[u], weight(u,v)))`
The path's "width" is the minimum edge weight along it. We want to maximize
this minimum (the bottleneck capacity).
## Complexity
- **Time:** O((V + E) log V)
- **Space:** O(V)
## See Also
- `shortest_path/1` - Standard Dijkstra for shortest paths
- Wikipedia: https://en.wikipedia.org/wiki/Widest_path_problem
"""
@spec widest_path(Yog.Graph.t(), source :: Yog.node_id(), target :: Yog.node_id()) ::
{:ok, Path.t()} | :error
def widest_path(graph, source, target) do
Dijkstra.widest_path(graph, source, target)
end
# =============================================================================
# Chinese Postman Problem
# =============================================================================
@doc """
Solves the Chinese Postman Problem (Route Inspection) for undirected graphs.
Finds the shortest closed walk that traverses every edge at least once.
## Example
iex> graph = Yog.from_edges(:undirected, [{1, 2, 1}, {2, 3, 1}, {3, 4, 1}, {4, 1, 1}])
iex> {:ok, _path, weight} = Yog.Pathfinding.chinese_postman(graph)
iex> weight
4
"""
defdelegate chinese_postman(graph), to: ChinesePostman
@doc """
Finds the k shortest loopless paths from `source` to `target` using Yen's algorithm.
## Options
- `:with` - Function to extract/transform edge weight
- `: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`)
## Example
iex> graph = Yog.directed() |> Yog.add_node(1, nil) |> Yog.add_node(2, nil) |> Yog.add_node(3, nil) |> Yog.add_edges!([{1, 2, 1}, {2, 3, 1}, {1, 3, 2}])
iex> {:ok, paths} = Yog.Pathfinding.k_shortest_paths(graph, 1, 3, 2)
iex> length(paths)
2
"""
@spec k_shortest_paths(Yog.Graph.t(), Yog.node_id(), Yog.node_id(), pos_integer(), keyword()) ::
{:ok, [Path.t()]} | :error
defdelegate k_shortest_paths(graph, source, target, k, opts \\ []), to: Yen
# =============================================================================
# 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,
compare \\ &Yog.Utils.compare/2,
subtract \\ &Kernel.-/2
) do
Johnson.johnson(graph, zero, add, compare, subtract)
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)
parallel_opts = [
max_concurrency: System.schedulers_online(),
timeout: :infinity,
ordered: false
]
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
# =============================================================================
# Single-Pair Shortest Path (Unweighted) - BFS with early termination
# =============================================================================
@doc """
Finds the shortest path between two nodes in an unweighted graph using BFS.
This is significantly faster than Dijkstra for unweighted graphs because:
1. Uses BFS instead of Dijkstra's algorithm (no heap overhead)
2. Stops as soon as the target is found (early termination)
3. Works for both directed and undirected graphs
4. Handles nil weights or uniform weights (e.g., all weight=1)
## Parameters
* `graph` - The unweighted graph (directed or undirected)
* `source` - Starting node ID
* `target` - Target node ID
## Returns
* `{:ok, [node_id]}` - List of nodes representing the shortest path from source to target
* `{:ok, [source]}` - When source == target
* `{:error, :no_path}` - When target is unreachable from source
## Complexity
* **Time:** O(V + E) worst case, but typically much less due to early termination
* **Space:** O(V) for the queue and visited set
## Examples
# Simple path: 1-2-3
iex> graph = Yog.undirected()
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> Yog.Pathfinding.shortest_path_unweighted(graph, 1, 3)
{:ok, [1, 2, 3]}
# Same source and target
iex> graph = Yog.undirected()
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> Yog.Pathfinding.shortest_path_unweighted(graph, 1, 1)
{:ok, [1]}
# No path exists
iex> graph = Yog.directed()
...> |> Yog.add_node(:a, nil)
...> |> Yog.add_node(:b, nil)
iex> Yog.Pathfinding.shortest_path_unweighted(graph, :a, :b)
{:error, :no_path}
# Directed graph - respects edge direction
iex> graph = Yog.directed()
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> Yog.Pathfinding.shortest_path_unweighted(graph, 1, 3)
{:ok, [1, 2, 3]}
iex> Yog.Pathfinding.shortest_path_unweighted(graph, 3, 1)
{:error, :no_path}
## When to Use
* **Unweighted graphs** - All edges have the same cost (or nil weight)
* **Single pair query** - When you only need one source-target path
* **Performance critical** - Faster than Dijkstra for unweighted graphs
## See Also
* `shortest_path/1` - Dijkstra for weighted graphs
* `bidirectional_unweighted/1` - Bidirectional BFS (potentially faster for large graphs)
* `all_pairs_shortest_paths_unweighted/1` - When you need all-pairs distances
"""
@spec shortest_path_unweighted(Yog.graph(), Yog.node_id(), Yog.node_id()) ::
{:ok, [Yog.node_id()]} | {:error, :no_path}
def shortest_path_unweighted(graph, source, target) when source == target do
if Yog.Model.has_node?(graph, source) do
{:ok, [source]}
else
{:error, :no_path}
end
end
def shortest_path_unweighted(graph, source, target) do
cond do
not Yog.Model.has_node?(graph, source) ->
{:error, :no_path}
not Yog.Model.has_node?(graph, target) ->
{:error, :no_path}
true ->
q = :queue.in({source, 0}, :queue.new())
visited = MapSet.new([source])
predecessors = %{source => nil}
case do_bfs_path_optimized(graph, q, visited, predecessors, target) do
nil -> {:error, :no_path}
preds -> {:ok, reconstruct_path(preds, target)}
end
end
end
# BFS with predecessor map - memory efficient
defp do_bfs_path_optimized(graph, q, visited, predecessors, target) do
case :queue.out(q) do
{:empty, _} ->
nil
{{:value, {curr, _depth}}, rest_q} ->
if curr == target do
predecessors
else
neighbors =
case Yog.Model.successors(graph, curr) do
[] -> []
succs -> Enum.map(succs, &elem(&1, 0))
end
{next_q, next_visited, next_preds, found} =
Enum.reduce(neighbors, {rest_q, visited, predecessors, false}, fn nb,
{q_acc, v_acc,
p_acc, found_acc} ->
cond do
found_acc ->
{q_acc, v_acc, p_acc, found_acc}
nb == target ->
next_q = :queue.in({nb, 0}, q_acc)
next_v = MapSet.put(v_acc, nb)
next_p = Map.put(p_acc, nb, curr)
{next_q, next_v, next_p, true}
MapSet.member?(v_acc, nb) ->
{q_acc, v_acc, p_acc, found_acc}
true ->
next_q = :queue.in({nb, 0}, q_acc)
next_v = MapSet.put(v_acc, nb)
next_p = Map.put(p_acc, nb, curr)
{next_q, next_v, next_p, found_acc}
end
end)
if found do
next_preds
else
do_bfs_path_optimized(graph, next_q, next_visited, next_preds, target)
end
end
end
end
# Reconstruct path from target back to source using predecessor map
defp reconstruct_path(predecessors, target) do
do_reconstruct_path(predecessors, target, [])
end
defp do_reconstruct_path(predecessors, node, acc) do
case Map.get(predecessors, node) do
nil -> [node | acc]
parent -> do_reconstruct_path(predecessors, parent, [node | acc])
end
end
# Standard BFS to find all distances from a single source: O(V + E)
defp bfs_distances(graph, source) do
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} ->
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
# =============================================================================
# LCA (Lowest Common Ancestor)
# =============================================================================
@doc """
Preprocesses a tree for LCA queries using binary lifting.
## Parameters
* `graph` - The tree graph
* `root` - The root node for the tree
## Returns
* `{:ok, LCA.State.t()}` - Preprocessed state for LCA queries
* `{:error, :root_not_found}` - Root node not in graph
* `{:error, :not_a_tree}` - Graph contains a cycle or is disconnected
## Example
iex> tree =
...> Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edges!([{1, 2, 1}, {1, 3, 1}])
iex> {:ok, state} = Yog.Pathfinding.lca_preprocess(tree, 1)
iex> Yog.Pathfinding.lca(state, 2, 3)
{:ok, 1}
"""
@spec lca_preprocess(Yog.Graph.t(), Yog.node_id()) ::
{:ok, LCA.State.t()} | {:error, :root_not_found | :not_a_tree}
def lca_preprocess(graph, root) do
LCA.lca_preprocess(graph, root)
end
@doc """
Returns the lowest common ancestor of two nodes.
## Returns
* `{:ok, node_id}` - The LCA node
* `{:error, :node_not_found}` - One or both nodes not in the tree
"""
@spec lca(LCA.State.t(), Yog.node_id(), Yog.node_id()) ::
{:ok, Yog.node_id()} | {:error, :node_not_found}
def lca(state, a, b) do
LCA.lca(state, a, b)
end
@doc """
Calculates the tree distance (number of edges) between two nodes.
## Returns
* `{:ok, distance}` - Number of edges between the nodes
* `{:error, :node_not_found}` - One or both nodes not in the tree
"""
@spec tree_distance(LCA.State.t(), Yog.node_id(), Yog.node_id()) ::
{:ok, non_neg_integer()} | {:error, :node_not_found}
def tree_distance(state, a, b) do
LCA.tree_distance(state, a, b)
end
@doc """
Finds two edge-disjoint paths of minimum total weight between `from` and `to`.
Uses Suurballe's algorithm, executing Dijkstra's algorithm twice with edge weight
adjustments and edge reversals to find paths that do not share any edges.
## Options
* `:zero` - Zero value for the weight type (default: `0`)
* `:add` - Addition function for weights (default: `&Kernel.+/2`)
* `:compare` - Comparison function for weights (default: `&Yog.Utils.compare/2`)
* `:subtract` - Subtraction function for weights (default: `&Kernel.-/2`)
* `:weight` - Function extracting a numeric weight from edge data
## Returns
* `{:ok, [path1, path2]}` - List of two Path structs on success
* `:error` - If less than two edge-disjoint paths exist
"""
@spec suurballe(Yog.graph(), Yog.node_id(), Yog.node_id(), keyword()) ::
{:ok, [Yog.Pathfinding.Path.t()]} | :error
def suurballe(graph, from, to, opts \\ []) do
zero = opts[:zero] || 0
add = opts[:add] || (&Kernel.+/2)
compare = opts[:compare] || (&Yog.Utils.compare/2)
subtract = opts[:subtract] || (&Kernel.-/2)
weight_fn = opts[:weight] || fn w -> if is_nil(w), do: 1, else: w end
Disjoint.suurballe(graph, from, to, zero, add, compare, subtract, weight_fn)
end
end