Skip to content

Commit a29a023

Browse files
committed
Optimize Floyd-Warshall
1 parent 246d228 commit a29a023

File tree

5 files changed

+117
-131
lines changed

5 files changed

+117
-131
lines changed

CHANGELOG.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased] - 1.3.0
8+
## [Unreleased] - 2.0.0
9+
10+
### Breaking Changes
11+
- **`pathfinding.floyd_warshall()`**: Return type changed from `Result(Dict(NodeId, Dict(NodeId, e)), Nil)` to `Result(Dict(#(NodeId, NodeId), e), Nil)`
12+
- **Before**: `let assert Ok(row) = dict.get(distances, 1); dict.get(row, 2)`
13+
- **After**: `dict.get(distances, #(1, 2))`
14+
- **Benefit**: Eliminates one layer of dictionary lookups for better performance
15+
16+
- **`topological_sort.lexicographical_topological_sort()`**: Parameter changed from `compare_ids: fn(NodeId, NodeId) -> Order` to `compare_nodes: fn(n, n) -> Order` ([#3](https://github.com/code-shoily/yog/issues/3))
17+
- **Before**: `lexicographical_topological_sort(graph, int.compare)` // Compared node IDs
18+
- **After**: `lexicographical_topological_sort(graph, string.compare)` // Compares node data
19+
- **Benefit**: Intuitive API that allows comparison by actual node data (e.g., alphabetical sorting by name, priority sorting by timestamp) without encoding sort logic into node IDs
20+
21+
### Fixed
22+
- **Critical bug in `min_cut.global_min_cut()`**: Fixed incorrect list reversal in Maximum Adjacency Search (MAS) that caused the algorithm to use the starting node instead of the last-added node, preventing it from finding the true minimum cut
23+
- **Critical bug in `transform.contract()`**: Fixed weight doubling for undirected graphs where edge weights were incorrectly combined twice during node contraction
24+
- These fixes enable correct minimum cut detection for unweighted graphs (e.g., AoC 2023 Day 25)
25+
26+
### Performance
27+
- **Grid builder (`builder/grid.from_2d_list`)**: Optimized from O(N²) to O(N) by using dictionary lookups instead of `list.drop` traversals when checking neighbor cell data. For a 100×100 grid, this eliminates ~40,000 unnecessary list traversals
28+
- **BFS traversal**: Optimized with O(1) amortized queue operations instead of O(n) `list.append`, improving BFS from O(V²) to O(V + E)
29+
- **Floyd-Warshall**: Flat dictionary structure eliminates nested lookups
30+
- **Maximum Adjacency Search**: Heap-based priority queue with lazy deletion, improving time complexity from O(V³) to O(V² log V)
31+
- New shared `yog/internal/queue` module (Okasaki-style two-list queue) used by both `max_flow` and `traversal`
32+
33+
## [1.3.0] - 2026-02-27
934

1035
### Added
1136
- **Maximum Flow (`yog/max_flow`)**: Edmonds-Karp algorithm with `edmonds_karp()` and `min_cut()` functions. Supports generic numeric types. Examples: `network_bandwidth.gleam`, `job_matching.gleam`.
@@ -83,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
83108
- Connected components: Tarjan's SCC
84109
- Graph transformations: transpose, map, filter, merge
85110

111+
[1.3.1]: https://github.com/code-shoily/yog/compare/v1.3.0...v1.3.1
86112
[1.3.0]: https://github.com/code-shoily/yog/compare/v1.2.4...v1.3.0
87113
[1.2.4]: https://github.com/code-shoily/yog/compare/v1.2.3...v1.2.4
88114
[1.2.3]: https://github.com/code-shoily/yog/compare/v1.2.2...v1.2.3

src/yog/pathfinding.gleam

Lines changed: 39 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -629,50 +629,49 @@ pub fn floyd_warshall(
629629
with_zero zero: e,
630630
with_add add: fn(e, e) -> e,
631631
with_compare compare: fn(e, e) -> Order,
632-
) -> Result(Dict(NodeId, Dict(NodeId, e)), Nil) {
632+
) -> Result(Dict(#(NodeId, NodeId), e), Nil) {
633633
let nodes = dict.keys(graph.nodes)
634634

635635
// Initialize distances: direct edges + zero distance to self
636+
// Using flat dictionary with composite keys for better performance
636637
let initial_distances =
637638
nodes
638639
|> list.fold(dict.new(), fn(distances, i) {
639-
let row =
640-
nodes
641-
|> list.fold(dict.new(), fn(row, j) {
642-
case i == j {
643-
True -> {
644-
// Self-distance: check for self-loop edge
645-
case dict.get(graph.out_edges, i) {
646-
Ok(neighbors) ->
647-
case dict.get(neighbors, j) {
648-
Ok(weight) -> {
649-
// Self-loop exists: use min(zero, weight)
650-
// If weight < 0, use it (will be detected as negative cycle)
651-
// If weight > 0, use zero (staying put is shorter)
652-
case compare(weight, zero) {
653-
Lt -> dict.insert(row, j, weight)
654-
_ -> dict.insert(row, j, zero)
655-
}
640+
nodes
641+
|> list.fold(distances, fn(distances, j) {
642+
case i == j {
643+
True -> {
644+
// Self-distance: check for self-loop edge
645+
case dict.get(graph.out_edges, i) {
646+
Ok(neighbors) ->
647+
case dict.get(neighbors, j) {
648+
Ok(weight) -> {
649+
// Self-loop exists: use min(zero, weight)
650+
// If weight < 0, use it (will be detected as negative cycle)
651+
// If weight > 0, use zero (staying put is shorter)
652+
case compare(weight, zero) {
653+
Lt -> dict.insert(distances, #(i, j), weight)
654+
_ -> dict.insert(distances, #(i, j), zero)
656655
}
657-
Error(Nil) -> dict.insert(row, j, zero)
658656
}
659-
Error(Nil) -> dict.insert(row, j, zero)
660-
}
657+
Error(Nil) -> dict.insert(distances, #(i, j), zero)
658+
}
659+
Error(Nil) -> dict.insert(distances, #(i, j), zero)
661660
}
662-
False -> {
663-
// Different nodes: check if there's a direct edge from i to j
664-
case dict.get(graph.out_edges, i) {
665-
Ok(neighbors) ->
666-
case dict.get(neighbors, j) {
667-
Ok(weight) -> dict.insert(row, j, weight)
668-
Error(Nil) -> row
669-
}
670-
Error(Nil) -> row
671-
}
661+
}
662+
False -> {
663+
// Different nodes: check if there's a direct edge from i to j
664+
case dict.get(graph.out_edges, i) {
665+
Ok(neighbors) ->
666+
case dict.get(neighbors, j) {
667+
Ok(weight) -> dict.insert(distances, #(i, j), weight)
668+
Error(Nil) -> distances
669+
}
670+
Error(Nil) -> distances
672671
}
673672
}
674-
})
675-
dict.insert(distances, i, row)
673+
}
674+
})
676675
})
677676

678677
// Floyd-Warshall: for each intermediate node k, try routing through k
@@ -684,21 +683,21 @@ pub fn floyd_warshall(
684683
nodes
685684
|> list.fold(distances, fn(distances, j) {
686685
// Try path i -> k -> j
687-
case get_distance(distances, i, k) {
686+
case dict.get(distances, #(i, k)) {
688687
Error(Nil) -> distances
689688
Ok(dist_ik) -> {
690-
case get_distance(distances, k, j) {
689+
case dict.get(distances, #(k, j)) {
691690
Error(Nil) -> distances
692691
Ok(dist_kj) -> {
693692
let new_dist = add(dist_ik, dist_kj)
694-
case get_distance(distances, i, j) {
693+
case dict.get(distances, #(i, j)) {
695694
Error(Nil) ->
696695
// No existing path, use new path
697-
update_distance(distances, i, j, new_dist)
696+
dict.insert(distances, #(i, j), new_dist)
698697
Ok(current_dist) -> {
699698
// Compare and keep shorter path
700699
case compare(new_dist, current_dist) {
701-
Lt -> update_distance(distances, i, j, new_dist)
700+
Lt -> dict.insert(distances, #(i, j), new_dist)
702701
_ -> distances
703702
}
704703
}
@@ -718,48 +717,16 @@ pub fn floyd_warshall(
718717
}
719718
}
720719

721-
/// Helper to get distance from the nested dictionary structure
722-
fn get_distance(
723-
distances: Dict(NodeId, Dict(NodeId, e)),
724-
from i: NodeId,
725-
to j: NodeId,
726-
) -> Result(e, Nil) {
727-
case dict.get(distances, i) {
728-
Ok(row) -> dict.get(row, j)
729-
Error(Nil) -> Error(Nil)
730-
}
731-
}
732-
733-
/// Helper to update distance in the nested dictionary structure
734-
fn update_distance(
735-
distances: Dict(NodeId, Dict(NodeId, e)),
736-
from i: NodeId,
737-
to j: NodeId,
738-
with dist: e,
739-
) -> Dict(NodeId, Dict(NodeId, e)) {
740-
case dict.get(distances, i) {
741-
Ok(row) -> {
742-
let new_row = dict.insert(row, j, dist)
743-
dict.insert(distances, i, new_row)
744-
}
745-
Error(Nil) -> {
746-
// Should not happen if initialized correctly
747-
let new_row = dict.new() |> dict.insert(j, dist)
748-
dict.insert(distances, i, new_row)
749-
}
750-
}
751-
}
752-
753720
/// Detects if there's a negative cycle by checking if any node has negative distance to itself
754721
fn detect_negative_cycle(
755-
distances: Dict(NodeId, Dict(NodeId, e)),
722+
distances: Dict(#(NodeId, NodeId), e),
756723
nodes: List(NodeId),
757724
zero: e,
758725
compare: fn(e, e) -> Order,
759726
) -> Bool {
760727
nodes
761728
|> list.any(fn(i) {
762-
case get_distance(distances, i, i) {
729+
case dict.get(distances, #(i, i)) {
763730
Ok(dist) ->
764731
case compare(dist, zero) {
765732
Lt -> True

src/yog/transform.gleam

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ pub fn contract(
371371
with b: NodeId,
372372
combine_weights with_combine: fn(e, e) -> e,
373373
) -> Graph(n, e) {
374+
// 1. Process outgoing edges (this handles both directions for Undirected graphs)
374375
let b_out = dict.get(graph.out_edges, b) |> result.unwrap(dict.new())
375376
let graph =
376377
dict.fold(b_out, graph, fn(acc_g, neighbor, weight) {
@@ -381,15 +382,27 @@ pub fn contract(
381382
}
382383
})
383384

384-
let b_in = dict.get(graph.in_edges, b) |> result.unwrap(dict.new())
385-
let graph =
386-
dict.fold(b_in, graph, fn(acc_g, neighbor, weight) {
387-
case neighbor == a || neighbor == b {
388-
True -> acc_g
389-
False ->
390-
model.add_edge_with_combine(acc_g, neighbor, a, weight, with_combine)
391-
}
392-
})
385+
// 2. Only process incoming edges if the graph is Directed!
386+
// For Undirected graphs, out_edges already contains all neighbors in both directions
387+
let graph = case graph.kind {
388+
model.Undirected -> graph
389+
model.Directed -> {
390+
let b_in = dict.get(graph.in_edges, b) |> result.unwrap(dict.new())
391+
dict.fold(b_in, graph, fn(acc_g, neighbor, weight) {
392+
case neighbor == a || neighbor == b {
393+
True -> acc_g
394+
False ->
395+
model.add_edge_with_combine(
396+
acc_g,
397+
neighbor,
398+
a,
399+
weight,
400+
with_combine,
401+
)
402+
}
403+
})
404+
}
405+
}
393406

394407
remove_node(graph, b)
395408
}

test/yog/pathfinding_test.gleam

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,22 +1874,19 @@ pub fn floyd_warshall_basic_test() {
18741874
case result {
18751875
Ok(distances) -> {
18761876
// Distance from 1 to 1 should be 0
1877-
let assert Ok(row1) = dict.get(distances, 1)
1878-
dict.get(row1, 1) |> should.equal(Ok(0))
1877+
dict.get(distances, #(1, 1)) |> should.equal(Ok(0))
18791878

18801879
// Distance from 1 to 2 should be 4
1881-
dict.get(row1, 2) |> should.equal(Ok(4))
1880+
dict.get(distances, #(1, 2)) |> should.equal(Ok(4))
18821881

18831882
// Distance from 1 to 3 should be 7 (via 2, not direct 10)
1884-
dict.get(row1, 3) |> should.equal(Ok(7))
1883+
dict.get(distances, #(1, 3)) |> should.equal(Ok(7))
18851884

18861885
// Distance from 2 to 3 should be 3
1887-
let assert Ok(row2) = dict.get(distances, 2)
1888-
dict.get(row2, 3) |> should.equal(Ok(3))
1886+
dict.get(distances, #(2, 3)) |> should.equal(Ok(3))
18891887

18901888
// No path from 3 to 1 (directed graph)
1891-
let assert Ok(row3) = dict.get(distances, 3)
1892-
dict.get(row3, 1) |> should.equal(Error(Nil))
1889+
dict.get(distances, #(3, 1)) |> should.equal(Error(Nil))
18931890
}
18941891
Error(Nil) -> should.fail()
18951892
}
@@ -1929,9 +1926,8 @@ pub fn floyd_warshall_single_node_test() {
19291926

19301927
case result {
19311928
Ok(distances) -> {
1932-
let assert Ok(row) = dict.get(distances, 1)
19331929
// Distance from node 1 to itself should be 0
1934-
dict.get(row, 1) |> should.equal(Ok(0))
1930+
dict.get(distances, #(1, 1)) |> should.equal(Ok(0))
19351931
}
19361932
Error(Nil) -> should.fail()
19371933
}
@@ -1958,9 +1954,8 @@ pub fn floyd_warshall_negative_weights_test() {
19581954

19591955
case result {
19601956
Ok(distances) -> {
1961-
let assert Ok(row1) = dict.get(distances, 1)
19621957
// Distance from 1 to 3 should be 3 (via 2: 5 + (-2))
1963-
dict.get(row1, 3) |> should.equal(Ok(3))
1958+
dict.get(distances, #(1, 3)) |> should.equal(Ok(3))
19641959
}
19651960
Error(Nil) -> should.fail()
19661961
}
@@ -2008,18 +2003,16 @@ pub fn floyd_warshall_disconnected_test() {
20082003
case result {
20092004
Ok(distances) -> {
20102005
// Path exists from 1 to 2
2011-
let assert Ok(row1) = dict.get(distances, 1)
2012-
dict.get(row1, 2) |> should.equal(Ok(5))
2006+
dict.get(distances, #(1, 2)) |> should.equal(Ok(5))
20132007

20142008
// No path from 1 to 3 (disconnected)
2015-
dict.get(row1, 3) |> should.equal(Error(Nil))
2009+
dict.get(distances, #(1, 3)) |> should.equal(Error(Nil))
20162010

20172011
// Path exists from 3 to 4
2018-
let assert Ok(row3) = dict.get(distances, 3)
2019-
dict.get(row3, 4) |> should.equal(Ok(3))
2012+
dict.get(distances, #(3, 4)) |> should.equal(Ok(3))
20202013

20212014
// No path from 3 to 1 (disconnected)
2022-
dict.get(row3, 1) |> should.equal(Error(Nil))
2015+
dict.get(distances, #(3, 1)) |> should.equal(Error(Nil))
20232016
}
20242017
Error(Nil) -> should.fail()
20252018
}
@@ -2047,11 +2040,10 @@ pub fn floyd_warshall_transitive_test() {
20472040

20482041
case result {
20492042
Ok(distances) -> {
2050-
let assert Ok(row1) = dict.get(distances, 1)
20512043
// All paths from 1 should be found
2052-
dict.get(row1, 2) |> should.equal(Ok(1))
2053-
dict.get(row1, 3) |> should.equal(Ok(3))
2054-
dict.get(row1, 4) |> should.equal(Ok(6))
2044+
dict.get(distances, #(1, 2)) |> should.equal(Ok(1))
2045+
dict.get(distances, #(1, 3)) |> should.equal(Ok(3))
2046+
dict.get(distances, #(1, 4)) |> should.equal(Ok(6))
20552047
}
20562048
Error(Nil) -> should.fail()
20572049
}
@@ -2086,10 +2078,7 @@ pub fn floyd_warshall_vs_shortest_path_test() {
20862078
|> list.each(fn(source) {
20872079
nodes
20882080
|> list.each(fn(target) {
2089-
let floyd_dist = case dict.get(distances, source) {
2090-
Ok(row) -> dict.get(row, target)
2091-
Error(Nil) -> Error(Nil)
2092-
}
2081+
let floyd_dist = dict.get(distances, #(source, target))
20932082

20942083
let shortest_path_result =
20952084
pathfinding.shortest_path(
@@ -2137,16 +2126,12 @@ pub fn floyd_warshall_undirected_test() {
21372126
case result {
21382127
Ok(distances) -> {
21392128
// Distance should be symmetric in undirected graph
2140-
let assert Ok(row1) = dict.get(distances, 1)
2141-
let assert Ok(row2) = dict.get(distances, 2)
2142-
let assert Ok(row3) = dict.get(distances, 3)
2143-
2144-
let dist_1_2 = dict.get(row1, 2)
2145-
let dist_2_1 = dict.get(row2, 1)
2129+
let dist_1_2 = dict.get(distances, #(1, 2))
2130+
let dist_2_1 = dict.get(distances, #(2, 1))
21462131
dist_1_2 |> should.equal(dist_2_1)
21472132

2148-
let dist_1_3 = dict.get(row1, 3)
2149-
let dist_3_1 = dict.get(row3, 1)
2133+
let dist_1_3 = dict.get(distances, #(1, 3))
2134+
let dist_3_1 = dict.get(distances, #(3, 1))
21502135
dist_1_3 |> should.equal(dist_3_1)
21512136

21522137
// Distance from 1 to 3 should be 7
@@ -2177,9 +2162,8 @@ pub fn floyd_warshall_float_weights_test() {
21772162

21782163
case result {
21792164
Ok(distances) -> {
2180-
let assert Ok(row1) = dict.get(distances, 1)
21812165
// Distance from 1 to 3 should be 4.0 (via 2: 2.5 + 1.5)
2182-
dict.get(row1, 3) |> should.equal(Ok(4.0))
2166+
dict.get(distances, #(1, 3)) |> should.equal(Ok(4.0))
21832167
}
21842168
Error(Nil) -> should.fail()
21852169
}
@@ -2227,11 +2211,10 @@ pub fn floyd_warshall_positive_self_loop_test() {
22272211

22282212
case result {
22292213
Ok(distances) -> {
2230-
let assert Ok(row1) = dict.get(distances, 1)
22312214
// Distance from 1 to itself should still be 0 (not 5)
2232-
dict.get(row1, 1) |> should.equal(Ok(0))
2215+
dict.get(distances, #(1, 1)) |> should.equal(Ok(0))
22332216
// Distance from 1 to 2 should be 10
2234-
dict.get(row1, 2) |> should.equal(Ok(10))
2217+
dict.get(distances, #(1, 2)) |> should.equal(Ok(10))
22352218
}
22362219
Error(Nil) -> should.fail()
22372220
}

0 commit comments

Comments
 (0)