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
27 changes: 27 additions & 0 deletions Containers.Test/OrderedCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.Containers.Tests;

using System.Collections;
using System.Diagnostics;
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
Expand Down Expand Up @@ -741,4 +742,30 @@ public void Constructor_FromEnumerableWithKeyComparer_PreservesInputOrderOfEqual
// Assert
Assert.AreSequenceEqual(source, collection);
}

[TestMethod]
public void Constructor_FromLargeDescendingSequence_RunsInLinearithmicTime()
{
const int size = 1_000_000;
int[] descending = [.. Enumerable.Range(0, size).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
OrderedCollection<int> collection = new(descending, Comparer<int>.Default);
stopwatch.Stop();

Assert.AreSequenceEqual(Enumerable.Range(0, size), collection);
Assert.IsLessThan(5_000, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void Constructor_FromLargeSequenceWithKeyComparer_KeepsEqualElementsInInsertionOrder()
{
// Enough elements that the bulk path merges rather than only insertion sorting
(int Key, string Name)[] source = [.. Enumerable.Range(0, 1_000).Select(i => (i * 7 % 10, i.ToString(System.Globalization.CultureInfo.InvariantCulture)))];

OrderedCollection<(int Key, string Name)> collection = new(source, ByKey);

(int Key, string Name)[] expected = [.. source.OrderBy(item => item.Key)];
Assert.AreSequenceEqual(expected, collection);
}
}
43 changes: 43 additions & 0 deletions Containers.Test/OrderedMapTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.Containers.Test;

using System.Collections;
using System.Diagnostics;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
Expand Down Expand Up @@ -529,4 +530,46 @@ public void Enumerate_ChangesMadeThroughEveryMutator_Throw() =>
[TestMethod]
public void Enumerate_Unchanged_VisitsEveryEntry() =>
Tests.MapEnumerationAssertions.UnchangedEnumerationVisitsEveryEntry(() => new OrderedMap<int, string>());

[TestMethod]
public void Constructor_FromLargeDictionary_RunsInLinearithmicTime()
{
const int size = 1_000_000;
Dictionary<int, int> dictionary = new(size);
for (int i = size - 1; i >= 0; i--)
{
dictionary.Add(i, -i);
}

Stopwatch stopwatch = Stopwatch.StartNew();
OrderedMap<int, int> map = new(dictionary, Comparer<int>.Default);
stopwatch.Stop();

Assert.HasCount(size, map);
Assert.AreSequenceEqual(Enumerable.Range(0, size), map.Keys);
Assert.AreEqual(-123, map[123]);
Assert.IsLessThan(5_000, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void Constructor_FromDictionaryWithKeysEqualUnderComparer_ThrowsLikeAdd()
{
Dictionary<string, int> dictionary = new() { ["a"] = 1, ["b"] = 2, ["A"] = 3 };

Assert.ThrowsExactly<ArgumentException>(() => _ = new OrderedMap<string, int>(dictionary, StringComparer.OrdinalIgnoreCase));

OrderedMap<string, int> map = new(StringComparer.OrdinalIgnoreCase) { ["a"] = 1, ["b"] = 2 };
Assert.ThrowsExactly<ArgumentException>(() => map.Add("A", 3));
}

[TestMethod]
public void Constructor_FromDictionaryWithCustomComparer_HonoursComparer()
{
Dictionary<int, string> dictionary = new() { [2] = "two", [1] = "one", [3] = "three" };

OrderedMap<int, string> map = new(dictionary, Comparer<int>.Create((x, y) => y.CompareTo(x)));

Assert.AreSequenceEqual([3, 2, 1], map.Keys);
Assert.AreSequenceEqual(["three", "two", "one"], map.Values);
}
}
145 changes: 145 additions & 0 deletions Containers.Test/OrderedSetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.Containers.Test;

using System.Collections;
using System.Diagnostics;
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
Expand Down Expand Up @@ -763,4 +764,148 @@
Assert.IsLessThan(result[i], result[i - 1], "Elements should remain sorted");
}
}

// Filling the backing list one binary-search insert at a time shifts the tail on every insert, so
// building from 1,000,000 descending items takes over a minute; a stable sort takes well under a second.
private const int BulkSize = 1_000_000;
private const int BulkBudgetMilliseconds = 5_000;

private static readonly IComparer<(int Key, int Index)> ByKey =
Comparer<(int Key, int Index)>.Create((x, y) => x.Key.CompareTo(y.Key));

[TestMethod]
public void Constructor_FromLargeDescendingSequence_RunsInLinearithmicTime()
{
int[] descending = [.. Enumerable.Range(0, BulkSize).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
OrderedSet<int> set = new(descending, Comparer<int>.Default);
stopwatch.Stop();

Assert.HasCount(BulkSize, set);
Assert.AreSequenceEqual(Enumerable.Range(0, BulkSize), set);
Assert.IsLessThan(BulkBudgetMilliseconds, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void IsSubsetOf_LargeDescendingArray_RunsInLinearithmicTime()
{
OrderedSet<int> set = [.. Enumerable.Range(0, BulkSize)];
int[] descending = [.. Enumerable.Range(0, BulkSize).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
bool isSubset = set.IsSubsetOf(descending);
stopwatch.Stop();

Assert.IsTrue(isSubset);
Assert.IsLessThan(BulkBudgetMilliseconds, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void SetEquals_LargeDescendingArray_RunsInLinearithmicTime()
{
OrderedSet<int> set = [.. Enumerable.Range(0, BulkSize)];
int[] descending = [.. Enumerable.Range(0, BulkSize).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
bool equal = set.SetEquals(descending);
stopwatch.Stop();

Assert.IsTrue(equal);
Assert.IsLessThan(BulkBudgetMilliseconds, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void SymmetricExceptWith_LargeDescendingArray_RunsInLinearithmicTime()
{
OrderedSet<int> set = [.. Enumerable.Range(0, BulkSize)];
int[] descending = [.. Enumerable.Range(BulkSize / 2, BulkSize).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
set.SymmetricExceptWith(descending);
stopwatch.Stop();

Assert.AreSequenceEqual(Enumerable.Range(0, BulkSize / 2).Concat(Enumerable.Range(BulkSize, BulkSize / 2)), set);
Assert.IsLessThan(BulkBudgetMilliseconds, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void IntersectWith_LargeDescendingArray_RunsInLinearithmicTime()
{
OrderedSet<int> set = [.. Enumerable.Range(0, BulkSize)];
int[] descending = [.. Enumerable.Range(BulkSize / 2, BulkSize).Reverse()];

Stopwatch stopwatch = Stopwatch.StartNew();
set.IntersectWith(descending);
stopwatch.Stop();

Assert.AreSequenceEqual(Enumerable.Range(BulkSize / 2, BulkSize / 2), set);
Assert.IsLessThan(BulkBudgetMilliseconds, stopwatch.ElapsedMilliseconds);
}

[TestMethod]
public void Constructor_FromSequenceWithDuplicates_KeepsFirstOccurrence()
{
// Enough elements that the bulk path merges rather than only insertion sorting
(int Key, int Index)[] source = [.. Enumerable.Range(0, 1_000).Select(i => (i * 7 % 10, i))];

OrderedSet<(int Key, int Index)> set = new(source, ByKey);

(int Key, int Index)[] expected = [.. Enumerable.Range(0, 10).Select(key => source.First(item => item.Key == key))];
Assert.AreSequenceEqual(expected, set);
}

[TestMethod]
public void Constructor_FromSequenceWithCustomComparer_HonoursComparer()
{
OrderedSet<string> set = new(["b", "A", "a", "B", "c"], StringComparer.OrdinalIgnoreCase);

Assert.AreSequenceEqual(["A", "b", "c"], set);
}

[TestMethod]
public void Constructor_FromSequenceWithReverseComparer_SortsDescending()
{
OrderedSet<int> set = new(Enumerable.Range(0, 100), Comparer<int>.Create((x, y) => y.CompareTo(x)));

Assert.AreSequenceEqual(Enumerable.Range(0, 100).Reverse(), set);
}

[TestMethod]
public void Constructor_FromSequence_MatchesRepeatedAdd()
{
Random random = new(42);
int[] source = [.. Enumerable.Range(0, 5_000).Select(_ => random.Next(1_000))];
OrderedSet<int> added = [];
foreach (int item in source)
{
added.Add(item);
}

OrderedSet<int> built = new(source, Comparer<int>.Default);

Assert.AreSequenceEqual(added, built);
}

[TestMethod]
public void SymmetricExceptWith_Self_EmptiesSet()
{
OrderedSet<int> set = [1, 2, 3];

set.SymmetricExceptWith(set);

Assert.HasCount(0, set);

Check warning on line 897 in Containers.Test/OrderedSetTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.HasCount'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Containers&issues=AaEZLDEkRaD1HFdw_MrS&open=AaEZLDEkRaD1HFdw_MrS&pullRequest=111
}

[TestMethod]
public void SymmetricExceptWith_OrderedSetWithSameComparer_KeepsElementsInOnlyOne()
{
OrderedSet<int> set = [1, 2, 3, 4];
OrderedSet<int> other = [3, 4, 5, 6];

set.SymmetricExceptWith(other);

Assert.AreSequenceEqual([1, 2, 5, 6], set);
Assert.AreSequenceEqual([3, 4, 5, 6], other);
}
}
18 changes: 8 additions & 10 deletions Containers/OrderedCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,12 @@ public OrderedCollection(IEnumerable<T> collection)
);
}

items = [];
comparer = Comparer<T>.Default;

foreach (T item in collection)
{
Add(item);
}
// Inserting one at a time is O(n^2); a stable sort keeps equal elements in their original order, as Add does
T[] array = [.. collection];
StableSort.Sort(array, comparer);
items = [.. array];
}

/// <summary>
Expand All @@ -177,13 +176,12 @@ public OrderedCollection(IEnumerable<T> collection, IComparer<T> comparer)
Ensure.NotNull(collection);
Ensure.NotNull(comparer);

items = [];
this.comparer = comparer;

foreach (T item in collection)
{
Add(item);
}
// Inserting one at a time is O(n^2); a stable sort keeps equal elements in their original order, as Add does
T[] array = [.. collection];
StableSort.Sort(array, this.comparer);
items = [.. array];
}

/// <summary>
Expand Down
42 changes: 34 additions & 8 deletions Containers/OrderedMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,8 @@ public OrderedMap(IDictionary<TKey, TValue> dictionary)
);
}

items = [];
comparer = Comparer<TKey>.Default;

foreach (KeyValuePair<TKey, TValue> pair in dictionary)
{
Add(pair.Key, pair.Value);
}
items = BuildSorted(dictionary, this.comparer);
}

/// <summary>
Expand All @@ -228,13 +223,44 @@ public OrderedMap(IDictionary<TKey, TValue> dictionary, IComparer<TKey> comparer
Ensure.NotNull(dictionary);
Ensure.NotNull(comparer);

items = [];
this.comparer = comparer;
items = BuildSorted(dictionary, this.comparer);
}

/// <summary>
/// Builds the backing list for a map from an existing dictionary.
/// </summary>
/// <param name="dictionary">The key-value pairs to add.</param>
/// <param name="comparer">The comparer that orders the keys and decides which are equal.</param>
/// <returns>The entries sorted by key.</returns>
/// <remarks>
/// Adding the pairs one at a time costs O(n) per insert, so O(n^2) in all. Sorting a copy costs O(n log n).
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when a key is null.</exception>
/// <exception cref="ArgumentException">Thrown when two keys compare equal under <paramref name="comparer"/>, as <see cref="Add(TKey, TValue)"/> would.</exception>
private static List<Entry> BuildSorted(IDictionary<TKey, TValue> dictionary, IComparer<TKey> comparer)
{
List<Entry> list = new(dictionary.Count);
foreach (KeyValuePair<TKey, TValue> pair in dictionary)
{
Add(pair.Key, pair.Value);
Ensure.NotNull((object?)pair.Key);
list.Add(new Entry(pair.Key, pair.Value));
}

Entry[] entries = [.. list];

StableSort.Sort(entries, Comparer<Entry>.Create((x, y) => comparer.Compare(x.Key, y.Key)));

for (int i = 1; i < entries.Length; i++)
{
if (comparer.Compare(entries[i - 1].Key, entries[i].Key) == 0)
{
// The stable sort keeps dictionary order, so this is the key Add would have rejected
throw new ArgumentException($"The key '{entries[i].Key}' already exists in the map.");
}
}

return [.. entries];
}

/// <summary>
Expand Down
Loading
Loading