diff --git a/README.md b/README.md index 4ca92b75..c4c2416e 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,9 @@ take a look at the [docs](https://bobluppes.github.io/graaf/algorithms/intro.htm 5. [**Strongly Connected Components Algorithms**](https://bobluppes.github.io/graaf/algorithms/strongly-connected-components/): - [Tarjan's Strongly Connected Components](https://bobluppes.github.io/graaf/algorithms/strongly-connected-components/tarjan.html) - [Kosaraju's Strongly Connected Components](https://bobluppes.github.io/graaf/algorithms/strongly-connected-components/kosarajus.html) -6. [**Topological Sorting Algorithms**](https://bobluppes.github.io/graaf/algorithms/topological-sort/topological-sort.html): +6. [**Topological Sorting Algorithms**](https://bobluppes.github.io/graaf/algorithms/topological-sort/): + - [DFS Based Topological Sort](https://bobluppes.github.io/graaf/algorithms/topological-sort/topological-sort.html) + - [Kahn's Algorithm](https://bobluppes.github.io/graaf/algorithms/topological-sort/kahn.html) 7. [**Traversal Algorithms**](https://bobluppes.github.io/graaf/algorithms/traversal/): - [Breadth-First Search (BFS)](https://bobluppes.github.io/graaf/algorithms/traversal/breadth-first-search.html) - [Depth-First Search (DFS)](https://bobluppes.github.io/graaf/algorithms/traversal/depth-first-search.html) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index cd0f7a09..a463e563 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -35,7 +35,9 @@ - [Strongly Connected Component Algorithms](algorithms/strongly-connected-components/README.md) - [Kosaraju's Strongly Connected Components](algorithms/strongly-connected-components/kosarajus.md) - [Tarjan's Strongly Connected Components](algorithms/strongly-connected-components/tarjan.md) -- [Topological sort algorithm](algorithms/topological-sort/topological-sort.md) +- [Topological Sorting Algorithms](algorithms/topological-sort/README.md) + - [Topological sort algorithm](algorithms/topological-sort/topological-sort.md) + - [Kahn's Algorithm](algorithms/topological-sort/kahn.md) - [Traversal Algorithms](algorithms/traversal/README.md) - [Breadth First Search (BFS)](algorithms/traversal/breadth-first-search.md) - [Depth First Search (DFS)](algorithms/traversal/depth-first-search.md) diff --git a/docs/src/algorithms/topological-sort/README.md b/docs/src/algorithms/topological-sort/README.md new file mode 100644 index 00000000..980c137b --- /dev/null +++ b/docs/src/algorithms/topological-sort/README.md @@ -0,0 +1 @@ +# Topological Sorting Algorithms diff --git a/docs/src/algorithms/topological-sort/kahn.md b/docs/src/algorithms/topological-sort/kahn.md new file mode 100644 index 00000000..1a2288c9 --- /dev/null +++ b/docs/src/algorithms/topological-sort/kahn.md @@ -0,0 +1,70 @@ +# Kahn's Algorithm + +Kahn's algorithm produces a topological ordering of a DAG (directed acyclic graph) by repeatedly emitting vertices that +have no remaining incoming edges. + +The algorithm first counts the in-degree of every vertex. Every vertex with an in-degree of zero is a valid starting +point, so all of them are placed in a queue. Vertices are then taken off the queue one at a time and appended to the +result. Taking a vertex conceptually removes its outgoing edges, so the in-degree of each of its neighbors is decreased +by one, and any neighbor whose in-degree drops to zero is pushed onto the queue in turn. + +Cycle detection falls out of the algorithm for free. A vertex on a cycle always has an incoming edge from another vertex +on that same cycle, so its in-degree never reaches zero and it is never emitted. If the result does not contain every +vertex of the graph, the graph contains a cycle and no topological ordering exists. + +The runtime of the algorithm is `O(|V| + |E|)` and the memory consumption is `O(|V|)`. Where V is the number of vertices +in the graph and E the number of edges. Each vertex enters and leaves the queue exactly once, and each edge is relaxed +exactly once, when its source vertex is emitted. + +[wikipedia](https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm) + +## Syntax + +```cpp +template +[[nodiscard]] std::vector kahn_topological_sort( + const graph& graph); +``` + +- **graph** The directed graph to traverse. +- **return** Vector of vertices sorted in topological order. +- **throws** `std::invalid_argument` if the graph contains a cycle, mirroring how `bellman_ford_shortest_paths` reports + a negative cycle. + +A graph generally admits more than one valid topological ordering. Which one is returned depends on the order in which +vertices with an in-degree of zero are encountered, so callers should not depend on a particular ordering beyond it +being topologically valid. + +## Comparison with the DFS based approach + +Graaf also ships a [DFS based topological sort](topological-sort.md). Both produce a valid topological ordering in +`O(|V| + |E|)` time using `O(|V|)` additional memory, and for a graph with several valid orderings the two will +generally return different ones. They differ in a few practical respects. + +| | `kahn_topological_sort` | `dfs_topological_sort` | +| --- | --- | --- | +| Traversal | BFS-like, explicit queue | DFS, recursive | +| Cycle detection | Falls out of the algorithm itself | Separate `dfs_cycle_detection` pass up front | +| Graph passes | One | Two (cycle detection, then the sort) | +| Reports a cycle by | Throwing `std::invalid_argument` | Returning `std::nullopt` | +| Return type | `std::vector` | `std::optional>` | + +Because Kahn's algorithm is iterative, its memory use does not depend on the depth of the graph. The DFS based version +recurses once per vertex along a path, so a graph with a very long path can exhaust the call stack; it inherits that +limitation from the DFS traversal it is built on. Prefer Kahn's algorithm for deep or very large graphs. + +Kahn's algorithm also lends itself to *layered* processing. Every vertex sitting in the queue at the start of an +iteration has all of its dependencies satisfied, so a batch drained from the queue in one go forms a dependency +"level" whose vertices can be processed in parallel. This is a natural fit for build systems and job schedulers. The +DFS based version does not expose that structure. + +Conversely, the DFS based version returns an `optional` rather than throwing, which fits better in code paths where a +cyclic graph is an expected outcome rather than an error. + +## Use cases + +- Build systems and package managers, ordering targets so dependencies are built first. +- Job and task schedulers, including parallel schedulers that use the level structure described above. +- Course or curriculum ordering with prerequisites. +- Spreadsheet formula evaluation order. +- Detecting whether a dependency graph has become circular. diff --git a/docs/src/algorithms/topological-sort/topological-sort.md b/docs/src/algorithms/topological-sort/topological-sort.md index 2bdf69da..7d9a4fad 100644 --- a/docs/src/algorithms/topological-sort/topological-sort.md +++ b/docs/src/algorithms/topological-sort/topological-sort.md @@ -16,3 +16,8 @@ template - **graph** The directed graph to traverse. - **return** Vector of vertices sorted in topological order. If the graph contains cycles, it returns std::nullopt. + +## Similar algorithms + +Graaf also implements [Kahn's algorithm](kahn.md), the BFS/in-degree based alternative. That page includes a +side-by-side comparison of the two approaches. diff --git a/docs/src/llms.txt b/docs/src/llms.txt index 1624a4f9..d3327d8a 100644 --- a/docs/src/llms.txt +++ b/docs/src/llms.txt @@ -20,7 +20,7 @@ Graaf requires no separate compilation: consume it as a header-only library, or - [Graph coloring](https://bobluppes.github.io/graaf/algorithms/coloring/README.html): greedy, Welsh-Powell. - [Strongly connected components](https://bobluppes.github.io/graaf/algorithms/strongly-connected-components/README.html): Kosaraju, Tarjan. - [Clique detection](https://bobluppes.github.io/graaf/algorithms/clique-detection/bron_kerbosch.html): Bron-Kerbosch. -- [Topological sort](https://bobluppes.github.io/graaf/algorithms/topological-sort/topological-sort.html) +- [Topological sort](https://bobluppes.github.io/graaf/algorithms/topological-sort/README.html): DFS-based, Kahn. ## Examples diff --git a/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.h b/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.h new file mode 100644 index 00000000..89cde7e9 --- /dev/null +++ b/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +namespace graaf::algorithm { +/** + * @brief Calculates order of vertices in topological order + * using Kahn's algorithm + * + * Repeatedly emits vertices with an in-degree of zero, removing their + * outgoing edges as it goes. Cycles are detected as a side effect: if not + * every vertex can be emitted, the remaining vertices lie on or downstream + * of a cycle. + * + * @tparam V The vertex type of the graph. + * @tparam E The edge type of the graph. + * @param graph The input graph. + * @return Vector of vertices sorted in topological order + * @throws std::invalid_argument If the graph contains a cycle. + */ +template +[[nodiscard]] std::vector kahn_topological_sort( + const graph& graph); + +} // namespace graaf::algorithm +#include "kahn_topological_sorting.tpp" diff --git a/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.tpp b/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.tpp new file mode 100644 index 00000000..edc8667e --- /dev/null +++ b/include/graaflib/algorithm/topological_sorting/kahn_topological_sorting.tpp @@ -0,0 +1,82 @@ +#pragma once +#include + +#include +#include +#include +#include +#include + +#include "kahn_topological_sorting.h" + +namespace graaf::algorithm { + +namespace detail { + +// Computes the in-degree of every vertex in a single pass over the adjacency +// lists. Note that properties::vertex_indegree scans the entire graph on every +// call, so seeding the in-degrees with it would cost O(|V| * (|V| + |E|)). +// Kahn's algorithm is expected to run in O(|V| + |E|), so we count the incoming +// edges in one sweep instead. +template +[[nodiscard]] std::unordered_map compute_indegrees( + const graph& graph) { + std::unordered_map indegrees{}; + indegrees.reserve(graph.vertex_count()); + + for (const auto& [vertex_id, _] : graph.get_vertices()) { + indegrees.try_emplace(vertex_id, 0); + } + + for (const auto& [vertex_id, _] : graph.get_vertices()) { + for (const auto& neighbor : graph.get_neighbors(vertex_id)) { + ++indegrees[neighbor]; + } + } + + return indegrees; +} + +}; // namespace detail + +template +std::vector kahn_topological_sort( + const graph& graph) { + auto indegrees{detail::compute_indegrees(graph)}; + + // All vertices without incoming edges can be emitted right away + std::queue sources{}; + for (const auto& [vertex_id, indegree] : indegrees) { + if (indegree == 0) { + sources.push(vertex_id); + } + } + + std::vector sorted_vertices{}; + sorted_vertices.reserve(graph.vertex_count()); + + while (!sources.empty()) { + const auto current_vertex{sources.front()}; + sources.pop(); + sorted_vertices.push_back(current_vertex); + + // Removing the outgoing edges of the current vertex can turn its + // neighbors into sources + for (const auto& neighbor : graph.get_neighbors(current_vertex)) { + if (--indegrees[neighbor] == 0) { + sources.push(neighbor); + } + } + } + + // Any vertex we could not emit still has an incoming edge, which means it + // lies on a cycle or is reachable from one. A self-loop is covered by this + // as well, since it contributes one to the in-degree of its own vertex. + if (sorted_vertices.size() != graph.vertex_count()) { + throw std::invalid_argument{"Cycle detected in the graph."}; + } + + return sorted_vertices; +} + +}; // namespace graaf::algorithm diff --git a/test/graaflib/algorithm/topological_sorting/kahn_topological_sorting_test.cpp b/test/graaflib/algorithm/topological_sorting/kahn_topological_sorting_test.cpp new file mode 100644 index 00000000..be273a76 --- /dev/null +++ b/test/graaflib/algorithm/topological_sorting/kahn_topological_sorting_test.cpp @@ -0,0 +1,277 @@ +#include +#include +#include + +#include +#include +#include +#include + +namespace graaf::algorithm { +namespace { +template +struct TypedKahnTopologicalSort : public testing::Test { + using graph_t = T; +}; + +TYPED_TEST_SUITE(TypedKahnTopologicalSort, + utils::fixtures::minimal_directed_graph_type); + +// Kahn's algorithm picks its next source from an unordered container, so a +// graph usually admits several valid orderings. Rather than enumerating them +// all, we assert the two properties that define a topological order: the +// result contains every vertex exactly once, and every edge points forward. +template +[[nodiscard]] bool is_topological_order( + const graph_t& graph, const std::vector& sorted_vertices) { + if (sorted_vertices.size() != graph.vertex_count()) { + return false; + } + + std::unordered_map position{}; + for (std::size_t index{0}; index < sorted_vertices.size(); ++index) { + const auto [_, + inserted]{position.try_emplace(sorted_vertices[index], index)}; + if (!inserted) { + // Duplicate vertex in the result + return false; + } + } + + for (const auto& [vertex_id, _] : graph.get_vertices()) { + if (!position.contains(vertex_id)) { + return false; + } + + for (const auto& neighbor : graph.get_neighbors(vertex_id)) { + if (position.at(vertex_id) >= position.at(neighbor)) { + return false; + } + } + } + + return true; +} + +}; // namespace + +TYPED_TEST(TypedKahnTopologicalSort, EmptyGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + + // THEN + ASSERT_TRUE(sorted_vertices.empty()); +} + +TYPED_TEST(TypedKahnTopologicalSort, SingleVertex) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + const std::vector expected_vertices{vertex_1}; + + // THEN + ASSERT_EQ(expected_vertices, sorted_vertices); +} + +TYPED_TEST(TypedKahnTopologicalSort, ShortGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + + graph.add_edge(vertex_1, vertex_2, 25); + graph.add_edge(vertex_2, vertex_3, 35); + graph.add_edge(vertex_3, vertex_4, 45); + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + const std::vector expected_vertices{vertex_1, vertex_2, vertex_3, + vertex_4}; + + // THEN + // A chain admits exactly one topological order + ASSERT_EQ(expected_vertices, sorted_vertices); +} + +TYPED_TEST(TypedKahnTopologicalSort, RhombusShapeGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + + graph.add_edge(vertex_1, vertex_2, 25); + graph.add_edge(vertex_1, vertex_3, 35); + graph.add_edge(vertex_3, vertex_4, 45); + graph.add_edge(vertex_2, vertex_4, 55); + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + + // THEN + ASSERT_TRUE(is_topological_order(graph, sorted_vertices)); + ASSERT_EQ(vertex_1, sorted_vertices.front()); + ASSERT_EQ(vertex_4, sorted_vertices.back()); +} + +TYPED_TEST(TypedKahnTopologicalSort, SimpleGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + const auto vertex_5{graph.add_vertex(50)}; + const auto vertex_6{graph.add_vertex(60)}; + const auto vertex_7{graph.add_vertex(70)}; + + graph.add_edge(vertex_1, vertex_5, 1); + graph.add_edge(vertex_5, vertex_3, 2); + graph.add_edge(vertex_3, vertex_7, 3); + graph.add_edge(vertex_1, vertex_4, 4); + graph.add_edge(vertex_1, vertex_2, 5); + graph.add_edge(vertex_4, vertex_2, 6); + graph.add_edge(vertex_2, vertex_6, 7); + graph.add_edge(vertex_6, vertex_3, 8); + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + + // THEN + ASSERT_TRUE(is_topological_order(graph, sorted_vertices)); + ASSERT_EQ(vertex_1, sorted_vertices.front()); + ASSERT_EQ(vertex_7, sorted_vertices.back()); +} + +TYPED_TEST(TypedKahnTopologicalSort, DisconnectedGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + // Component one: a chain + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + const auto vertex_3{graph.add_vertex(30)}; + + // Component two: a fork + const auto vertex_4{graph.add_vertex(40)}; + const auto vertex_5{graph.add_vertex(50)}; + const auto vertex_6{graph.add_vertex(60)}; + + graph.add_edge(vertex_1, vertex_2, 15); + graph.add_edge(vertex_2, vertex_3, 25); + graph.add_edge(vertex_4, vertex_5, 35); + graph.add_edge(vertex_4, vertex_6, 45); + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + + // THEN + ASSERT_EQ(graph.vertex_count(), sorted_vertices.size()); + ASSERT_TRUE(is_topological_order(graph, sorted_vertices)); +} + +TYPED_TEST(TypedKahnTopologicalSort, DisconnectedGraphWithIsolatedVertices) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + + // Two vertices without any edges at all + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + + graph.add_edge(vertex_1, vertex_2, 15); + + // WHEN + const auto sorted_vertices{kahn_topological_sort(graph)}; + + // THEN + ASSERT_EQ(4, sorted_vertices.size()); + ASSERT_TRUE(is_topological_order(graph, sorted_vertices)); + ASSERT_NE(sorted_vertices.end(), + std::ranges::find(sorted_vertices, vertex_3)); + ASSERT_NE(sorted_vertices.end(), + std::ranges::find(sorted_vertices, vertex_4)); +} + +TYPED_TEST(TypedKahnTopologicalSort, CycleGraph) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + + graph.add_edge(vertex_1, vertex_2, 25); + graph.add_edge(vertex_2, vertex_3, 35); + graph.add_edge(vertex_3, vertex_4, 45); + graph.add_edge(vertex_4, vertex_1, 55); + + // WHEN - THEN + ASSERT_THROW(std::ignore = kahn_topological_sort(graph), + std::invalid_argument); +} + +TYPED_TEST(TypedKahnTopologicalSort, SelfLoop) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + + graph.add_edge(vertex_1, vertex_1, -1); + graph.add_edge(vertex_1, vertex_2, 15); + + // WHEN - THEN + ASSERT_THROW(std::ignore = kahn_topological_sort(graph), + std::invalid_argument); +} + +TYPED_TEST(TypedKahnTopologicalSort, CycleInOneComponentOnly) { + // GIVEN + using graph_t = typename TestFixture::graph_t; + graph_t graph{}; + + // An acyclic component... + const auto vertex_1{graph.add_vertex(10)}; + const auto vertex_2{graph.add_vertex(20)}; + + // ...next to a cyclic one + const auto vertex_3{graph.add_vertex(30)}; + const auto vertex_4{graph.add_vertex(40)}; + + graph.add_edge(vertex_1, vertex_2, 15); + graph.add_edge(vertex_3, vertex_4, 25); + graph.add_edge(vertex_4, vertex_3, 35); + + // WHEN - THEN + // A cycle anywhere in the graph makes a topological order impossible + ASSERT_THROW(std::ignore = kahn_topological_sort(graph), + std::invalid_argument); +} + +}; // namespace graaf::algorithm