defmodule Yog.Pathfinding.Bidirectional do @moduledoc """ Bidirectional search algorithms that meet in the middle for dramatic speedups. These algorithms start two simultaneous searches — one from the source and one from the target — that meet in the middle. This can dramatically reduce the search space compared to single-direction search. For a graph with branching factor `b` and depth `d`: - **Standard BFS**: `O(b^d)` nodes explored - **Bidirectional BFS**: `O(2 × b^(d/2))` nodes explored (up to 500x faster for long paths) ## Requirements - Target node must be known in advance (unlike Dijkstra, which can route many at once). - Designed for point-to-point queries. ## Examples
digraph G { rankdir=LR; bgcolor="transparent"; node [shape=circle, fontname="inherit"]; edge [fontname="inherit", fontsize=10]; S [label="S"]; A [label="A"]; B [label="B"]; M [label="M", color="#6366f1", penwidth=2]; C [label="C"]; D [label="D"]; T [label="T"]; S -> A [label="2", color="#6366f1", penwidth=2.5]; A -> M [label="2", color="#6366f1", penwidth=2.5]; S -> B [label="5"]; B -> M [label="1"]; M -> C [label="2", color="#6366f1", penwidth=2.5]; C -> T [label="2", color="#6366f1", penwidth=2.5]; M -> D [label="5"]; D -> T [label="1"]; }
iex> alias Yog.Pathfinding.Bidirectional iex> graph = Yog.from_edges(:directed, [ ...> {"S", "A", 2}, {"A", "M", 2}, {"S", "B", 5}, {"B", "M", 1}, ...> {"M", "C", 2}, {"C", "T", 2}, {"M", "D", 5}, {"D", "T", 1} ...> ]) iex> {:ok, path} = Bidirectional.shortest_path(graph, "S", "T") iex> path.nodes ["S", "A", "M", "C", "T"] iex> path.weight 8 """ # credo:disable-for-this-file Credo.Check.Refactor.AppendSingleItem alias Yog.PairingHeap, as: PQ 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 """ Finds the shortest path in an unweighted graph using bidirectional BFS. This runs BFS from both source and target simultaneously, stopping when the frontiers meet. ## Options * `:in` - The graph * `:from` - The starting node ID * `:to` - The target node ID ## Examples iex> graph = Yog.undirected() ...> |> 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) ...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1) iex> {:ok, path} = Yog.Pathfinding.Bidirectional.shortest_path_unweighted(in: graph, from: 1, to: 3) iex> path.nodes [1, 2, 3] iex> path.weight 2 iex> Yog.Pathfinding.Bidirectional.shortest_path_unweighted(in: graph, from: 1, to: 99) :error """ @spec shortest_path_unweighted(keyword()) :: path_result() def shortest_path_unweighted(opts) do graph = Keyword.fetch!(opts, :in) from = Keyword.fetch!(opts, :from) to = Keyword.fetch!(opts, :to) shortest_path_unweighted(graph, from, to) end @doc """ Finds the shortest path in a weighted graph using bidirectional Dijkstra. ## Options * `:in` - The graph * `:from` - The starting node ID * `:to` - The target node ID * `:zero` - The identity element for weights (e.g. `0`) * `:add` - Weight addition function (e.g. `fn a, b -> a + b end`) * `:compare` - Comparison function (e.g. `&Yog.Utils.compare/2`) ## Examples iex> graph = Yog.undirected() ...> |> Yog.add_node(1, nil) |> Yog.add_node(2, nil) |> Yog.add_node(3, nil) ...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 5) ...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 10) iex> {:ok, path} = Yog.Pathfinding.Bidirectional.shortest_path( ...> in: graph, from: 1, to: 3, ...> zero: 0, add: &+/2, compare: &Yog.Utils.compare/2 ...> ) iex> path.nodes [1, 2, 3] iex> path.weight 15 """ @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 = opts[:zero] || 0 add = opts[:add] || (&Kernel.+/2) compare = opts[:compare] || (&Yog.Utils.compare/2) shortest_path(graph, from, to, zero, add, compare) end # ============================================================ # Direct API # ============================================================ @doc """ Finds the shortest path in an unweighted graph using bidirectional BFS. ## Parameters * `graph` - The graph to search * `from` - The starting node ID * `to` - The target node ID ## Returns * `{:ok, path}` - A `Path` struct containing the nodes and edge count * `:error` - No path exists between the nodes ## Examples iex> graph = Yog.undirected() ...> |> 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) ...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1) iex> {:ok, path} = Yog.Pathfinding.Bidirectional.shortest_path_unweighted(graph, 1, 3) iex> path.nodes [1, 2, 3] iex> path.weight 2 iex> Yog.Pathfinding.Bidirectional.shortest_path_unweighted(graph, 1, 99) :error """ @spec shortest_path_unweighted(Yog.t(), Yog.node_id(), Yog.node_id()) :: path_result() | :error def shortest_path_unweighted(graph, from, to) do if from == to do {:ok, Path.new([from], 0, :bidirectional_bfs)} else do_bidirectional_bfs(graph, from, to) end end @doc """ Finds the shortest path in a weighted graph using bidirectional Dijkstra. ## Parameters * `graph` - The graph to search * `from` - The starting node ID * `to` - The target node ID * `zero` - The identity element for weights (e.g. `0`) * `add` - Weight addition function (e.g. `fn a, b -> a + b end`) * `compare` - Comparison function returning `: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.undirected() ...> |> Yog.add_node(1, nil) |> Yog.add_node(2, nil) |> Yog.add_node(3, nil) ...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 5) ...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 10) iex> {:ok, path} = Yog.Pathfinding.Bidirectional.shortest_path(graph, 1, 3, 0, &+/2, &Yog.Utils.compare/2) iex> path.nodes [1, 2, 3] iex> path.weight 15 """ @spec shortest_path( Yog.t(), Yog.node_id(), Yog.node_id(), weight, (weight, weight -> weight), (weight, weight -> :lt | :eq | :gt) ) :: path_result() | :error when weight: var def shortest_path( graph, from, to, zero \\ 0, add \\ &Kernel.+/2, compare \\ &Yog.Utils.compare/2 ) do if from == to do {:ok, Path.new([from], zero, :bidirectional_dijkstra)} else do_bidirectional_dijkstra(graph, from, to, zero, add, compare) end end # ============================================================ # Helper functions # ============================================================ # Bidirectional BFS implementation # Uses level-by-level expansion and tracks the best meeting point to guarantee # the shortest path is found, not just the first meeting point. defp do_bidirectional_bfs(graph, from, to) do fwd_frontier = [{from, [from]}] bwd_frontier = [{to, [to]}] visited_fwd = %{from => [from]} visited_bwd = %{to => [to]} do_bfs_level(graph, fwd_frontier, bwd_frontier, visited_fwd, visited_bwd, 0, 0, nil) end defp do_bfs_level(_graph, [], _, _, _, _, _, nil), do: :error defp do_bfs_level(_graph, _, [], _, _, _, _, nil), do: :error defp do_bfs_level(_graph, [], _, _, _, _, _, {best_len, best_path}), do: {:ok, Path.new(best_path, best_len, :bidirectional_bfs)} defp do_bfs_level(_graph, _, [], _, _, _, _, {best_len, best_path}), do: {:ok, Path.new(best_path, best_len, :bidirectional_bfs)} defp do_bfs_level(graph, fwd_frontier, bwd_frontier, v_fwd, v_bwd, depth_fwd, depth_bwd, best) do best_len = if best, do: elem(best, 0), else: nil # Stop when both sides have expanded enough that no shorter path can exist if best_len != nil and depth_fwd + depth_bwd >= best_len do {best_len, best_path} = best {:ok, Path.new(best_path, best_len, :bidirectional_bfs)} else if length(fwd_frontier) <= length(bwd_frontier) do {new_frontier, new_v, new_best} = expand_frontier(graph, :fwd, fwd_frontier, v_fwd, v_bwd, best) do_bfs_level( graph, new_frontier, bwd_frontier, new_v, v_bwd, depth_fwd + 1, depth_bwd, new_best ) else {new_frontier, new_v, new_best} = expand_frontier(graph, :bwd, bwd_frontier, v_bwd, v_fwd, best) do_bfs_level( graph, fwd_frontier, new_frontier, v_fwd, new_v, depth_fwd, depth_bwd + 1, new_best ) end end end defp expand_frontier(graph, side, frontier, visited, other_visited, best) do edges_map = case side do :fwd -> graph.out_edges :bwd -> graph.in_edges end expand_frontier_nodes(frontier, edges_map, side, visited, other_visited, [], best) end defp expand_frontier_nodes([], _edges_map, _side, visited, _other_visited, acc, best) do {acc, visited, best} end defp expand_frontier_nodes( [{node, path} | rest], edges_map, side, visited, other_visited, acc, best ) do neighbors = case Map.fetch(edges_map, node) do {:ok, edges} -> Map.keys(edges) :error -> [] end {new_acc, new_visited, new_best} = Enum.reduce(neighbors, {acc, visited, best}, fn neighbor, {a, v, b} -> if Map.has_key?(v, neighbor) do {a, v, b} else new_path = [neighbor | path] new_v = Map.put(v, neighbor, new_path) b2 = case Map.fetch(other_visited, neighbor) do {:ok, other_path} -> full_path = if side == :fwd do Enum.reverse(new_path) ++ tl(other_path) else Enum.reverse(other_path) ++ tl(new_path) end len = length(full_path) - 1 if is_nil(b) or len < elem(b, 0) do {len, full_path} else b end :error -> b end {[{neighbor, new_path} | a], new_v, b2} end end) expand_frontier_nodes(rest, edges_map, side, new_visited, other_visited, new_acc, new_best) end # Bidirectional Dijkstra implementation # Runs two Dijkstra searches simultaneously (forward from source, backward from target) # and stops when they meet. This is significantly faster than single-direction Dijkstra # for point-to-point queries. defp do_bidirectional_dijkstra(graph, from, to, zero, add, compare) do # Forward search (from source) queue_fwd = PQ.new(fn {d1, _}, {d2, _} -> compare.(d1, d2) != :gt end) |> PQ.push({zero, from}) dist_fwd = %{from => zero} pred_fwd = %{} queue_bwd = PQ.new(fn {d1, _}, {d2, _} -> compare.(d1, d2) != :gt end) |> PQ.push({zero, to}) dist_bwd = %{to => zero} pred_bwd = %{} best_path = nil best_weight = nil do_bidirectional_dijkstra_step( graph, queue_fwd, queue_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, from, to, add, compare, best_path, best_weight ) end defp do_bidirectional_dijkstra_step( graph, q_fwd, q_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) do if should_continue?(q_fwd, q_bwd, best_weight, compare) do expansion_side = decide_expansion_side(q_fwd, q_bwd, compare) perform_expansion_step( expansion_side, graph, q_fwd, q_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) else finalize_result(best_path, best_weight) end end defp should_continue?(q_fwd, q_bwd, nil, _compare) do not PQ.empty?(q_fwd) and not PQ.empty?(q_bwd) end defp should_continue?(q_fwd, q_bwd, best_weight, compare) do fwd_possible = case PQ.peek(q_fwd) do {:ok, {d, _}} -> compare.(d, best_weight) == :lt :error -> false end bwd_possible = case PQ.peek(q_bwd) do {:ok, {d, _}} -> compare.(d, best_weight) == :lt :error -> false end fwd_possible or bwd_possible end defp decide_expansion_side(q_fwd, q_bwd, compare) do case {PQ.peek(q_fwd), PQ.peek(q_bwd)} do {{:ok, {d_fwd, _}}, {:ok, {d_bwd, _}}} -> if compare.(d_fwd, d_bwd) != :gt, do: :fwd, else: :bwd {{:ok, _}, :error} -> :fwd {:error, {:ok, _}} -> :bwd _ -> :none end end defp finalize_result(nil, _weight), do: :error defp finalize_result(path, weight), do: {:ok, Path.new(path, weight, :bidirectional_dijkstra)} defp perform_expansion_step( :fwd, graph, q_fwd, q_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) do case PQ.pop(q_fwd) do :error -> finalize_result(best_path, best_weight) {:ok, {dist_u, u}, rest_q} -> if Map.get(dist_fwd, u) != dist_u do # Stale entry do_bidirectional_dijkstra_step( graph, rest_q, q_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) else successors = Map.get(graph.out_edges, u, %{}) |> Map.to_list() {new_q, new_dist, new_pred, new_path, new_weight} = process_neighbors( :fwd, successors, rest_q, dist_fwd, dist_bwd, pred_fwd, pred_bwd, u, dist_u, add, compare, best_path, best_weight ) do_bidirectional_dijkstra_step( graph, new_q, q_bwd, new_dist, dist_bwd, new_pred, pred_bwd, source, target, add, compare, new_path, new_weight ) end end end defp perform_expansion_step( :bwd, graph, q_fwd, q_bwd, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) do case PQ.pop(q_bwd) do :error -> finalize_result(best_path, best_weight) {:ok, {dist_v, v}, rest_q} -> if Map.get(dist_bwd, v) != dist_v do # Stale entry do_bidirectional_dijkstra_step( graph, q_fwd, rest_q, dist_fwd, dist_bwd, pred_fwd, pred_bwd, source, target, add, compare, best_path, best_weight ) else predecessors = Map.get(graph.in_edges, v, %{}) |> Map.to_list() {new_q, new_dist, new_pred, new_path, new_weight} = process_neighbors( :bwd, predecessors, rest_q, dist_bwd, dist_fwd, pred_bwd, pred_fwd, v, dist_v, add, compare, best_path, best_weight ) do_bidirectional_dijkstra_step( graph, q_fwd, new_q, dist_fwd, new_dist, pred_fwd, new_pred, source, target, add, compare, new_path, new_weight ) end end end defp perform_expansion_step(:none, _, _, _, _, _, _, _, _, _, _, _, path, weight) do finalize_result(path, weight) end # Unified neighbor processing for both directions defp process_neighbors( _side, [], queue, dist_own, _dist_other, pred_own, _pred_other, _u, _dist_u, _add, _compare, best_path, best_weight ) do {queue, dist_own, pred_own, best_path, best_weight} end defp process_neighbors( side, [{v, weight} | rest], queue, dist_own, dist_other, pred_own, pred_other, u, dist_u, add, compare, best_path, best_weight ) do new_dist_v = add.(dist_u, weight) current_best_v = Map.get(dist_own, v) if is_nil(current_best_v) or compare.(new_dist_v, current_best_v) == :lt do new_queue = PQ.push(queue, {new_dist_v, v}) new_dist_own = Map.put(dist_own, v, new_dist_v) new_pred_own = Map.put(pred_own, v, u) # Determine pred_fwd and pred_bwd for path reconstruction {p_fwd, p_bwd} = if side == :fwd, do: {new_pred_own, pred_other}, else: {pred_other, new_pred_own} {updated_path, updated_weight} = check_meeting_point( v, new_dist_v, dist_other, p_fwd, p_bwd, add, compare, best_path, best_weight ) process_neighbors( side, rest, new_queue, new_dist_own, dist_other, new_pred_own, pred_other, u, dist_u, add, compare, updated_path, updated_weight ) else process_neighbors( side, rest, queue, dist_own, dist_other, pred_own, pred_other, u, dist_u, add, compare, best_path, best_weight ) end end # Check if a node reached from one side has been reached by the other side defp check_meeting_point( node, dist_side, dist_other, pred_fwd, pred_bwd, add, compare, best_path, best_weight ) do case Map.fetch(dist_other, node) do {:ok, d_other} -> total_dist = add.(dist_side, d_other) if is_nil(best_weight) or compare.(total_dist, best_weight) == :lt do path = reconstruct_bidirectional_path(node, pred_fwd, pred_bwd) {path, total_dist} else {best_path, best_weight} end :error -> {best_path, best_weight} end end # Reconstruct path from meeting point defp reconstruct_bidirectional_path(meeting_point, pred_fwd, pred_bwd) do fwd_path = reconstruct_path_to_source(pred_fwd, meeting_point, [meeting_point]) bwd_path = reconstruct_path_to_target(pred_bwd, meeting_point, []) fwd_path ++ bwd_path end # Walk backwards from meeting_point to source, building path in correct order defp reconstruct_path_to_source(pred, node, acc) do case Map.fetch(pred, node) do {:ok, parent} -> reconstruct_path_to_source(pred, parent, [parent | acc]) :error -> acc end end # Walk backwards from meeting_point to target (following pred_bwd which goes toward target) defp reconstruct_path_to_target(pred, node, acc) do case Map.fetch(pred, node) do {:ok, next} -> reconstruct_path_to_target(pred, next, [next | acc]) :error -> Enum.reverse(acc) end end end