diff --git a/Sorting.Test/NaturalStringComparerTests.cs b/Sorting.Test/NaturalStringComparerTests.cs index a89de38..3d0a2ec 100644 --- a/Sorting.Test/NaturalStringComparerTests.cs +++ b/Sorting.Test/NaturalStringComparerTests.cs @@ -297,4 +297,43 @@ public void Compare_DigitsOutsideTheBasicMultilingualPlane_ComparedByNumericValu Assert.IsGreaterThan(0, _comparer.Compare("file\U0001D7D7", "file8")); Assert.AreEqual(0, _comparer.Compare("file\U0001D7D7", "file9")); } + + [TestMethod] + public void Compare_AllocatesNothing() + { + // A sort makes O(n log n) comparisons, so anything a comparison allocates is paid that many + // times over. Pairs cover text, ASCII and non-ASCII numbers, leading zeros, surrogate pairs, + // and a number against text. + (string X, string Y)[] pairs = + [ + ("file2.txt", "file10.txt"), + ("img007-final", "img7-draft"), + ("a\u0665b", "a5c"), + ("file\U0001D7D7", "file\U0001D7CF\U0001D7CE"), + ("5a", "a5"), + ("00000000000000000000000000000001", "1"), + ("same prefix, longer", "same prefix"), + ]; + + // Warm up first, so that what is measured is the comparing rather than anything the runtime + // does the first time a method is called. + foreach ((string x, string y) in pairs) + { + _comparer.Compare(x, y); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 1000; iteration++) + { + foreach ((string x, string y) in pairs) + { + _comparer.Compare(x, y); + _comparer.Compare(y, x); + } + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.AreEqual(0L, allocated, $"{allocated} bytes allocated over {pairs.Length * 2000} comparisons"); + } } diff --git a/Sorting/NaturalStringComparer.cs b/Sorting/NaturalStringComparer.cs index 108c6f6..b4dad2b 100644 --- a/Sorting/NaturalStringComparer.cs +++ b/Sorting/NaturalStringComparer.cs @@ -3,7 +3,6 @@ namespace ktsu.Sorting; using System.Globalization; -using System.Text; /// /// Comparer that performs a natural comparison between strings, correctly comparing embedded numbers. @@ -48,151 +47,202 @@ public int Compare(string? x, string? y) return 0; } - List xChunks = SplitIntoChunks(x); - List yChunks = SplitIntoChunks(y); - - int i = 0, j = 0; - while (i < xChunks.Count && j < yChunks.Count) + // Walk both strings a chunk at a time, in place, so a comparison allocates nothing. A sort + // makes O(n log n) of them, and building each string's chunks first cost more than the + // comparing did. + int xIndex = 0; + int yIndex = 0; + while (xIndex < x.Length && yIndex < y.Length) { - Chunk xChunk = xChunks[i++]; - Chunk yChunk = yChunks[j++]; + bool xIsNumeric = IsDigitAt(x, xIndex); + bool yIsNumeric = IsDigitAt(y, yIndex); + int xEnd = ChunkEnd(x, xIndex, xIsNumeric); + int yEnd = ChunkEnd(y, yIndex, yIsNumeric); - // If both chunks are numeric, compare them as numbers - if (xChunk.IsNumeric && yChunk.IsNumeric) + int comparison; + if (xIsNumeric && yIsNumeric) + { + // If both chunks are numeric, compare them as numbers + comparison = CompareNumericChunks(x, xIndex, xEnd, y, yIndex, yEnd); + } + else if (xIsNumeric || yIsNumeric) + { + // A number is compared with text as its ASCII digits would be, and a text chunk never + // starts with a digit, so the first character decides. Comparing a non-ASCII digit's + // own code point here would sort it after letters while its ASCII equal sorts before + // them, which makes the order intransitive. + comparison = xIsNumeric + ? AsciiDigitAt(x, xIndex).CompareTo(y[yIndex]) + : x[xIndex].CompareTo(AsciiDigitAt(y, yIndex)); + } + else { - int numComparison = CompareNumericChunks(xChunk.Value, yChunk.Value); - if (numComparison != 0) - { - return numComparison; - } + // Otherwise, compare them as strings + comparison = CompareTextChunks(x, xIndex, xEnd, y, yIndex, yEnd); } - else // Otherwise, compare them as strings + + if (comparison != 0) { - // A numeric chunk holds ASCII digits and a text chunk never starts with a digit, so a - // number against text is decided by the first character, the same way for every - // digit script. Comparing a non-ASCII digit's own code point here would sort it after - // letters while its ASCII equal sorts before them, which makes the order intransitive. - int stringComparison = string.Compare(xChunk.Value, yChunk.Value, StringComparison.Ordinal); - if (stringComparison != 0) - { - return stringComparison; - } + return comparison; } + + xIndex = xEnd; + yIndex = yEnd; } // If we've exhausted one sequence but not the other, the shorter one comes first - return xChunks.Count.CompareTo(yChunks.Count); + return xIndex < x.Length ? 1 : yIndex < y.Length ? -1 : 0; } /// - /// Splits a string into alternating runs of decimal digits and of other text, walking it by code - /// point so that a digit encoded as a surrogate pair still counts as a digit. + /// Reports whether the code point at is a Unicode decimal digit + /// (category Nd), reading a surrogate pair as the one code point it encodes. /// - /// - /// A numeric chunk's value is rewritten in ASCII digits, so that numbers from every script compare - /// alike both with each other and with text. A text chunk keeps its original characters. - /// - /// The string to split. - /// The chunks of , in order. - private static List SplitIntoChunks(string value) - { - List chunks = []; - StringBuilder current = new(); - bool currentIsNumeric = false; - int index = 0; - while (index < value.Length) - { - bool isDigit = CharUnicodeInfo.GetUnicodeCategory(value, index) == UnicodeCategory.DecimalDigitNumber; - if (current.Length > 0 && isDigit != currentIsNumeric) - { - chunks.Add(new Chunk(currentIsNumeric, current.ToString())); - current.Clear(); - } + /// The string to read. + /// The index of the code point. + /// True if the code point is a decimal digit. + private static bool IsDigitAt(string value, int index) => + CharUnicodeInfo.GetUnicodeCategory(value, index) == UnicodeCategory.DecimalDigitNumber; - currentIsNumeric = isDigit; - int width = char.IsSurrogatePair(value, index) ? 2 : 1; - if (isDigit) - { - current.Append((char)('0' + CharUnicodeInfo.GetDecimalDigitValue(value, index))); - } - else - { - current.Append(value, index, width); - } + /// + /// Returns how many UTF-16 code units the code point at takes. + /// + /// The string to read. + /// The index of the code point. + /// Two for a surrogate pair, otherwise one. + private static int WidthAt(string value, int index) => char.IsSurrogatePair(value, index) ? 2 : 1; - index += width; - } + /// + /// Returns the ASCII digit with the same value as the decimal digit at . + /// + /// The string to read. + /// The index of a decimal digit. + /// The digit, rewritten in ASCII. + private static char AsciiDigitAt(string value, int index) => + (char)('0' + CharUnicodeInfo.GetDecimalDigitValue(value, index)); - if (current.Length > 0) + /// + /// Finds where the run of digits, or of other text, that starts at ends, + /// walking by code point so that a digit encoded as a surrogate pair still counts as a digit. + /// + /// The string to scan. + /// The index the chunk starts at. + /// Whether the chunk is a run of decimal digits. + /// The index just past the chunk. + private static int ChunkEnd(string value, int start, bool isNumeric) + { + int index = start; + while (index < value.Length && IsDigitAt(value, index) == isNumeric) { - chunks.Add(new Chunk(currentIsNumeric, current.ToString())); + index += WidthAt(value, index); } - return chunks; + return index; + } + + /// + /// Compares two runs of text ordinally, the shorter first when one is a prefix of the other. + /// + /// The first string. + /// Where its chunk starts. + /// Where its chunk ends. + /// The second string. + /// Where its chunk starts. + /// Where its chunk ends. + /// A negative number, zero, or a positive number, as for . + private static int CompareTextChunks(string x, int xStart, int xEnd, string y, int yStart, int yEnd) + { + int xLength = xEnd - xStart; + int yLength = yEnd - yStart; + int comparison = string.CompareOrdinal(x, xStart, y, yStart, Math.Min(xLength, yLength)); + return comparison != 0 ? comparison : xLength.CompareTo(yLength); } /// - /// Compares two chunks of decimal digits by the numeric value they spell, rather than by their + /// Compares two runs of decimal digits by the numeric value they spell, rather than by their /// code points. /// /// - /// Both chunks come from , which has already rewritten every Unicode - /// decimal digit (category Nd) as the ASCII digit of the same value. That is what keeps - /// non-ASCII digit scripts ordering by magnitude: '٥' (Arabic-Indic five) is numerically - /// less than '9', even though its code point is far greater. + /// Each digit is read by its value, which is what keeps non-ASCII digit scripts ordering by + /// magnitude: '٥' (Arabic-Indic five) is numerically less than '9', even though its + /// code point is far greater. /// - /// First digit chunk to compare. - /// Second digit chunk to compare. + /// The first string. + /// Where its digits start. + /// Where its digits end. + /// The second string. + /// Where its digits start. + /// Where its digits end. /// A negative number, zero, or a positive number, as for . - private static int CompareNumericChunks(string xChunk, string yChunk) + private static int CompareNumericChunks(string x, int xStart, int xEnd, string y, int yStart, int yEnd) { - int xStart = SkipLeadingZeros(xChunk); - int yStart = SkipLeadingZeros(yChunk); + int xIndex = SkipLeadingZeros(x, xStart, xEnd); + int yIndex = SkipLeadingZeros(y, yStart, yEnd); // With leading zeros gone, the chunk spelling more digits is the larger number - int xDigits = xChunk.Length - xStart; - int yDigits = yChunk.Length - yStart; - int lengthComparison = xDigits.CompareTo(yDigits); + int lengthComparison = CountDigits(x, xIndex, xEnd).CompareTo(CountDigits(y, yIndex, yEnd)); if (lengthComparison != 0) { return lengthComparison; } // Same digit count, so the first differing digit decides - for (int offset = 0; offset < xDigits; offset++) + while (xIndex < xEnd) { - int digitComparison = xChunk[xStart + offset].CompareTo(yChunk[yStart + offset]); + int digitComparison = AsciiDigitAt(x, xIndex).CompareTo(AsciiDigitAt(y, yIndex)); if (digitComparison != 0) { return digitComparison; } + + xIndex += WidthAt(x, xIndex); + yIndex += WidthAt(y, yIndex); } return 0; } /// - /// Returns the index of the first digit in that is not a zero, or - /// chunk.Length - 1 when the chunk is all zeros, so a chunk of zeros compares as a - /// single zero digit. + /// Counts the digits between and , a surrogate pair + /// counting as one. /// - /// The digit chunk to scan. - /// The index at which the chunk's significant digits begin. - private static int SkipLeadingZeros(string chunk) + /// The string to read. + /// Where the digits start. + /// Where the digits end. + /// The number of digits. + private static int CountDigits(string value, int start, int end) { - int index = 0; - while (index < chunk.Length - 1 && chunk[index] == '0') + int count = 0; + for (int index = start; index < end; index += WidthAt(value, index)) { - index++; + count++; } - return index; + return count; } /// - /// A run of decimal digits, held as ASCII digits, or a run of other text. + /// Returns the index of the first digit in the run that is not a zero, or of its last digit when + /// the run is all zeros, so a run of zeros compares as a single zero digit. /// - /// Whether the chunk is a run of decimal digits. - /// The chunk's ASCII digits when numeric, otherwise its original text. - private readonly record struct Chunk(bool IsNumeric, string Value); + /// The string to read. + /// Where the digits start. + /// Where the digits end. + /// The index at which the run's significant digits begin. + private static int SkipLeadingZeros(string value, int start, int end) + { + int index = start; + while (CharUnicodeInfo.GetDecimalDigitValue(value, index) == 0) + { + int next = index + WidthAt(value, index); + if (next >= end) + { + break; + } + + index = next; + } + + return index; + } }