-module(yog@pathfinding@johnson). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/yog/pathfinding/johnson.gleam"). -export([johnson/5, johnson_int/1, johnson_float/1]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC( " [Johnson's algorithm](https://en.wikipedia.org/wiki/Johnson%27s_algorithm) for\n" " all-pairs shortest paths in weighted graphs with negative edge weights.\n" "\n" " Johnson's algorithm efficiently computes shortest paths between all pairs of nodes\n" " in sparse graphs, even when edges have negative weights (but no negative cycles).\n" " It combines Bellman-Ford and Dijkstra's algorithms with a reweighting technique.\n" "\n" " ## Algorithm\n" "\n" " | Algorithm | Function | Complexity | Best For |\n" " |-----------|----------|------------|----------|\n" " | [Johnson's](https://en.wikipedia.org/wiki/Johnson%27s_algorithm) | `johnson/4` | O(V² log V + VE) | Sparse graphs with negative weights |\n" "\n" " ## Key Concepts\n" "\n" " - **Reweighting**: Transform negative weights to non-negative while preserving shortest paths\n" " - **Bellman-Ford Phase**: Compute reweighting function and detect negative cycles\n" " - **Dijkstra Phase**: Run Dijkstra from each vertex on reweighted graph\n" " - **Distance Adjustment**: Transform reweighted distances back to original weights\n" "\n" " ## How Reweighting Works\n" "\n" " The algorithm computes a potential function `h(v)` for each vertex such that:\n" " ```\n" " w'(u,v) = w(u,v) + h(u) - h(v) ≥ 0\n" " ```\n" "\n" " This transformation preserves shortest paths because for any path p = v₁→v₂→...→vₖ:\n" " ```\n" " w'(p) = w(p) + h(v₁) - h(vₖ)\n" " ```\n" "\n" " So the relative ordering of path weights remains the same!\n" "\n" " ## The Algorithm Steps\n" "\n" " 1. **Add temporary source**: Create new vertex `s` with 0-weight edges to all vertices\n" " 2. **Run Bellman-Ford**: From `s` to compute h(v) = distance[v] and detect negative cycles\n" " 3. **Reweight edges**: Set w'(u,v) = w(u,v) + h(u) - h(v) for all edges\n" " 4. **Run V × Dijkstra**: Compute shortest paths on reweighted graph\n" " 5. **Adjust distances**: Set dist(u,v) = dist'(u,v) - h(u) + h(v)\n" "\n" " ## Comparison with Other All-Pairs Algorithms\n" "\n" " | Approach | Complexity | Best For |\n" " |----------|------------|----------|\n" " | Floyd-Warshall | O(V³) | Dense graphs (E ≈ V²) |\n" " | Johnson's | O(V² log V + VE) | Sparse graphs (E ≪ V²) |\n" " | V × Dijkstra | O(V(V+E) log V) | Sparse graphs, non-negative weights only |\n" " | V × Bellman-Ford | O(V²E) | Rarely optimal |\n" "\n" " **Rule of thumb**:\n" " - Use Johnson's for sparse graphs with negative weights\n" " - Use Floyd-Warshall for dense graphs or when simplicity matters\n" " - Use V × Dijkstra for sparse graphs with only non-negative weights\n" "\n" " ## Complexity Analysis\n" "\n" " - **Bellman-Ford phase**: O(VE) - run once\n" " - **Dijkstra phase**: O(V × (V+E) log V) = O(V² log V + VE) - run V times\n" " - **Total**: O(V² log V + VE)\n" "\n" " For sparse graphs where E = O(V), this is O(V² log V), much better than\n" " Floyd-Warshall's O(V³)!\n" "\n" " ## Negative Cycles\n" "\n" " The algorithm detects negative cycles during the Bellman-Ford phase.\n" " If a negative cycle exists, the function returns `Error(Nil)`.\n" "\n" " ## Use Cases\n" "\n" " - **Sparse road networks**: Computing all-pairs distances with tolls/credits\n" " - **Currency arbitrage**: Finding profitable exchange cycles\n" " - **Network routing**: Precomputing routing tables with various costs\n" " - **Game AI**: Pathfinding in large sparse maps with varied terrain costs\n" "\n" " ## History\n" "\n" " Published by Donald B. Johnson in 1977. The algorithm is a brilliant\n" " combination of Bellman-Ford's ability to handle negative weights and\n" " Dijkstra's efficiency with non-negative weights.\n" "\n" " ## References\n" "\n" " - [Wikipedia: Johnson's Algorithm](https://en.wikipedia.org/wiki/Johnson%27s_algorithm)\n" " - [Johnson's Original Paper (1977)](https://doi.org/10.1145/321992.321993)\n" " - [CP-Algorithms: Johnson's Algorithm](https://cp-algorithms.com/graph/all-pair-shortest-path-johnson.html)\n" " - [MIT 6.006: Advanced Shortest Paths](https://ocw.mit.edu/courses/6-006-introduction-to-algorithms-spring-2020/)\n" ). -file("src/yog/pathfinding/johnson.gleam", 183). ?DOC(" Finds a node ID that doesn't exist in the graph (for temporary source)\n"). -spec find_new_node_id(list(integer())) -> integer(). find_new_node_id(Nodes) -> case Nodes of [] -> 0; _ -> Max_node = gleam@list:fold( Nodes, 0, fun(Max, Node) -> case Node > Max of true -> Node; false -> Max end end ), Max_node + 1 end. -file("src/yog/pathfinding/johnson.gleam", 246). ?DOC(" Modified Bellman-Ford relaxation that includes edges from temporary source\n"). -spec bellman_ford_with_temp_source( yog@model:graph(any(), WYC), integer(), list(integer()), gleam@dict:dict(integer(), WYC), gleam@dict:dict(integer(), integer()), integer(), WYC, fun((WYC, WYC) -> WYC), fun((WYC, WYC) -> gleam@order:order()) ) -> {gleam@dict:dict(integer(), WYC), gleam@dict:dict(integer(), integer())}. bellman_ford_with_temp_source( Graph, Temp_source, All_nodes, Distances, Predecessors, Remaining, Zero, Add, Compare ) -> case Remaining =< 0 of true -> {Distances, Predecessors}; false -> {New_distances, New_predecessors} = gleam@list:fold( All_nodes, {Distances, Predecessors}, fun(Acc, U) -> {Dists, Preds} = Acc, case gleam_stdlib:map_get(Dists, U) of {error, nil} -> Acc; {ok, U_dist} -> Neighbors = case U =:= Temp_source of true -> _pipe = gleam@list:filter( All_nodes, fun(Node) -> Node /= Temp_source end ), gleam@list:map( _pipe, fun(Node@1) -> {Node@1, Zero} end ); false -> yog@model:successors(Graph, U) end, gleam@list:fold( Neighbors, {Dists, Preds}, fun(Inner_acc, Edge) -> {V, Weight} = Edge, {Curr_dists, Curr_preds} = Inner_acc, New_dist = Add(U_dist, Weight), case gleam_stdlib:map_get(Curr_dists, V) of {error, nil} -> {gleam@dict:insert( Curr_dists, V, New_dist ), gleam@dict:insert( Curr_preds, V, U )}; {ok, V_dist} -> case Compare(New_dist, V_dist) of lt -> {gleam@dict:insert( Curr_dists, V, New_dist ), gleam@dict:insert( Curr_preds, V, U )}; _ -> Inner_acc end end end ) end end ), bellman_ford_with_temp_source( Graph, Temp_source, All_nodes, New_distances, New_predecessors, Remaining - 1, Zero, Add, Compare ) end. -file("src/yog/pathfinding/johnson.gleam", 315). ?DOC(" Checks for negative cycles including edges from temporary source\n"). -spec has_negative_cycle_with_temp_source( yog@model:graph(any(), WYP), integer(), list(integer()), gleam@dict:dict(integer(), WYP), WYP, fun((WYP, WYP) -> WYP), fun((WYP, WYP) -> gleam@order:order()) ) -> boolean(). has_negative_cycle_with_temp_source( Graph, Temp_source, Nodes, Distances, Zero, Add, Compare ) -> All_nodes = [Temp_source | Nodes], gleam@list:any( All_nodes, fun(U) -> case gleam_stdlib:map_get(Distances, U) of {error, nil} -> false; {ok, U_dist} -> Neighbors = case U =:= Temp_source of true -> _pipe = gleam@list:filter( All_nodes, fun(Node) -> Node /= Temp_source end ), gleam@list:map( _pipe, fun(Node@1) -> {Node@1, Zero} end ); false -> yog@model:successors(Graph, U) end, gleam@list:any( Neighbors, fun(Edge) -> {V, Weight} = Edge, New_dist = Add(U_dist, Weight), case gleam_stdlib:map_get(Distances, V) of {error, nil} -> false; {ok, V_dist} -> case Compare(New_dist, V_dist) of lt -> true; _ -> false end end end ) end end ). -file("src/yog/pathfinding/johnson.gleam", 200). ?DOC(" Runs Bellman-Ford to compute reweighting function h(v) for each vertex\n"). -spec compute_reweighting_function( yog@model:graph(any(), WXT), integer(), list(integer()), WXT, fun((WXT, WXT) -> WXT), fun((WXT, WXT) -> gleam@order:order()) ) -> {ok, gleam@dict:dict(integer(), WXT)} | {error, nil}. compute_reweighting_function(Graph, Temp_source, Nodes, Zero, Add, Compare) -> Initial_distances = maps:from_list([{Temp_source, Zero}]), Initial_predecessors = maps:new(), Node_count = erlang:length(Nodes) + 1, {Distances, _} = bellman_ford_with_temp_source( Graph, Temp_source, [Temp_source | Nodes], Initial_distances, Initial_predecessors, Node_count - 1, Zero, Add, Compare ), case has_negative_cycle_with_temp_source( Graph, Temp_source, Nodes, Distances, Zero, Add, Compare ) of true -> {error, nil}; false -> {ok, Distances} end. -file("src/yog/pathfinding/johnson.gleam", 393). ?DOC(" Helper to unwrap Result with a provided default value\n"). -spec unwrap_or({ok, WZD} | {error, any()}, WZD) -> WZD. unwrap_or(Result, Default) -> case Result of {ok, Value} -> Value; {error, _} -> Default end. -file("src/yog/pathfinding/johnson.gleam", 356). ?DOC(" Creates a new graph with reweighted edges: w'(u,v) = w(u,v) + h(u) - h(v)\n"). -spec reweight_graph( yog@model:graph(WYV, WYW), gleam@dict:dict(integer(), WYW), WYW, fun((WYW, WYW) -> WYW), fun((WYW, WYW) -> WYW) ) -> yog@model:graph(WYV, WYW). reweight_graph(Graph, H, Zero, Add, Subtract) -> New_out_edges = gleam@dict:fold( erlang:element(4, Graph), maps:new(), fun(Acc, U, Neighbors) -> H_u = begin _pipe = gleam_stdlib:map_get(H, U), unwrap_or(_pipe, Zero) end, New_neighbors = gleam@dict:fold( Neighbors, maps:new(), fun(Inner_acc, V, Weight) -> H_v = begin _pipe@1 = gleam_stdlib:map_get(H, V), unwrap_or(_pipe@1, Zero) end, New_weight = Subtract(Add(Weight, H_u), H_v), gleam@dict:insert(Inner_acc, V, New_weight) end ), gleam@dict:insert(Acc, U, New_neighbors) end ), New_in_edges = gleam@dict:fold( erlang:element(5, Graph), maps:new(), fun(Acc@1, V@1, Sources) -> H_v@1 = begin _pipe@2 = gleam_stdlib:map_get(H, V@1), unwrap_or(_pipe@2, Zero) end, New_sources = gleam@dict:fold( Sources, maps:new(), fun(Inner_acc@1, U@1, Weight@1) -> H_u@1 = begin _pipe@3 = gleam_stdlib:map_get(H, U@1), unwrap_or(_pipe@3, Zero) end, New_weight@1 = Subtract(Add(Weight@1, H_u@1), H_v@1), gleam@dict:insert(Inner_acc@1, U@1, New_weight@1) end ), gleam@dict:insert(Acc@1, V@1, New_sources) end ), {graph, erlang:element(2, Graph), erlang:element(3, Graph), New_out_edges, New_in_edges}. -file("src/yog/pathfinding/johnson.gleam", 131). ?DOC( " Computes shortest paths between all pairs of nodes using Johnson's algorithm.\n" "\n" " **Time Complexity:** O(V² log V + VE)\n" "\n" " Returns an error if a negative cycle is detected.\n" "\n" " ## Parameters\n" "\n" " - `zero`: The identity element for addition (e.g., 0 for integers)\n" " - `add`: Function to add two weights\n" " - `subtract`: Function to subtract two weights (for reweighting)\n" " - `compare`: Function to compare two weights\n" "\n" " ## Example\n" "\n" " ```gleam\n" " let result = johnson.johnson(\n" " in: graph,\n" " with_zero: 0,\n" " with_add: int.add,\n" " with_subtract: int.subtract,\n" " with_compare: int.compare\n" " )\n" " // => Ok(Dict([#(#(1, 2), 10), #(#(1, 3), 25), ...]))\n" " ```\n" "\n" " ## When to Use\n" "\n" " Use Johnson's algorithm for **sparse graphs** when you need all-pairs shortest\n" " paths and the graph may contain negative edge weights (but no negative cycles).\n" " For dense graphs, prefer Floyd-Warshall. For graphs with only non-negative\n" " weights, consider running Dijkstra from each vertex directly.\n" ). -spec johnson( yog@model:graph(any(), WXK), WXK, fun((WXK, WXK) -> WXK), fun((WXK, WXK) -> WXK), fun((WXK, WXK) -> gleam@order:order()) ) -> {ok, gleam@dict:dict({integer(), integer()}, WXK)} | {error, nil}. johnson(Graph, Zero, Add, Subtract, Compare) -> Nodes = yog@model:all_nodes(Graph), Temp_source = find_new_node_id(Nodes), case compute_reweighting_function( Graph, Temp_source, Nodes, Zero, Add, Compare ) of {error, nil} -> {error, nil}; {ok, H} -> All_pairs_distances = gleam@list:fold( Nodes, maps:new(), fun(Distances, U) -> Distances_from_u = yog@pathfinding@dijkstra:single_source_distances( reweight_graph(Graph, H, Zero, Add, Subtract), U, Zero, Add, Compare ), gleam@list:fold( Nodes, Distances, fun(Acc, V) -> case gleam_stdlib:map_get(Distances_from_u, V) of {ok, Dist_reweighted} -> H_u = begin _pipe = gleam_stdlib:map_get(H, U), unwrap_or(_pipe, Zero) end, H_v = begin _pipe@1 = gleam_stdlib:map_get(H, V), unwrap_or(_pipe@1, Zero) end, Actual_dist = Add( Subtract(Dist_reweighted, H_u), H_v ), gleam@dict:insert(Acc, {U, V}, Actual_dist); {error, nil} -> Acc end end ) end ), {ok, All_pairs_distances} end. -file("src/yog/pathfinding/johnson.gleam", 426). ?DOC( " Computes all-pairs shortest paths with **integer weights** using Johnson's algorithm.\n" "\n" " This is a convenience wrapper around `johnson` that uses:\n" " - `0` as the zero element\n" " - `int.add` for addition\n" " - `int.subtract` for subtraction\n" " - `int.compare` for comparison\n" "\n" " ## Example\n" "\n" " ```gleam\n" " let result = johnson.johnson_int(graph)\n" " // => Ok(Dict([#(#(1, 2), 10), #(#(1, 3), 25), ...]))\n" " ```\n" "\n" " ## When to Use\n" "\n" " Use this for **sparse graphs** where you need all-pairs distances with `Int`\n" " weights that may be negative. For dense graphs, prefer `floyd_warshall_int`.\n" " For graphs with only non-negative weights, consider running `dijkstra` V times.\n" "\n" " Returns `Error(Nil)` if a negative cycle is detected.\n" ). -spec johnson_int(yog@model:graph(any(), integer())) -> {ok, gleam@dict:dict({integer(), integer()}, integer())} | {error, nil}. johnson_int(Graph) -> johnson( Graph, 0, fun gleam@int:add/2, fun gleam@int:subtract/2, fun gleam@int:compare/2 ). -file("src/yog/pathfinding/johnson.gleam", 451). ?DOC( " Computes all-pairs shortest paths with **float weights** using Johnson's algorithm.\n" "\n" " This is a convenience wrapper around `johnson` that uses:\n" " - `0.0` as the zero element\n" " - `float.add` for addition\n" " - `float.subtract` for subtraction\n" " - `float.compare` for comparison\n" "\n" " ## Warning\n" "\n" " Float arithmetic has precision limitations. Negative cycles might not be\n" " detected reliably due to floating-point errors. Prefer `Int` weights for\n" " critical calculations.\n" ). -spec johnson_float(yog@model:graph(any(), float())) -> {ok, gleam@dict:dict({integer(), integer()}, float())} | {error, nil}. johnson_float(Graph) -> johnson( Graph, +0.0, fun gleam@float:add/2, fun gleam@float:subtract/2, fun gleam@float:compare/2 ).