Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions src/pgraph/PGraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 86 additions & 16 deletions tests/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,39 +199,109 @@ 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)

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
Expand Down
Loading