diff --git a/build.sbt b/build.sbt index 271f169..83ae7c2 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.8.2" +val scala3: String = "3.8.3" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") diff --git a/modules/core/src/main/scala/gs/graph/v0/SCC.scala b/modules/core/src/main/scala/gs/graph/v0/SCC.scala index c0669f4..b768de2 100644 --- a/modules/core/src/main/scala/gs/graph/v0/SCC.scala +++ b/modules/core/src/main/scala/gs/graph/v0/SCC.scala @@ -6,8 +6,51 @@ import scala.collection.mutable.ListBuffer import scala.collection.mutable.Stack /** Represents a _Strongly Connected Component_ (SCC). + * + * Each SCC is guaranteed to be non-empty. */ -opaque type SCC = Vector[Vertex] +final class SCC private (val value: Set[Vertex]): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: SCC => value.iterator.sameElements(other.value) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = value.hashCode() + + /** @inheritDocs + */ + override def toString(): String = + val sb = StringBuilder() + sb.append("[") + sb.append(value.mkString(",")) + sb.append("]") + sb.toString() + + /** @return + * The number of vertices contained by this SCC. + */ + def size: Int = value.size + + /** @return + * True if this SCC contains a single vertex. + */ + def isSingle: Boolean = value.size == 1 + + /** @return + * The set of vertices in the SCC. + */ + def vertices: Set[Vertex] = value + + /** Retrieve an arbitrary SCC member as the root vertex. + */ + lazy val root: Vertex = value.toList.head + +end SCC object SCC: @@ -20,32 +63,38 @@ object SCC: * @return * The new SCC. */ - def apply(value: Seq[Vertex]): SCC = value.distinct.toVector + def apply(value: Vertex*): SCC = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty strongly connected components (SCC) are not allowed." + ) + else new SCC(value.toSet) + + def of(value: Int*): SCC = + if value.isEmpty then + throw new IllegalArgumentException( + "Empty strongly connected components (SCC) are not allowed." + ) + else new SCC(value.map(Vertex.apply).toSet) + + /** Instantiate a new SCC that contains a single [[Vertex]]. + * + * @param value + * The input vertex. + * @return + * The new SCC. + */ + def single(value: Vertex): SCC = new SCC(Set(value)) given CanEqual[SCC, SCC] = CanEqual.derived - extension (scc: SCC) - /** @return - * The underlying value of this SCC. - */ - def unwrap(): Vector[Vertex] = scc - - /** @return - * The vertices contained by this SCC. - */ - def vertices: Vector[Vertex] = scc - - /** @return - * The number of vertices contained by this SCC. - */ - def size: Int = scc.size - - /** @return - * True if this SCC contains a single vertex. - */ - def isSingle: Boolean = scc.size == 1 - /** Alias for `findAll`. + * + * Implementation of Tarjan's Algorithm for finding all strongly connected + * components for some graph. + * + * This algorithm captures directed cycles. Any vertex not part of some + * directed cycle (e.g. every node in a DAG) forms an SCC by itself. * * @param g * The graph to analyze. @@ -57,6 +106,9 @@ object SCC: /** Implementation of Tarjan's Algorithm for finding all strongly connected * components for some graph. * + * This algorithm captures directed cycles. Any vertex not part of some + * directed cycle (e.g. every node in a DAG) forms an SCC by itself. + * * @param g * The graph to analyze. * @return @@ -143,14 +195,22 @@ object SCC: sccVertex = s.pop() scc.addOne(sccVertex) - output.addOne(SCC(scc.toVector)) + if !scc.isEmpty then output.addOne(new SCC(scc.toSet)) + else println("ERROR") else () - final private class Counter(var i: Int = 0): + /** Mutable state wrapper around an integer. + * + * @param i + * The integer value. + */ + final private class Counter(var i: Int): def increment(): Unit = i += 1 def get(): Int = i + end Counter + end SCC diff --git a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala index d7e9618..211d621 100644 --- a/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala +++ b/modules/core/src/main/scala/gs/graph/v0/UndirectedGraph.scala @@ -34,7 +34,7 @@ class UndirectedGraph( SCC.findAll(this) private def oneRootFromEachSCC(sccs: List[SCC]): List[Vertex] = - sccs.map(_.vertices.apply(0)) + sccs.map(_.root) end UndirectedGraph diff --git a/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala b/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala new file mode 100644 index 0000000..b5ec1e6 --- /dev/null +++ b/modules/core/src/test/scala/gs/graph/v0/TarjansTests.scala @@ -0,0 +1,63 @@ +package gs.graph.v0 + +import gs.graph.v0.directed.Digraph +import munit.* + +class TarjansTests extends FunSuite: + + test("should provide basic SCC representation") { + val v0 = Vertex.Zero + val v1 = Vertex(1) + val v2 = Vertex(2) + val s1 = SCC(v0) + val s2 = SCC(v0, v1, v2) + val s3 = SCC(v0) + + assertEquals(s1, s3) + assertNotEquals(s1, s2) + assertEquals(s2.value, Set(v0, v1, v2)) + assertEquals(s2.vertices, Set(v0, v1, v2)) + assertEquals(s2.size, 3) + assertEquals(s2.isSingle, false) + assertEquals(s1.isSingle, true) + } + + test("should produce an empty list of SCCs for an empty graph") { + val g = UndirectedGraph.Empty + val sccs = SCC.tarjan(g) + val alt = SCC.findAll(g) + assertEquals(sccs, Nil) + assertEquals(alt, Nil) + } + + test( + "should produce a list of once SCC with one vertex for a graph with one vertex" + ) { + val g = Graph.undirected(Size.One) + val sccs = SCC.findAll(g) + val expected = SCC.single(Vertex.Zero) + assertEquals(sccs, List(expected)) + } + + /** Each vertex of a DAG is an SCC. + */ + test("should identify a single SCC for a single 'line' of vertices") { + val n = Size(3) + + // 0 -> 1 -> 2 + val g = Digraph.fromAdjacency( + Adjacency.fromEdges( + n, + List( + Edge(0, 1), + Edge(1, 2) + ) + ) + ) + + val sccs = SCC.findAll(g) + assertEquals(g.roots, Vector(Vertex.Zero)) + assertEquals(sccs.toSet, Set(SCC.of(0), SCC.of(1), SCC.of(2))) + } + +end TarjansTests diff --git a/project/build.properties b/project/build.properties index 08a6fc0..df061f4 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.12.8 +sbt.version=1.12.9