Current section
Files
Jump to
Current section
Files
lib/yog/connectivity.ex
defmodule Yog.Connectivity do
@moduledoc """
Graph connectivity analysis - finding connected components, bridges, articulation points,
and strongly connected components.
This module provides algorithms for analyzing the connectivity structure of graphs,
identifying components, and finding critical elements whose removal would disconnect the graph.
## Component Types
| Component Type | Function | Graph Type | Description |
|----------------|----------|------------|-------------|
| **Connected Components** | `connected_components/1` | Undirected | Maximal connected subgraphs |
| **Weakly Connected Components** | `weakly_connected_components/1` | Directed | Connected when ignoring direction |
| **Strongly Connected Components** | `strongly_connected_components/1` | Directed | Connected following edge directions |
## Bridges vs Articulation Points
- **Bridge** (cut edge): An edge whose removal increases the number of connected components.
In a network, this represents a single point of failure.
- **Articulation Point** (cut vertex): A node whose removal increases the number of connected
components. These are critical nodes in the network.
## Algorithms
| Algorithm | Function | Use Case | Complexity |
|-----------|----------|----------|------------|
| DFS-based CC | `connected_components/1` | Undirected graph components | O(V + E) |
| DFS-based WCC | `weakly_connected_components/1` | Directed graph, ignore direction | O(V + E) |
| [Tarjan's SCC](https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm) | `strongly_connected_components/1` | Find SCCs in one pass | O(V + E) |
| [Kosaraju's Algorithm](https://en.wikipedia.org/wiki/Kosaraju%27s_algorithm) | `kosaraju/1` | Find SCCs using two DFS passes | O(V + E) |
| [Tarjan's Bridge-Finding](https://en.wikipedia.org/wiki/Bridge_(graph_theory)) | `analyze/1` | Find bridges and articulation points | O(V + E) |
All algorithms run in **O(V + E)** linear time.
"""
alias Yog.Model
@type bridge :: {Yog.node_id(), Yog.node_id()}
@type component :: [Yog.node_id()]
@type connectivity_results :: %{
bridges: [bridge()],
articulation_points: [Yog.node_id()]
}
@doc """
Analyzes an **undirected graph** to find all bridges and articulation points.
Can be called in two ways:
- With keyword options: `analyze(in: graph)`
- With graph directly (for pipelines): `graph |> analyze()`
## Examples
Using keyword options:
iex> graph = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge!(from: 1, to: 2, with: nil)
...> |> Yog.add_edge!(from: 2, to: 3, with: nil)
iex> results = Yog.Connectivity.analyze(in: graph)
iex> results.bridges
[{1, 2}, {2, 3}]
iex> results.articulation_points
[2]
Using pipeline style:
iex> results =
...> Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge!(from: 1, to: 2, with: nil)
...> |> Yog.add_edge!(from: 2, to: 3, with: nil)
...> |> Yog.Connectivity.analyze()
iex> results.bridges
[{1, 2}, {2, 3}]
iex> results.articulation_points
[2]
"""
@spec analyze(keyword() | Yog.graph()) :: connectivity_results()
def analyze(options_or_graph) do
graph =
if is_list(options_or_graph) do
Keyword.fetch!(options_or_graph, :in)
else
options_or_graph
end
do_analyze(graph)
end
defp do_analyze(graph) do
all_nodes = Model.all_nodes(graph)
# State: {disc, low, parent, time, bridges, articulation_points}
initial_state = %{
disc: %{},
low: %{},
parent: %{},
time: 0,
bridges: [],
articulation_points: MapSet.new()
}
final_state =
Enum.reduce(all_nodes, initial_state, fn node, state ->
if Map.has_key?(state.disc, node) do
state
else
analyze_dfs(graph, node, state)
end
end)
%{
bridges: Enum.sort(final_state.bridges),
articulation_points: Enum.sort(MapSet.to_list(final_state.articulation_points))
}
end
defp analyze_dfs(graph, node, state) do
state =
Map.update!(state, :disc, &Map.put(&1, node, state.time))
|> Map.update!(:low, &Map.put(&1, node, state.time))
|> Map.update!(:time, &(&1 + 1))
neighbors = Model.successor_ids(graph, node)
children_count = init_counter(0)
state_after_neighbors =
Enum.reduce(neighbors, state, fn neighbor, acc_state ->
case Map.fetch(acc_state.disc, neighbor) do
:error ->
# Tree edge
increment_counter(children_count)
new_state =
Map.update!(acc_state, :parent, &Map.put(&1, neighbor, node))
|> then(&analyze_dfs(graph, neighbor, &1))
# Update low value
new_low = min(Map.fetch!(new_state.low, node), Map.fetch!(new_state.low, neighbor))
new_state = Map.update!(new_state, :low, &Map.put(&1, node, new_low))
# Check for articulation point
parent = Map.get(new_state.parent, node)
new_state =
if parent != nil and
Map.fetch!(new_state.low, neighbor) >= Map.fetch!(new_state.disc, node) do
Map.update!(new_state, :articulation_points, &MapSet.put(&1, node))
else
new_state
end
# Check for bridge
new_state =
if Map.fetch!(new_state.low, neighbor) > Map.fetch!(new_state.disc, node) do
bridge = if node < neighbor, do: {node, neighbor}, else: {neighbor, node}
Map.update!(new_state, :bridges, &[bridge | &1])
else
new_state
end
new_state
{:ok, _} ->
# Back edge
if Map.get(acc_state.parent, node) != neighbor do
new_low = min(Map.fetch!(acc_state.low, node), Map.fetch!(acc_state.disc, neighbor))
Map.update!(acc_state, :low, &Map.put(&1, node, new_low))
else
acc_state
end
end
end)
# Root node articulation point check
if Map.get(state_after_neighbors.parent, node) == nil and get_counter(children_count) > 1 do
Map.update!(state_after_neighbors, :articulation_points, &MapSet.put(&1, node))
else
state_after_neighbors
end
end
# Simple mutable counter using Agent-like pattern with a tuple
# We use the process dictionary for this local mutable state
defp init_counter(initial) do
key = make_ref()
Process.put(key, initial)
key
end
defp get_counter(key), do: Process.get(key)
defp increment_counter(key) do
current = Process.get(key)
Process.put(key, current + 1)
end
@doc """
Finds Strongly Connected Components (SCC) using Tarjan's Algorithm.
Returns a list of components, where each component is a list of node IDs.
O(V + E) linear time.
"""
@spec strongly_connected_components(Yog.graph()) :: [[Yog.node_id()]]
def strongly_connected_components(graph) do
all_nodes = Model.all_nodes(graph)
state = %{
index: 0,
indices: %{},
lowlinks: %{},
stack: [],
on_stack: MapSet.new(),
sccs: []
}
final_state =
Enum.reduce(all_nodes, state, fn node, acc_state ->
if Map.has_key?(acc_state.indices, node) do
acc_state
else
tarjan_dfs(graph, node, acc_state)
end
end)
final_state.sccs
end
defp tarjan_dfs(graph, node, state) do
state =
Map.update!(state, :indices, &Map.put(&1, node, state.index))
|> Map.update!(:lowlinks, &Map.put(&1, node, state.index))
|> Map.update!(:index, &(&1 + 1))
|> Map.update!(:stack, &[node | &1])
|> Map.update!(:on_stack, &MapSet.put(&1, node))
neighbors = Model.successor_ids(graph, node)
state_after_neighbors =
Enum.reduce(neighbors, state, fn neighbor, acc_state ->
case Map.fetch(acc_state.indices, neighbor) do
:error ->
# Successor not yet visited
new_state = tarjan_dfs(graph, neighbor, acc_state)
new_lowlink =
min(
Map.fetch!(new_state.lowlinks, node),
Map.fetch!(new_state.lowlinks, neighbor)
)
Map.update!(new_state, :lowlinks, &Map.put(&1, node, new_lowlink))
{:ok, _} ->
# Successor already visited
if MapSet.member?(acc_state.on_stack, neighbor) do
new_lowlink =
min(
Map.fetch!(acc_state.lowlinks, node),
Map.fetch!(acc_state.indices, neighbor)
)
Map.update!(acc_state, :lowlinks, &Map.put(&1, node, new_lowlink))
else
acc_state
end
end
end)
# If node is a root node, pop the stack to generate an SCC
if Map.fetch!(state_after_neighbors.lowlinks, node) ==
Map.fetch!(state_after_neighbors.indices, node) do
{scc, new_stack, new_on_stack} =
pop_scc(state_after_neighbors.stack, state_after_neighbors.on_stack, node, [])
%{
state_after_neighbors
| stack: new_stack,
on_stack: new_on_stack,
sccs: [scc | state_after_neighbors.sccs]
}
else
state_after_neighbors
end
end
defp pop_scc([head | rest], on_stack, target, acc) when head == target do
{Enum.reverse([head | acc]), rest, MapSet.delete(on_stack, head)}
end
defp pop_scc([head | rest], on_stack, target, acc) do
pop_scc(rest, MapSet.delete(on_stack, head), target, [head | acc])
end
@doc """
Alias for `strongly_connected_components/1`.
"""
@spec scc(Yog.graph()) :: [[Yog.node_id()]]
def scc(graph), do: strongly_connected_components(graph)
@doc """
Finds Strongly Connected Components (SCC) using Kosaraju's Algorithm.
Returns a list of components, where each component is a list of node IDs.
Kosaraju's algorithm uses two DFS passes and graph transposition:
1. First DFS: Compute finishing times
2. Transpose the graph (reverse all edges) - O(1) operation in Yog!
3. Second DFS: Process nodes in reverse finishing time order on transposed graph
Time Complexity: O(V + E)
## Example
iex> graph = Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_node(3, "C")
...> |> Yog.add_edge!(from: 1, to: 2, with: 1)
...> |> Yog.add_edge!(from: 2, to: 3, with: 1)
...> |> Yog.add_edge!(from: 3, to: 1, with: 1)
iex>
iex> sccs = Yog.Connectivity.kosaraju(graph)
iex> hd(sccs) |> Enum.sort()
[1, 2, 3]
"""
@spec kosaraju(Yog.graph()) :: [[Yog.node_id()]]
def kosaraju(graph) do
all_nodes = Model.all_nodes(graph)
# First DFS to get finishing order
{_, finish_order} =
Enum.reduce(all_nodes, {MapSet.new(), []}, fn node, {visited, order} ->
if MapSet.member?(visited, node) do
{visited, order}
else
dfs_finish(graph, node, visited, order)
end
end)
# Transpose the graph
transposed = Yog.Transform.transpose(graph)
# Second DFS on transposed graph in reverse finishing order
{_, sccs} =
Enum.reduce(finish_order, {MapSet.new(), []}, fn node, {visited, components} ->
if MapSet.member?(visited, node) do
{visited, components}
else
{new_visited, component} = dfs_collect(transposed, node, visited, [])
{new_visited, [component | components]}
end
end)
sccs
end
defp dfs_finish(graph, node, visited, order) do
visited = MapSet.put(visited, node)
neighbors = Model.successor_ids(graph, node)
{final_visited, final_order} =
Enum.reduce(neighbors, {visited, order}, fn neighbor, {acc_visited, acc_order} ->
if MapSet.member?(acc_visited, neighbor) do
{acc_visited, acc_order}
else
dfs_finish(graph, neighbor, acc_visited, acc_order)
end
end)
{final_visited, [node | final_order]}
end
defp dfs_collect(graph, node, visited, component) do
visited = MapSet.put(visited, node)
neighbors = Model.successor_ids(graph, node)
Enum.reduce(neighbors, {visited, [node | component]}, fn neighbor, {acc_visited, acc_comp} ->
if MapSet.member?(acc_visited, neighbor) do
{acc_visited, acc_comp}
else
dfs_collect(graph, neighbor, acc_visited, acc_comp)
end
end)
end
@doc """
Finds Connected Components in an **undirected graph**.
A connected component is a maximal subgraph where every node is reachable
from every other node via undirected edges. This uses simple DFS and runs
in linear time.
Important: This algorithm is designed for undirected graphs. For directed
graphs, use `weakly_connected_components/1` instead.
Time Complexity: O(V + E)
## Example
iex> graph = Yog.undirected()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_node(3, "C")
...> |> Yog.add_node(4, "D")
...> |> Yog.add_edge!(from: 1, to: 2, with: 1)
...> |> Yog.add_edge!(from: 3, to: 4, with: 1)
iex>
iex> components = Yog.Connectivity.connected_components(graph)
iex> Enum.map(components, &Enum.sort/1) |> Enum.sort()
[[1, 2], [3, 4]]
"""
@spec connected_components(Yog.graph()) :: [component()]
def connected_components(graph) do
all_nodes = Model.all_nodes(graph)
{_, components} =
Enum.reduce(all_nodes, {MapSet.new(), []}, fn node, {visited, components} ->
if MapSet.member?(visited, node) do
{visited, components}
else
{new_visited, component} = dfs_component(graph, node, visited, [])
{new_visited, [component | components]}
end
end)
components
end
defp dfs_component(graph, node, visited, component) do
visited = MapSet.put(visited, node)
neighbors = Model.successor_ids(graph, node)
Enum.reduce(neighbors, {visited, [node | component]}, fn neighbor, {acc_visited, acc_comp} ->
if MapSet.member?(acc_visited, neighbor) do
{acc_visited, acc_comp}
else
dfs_component(graph, neighbor, acc_visited, acc_comp)
end
end)
end
@doc """
Finds Weakly Connected Components in a **directed graph**.
A weakly connected component is a maximal subgraph where, if you ignore
edge directions, all nodes are reachable from each other. This is equivalent
to finding connected components on the underlying undirected graph.
Time Complexity: O(V + E)
## Example
iex> graph = Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.add_node(3, "C")
...> |> Yog.add_edge!(from: 1, to: 2, with: 1)
...> |> Yog.add_edge!(from: 3, to: 2, with: 1) # 1->2<-3
iex>
iex> # WCCs: [1, 2, 3] (weakly connected as undirected)
iex> wccs = Yog.Connectivity.weakly_connected_components(graph)
iex> hd(wccs) |> Enum.sort()
[1, 2, 3]
"""
@spec weakly_connected_components(Yog.graph()) :: [component()]
def weakly_connected_components(graph) do
all_nodes = Model.all_nodes(graph)
# For weak connectivity, we need to treat the graph as undirected
# So we need to consider both successors and predecessors
{_, components} =
Enum.reduce(all_nodes, {MapSet.new(), []}, fn node, {visited, components} ->
if MapSet.member?(visited, node) do
{visited, components}
else
{new_visited, component} = dfs_weak_component(graph, node, visited, [])
{new_visited, [component | components]}
end
end)
components
end
defp dfs_weak_component(graph, node, visited, component) do
visited = MapSet.put(visited, node)
# Get all neighbors (both successors and predecessors)
successors = Model.successor_ids(graph, node)
predecessors = Model.predecessors(graph, node) |> Enum.map(fn {id, _} -> id end)
all_neighbors = Enum.uniq(successors ++ predecessors)
Enum.reduce(all_neighbors, {visited, [node | component]}, fn neighbor,
{acc_visited, acc_comp} ->
if MapSet.member?(acc_visited, neighbor) do
{acc_visited, acc_comp}
else
dfs_weak_component(graph, neighbor, acc_visited, acc_comp)
end
end)
end
end