← Work/002 · Shipped · 2026
GraphSTL
A zero-dependency graph library in the style of the STL: one templated graph type, custom iterators, and algorithms written as free functions.
#include <GraphSTL/graph.h>#include <GraphSTL/graph_algorithms.h>using namespace custom_stl; graph<char, int, true> g; // directedfor (char c : {'A','B','C','D','E','F'}) g.addNode(c);g.addEdge('A','B',4); g.addEdge('A','C',2);g.addEdge('B','D',10); g.addEdge('C','E',3);g.addEdge('C','D',5); g.addEdge('D','F',1);g.addEdge('E','F',5); auto [cost, path] = dijkstra(g, 'A', 'F');// cost = 8, path = A C D FProblem
The C++ standard library has containers for sequences, maps and sets, but nothing for graphs. Graph code tends to be rewritten for each problem: an adjacency list of one shape here, a matrix there, and algorithms tied to that one representation.
GraphSTL is one graph type that behaves like an STL container, with the algorithms kept separate from it, the way std::sort is separate from std::vector.
Constraints
- Header-only C++17 with zero dependencies: include two headers, or link the graphstl::graphstl CMake target. A vcpkg manifest and CMake package config ship with it. MIT licensed.
- Node values can be any hashable T, weights any arithmetic type, and directedness is fixed at compile time.
- Algorithms must work on every graph<T, Weight, IsDirected> instantiation, and must refuse graphs they don't apply to.
- Builds under -Wall -Wextra -Wpedantic in CI.
Architecture
- graph<T, Weight = int, IsDirected = false>
- Three hash maps: NodeID → value, value → NodeID, and NodeID → vector of {target, weight} edges. IDs come from a counter and are never reused.
- NodeIterator / ConstNodeIterator
- Forward iterators over (NodeID, value) pairs, so std::find_if and std::count_if work on a graph (both tested).
- graph_algorithms.h
- BFS, DFS, Dijkstra (binary heap) and a naive O(V²) Dijkstra, Bellman-Ford, A*, Floyd-Warshall, Kruskal, Kahn, Kosaraju and Tarjan, as templates over any graph. Cycle detection is a member function.
- DSU
- Disjoint-set union with path compression and union by rank, used by Kruskal's MST.
- Build and packaging
- An INTERFACE CMake target with install rules and a version file, options to build the tests, benchmark and demo, and a dependency-free vcpkg manifest.
- Tests and CI
- One test runner with 46 cases. Two GitHub Actions jobs: a plain g++ build that runs the tests and compiles the benchmark, and a CMake build that runs CTest.
Key decisions & trade-offs
Values on the outside, integer IDs on the inside
Callers write g.addEdge('A', 'B', 4) with their own values; a value → ID hash map turns that into an O(1) average lookup, and every algorithm works on plain size_t IDs.
Trade-offEach value is stored twice, and T must be hashable (std::hash<T> and ==).
Directedness is a template parameter
graph<T, W, true> and graph<T, W, false> are different types. addEdge adds the reverse edge for undirected graphs, if constexpr picks the right cycle-detection strategy, and Kahn's sort and Kosaraju static_assert that the graph is directed, so misuse is a compile error.
Trade-offDirectedness can't change at runtime, and the two kinds can't be mixed in one container.
Algorithms are free functions in their own header
Like std::sort over iterators, the algorithms sit outside the container, so adding one never touches the graph class. They read the graph through getAdjList() and getData().
Trade-offThose accessors return the internal maps (as const references), so the representation is effectively part of the public API.
Results as values, unpacked with structured bindings
auto [cost, path] = dijkstra(g, 'A', 'F'); returns everything in one value. Unreachable targets come back as {numeric_limits<Weight>::max(), {}}, a negative cycle in Bellman-Ford as {min(), {}}, and Kahn's sort on a cyclic graph as an empty vector.
Trade-offThe sentinels are conventions the caller has to know; std::optional or a status field would make them impossible to miss.
Two Dijkstras, kept on purpose
The main version uses a binary-heap priority queue with lazy deletion (stale entries are skipped) and stops at the target. dijkstra_naive scans every node, O(V²), and exists so the benchmark can compare the two and check they agree.
Trade-offA second implementation to keep correct.
Failure modes handled
A node that doesn't exist
Lookups by value throw std::runtime_error("Node not found") rather than inventing a node. Adding an existing value returns its existing ID instead of creating a duplicate.
No path to the target
Dijkstra, A* and Bellman-Ford return the sentinel cost with an empty path; Floyd-Warshall's path() returns an empty vector. Each case is tested.
Negative weights and negative cycles
Bellman-Ford handles negative weights and reports a reachable negative cycle; Floyd-Warshall sets hasNegativeCycle. Both are tested.
A cycle where a DAG is required
Kahn's sort returns an empty vector when it can't order every node, tested together with cyclePresent().
The wrong kind of graph
Kahn's sort and Kosaraju's SCC on an undirected graph fail at compile time with a readable static_assert message.
Removing nodes and edges
removeNode also deletes every incoming edge from every other adjacency list; removeEdge on an undirected graph removes both directions. Both are tested, as is a self-loop counting as a cycle.
Runaway path reconstruction
Floyd-Warshall's path() walks the next-hop table at most |V| + 1 steps, so a corrupt table can't loop forever. Bellman-Ford stops as soon as a pass changes nothing.
What I'd do differently
Iterate instead of recursing
Tarjan, Kosaraju and cycle detection recurse through std::function lambdas, so a long chain of nodes can overflow the stack. Explicit stacks, as dfs() already uses, would remove the limit and the indirect call per visit.
Guard every precondition the way Kahn does
Dijkstra and A* silently return wrong answers on negative weights, and Kruskal accepts a directed graph and treats its edges as undirected. A static_assert(!IsDirected) on Kruskal and a negative-weight check would match the static_assert already on Kahn and Kosaraju.
Benchmark with repetitions and full runs
Each case is timed once with no warm-up, and both Dijkstras stop early at node n − 1. That measures early exit on a dense random graph more than the O((V+E) log V) versus O(V²) gap. Repeated runs, or Google Benchmark, over full single-source searches would show the real curve.
Store adjacency in vectors
NodeIDs are dense counters, yet adjacency and every algorithm's dist and parent use hash maps. Vectors indexed by ID would be faster and simpler; removed nodes would need a free list.
Property tests for every algorithm
A* and Kruskal have one test each. This site's TypeScript port cross-checks 200 random graphs (the shortest-path algorithms agree, Kruskal equals a brute-force Prim, Tarjan equals Kosaraju), and the same idea would fit the C++ suite.
Results
- 46 / 46
- Tests passing
- GitHub Actions (g++ and CMake/CTest), and re-run for this page with Apple clang 17 at -O2.
- 1.4–4.1×
- Heap Dijkstra vs naive
- The repo's benchmark, run three times for this page on an Apple M4 at -O2: 1.4–1.6× at 100 nodes, 1.8–2.2× at 500, 3.3–4.1× at 1,000. Costs matched in every case.
- 12
- Algorithms
- 11 free functions in graph_algorithms.h plus cyclePresent().
- 0
- Dependencies
- Standard library only; the vcpkg manifest lists none.
Stack
- C++17
- CMake
- vcpkg
- GitHub Actions