Current section
Files
Jump to
Current section
Files
lib/yog/pathfinding/dijkstra.ex
defmodule Yog.Pathfinding.Dijkstra do
@moduledoc """
Dijkstra's algorithm for single-source shortest paths.
Dijkstra's algorithm finds the shortest path from a source node to all other
reachable nodes in a graph with non-negative edge weights.
## Algorithm Characteristics
- **Time Complexity**: O((V + E) log V) with a binary heap
- **Space Complexity**: O(V)
- **Requirements**: Non-negative edge weights
- **Optimality**: Guaranteed optimal for graphs with non-negative weights
## When to Use
- When all edge weights are non-negative
- For single-source shortest path problems
- When you need paths to all nodes from a source
- As a baseline comparison for other algorithms
## Examples
# Find shortest path between two nodes
graph = Yog.directed()
|> Yog.add_node(:a, nil)
|> Yog.add_node(:b, nil)
|> Yog.add_node(:c, nil)
|> Yog.add_edge!(:a, :b, 4)
|> Yog.add_edge!(:b, :c, 1)
compare = &Yog.Utils.compare/2
Dijkstra.shortest_path(graph, :a, :c, 0, &(&1 + &2), compare)
#=> {:some, {:path, [:a, :b, :c], 5}}
# Find all distances from a source
Dijkstra.single_source_distances(graph, :a, 0, &(&1 + &2), compare)
#=> %{:a => 0, :b => 4, :c => 5}
"""
alias Yog.Model
alias Yog.Pathfinding.Path
@typedoc "Result type for shortest path queries"
@type path_result :: {:ok, Path.t()} | :error
# ============================================================
# Keyword-style API (for Pathfinding module delegation)
# ============================================================
@doc """
Find shortest path using keyword options.
## Options
* `:in` - The graph to search
* `:from` - Starting node
* `:to` - Target node
* `:zero` - Identity value for the weight type
* `:add` - Function to add two weights
* `:compare` - Function to compare weights (`:lt`, `:eq`, `:gt`)
## Examples
Pathfinding.shortest_path(
in: graph,
from: :a,
to: :c,
zero: 0,
add: &(&1 + &2),
compare: &Yog.Utils.compare/2
)
"""
@spec shortest_path(keyword()) :: path_result()
def shortest_path(opts) do
graph = Keyword.fetch!(opts, :in)
from = Keyword.fetch!(opts, :from)
to = Keyword.fetch!(opts, :to)
zero = Keyword.fetch!(opts, :zero)
add = Keyword.fetch!(opts, :add)
compare = Keyword.fetch!(opts, :compare)
shortest_path(graph, from, to, zero, add, compare)
end
@doc """
Find shortest path using keyword options (alias for `shortest_path/1`).
"""
@spec shortest_path_int(keyword()) :: path_result()
def shortest_path_int(opts) do
shortest_path(opts)
end
@doc """
Find shortest path using keyword options (alias for `shortest_path/1`).
"""
@spec shortest_path_float(keyword()) :: path_result()
def shortest_path_float(opts) do
shortest_path(opts)
end
@doc """
Single-source distances using keyword options.
## Options
* `:in` - The graph to search
* `:from` - Starting node
* `:zero` - Identity value for the weight type
* `:add` - Function to add two weights
* `:compare` - Function to compare weights
## Examples
Pathfinding.single_source_distances(
in: graph,
from: :a,
zero: 0,
add: &(&1 + &2),
compare: &Yog.Utils.compare/2
)
"""
@spec single_source_distances(keyword()) :: %{Yog.node_id() => any()}
def single_source_distances(opts) do
graph = Keyword.fetch!(opts, :in)
from = Keyword.fetch!(opts, :from)
zero = Keyword.fetch!(opts, :zero)
add = Keyword.fetch!(opts, :add)
compare = Keyword.fetch!(opts, :compare)
single_source_distances(graph, from, zero, add, compare)
end
@doc """
Implicit Dijkstra using keyword options.
## Options
* `:from` - Starting state
* `:successors_with_cost` - Function returning neighbors with costs
* `:is_goal` - Function to check if a state is the goal
* `:zero` - Identity value for the weight type
* `:add` - Function to add two weights
* `:compare` - Function to compare weights
## Examples
Pathfinding.implicit_dijkstra(
from: 1,
successors_with_cost: fn n -> [{n+1, 1}] end,
is_goal: fn n -> n == 10 end,
zero: 0,
add: &(&1 + &2),
compare: &Yog.Utils.compare/2
)
"""
@spec implicit_dijkstra(keyword()) :: {:some, any()} | :none
def implicit_dijkstra(opts) do
from = Keyword.fetch!(opts, :from)
successors = Keyword.fetch!(opts, :successors_with_cost)
is_goal = Keyword.fetch!(opts, :is_goal)
zero = Keyword.fetch!(opts, :zero)
add = Keyword.fetch!(opts, :add)
compare = Keyword.fetch!(opts, :compare)
implicit_dijkstra(from, successors, is_goal, zero, add, compare)
end
@doc """
Implicit Dijkstra with key function using keyword options.
## Options
* `:from` - Starting state
* `:successors_with_cost` - Function returning neighbors with costs
* `:visited_by` - Function to extract a key for visited tracking
* `:is_goal` - Function to check if a state is the goal
* `:zero` - Identity value for the weight type
* `:add` - Function to add two weights
* `:compare` - Function to compare weights
"""
@spec implicit_dijkstra_by(keyword()) :: {:some, any()} | :none
def implicit_dijkstra_by(opts) do
from = Keyword.fetch!(opts, :from)
successors = Keyword.fetch!(opts, :successors_with_cost)
visited_by = Keyword.fetch!(opts, :visited_by)
is_goal = Keyword.fetch!(opts, :is_goal)
zero = Keyword.fetch!(opts, :zero)
add = Keyword.fetch!(opts, :add)
compare = Keyword.fetch!(opts, :compare)
implicit_dijkstra_by(from, successors, visited_by, is_goal, zero, add, compare)
end
# ============================================================
# Direct API
# ============================================================
@doc """
Find the shortest path between two nodes using custom numeric operations.
## Parameters
* `graph` - The graph to search
* `from` - Starting node
* `to` - Target node
* `zero` - Identity value for the weight type
* `add` - Function to add two weights: `(weight, weight) -> weight`
* `compare` - Function to compare weights, returns `:lt`, `:eq`, or `:gt`
## Returns
* `{:ok, path}` - A `Path` struct containing the nodes and total weight
* `:error` - No path exists between the nodes
## Examples
iex> graph = Yog.directed()
...> |> Yog.add_node(:a, nil)
...> |> Yog.add_node(:b, nil)
...> |> Yog.add_node(:c, nil)
...> |> Yog.add_edge!(:a, :b, 4)
...> |> Yog.add_edge!(:b, :c, 1)
iex> compare = &Yog.Utils.compare/2
iex> {:ok, path} = Dijkstra.shortest_path(graph, :a, :c, 0, &(&1 + &2), compare)
iex> path.nodes
[:a, :b, :c]
iex> path.weight
5
iex> graph = Yog.directed()
...> |> Yog.add_node(:a, nil)
...> |> Yog.add_node(:b, nil)
...> |> Yog.add_node(:c, nil)
...> |> Yog.add_edge!(:a, :b, 4)
...> |> Yog.add_edge!(:b, :c, 1)
iex> compare = &Yog.Utils.compare/2
iex> Dijkstra.shortest_path(graph, :a, :nonexistent, 0, &(&1 + &2), compare)
:error
"""
@spec shortest_path(
Yog.t(),
Yog.node_id(),
Yog.node_id(),
weight,
(weight, weight -> weight),
(weight, weight -> :lt | :eq | :gt)
) :: path_result()
when weight: var
def shortest_path(graph, from, to, zero, add, compare) do
case do_dijkstra(graph, from, to, zero, add, compare, true) do
:error -> :error
{path, weight} -> {:ok, Path.new(path, weight, :dijkstra)}
end
end
@doc """
Find the shortest path using integer weights.
Uses built-in integer arithmetic for efficient computation.
## Examples
iex> graph = Yog.directed()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge!(1, 2, 4)
...> |> Yog.add_edge!(2, 3, 1)
iex> {:ok, path} = Dijkstra.shortest_path_int(graph, 1, 3)
iex> path.nodes
[1, 2, 3]
iex> path.weight
5
"""
@spec shortest_path_int(Yog.t(), Yog.node_id(), Yog.node_id()) :: path_result()
def shortest_path_int(graph, from, to) do
shortest_path(graph, from, to, 0, &(&1 + &2), &Yog.Utils.compare/2)
end
@doc """
Find the shortest path using float weights.
Uses built-in float arithmetic for efficient computation.
## Examples
iex> graph = Yog.directed()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge!(1, 2, 4.5)
...> |> Yog.add_edge!(2, 3, 1.5)
iex> {:ok, path} = Dijkstra.shortest_path_float(graph, 1, 3)
iex> path.nodes
[1, 2, 3]
iex> path.weight
6.0
"""
@spec shortest_path_float(Yog.t(), Yog.node_id(), Yog.node_id()) :: path_result()
def shortest_path_float(graph, from, to) do
shortest_path(graph, from, to, 0.0, &(&1 + &2), &Yog.Utils.compare/2)
end
@doc """
Calculate single-source shortest distances to all reachable nodes.
Returns a map of node IDs to their shortest distance from the source.
## Parameters
* `graph` - The graph to search
* `from` - Source node
* `zero` - Identity value for the weight type
* `add` - Function to add two weights
* `compare` - Function to compare weights
## Examples
iex> graph = Yog.directed()
...> |> Yog.add_node(:a, nil)
...> |> Yog.add_node(:b, nil)
...> |> Yog.add_node(:c, nil)
...> |> Yog.add_edge!(:a, :b, 4)
...> |> Yog.add_edge!(:a, :c, 2)
...> |> Yog.add_edge!(:b, :c, 1)
iex> compare = &Yog.Utils.compare/2
iex> Dijkstra.single_source_distances(graph, :a, 0, &(&1 + &2), compare)
%{a: 0, b: 4, c: 2}
"""
@spec single_source_distances(
Yog.t(),
Yog.node_id(),
weight,
(weight, weight -> weight),
(weight, weight -> :lt | :eq | :gt)
) :: %{Yog.node_id() => weight}
when weight: var
def single_source_distances(graph, from, zero, add, compare) do
case do_dijkstra(graph, from, nil, zero, add, compare, false) do
:none -> %{}
{_path, _weight, distances} -> distances
end
end
@doc """
Run Dijkstra on an implicit (generated) graph.
Instead of storing all edges explicitly, provide a successor function that
generates neighbors on demand. This is useful for:
- Infinite or very large graphs
- Grid-based pathfinding with dynamic obstacles
- Game state spaces
## Parameters
* `from` - Starting state
* `successors` - Function `state -> [{neighbor, cost}]`
* `is_goal` - Function `state -> boolean` to check if goal reached
* `zero` - Identity value for the weight type
* `add` - Function to add two weights
* `compare` - Function to compare weights
## Returns
* `{:ok, cost}` - Minimum cost to reach goal
* `:error` - Goal is unreachable
## Examples
# Search on a linear chain: 1->2->3->4 with costs 1,2,3
iex> successors = fn
...> 1 -> [{2, 1}]
...> 2 -> [{3, 2}]
...> 3 -> [{4, 3}]
...> 4 -> []
...> end
iex> compare = &Yog.Utils.compare/2
iex> {:ok, cost} = Dijkstra.implicit_dijkstra(
...> 1, successors, fn x -> x == 4 end,
...> 0, &(&1 + &2), compare
...> )
iex> cost
6
"""
@spec implicit_dijkstra(
state,
(state -> [{state, cost}]),
(state -> boolean),
cost,
(cost, cost -> cost),
(cost, cost -> :lt | :eq | :gt)
) :: {:ok, cost} | :error
when state: var, cost: var
def implicit_dijkstra(from, successors, is_goal, zero, add, compare) do
implicit_dijkstra_by(from, successors, fn x -> x end, is_goal, zero, add, compare)
end
@doc """
Implicit Dijkstra with a key function for visited state tracking.
Similar to `implicit_dijkstra/6`, but uses a key function to determine
when states should be considered "visited". This allows:
- Efficient pruning of equivalent states
- Custom equivalence relations beyond simple equality
## Parameters
* `from` - Starting state
* `successors` - Function `state -> [{neighbor, cost}]`
* `key_fn` - Function `state -> key` for visited tracking
* `is_goal` - Function `state -> boolean` to check if goal reached
* `zero` - Identity value for the weight type
* `add` - Function to add two weights
* `compare` - Function to compare weights
## Examples
iex> successors = fn
...> {pos, _dir} when pos < 3 -> [{{pos + 1, :fwd}, 1}]
...> _ -> []
...> end
iex> key_fn = fn {pos, _dir} -> pos end
iex> goal_fn = fn {pos, _dir} -> pos == 3 end
iex> compare = &Yog.Utils.compare/2
iex> {:ok, cost} = Dijkstra.implicit_dijkstra_by(
...> {0, :start}, successors, key_fn,
...> goal_fn, 0, &(&1 + &2), compare
...> )
iex> cost
3
"""
@spec implicit_dijkstra_by(
state,
(state -> [{state, cost}]),
(state -> term()),
(state -> boolean),
cost,
(cost, cost -> cost),
(cost, cost -> :lt | :eq | :gt)
) :: {:ok, cost} | :error
when state: var, cost: var
def implicit_dijkstra_by(from, successors, key_fn, is_goal, zero, add, compare) do
initial_queue = [{zero, from}]
initial_visited = %{key_fn.(from) => zero}
do_implicit_dijkstra(
initial_queue,
successors,
key_fn,
is_goal,
add,
compare,
initial_visited
)
end
# Main Dijkstra implementation
# Returns :error | {path, weight} if return_path=true
# Returns :error | {path, weight, distances} if return_path=false
defp do_dijkstra(graph, from, to, zero, add, compare, return_path) do
initial_queue = [{zero, from, [from]}]
initial_distances = %{from => zero}
do_dijkstra_loop(graph, initial_queue, to, add, compare, return_path, initial_distances)
end
defp do_dijkstra_loop(_graph, [], to, _add, _compare, _return_path, distances) do
if to == nil do
# Single-source case: return all distances
{[], 0, distances}
else
:error
end
end
defp do_dijkstra_loop(
graph,
[{dist, node, path} | rest],
to,
add,
compare,
return_path,
distances
) do
# Check if we've found a better path already
current_best = Map.get(distances, node)
if current_best != nil and compare.(dist, current_best) == :gt do
# Skip this outdated entry
do_dijkstra_loop(graph, rest, to, add, compare, return_path, distances)
else
# Check if we reached the target (only if to is specified)
if to != nil and node == to do
if return_path do
{Enum.reverse(path), dist}
else
{Enum.reverse(path), dist, distances}
end
else
# Expand neighbors
successors = Model.successors(graph, node)
{new_queue, new_distances} =
Enum.reduce(successors, {rest, distances}, fn {neighbor, weight}, {q, d} ->
new_dist = add.(dist, weight)
case Map.fetch(d, neighbor) do
{:ok, current} ->
if compare.(new_dist, current) == :lt do
new_q = insert_sorted(q, {new_dist, neighbor, [neighbor | path]}, compare)
new_d = Map.put(d, neighbor, new_dist)
{new_q, new_d}
else
{q, d}
end
:error ->
new_q = insert_sorted(q, {new_dist, neighbor, [neighbor | path]}, compare)
new_d = Map.put(d, neighbor, new_dist)
{new_q, new_d}
end
end)
if to == nil do
# Single-source: continue until queue is empty
do_dijkstra_loop(graph, new_queue, to, add, compare, return_path, new_distances)
else
# Shortest path: continue searching
do_dijkstra_loop(graph, new_queue, to, add, compare, return_path, new_distances)
end
end
end
end
# Implicit Dijkstra implementation
defp do_implicit_dijkstra([], _successors, _key_fn, _is_goal, _add, _compare, _visited) do
:error
end
defp do_implicit_dijkstra(
[{dist, state} | rest],
successors,
key_fn,
is_goal,
add,
compare,
visited
) do
key = key_fn.(state)
current_best = Map.get(visited, key)
if current_best != nil and compare.(dist, current_best) == :gt do
# Skip outdated entry
do_implicit_dijkstra(rest, successors, key_fn, is_goal, add, compare, visited)
else
if is_goal.(state) do
{:ok, dist}
else
# Expand successors
next_states = successors.(state)
{new_queue, new_visited} =
Enum.reduce(next_states, {rest, visited}, fn {next_state, cost}, {q, v} ->
next_key = key_fn.(next_state)
new_dist = add.(dist, cost)
case Map.fetch(v, next_key) do
{:ok, current} ->
if compare.(new_dist, current) == :lt do
new_q = insert_sorted(q, {new_dist, next_state}, compare)
new_v = Map.put(v, next_key, new_dist)
{new_q, new_v}
else
{q, v}
end
:error ->
new_q = insert_sorted(q, {new_dist, next_state}, compare)
new_v = Map.put(v, next_key, new_dist)
{new_q, new_v}
end
end)
do_implicit_dijkstra(new_queue, successors, key_fn, is_goal, add, compare, new_visited)
end
end
end
# Insert into sorted list (priority queue)
defp insert_sorted([], item, _compare), do: [item]
defp insert_sorted([head | tail], item, compare) do
# Handle both 2-tuples {dist, node} and 3-tuples {dist, node, path}
dist_item = elem(item, 0)
dist_head = elem(head, 0)
if compare.(dist_item, dist_head) == :lt do
[item, head | tail]
else
[head | insert_sorted(tail, item, compare)]
end
end
end