From c029a825b6b70bb0f3a3ca591204856cb5579ab5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 10:56:42 +0000 Subject: [PATCH] [patch] Compare natural strings in place, allocating nothing per comparison Compare split both strings into a List through a StringBuilder before comparing, so every comparison allocated two lists, a builder per string and a string per chunk: about 616 bytes. A sort makes O(n log n) comparisons. It now walks both strings a chunk at a time by index. Digits are read by their value with CharUnicodeInfo, text chunks compare with string.CompareOrdinal, and a number against text compares its first digit's ASCII equivalent with the text's first character. The ordering is unchanged: a differential run of 2,000,000 random pairs (ASCII, Arabic-Indic, Devanagari and mathematical digits, leading zeros, lone surrogates, emoji) against the previous implementation found no difference in sign. Sorting 50,000 file names went from 532 ms to 115 ms in Release. Fixes #56 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015zULxdhphqscknnLMm1Bk1 --- Sorting.Test/NaturalStringComparerTests.cs | 39 ++++ Sorting/NaturalStringComparer.cs | 234 +++++++++++++-------- 2 files changed, 181 insertions(+), 92 deletions(-) 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; + } }