From eb6974fa4f70e7817f1835ee5940aa5a51108aa9 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Tue, 13 Oct 2026 10:17:40 +1000 Subject: [PATCH] perf: components by a union-find over the edges The labelling walked the neighbours of every vertex, which is a call to Edge.next for every incident edge, and for a directed graph a second list of predecessors. Looking at the two ends of each edge once, with a union-find, needs neither, ignores direction by construction, and gives the same labels: the root of a component is its first vertex, so numbering the roots in the order of the vertices keeps the numbering. The labels are written directly to the vertices, not through the coordinate hook. 10,000 vertices, 30,000 edges: 8.7 -> about 4 ms; 100,000 vertices, 200,000 edges: 136 -> about 40 ms. The test of recomputation poisons the labels and sees whether they survive, since it no longer calls adjacent(); new tests of the numbering and of agreement with a traversal written out in the test. Co-Authored-By: Claude Sonnet 5.5 --- src/pgraph/PGraph.py | 63 +++++++++++++++--------- tests/test_components.py | 102 +++++++++++++++++++++++++++++++++------ 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 9b84de55..956fd0e8 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -1624,39 +1624,56 @@ def _graphcolor(self) -> None: direction of an edge is ignored, so a vertex is in the component of every vertex that it has a path to or from. - This is a single pass over the vertices and edges, O(V + E), and is - only done again when the graph has changed since, which every + This is a single pass over the edges with a union-find, nearly O(E), + and is only done again when the graph has changed since, which every operation that adds or removes a vertex or edge records in the graph's ``_connectivitychange``. + Looking at the two ends of each edge, rather than walking the + neighbours of each vertex, makes the direction of an edge irrelevant + and avoids a call per incident edge to find the far end, which is most + of the time of a traversal. The root of a component is its first + vertex, so numbering the roots in order of the vertices gives the + numbering described above. + :seealso: :meth:`nc` :meth:`component` """ if not self._connectivitychange: return - for vertex in self: - vertex._label = None - vertex._connectivitychange = False + vertices = self._vertexlist + index = {v: i for i, v in enumerate(vertices)} + parent = list(range(len(vertices))) + + for edge in self._edges: + a = index[edge._v1] # type: ignore[index] + b = index[edge._v2] # type: ignore[index] + while parent[a] != a: # find the root, halving the path as we go + parent[a] = parent[parent[a]] + a = parent[a] + while parent[b] != b: + parent[b] = parent[parent[b]] + b = parent[b] + if a < b: # the smaller index is the root, so the first vertex + parent[b] = a + elif b < a: + parent[a] = b count = 0 - for start in self: - if start._label is not None: - continue - start._label = count - frontier = [start] - while frontier: - v = frontier.pop() - # an edge may be followed against its direction, so the - # vertices that lead to this one count as well as those it - # leads to; in an undirected graph they are the same - around = v.adjacent() - if self.directed: - around = around + v.predecessors() - for w in around: - if w._label is None: - w._label = count - frontier.append(w) - count += 1 + label = [0] * len(vertices) + for i, vertex in enumerate(vertices): + root = i + while parent[root] != root: + parent[root] = parent[parent[root]] + root = parent[root] + if root == i: # the first vertex of a component + label[i] = count + count += 1 + # the graph-level flag is what is consulted; writing the + # attributes directly avoids the hook of ``Vertex.__setattr__`` + attrs = vertex.__dict__ + attrs["_label"] = label[root] + attrs["_connectivitychange"] = False self._ncomponents = count self._connectivitychange = False diff --git a/tests/test_components.py b/tests/test_components.py index e5b6138e..be09b2cd 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -199,32 +199,40 @@ def test_samecomponent(self): class TestNotRecomputed(unittest.TestCase): - """The traversal runs once, and again only after the graph has changed""" + """The labels are found once, and again only after the graph has changed - def traversals(self, g, ask): - calls = [] - original = type(g[0]).adjacent + Recomputing would write every vertex's label, so the labels are replaced by + a marker that nothing else could produce, and a recomputation shows as the + marker being overwritten. + """ - def spy(vertex, *args, **kwargs): - calls.append(vertex) - return original(vertex, *args, **kwargs) + MARK = -7 - with unittest.mock.patch.object(type(g[0]), "adjacent", spy): - ask() - return len(calls) + def poison(self, g): + for v in g: + v.__dict__["_label"] = self.MARK + + def marks(self, g): + return [v.__dict__["_label"] for v in g] def check(self, cls): g = build(cls, 4, [(0, 1), (2, 3)]) - ask = lambda: (g.nc, g.component(g[0]), g.samecomponent(g[0], g[1]), g[2].label) + self.assertEqual(g.nc, 2) # the first time: does the work + self.poison(g) - self.assertGreater(self.traversals(g, ask), 0) # first time: does the work - self.assertEqual(self.traversals(g, ask), 0) # nothing changed: none - self.assertEqual(self.traversals(g, ask), 0) + # nothing has changed: asked for again, in each way, nothing is redone + self.assertEqual(g.nc, 2) + g.component(0) + g.samecomponent(g[0], g[1]) + self.assertEqual(self.marks(g), [self.MARK] * 4) g.add_edge(g[1], g[2]) # a change: once more, and the answer is new - self.assertGreater(self.traversals(g, ask), 0) self.assertEqual(g.nc, 1) - self.assertEqual(self.traversals(g, ask), 0) + self.assertEqual([v.label for v in g], [0, 0, 0, 0]) + + self.poison(g) + self.assertEqual(g.nc, 1) + self.assertEqual(self.marks(g), [self.MARK] * 4) def test_undirected(self): self.check(UGraph) @@ -232,6 +240,68 @@ def test_undirected(self): def test_directed(self): self.check(DGraph) + def test_removals_and_additions_are_changes(self): + for cls in (UGraph, DGraph): + with self.subTest(cls=cls.__name__): + g = build(cls, 4, [(0, 1), (1, 2), (2, 3)]) + self.assertEqual(g.nc, 1) + g.remove_edge(g.edges()[1]) # splits 0-1 from 2-3 + self.assertEqual(g.nc, 2) + self.assertEqual([v.label for v in g], [0, 0, 1, 1]) + g.remove_vertex(g[1]) + self.assertEqual(g.nc, 2) + self.assertEqual([v.label for v in g], [0, 1, 1]) + g.add_vertex(name="new") + self.assertEqual(g.nc, 3) + self.assertEqual([v.label for v in g], [0, 1, 1, 2]) + + +class TestLabelNumbering(unittest.TestCase): + """Components are numbered from 0 in the order of their first vertex""" + + def test_numbering_follows_the_first_vertex_not_the_edges(self): + for cls in (UGraph, DGraph): + with self.subTest(cls=cls.__name__): + # the edges are added last-component-first and end to end the + # wrong way round, but 0 is still the component of vertex 0 + g = build(cls, 7, [(6, 5), (4, 3), (3, 2), (1, 0)]) + self.assertEqual([v.label for v in g], [0, 0, 1, 1, 1, 2, 2]) + self.assertEqual(g.nc, 3) + + def test_loops_and_parallel_edges(self): + for cls in (UGraph, DGraph): + with self.subTest(cls=cls.__name__): + g = build(cls, 3, [(0, 0), (1, 2), (1, 2), (2, 1), (2, 2)]) + self.assertEqual([v.label for v in g], [0, 1, 1]) + + def test_agrees_with_a_traversal_written_out_in_full(self): + rng = np.random.default_rng(11) + for trial in range(30): + n = int(rng.integers(1, 40)) + edges = [tuple(int(x) for x in rng.integers(0, n, 2)) for _ in range(int(rng.integers(0, 2 * n)))] + for cls in (UGraph, DGraph): + with self.subTest(trial=trial, cls=cls.__name__): + g = build(cls, n, edges) + # neighbours of either end, by the edge list, from vertex 0 up + around = {i: set() for i in range(n)} + for a, b in edges: + around[a].add(b) + around[b].add(a) + expected, count = [None] * n, 0 + for s in range(n): + if expected[s] is not None: + continue + expected[s], stack = count, [s] + while stack: + x = stack.pop() + for y in around[x]: + if expected[y] is None: + expected[y] = count + stack.append(y) + count += 1 + self.assertEqual([v.label for v in g], expected) + self.assertEqual(g.nc, count) + class TestUndirectedComponents(_Components, unittest.TestCase): cls = UGraph