Current section

Files

Jump to
yog_ex lib yog functional algorithms.ex
Raw

lib/yog/functional/algorithms.ex

defmodule Yog.Functional.Algorithms do
@moduledoc """
Classic graph algorithms using purely functional inductive decomposition.
Each algorithm leverages `Yog.Functional.Model.match/2` as its core operation.
By extracting a node and working with the resulting *shrunken* graph, these
implementations avoid mutable state and explicit "visited" sets. Correctness
and termination follow naturally from the inductive structure.
## Available Algorithms
| Algorithm | Function | Time Complexity |
|-----------|----------|-----------------|
| [Topological Sort](https://en.wikipedia.org/wiki/Topological_sorting) | `topsort/1` | O(V + E) |
| [Dijkstra's Shortest Path](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) | `shortest_path/3` | O((V + E) log V) |
| Distances | `distances/2` | Compute all-node distances from source |
| [Prim's MST](https://en.wikipedia.org/wiki/Prim%27s_algorithm) | `mst_prim/1` | O(E log V) |
| [Kosaraju's SCC](https://en.wikipedia.org/wiki/Kosaraju%27s_algorithm) | `scc/1` | O(V + E) |
## Key Principle
Every algorithm consumes the graph structurally: `match/2` removes a node and
all its incident edges, so already-processed nodes simply won't be found in the
remaining graph. No external "visited" bookkeeping is needed.
## References
- [Original FGL Paper (Erwig, 2001)](https://web.engr.oregonstate.edu/~erwig/papers/InductiveGraphs_JFP01.pdf)
- [Wikipedia: Graph Algorithms](https://en.wikipedia.org/wiki/Graph_algorithm)
"""
alias Yog.Functional.Model
alias Yog.Functional.{Transform, Traversal}
alias Yog.PriorityQueue, as: PQ
@doc """
Performs a Topological Sort by repeatedly extracting nodes with 0 in-degree.
Returns `{:ok, [node_ids]}` or `{:error, :cycle_detected}`.
Inductive approach: each round, we scan for a node whose `in_edges` map is
empty in the current (shrunken) graph, extract it with `match/2`, and recurse.
When an edge is removed by `match/2`, affected neighbours' `in_edges` are
automatically updated by the inductive model, so the in-degree invariant is
always up to date without a separate tracking structure.
## Examples
iex> alias Yog.Functional.{Model, Algorithms}
iex> graph = Model.empty() |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.add_edge!(1, 2)
iex> {:ok, order} = Algorithms.topsort(graph)
iex> order
[1, 2]
"""
def topsort(graph), do: do_topsort(graph, [])
defp do_topsort(graph, acc) do
if Model.empty?(graph) do
{:ok, Enum.reverse(acc)}
else
find_and_match_next(graph, acc)
end
end
defp find_and_match_next(graph, acc) do
case Enum.find(Model.nodes(graph), fn ctx -> map_size(ctx.in_edges) == 0 end) do
nil ->
{:error, :cycle_detected}
ctx ->
{:ok, _ctx, remaining_graph} = Model.match(graph, ctx.id)
do_topsort(remaining_graph, [ctx.id | acc])
end
end
@doc """
Finds the shortest path between two nodes using Dijkstra's algorithm.
Returns `{:ok, [node_ids], total_distance}` or `{:error, :no_path}`.
Edge labels are used as weights (defaults to 1 if `nil`).
Inductive approach: we maintain a sorted priority queue of `{dist, node, path}`
entries. Each step extracts the minimum-distance frontier node using `match/2`.
Because `match/2` removes the node from the graph, we naturally skip
already-settled nodes — they simply won't be found anymore.
## Examples
iex> alias Yog.Functional.{Model, Algorithms}
iex> graph = Model.empty() |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.add_edge!(1, 2, 10)
iex> Algorithms.shortest_path(graph, 1, 2)
{:ok, [1, 2], 10}
"""
def shortest_path(graph, start_id, target_id) do
if start_id == target_id do
if Model.has_node?(graph, start_id),
do: {:ok, [start_id], 0},
else: {:error, :no_path}
else
do_dijkstra(
graph,
PQ.new(fn {a, _, _}, {b, _, _} -> a <= b end) |> PQ.push({0, start_id, []}),
target_id
)
end
end
defp do_dijkstra(graph, pq, target_id) do
if PQ.empty?(pq) do
{:error, :no_path}
else
{:ok, {dist, current, path}, rest_pq} = PQ.pop(pq)
case Model.match(graph, current) do
{:error, :not_found} ->
do_dijkstra(graph, rest_pq, target_id)
{:ok, ctx, remaining_graph} ->
process_dijkstra_node(ctx, remaining_graph, dist, path, rest_pq, target_id)
end
end
end
defp process_dijkstra_node(ctx, _remaining_graph, dist, path, _pq, target_id)
when ctx.id == target_id do
{:ok, Enum.reverse([ctx.id | path]), dist}
end
defp process_dijkstra_node(ctx, remaining_graph, dist, path, pq, target_id) do
new_pq =
Enum.reduce(ctx.out_edges, pq, fn {neighbor_id, weight}, acc_pq ->
w = weight || 1
PQ.push(acc_pq, {dist + w, neighbor_id, [ctx.id | path]})
end)
do_dijkstra(remaining_graph, new_pq, target_id)
end
@doc """
Computes the distance from a start node to all reachable nodes using Dijkstra's algorithm.
Returns `%{node_id => distance}`.
## Examples
iex> alias Yog.Functional.{Model, Algorithms}
iex> graph = Model.empty() |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.add_edge!(1, 2, 10)
iex> Algorithms.distances(graph, 1)
%{1 => 0, 2 => 10}
"""
@spec distances(Model.t(), Model.node_id()) :: %{Model.node_id() => number()}
def distances(graph, start) do
pq = PQ.new(fn {d1, _}, {d2, _} -> d1 <= d2 end) |> PQ.push({0, start})
do_distances(graph, pq, %{})
end
defp do_distances(graph, pq, acc) do
if PQ.empty?(pq) do
acc
else
{:ok, {dist, current}, rest_pq} = PQ.pop(pq)
case Model.match(graph, current) do
{:error, :not_found} ->
do_distances(graph, rest_pq, acc)
{:ok, ctx, remaining_graph} ->
new_acc = Map.put(acc, current, dist)
new_pq =
Enum.reduce(ctx.out_edges, rest_pq, fn {neighbor_id, weight}, acc_pq ->
w = weight || 1
PQ.push(acc_pq, {dist + w, neighbor_id})
end)
do_distances(remaining_graph, new_pq, new_acc)
end
end
end
@doc """
Finds the Minimum Spanning Tree of the connected component containing the first node.
Returns `{:ok, [{from, to, weight}]}` or `{:ok, []}` for empty graphs.
Treats the graph as undirected by extracting both in and out edges from each context.
Inductive approach (Prim's): start from any node (extracted via `match/2`),
insert its adjacent edges into a sorted priority queue, then repeatedly
dequeue the minimum-weight edge whose target is still in the unvisited graph.
`match/2` is used to extract the target: if it's already been visited,
`match/2` returns `{:error, :not_found}` and we skip to the next candidate.
"""
def mst_prim(graph) do
case Model.node_ids(graph) do
[] ->
{:ok, []}
[start | _] ->
{:ok, ctx, remaining_graph} = Model.match(graph, start)
edges = extract_undirected_edges(ctx)
do_mst_prim(remaining_graph, Enum.sort(edges), [])
end
end
defp do_mst_prim(_graph, [], acc), do: {:ok, Enum.reverse(acc)}
defp do_mst_prim(graph, [{weight, from, to} | pq], acc) do
case Model.match(graph, to) do
{:error, :not_found} ->
do_mst_prim(graph, pq, acc)
{:ok, ctx, remaining_graph} ->
new_acc = [{from, to, weight} | acc]
new_edges = extract_undirected_edges(ctx)
new_pq = merge_sorted_lists(pq, Enum.sort(new_edges))
do_mst_prim(remaining_graph, new_pq, new_acc)
end
end
@doc """
Finds the Strongly Connected Components (SCCs) of a directed graph.
Returns a list of lists, where each inner list contains the node IDs of one SCC.
Uses Kosaraju's two-pass algorithm, adapted for functional inductive graphs:
1. Pass 1: compute the DFS finishing order of all nodes.
2. Reverse the graph's edges.
3. Pass 2: extract components by running DFS on the reversed graph in the compute order.
Due to the inductive `match/2` operations, nodes visited in one component
are naturally removed from the graph, preventing them from bleeding into
subsequent components.
## Examples
iex> alias Yog.Functional.{Model, Algorithms}
iex> graph = Model.empty() |> Model.put_node(1, "A") |> Model.put_node(2, "B")
...> |> Model.add_edge!(1, 2) |> Model.add_edge!(2, 1)
iex> sccs = Algorithms.scc(graph)
iex> Enum.map(sccs, &Enum.sort/1) |> Enum.sort()
[[1, 2]]
"""
def scc(%Model{direction: :undirected}) do
raise ArgumentError, "Strongly Connected Components requires a directed graph"
end
def scc(%Model{} = graph) do
{finishing_order, _shrunken_completely} =
Traversal.finishing_order(graph, Model.node_ids(graph), [])
reversed_graph = Transform.reverse(graph)
extract_sccs(reversed_graph, finishing_order, [])
end
defp extract_sccs(_graph, [], acc), do: Enum.reverse(acc)
defp extract_sccs(graph, [current | rest], acc) do
case Model.match(graph, current) do
{:error, :not_found} ->
extract_sccs(graph, rest, acc)
{:ok, _ctx, _remaining} ->
{component_ids, remainder} = extract_component(graph, [current], [])
extract_sccs(remainder, rest, [component_ids | acc])
end
end
defp extract_component(graph, [], acc), do: {Enum.reverse(acc), graph}
defp extract_component(graph, [current | stack], acc) do
case Model.match(graph, current) do
{:error, :not_found} ->
extract_component(graph, stack, acc)
{:ok, ctx, remaining_graph} ->
neighbors = Map.keys(ctx.out_edges)
new_stack = neighbors ++ stack
extract_component(remaining_graph, new_stack, [current | acc])
end
end
defp extract_undirected_edges(ctx) do
out_e = Enum.map(ctx.out_edges, fn {to, w} -> {w || 1, ctx.id, to} end)
in_e = Enum.map(ctx.in_edges, fn {from, w} -> {w || 1, ctx.id, from} end)
out_e ++ in_e
end
defp merge_sorted_lists(list1, list2), do: :lists.merge(list1, list2)
end