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