diff --git a/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs b/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs new file mode 100644 index 00000000000..c2724019060 --- /dev/null +++ b/analyzers/src/SonarAnalyzer.Common/Common/DisjointSets.cs @@ -0,0 +1,52 @@ +/* + * SonarAnalyzer for .NET + * Copyright (C) 2015-2024 SonarSource SA + * mailto: contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +namespace SonarAnalyzer.Common; + +/// +/// Data structure for working with disjoint sets of strings, to perform union-find operations with equality semantics: +/// i.e. reflexivity, symmetry and transitivity. +/// +/// See https://en.wikipedia.org/wiki/Disjoint-set_data_structure for an introduction to the data structure and +/// https://www.geeksforgeeks.org/introduction-to-disjoint-set-data-structure-or-union-find-algorithm/ for examples of +/// its use. +/// +/// An example of use is to build undirected connected components of dependencies, where each node is the identifier. +/// +/// Uses a dictionary of strings as a backing store. The dictionary represents a forest of trees, where each node is +/// a string and each tree is a set of nodes. +/// +public class DisjointSets +{ + private readonly Dictionary parents; + + public DisjointSets(IEnumerable elements) => + parents = elements.ToDictionary(x => x, x => x); + + public void Union(string from, string to) => + parents[FindRoot(from)] = FindRoot(to); + + public string FindRoot(string element) => + parents[element] is var root && root != element ? FindRoot(root) : root; + + // Set elements are sorted in ascending order. Sets are sorted in ascending order by their first element. + public List> GetAllSets() => + [.. parents.GroupBy(x => FindRoot(x.Key), x => x.Key).Select(x => x.OrderBy(x => x).ToList()).OrderBy(x => x[0])]; +} diff --git a/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs b/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs new file mode 100644 index 00000000000..3c09b54b734 --- /dev/null +++ b/analyzers/tests/SonarAnalyzer.Test/Common/DisjointSetsTest.cs @@ -0,0 +1,82 @@ +namespace SonarAnalyzer.Test.Common; + +[TestClass] +public class DisjointSetsTest +{ + private static readonly string[] FirstSixPositiveInts = Enumerable.Range(1, 6).Select(x => x.ToString()).ToArray(); + + [TestMethod] + public void FindRootAndUnion_AreConsistent() + { + var sets = new DisjointSets(FirstSixPositiveInts); + foreach (var element in FirstSixPositiveInts) + { + sets.FindRoot(element).Should().Be(element); + } + + sets.Union("1", "1"); + sets.FindRoot("1").Should().Be("1"); // Reflexivity + sets.Union("1", "2"); + sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Correctness + sets.Union("1", "2"); + sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Idempotency + sets.Union("2", "1"); + sets.FindRoot("1").Should().Be(sets.FindRoot("2")); // Symmetry + + sets.FindRoot("3").Should().Be("3"); + sets.Union("2", "3"); + sets.FindRoot("2").Should().Be(sets.FindRoot("3")); + sets.FindRoot("1").Should().Be(sets.FindRoot("3")); // Transitivity + sets.Union("3", "4"); + sets.FindRoot("1").Should().Be(sets.FindRoot("4")); // Double transitivity + sets.Union("4", "1"); + sets.FindRoot("4").Should().Be(sets.FindRoot("1")); // Idempotency after transitivity + } + + [TestMethod] + public void GetAllSetsAndUnion_AreConsistent() + { + var sets = new DisjointSets(FirstSixPositiveInts); + AssertSets([["1"], ["2"], ["3"], ["4"], ["5"], ["6"]], sets); // Initial state + sets.Union("1", "2"); + AssertSets([["1", "2"], ["3"], ["4"], ["5"], ["6"]], sets); // Correctness + sets.Union("1", "2"); + AssertSets([["1", "2"], ["3"], ["4"], ["5"], ["6"]], sets); // Idempotency + + sets.Union("2", "3"); + AssertSets([["1", "2", "3"], ["4"], ["5"], ["6"]], sets); // Transitivity + sets.Union("1", "3"); + AssertSets([["1", "2", "3"], ["4"], ["5"], ["6"]], sets); // Idempotency after transitivity + + sets.Union("4", "5"); + AssertSets([["1", "2", "3"], ["4", "5"], ["6"]], sets); // Separated trees + sets.Union("3", "4"); + AssertSets([["1", "2", "3", "4", "5"], ["6"]], sets); // Merged trees + } + + [TestMethod] + public void GetAllSetsAndUnion_OfNestedTrees() + { + var sets = new DisjointSets(FirstSixPositiveInts); + sets.Union("1", "2"); + sets.Union("3", "4"); + sets.Union("5", "6"); + AssertSets([["1", "2"], ["3", "4"], ["5", "6"]], sets); // Merge of 1-height trees + sets.Union("2", "3"); + AssertSets([["1", "2", "3", "4"], ["5", "6"]], sets); // Merge of 2-height trees + sets.Union("4", "5"); + AssertSets([["1", "2", "3", "4", "5", "6"]], sets); // Merge of 1-height tree and 2-height tree + } + + [TestMethod] + public void GetAllSets_ReturnsSortedSets() + { + var sets = new DisjointSets(["3", "2", "1"]); + AssertSets([["1"], ["2"], ["3"]], sets); + sets.Union("3", "1"); + AssertSets([["1", "3"], ["2"]], sets); + } + + private static void AssertSets(List> expected, DisjointSets sets) => + sets.GetAllSets().Should().BeEquivalentTo(expected, options => options.WithStrictOrdering()); +}