Current section
Files
Jump to
Current section
Files
lib/yog/pathfinding/yen.ex
defmodule Yog.Pathfinding.Yen do
@moduledoc """
Yen's algorithm for finding the k shortest loopless paths between two nodes.
This algorithm iteratively finds the next shortest path by exploring "spur"
deviations from previously found paths. It is a natural extension of Dijkstra's
algorithm for applications that require backup routes or alternative paths.
**Time Complexity:** O(k · N · (E + V log V)) where N is the number of nodes
in the shortest path.
## Algorithm
1. Find the shortest path using Dijkstra.
2. For each previously found path, consider every node as a "spur node".
3. Temporarily remove the edge following the spur node and all earlier nodes
in the path, then run Dijkstra from the spur node to the target.
4. Combine the root path with the spur path to form a candidate.
5. Select the lowest-weight candidate as the next shortest path.
## Example
iex> graph = Yog.from_edges(:directed, [
...> {1, 2, 1}, {1, 3, 2}, {2, 3, 1}, {2, 4, 3},
...> {3, 4, 1}, {3, 5, 4}, {4, 5, 1}
...> ])
iex> {:ok, paths} = Yog.Pathfinding.Yen.k_shortest_paths(graph, 1, 5, 3)
iex> length(paths)
3
iex> hd(paths).weight
4
## References
- Jin Y. Yen (1971). "Finding the k shortest loopless paths in a network"
"""
alias Yog.Graph
alias Yog.PairingHeap
alias Yog.Pathfinding.Dijkstra
alias Yog.Pathfinding.Path
alias Yog.Transform
@doc """
Finds the k shortest loopless paths from `source` to `target`.
## Options
- `:with` - Function to extract/transform edge weight before pathfinding
- `:zero` - Identity value for the weight type (default: `0`)
- `:add` - Function to add two weights (default: `&Kernel.+/2`)
- `:compare` - Function comparing two weights returning `:lt`, `:eq`, or `:gt`
(default: `&Yog.Utils.compare/2`)
## Returns
- `{:ok, [Path.t()]}` — list of paths sorted by total weight, shortest first
- `:error` — if no path exists at all
"""
@spec k_shortest_paths(
Graph.t(),
Yog.node_id(),
Yog.node_id(),
pos_integer(),
keyword()
) :: {:ok, [Path.t()]} | :error
def k_shortest_paths(graph, source, target, k, opts \\ []) when k >= 1 do
zero = opts[:zero] || 0
add = opts[:add] || (&Kernel.+/2)
compare = opts[:compare] || (&Yog.Utils.compare/2)
weight_fn = opts[:with] || (&Function.identity/1)
graph =
if weight_fn != (&Function.identity/1),
do: Transform.map_edges(graph, weight_fn),
else: graph
case Dijkstra.shortest_path(graph, source, target, zero, add, compare) do
:error ->
:error
{:ok, first_path} ->
paths = [first_path]
seen_paths = MapSet.new([first_path.nodes])
seen_candidates = MapSet.new()
heap = PairingHeap.new(fn {w1, _}, {w2, _} -> compare.(w1, w2) != :gt end)
{candidates, seen_candidates} =
generate_candidates(
graph,
first_path,
paths,
seen_paths,
seen_candidates,
heap,
target,
zero,
add,
compare
)
do_k_iterations(
graph,
k - 1,
paths,
seen_paths,
seen_candidates,
candidates,
target,
zero,
add,
compare
)
end
end
# ---------------------------------------------------------------------------
# Core iteration loop
# ---------------------------------------------------------------------------
defp do_k_iterations(
_graph,
0,
paths,
_seen_paths,
_seen_candidates,
_candidates,
_target,
_zero,
_add,
_compare
) do
{:ok, Enum.reverse(paths)}
end
defp do_k_iterations(
graph,
remaining,
paths,
seen_paths,
seen_candidates,
candidates,
target,
zero,
add,
compare
) do
case pop_valid_candidate(candidates, seen_paths) do
:error ->
{:ok, Enum.reverse(paths)}
{:ok, {weight, nodes}, candidates} ->
path = Path.new(nodes, weight, :yen)
seen_paths = MapSet.put(seen_paths, nodes)
paths = [path | paths]
{candidates, seen_candidates} =
generate_candidates(
graph,
path,
paths,
seen_paths,
seen_candidates,
candidates,
target,
zero,
add,
compare
)
do_k_iterations(
graph,
remaining - 1,
paths,
seen_paths,
seen_candidates,
candidates,
target,
zero,
add,
compare
)
end
end
# ---------------------------------------------------------------------------
# Candidate generation
# ---------------------------------------------------------------------------
defp generate_candidates(
graph,
prev_path,
paths,
seen_paths,
seen_candidates,
candidates,
target,
zero,
add,
compare
) do
prev_nodes = prev_path.nodes
max_spur_index = length(prev_nodes) - 2
if max_spur_index < 0 do
{candidates, seen_candidates}
else
prefix_weights = build_prefix_weights(prev_nodes, graph, zero, add)
Enum.reduce(
0..max_spur_index,
{candidates, seen_candidates},
fn i, {cand_acc, cand_seen_acc} ->
spur_node = Enum.at(prev_nodes, i)
root_path = Enum.take(prev_nodes, i + 1)
root_weight = Map.fetch!(prefix_weights, i)
root_nodes = Enum.take(prev_nodes, i)
edges_to_remove =
Enum.reduce(paths, [], fn p, acc ->
p_nodes = p.nodes
if List.starts_with?(p_nodes, root_path) do
next_node = Enum.at(p_nodes, i + 1)
[{spur_node, next_node} | acc]
else
acc
end
end)
modified_graph =
graph
|> remove_edges(edges_to_remove)
|> remove_nodes(root_nodes)
case Dijkstra.shortest_path(
modified_graph,
spur_node,
target,
zero,
add,
compare
) do
:error ->
{cand_acc, cand_seen_acc}
{:ok, spur_path} ->
total_nodes = root_path ++ tl(spur_path.nodes)
total_weight = add.(root_weight, spur_path.weight)
if MapSet.member?(seen_paths, total_nodes) or
MapSet.member?(cand_seen_acc, total_nodes) do
{cand_acc, cand_seen_acc}
else
{
PairingHeap.push(cand_acc, {total_weight, total_nodes}),
MapSet.put(cand_seen_acc, total_nodes)
}
end
end
end
)
end
end
defp build_prefix_weights(nodes, graph, zero, add) do
# Returns %{index => weight_from_source_to_node_at_index}
# index 0 is the source node itself, so weight is zero
do_prefix_weights(nodes, graph, zero, add, 0, %{0 => zero})
end
defp do_prefix_weights([_last], _graph, _zero, _add, _idx, acc), do: acc
defp do_prefix_weights([u, v | rest], graph, zero, add, idx, acc) do
w = get_edge_weight(graph, u, v)
next_weight = add.(Map.fetch!(acc, idx), w)
next_acc = Map.put(acc, idx + 1, next_weight)
do_prefix_weights([v | rest], graph, zero, add, idx + 1, next_acc)
end
defp get_edge_weight(%Graph{out_edges: out_edges}, u, v) do
case Map.fetch(out_edges, u) do
{:ok, inner} -> Map.fetch!(inner, v)
:error -> raise "Edge #{inspect(u)} -> #{inspect(v)} not found"
end
end
# ---------------------------------------------------------------------------
# Candidate heap helpers
# ---------------------------------------------------------------------------
defp pop_valid_candidate(heap, seen_paths) do
case PairingHeap.pop(heap) do
:error ->
:error
{:ok, {_weight, nodes} = candidate, new_heap} ->
if MapSet.member?(seen_paths, nodes) do
pop_valid_candidate(new_heap, seen_paths)
else
{:ok, candidate, new_heap}
end
end
end
# ---------------------------------------------------------------------------
# Graph mutation helpers (operate on copies)
# ---------------------------------------------------------------------------
defp remove_edges(graph, edges) do
Enum.reduce(edges, graph, fn {u, v}, g ->
g = remove_directed_edge(g, u, v)
if g.kind == :undirected, do: remove_directed_edge(g, v, u), else: g
end)
end
defp remove_directed_edge(%Graph{} = g, u, v) do
new_out =
case Map.fetch(g.out_edges, u) do
{:ok, inner} -> %{g.out_edges | u => Map.delete(inner, v)}
:error -> g.out_edges
end
new_in =
case Map.fetch(g.in_edges, v) do
{:ok, inner} -> %{g.in_edges | v => Map.delete(inner, u)}
:error -> g.in_edges
end
%Graph{g | out_edges: new_out, in_edges: new_in}
end
defp remove_nodes(graph, nodes) do
Enum.reduce(nodes, graph, fn node, g -> remove_node(g, node) end)
end
defp remove_node(%Graph{} = g, node) do
new_nodes = Map.delete(g.nodes, node)
# Update out_edges: delete the node itself, and delete references to it
# from other nodes' adjacency maps (using in_edges to find predecessors).
new_out =
g.out_edges
|> Map.delete(node)
|> then(fn out ->
preds = Map.get(g.in_edges, node, %{})
Enum.reduce(preds, out, fn {pred, _}, acc ->
case Map.fetch(acc, pred) do
{:ok, inner} -> %{acc | pred => Map.delete(inner, node)}
:error -> acc
end
end)
end)
# Update in_edges: delete the node itself, and delete references to it
# from other nodes' adjacency maps (using out_edges to find successors).
new_in =
g.in_edges
|> Map.delete(node)
|> then(fn inn ->
succs = Map.get(g.out_edges, node, %{})
Enum.reduce(succs, inn, fn {succ, _}, acc ->
case Map.fetch(acc, succ) do
{:ok, inner} -> %{acc | succ => Map.delete(inner, node)}
:error -> acc
end
end)
end)
%Graph{g | nodes: new_nodes, out_edges: new_out, in_edges: new_in}
end
end