ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#数字字符串排序:自然排序原理与IComparer实战

C#数字字符串排序:自然排序原理与IComparer实战

列表里躺着几百个设备日志文件,命名从camera_1.log一直到camera_12.log,还有camera_100.log。用OrderBy(f => f.Name)一跑,出来的顺序是 1、10、100、11、12、2、3……某个同事盯着屏幕看了半天,说“这顺序有点怪”。不是顺序怪,是字符串比较本来就这么排的。字典序逐字符比对,'1'和'2'之间夹着一堆以1开头的更长字符串,谁先谁的第二个字符更小,谁就排前面,跟数字大小没有半点关系。这就是C# 数字字符串排序这件事最典型的现场:你手里拿的是字符串,脑子里想的是数字,两者打架。要解决它,靠的不是某个神秘 API,而是把字符串切成“文本块 + 数字块”再分别比较,这套做法在圈子里通常叫自然排序(Natural Sort),也有人叫 human sort、alphanum sort。它适合所有跟文件名、编号、版本号、订单号、章节标题、坐标标签、机位编号打交道的场景,也适合那些被ORDER BY坑过一次、想搞清楚底层到底发生什么的人。下面我按自己实际踩过的路,把方案选型、核心算法、完整代码和排查经验一次讲透。

1. 需求拆解与方案选型:别急着写比较器

1.1 默认字符串排序到底“错”在哪

先把这件事的因果链理清楚,不然写出来的比较器一定是缝缝补补。C# 里string的默认比较有两套完全不同的语义:一套是StringComparison.Ordinal,按 UTF-16 码元的数值逐位比;另一套是文化敏感比较(culture-sensitive),走CompareInfo,会考虑大小写、重音、连字、以及特定语言的排序习惯。List<string>.Sort()不传参数时用的是Comparer<string>.Default,它底层调string.CompareTo,也就是当前文化的文化敏感比较。OrderBy(x => x)同理。而SortedDictionary<string, T>、SortedSet<string>默认也是这套。

不管哪套,它们对“数字”这件事的理解都只有一种:把0-9当成普通字符。"file10"和"file2"比较时,前 4 个字符相同,第 5 个字符'1'对'2','1'更小,于是file10排前面。这跟长度、跟数值都没关系,纯粹是码位大小。所以这不是 bug,是语义错配——你要的是“数值序”,给它的是“字符序”。

还有个更隐蔽的坑:文化敏感比较在不同 locale 下结果可能不同。同一批数据在开发机上排得好好的,到了另一台机器或者容器里换个语言环境,顺序就变了。这在分布式系统里排查起来极其痛苦,因为你复现不了。我个人的规矩是:凡是参与排序、分组、字典键的字符串,一律用Ordinal或OrdinalIgnoreCase,把文化因素彻底排除掉,除非业务真的需要按人名、按语言习惯排。

1.2 三条可行路线,各自的代价

搞清楚问题之后,能走的路其实就三条,我把它们摆在一起对比,方便你按场景挑。

路线适用版本优点代价
内置CompareOptions.NumericOrdering.NET 5 及以上一行代码,官方维护,边界情况处理到位依赖 ICU,NLS 回退环境下会静默失效;行为细节不完全可控
平台 APIStrCmpLogicalW仅 Windows与资源管理器排序完全一致,用户零学习成本平台锁定,无法跨平台;P/Invoke 有额外开销;字符串长度和字符集有隐含限制
自研IComparer<string>全平台全版本行为完全可控,可定制前导零、负数、小数规则需要自己处理严格弱序、边界与性能

先说第一条。.NET 5在CompareOptions里加了NumericOrdering,意思就是“把连续数字当数值比”。用法很直接:

var comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering | CompareOptions.IgnoreCase);

这一行基本能覆盖 80% 的日常需求,尤其是那些不想维护几百行比较器的人。但它有两个前提你必须知道:第一,这个枚举值在 .NET Framework 上根本不存在,老项目用不了;第二,它依赖 ICU 提供的排序规则,如果运行环境回退到 NLS(比如通过运行时配置强制旧行为),这个标志会被忽略,排序结果会悄悄退回普通字符串顺序——不报错,只是排错,这种“静默降级”最难查。

第二条是 Windows 上的老牌方案,shlwapi.dll里的StrCmpLogicalW,资源管理器用的就是它。它的好处是“用户觉得对”,因为用户天天看资源管理器,习惯那套顺序。坏处是锁死在 Windows,而且它对大小写、前导零的处理有自己的脾气,未必符合你的业务预期。

第三条是自研。我知道很多人一听“自己写比较器”就头大,觉得容易出 bug。确实容易出 bug,但只要把“严格弱序”这条红线守住,剩下的就是体力活。而且自研的好处是你能精确控制每一个细节:"001"和"1"算不算相等、负数怎么排、带小数的版本号怎么比、遇到非 ASCII 数字怎么办。这些在业务里都是真实存在的分歧点。

1.3 比较器该挂在哪一层,比怎么写更重要

我见过不少项目,比较器写得挺漂亮,但挂的位置不对,结果还是乱。这里有几个必须想清楚的问题。

第一,OrderBy是稳定排序,List<T>.Sort()和Array.Sort()是不稳定排序。稳定意味着相等元素的原始相对顺序会被保留;不稳定意味着不保证。如果你有两个文件IMG_01.jpg和IMG_1.jpg,在自然排序下它们可能被判为相等,这时候稳定排序能保证它们维持原顺序,不稳定排序可能把它们换来换去,导致同一批数据两次刷新结果不一样。解决办法很简单:在比较器的最后加一个兜底 tie-breaker,用原始字符串的Ordinal比较,保证任何两个不同的字符串都不会返回 0。

第二,SortedDictionary和SortedSet用比较器时,返回 0 就意味着“这两个键相同”,后插入的会覆盖先插入的,或者被当作重复元素丢掉。如果你的自然排序比较器会把"a01"和"a1"判成相等,那在SortedDictionary里它们就是同一个键——这个坑非常隐蔽,因为集合本身不会报错,只是数据少了。所以我强烈建议:给SortedDictionary/SortedSet用的比较器,必须保证不同的字符串永不返回 0。

第三,如果排序发生在 UI 层,比如 WPF 的DataGrid或者 WinForms 的BindingList,你要挂的是ICollectionView.CustomSort或者ListCollectionView.CustomSort,而不是在数据源上排一遍就完事。挂错层的话,用户点列头排序还是走默认比较,白干。

2. 核心机制拆解:把字符串切成“数字块”和“文本块”

2.1 分块是整件事的地基

自然排序的全部秘密就一句话:不要逐字符比较,先把字符串切成交替的“数字块”和“文本块”,逐块比较,块内按各自的规则比。比如"file12a3.txt"切成["file", "12", "a", "3", ".txt"],"file2a10.txt"切成["file", "2", "a", "10", ".txt"]。逐块比:file对file相等;12对2走数值比较,12 大于 2,直接返回,后面的块都不用看了。

这个切法有两个天然优势。一是它把“数值语义”和“字符语义”彻底隔离开了,每个块内部规则单一,不会有歧义。二是它天然支持任意长度的数字——"999999999999999999999"这种超出long范围的数字,只要按“去前导零后的长度优先、长度相同再逐位比”的规则处理,根本不需要解析成数值,也就没有溢出问题。这一点很多人第一次写会踩,习惯性地上int.Parse或者long.TryParse,遇到超长编号或者哈希串里的数字段就抛异常或者截断。

不过分块也有代价:需要额外存储块的信息。用正则Regex.Matches是最直观的做法,但每次比较都要跑两遍正则、分配一堆Match对象,在几万条数据上排序时会明显卡顿。所以后面我会给出一个不分配内存的逐字符扫描版本,两者结合起来看会更清楚。

2.2 数字块的比较:千万别解析成数值

数字块的核心规则我总结成三步,按顺序执行,命中即返回:

  1. 先去掉前导零。"0012"和"12"去掉零之后都是"12",视为数值相等。
  2. 比有效长度。有效长度长的数值大。"12"有效长度 2,"9"有效长度 1,所以 12 > 9。这一步直接绕开了数值解析。
  3. 长度相同时逐位比字符。都是两位,'1'对'9','1'小,所以 12 < 99。

这三步的妙处在于,它不需要任何数值类型参与,纯字符串操作,天然支持任意长度、天然不会溢出、天然不受double精度影响。代价是不能直接处理小数和负号,但那本来就是另一层语义,应该交给业务层决定,而不是塞进比较器里。

前导零的处理是个可以商量的点。"img_001"和"img_1"在自然排序下数值相等,这时候该怎么排?三种常见策略:一是视为相等,交给后面的 tie-breaker;二是前导零多的排前面(视觉上更整齐,因为 001、002、003 和 1、2、3 各自成组);三是直接按原始字符串兜底。我一般选第二种的一点点变体:数字块数值相等时,比较两个数字块的原始长度,长的排前面。这样img_001, img_002, img_1, img_2会是这个顺序,看起来比较自然。

还有一个容易忽略的点:哪些字符算“数字”。char.IsDigit对阿拉伯-印度数字、全角数字、上标数字都返回 true,但它不告诉你具体是哪个字符集。如果你用c - '0'去算数值,非 ASCII 数字就全错了。所以我倾向显式定义一个IsAsciiDigit:

private static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9;

这个写法利用了无符号整数的回绕,c - '0'若是负数会变成极大的无符号数,直接排除,比c >= '0' && c <= '9'少一次比较,而且不需要担心编译器优化。如果你的数据里真的有全角数字,最稳妥的做法是不把它当数字块,让它走文本比较——反正全角数字在业务里通常也是异常数据。

2.3 文本块的比较:文化敏感是个陷阱

文本块看起来最简单,其实最容易埋雷。用string.Compare(a, b, StringComparison.Ordinal)是安全的,结果确定、跨平台一致、速度快。用文化敏感比较就麻烦了:在瑞典语环境下ä排在z后面,在德语环境下又不一样,你的排序结果会随机器环境漂移。更麻烦的是,文化敏感比较和逐字符比较的语义不一致,混用会破坏严格弱序,导致Array.Sort抛ArgumentException: Failed to compare two elements in the array。这个异常信息极其含糊,我第一次遇到时查了大半天。

所以我的默认选择是Ordinal,需要忽略大小写就用OrdinalIgnoreCase。OrdinalIgnoreCase的实现是先把字符转成大写不变式再比码元,行为确定。唯一的“不公平”是_、-、.这些标点排在字母前面,导致IMG_001排在IMGA001前面。多数情况下这没问题,如果你确实需要“忽略标点”,那就得在分块阶段把标点过滤掉,但这样又会影响相等判断,得慎重。

还有一种情况要提:数字块和文本块类型不匹配时怎么办?比如"a1"和"ab",第一个块是a对a,第二个块1(数字)对b(文本)。理论上两边分块序列的块类型应该始终对齐,但现实字符串千奇百怪,难免出现错位。稳妥的约定是:数字块排在文本块前面,并且这个约定必须全局一致,否则传递性就崩了。我在实现里会在类型不匹配时直接返回一个固定值,而不是去比首字符,这样至少能保证结果确定。

2.4 边界情况:null、空串、负数、小数

这四类情况得单独说,因为它们决定了比较器能不能放心用。

null的处理要显式写,不要依赖默认行为。IComparer<string>.Compare的签名接受string?,如果传进来null而你直接访问.Length,就是NullReferenceException。标准约定是:null排在最前,两个null相等。写成if (ReferenceEquals(a, b)) return 0; if (a is null) return -1; if (b is null) return 1;三行搞定。

空串""是合法的非 null 字符串,长度 0,走完循环后靠剩余长度比较,会排在"a"前面,这符合直觉。

负号和小数点属于“业务语义”,不该由通用比较器决定。"-5"在通用自然排序里会被切成["-", "5"],结果是-5排在5前面,但-10和-5的先后就完全错了,因为数字块只比较10和5的绝对值。如果业务里真的有负数或版本号1.2.10,正确做法是写专门的比较器(比如版本号比较器,按.分段后每段数值比),而不是硬塞进通用版。同理,小数点如果在文件名里表示扩展名分隔符,把它当小数处理会直接把file1.5.txt和file1.10.txt排错。这个决定要在设计阶段就拍死。

3. 完整实现与实操过程

3.1 第一版:用正则分块,先跑通再说

我建议所有人在写自研比较器之前,先花十分钟用正则写个最小可用版本,把逻辑跑通、把测试用例跑一遍,确认自己对“什么算正确”的理解和业务方一致。这一版性能差没关系,它就是用来对齐语义的。

using System; using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; public sealed class NaturalComparerV1 : IComparer<string> { public static readonly NaturalComparerV1 Instance = new(); private static readonly Regex ChunkRegex = new(@"(\d+|\D+)", RegexOptions.Compiled | RegexOptions.CultureInvariant); public int Compare(string? a, string? b) { if (ReferenceEquals(a, b)) return 0; if (a is null) return -1; if (b is null) return 1; var chunksA = ChunkRegex.Matches(a); var chunksB = ChunkRegex.Matches(b); int i = 0, j = 0; while (i < chunksA.Count && j < chunksB.Count) { string ca = chunksA[i].Value; string cb = chunksB[j].Value; bool na = IsAsciiDigit(ca[0]); bool nb = IsAsciiDigit(cb[0]); int cmp; if (na && nb) cmp = CompareNumericChunk(ca, cb); else if (na != nb) cmp = na ? -1 : 1; // 数字块统一排在文本块前 else cmp = string.Compare(ca, cb, StringComparison.OrdinalIgnoreCase); if (cmp != 0) return cmp; i++; j++; } // 块数量不同时,先看剩余块 if (chunksA.Count - i != chunksB.Count - j) return (chunksA.Count - i).CompareTo(chunksB.Count - j); // 全部相等,用 Ordinal 兜底,保证不返回 0 return string.Compare(a, b, StringComparison.Ordinal); } private static int CompareNumericChunk(string a, string b) { int sa = 0, sb = 0; while (sa < a.Length && a[sa] == '0') sa++; while (sb < b.Length && b[sb] == '0') sb++; int lenA = a.Length - sa; int lenB = b.Length - sb; if (lenA != lenB) return lenA < lenB ? -1 : 1; for (int k = 0; k < lenA; k++) { if (a[sa + k] != b[sb + k]) return a[sa + k] < b[sb + k] ? -1 : 1; } // 数值相等,前导零多的排前面,保证不返回 0 return b.Length.CompareTo(a.Length); } private static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9; }

这段代码有几个地方值得说。RegexOptions.Compiled在这里其实没太大意义,因为模式很简单,编译开销反而不划算,真正需要编译的是复杂模式。RegexOptions.CultureInvariant是我加上去的保险,防止正则引擎在匹配时受当前文化影响。(\d+|\D+)这个模式保证每段要么全是数字,要么全是非数字,不会出现空段。

最后那个string.Compare(a, b, StringComparison.Ordinal)兜底非常关键。前面所有块都相等,说明两个字符串在自然排序语义下“等价”,但如果原始字符串不同,返回 0 会让SortedDictionary把它们当成同一个键。加这一行之后,任何两个不同的字符串都必然返回非零,Array.Sort也不会抱怨比较器不一致。

实测下来,这个版本在几千条数据以内完全够用,排序耗时在十几毫秒量级。但数据量上到几万条,正则匹配的开销就很明显了,因为它每次比较都要重新切两遍分块,而且是全分配。

3.2 第二版:手写状态机扫描,做到零分配

第二版的核心思路是:不预先切块,而是一边扫描一边比较,用两个下标指针同步推进。遇到两边都是数字就进数字块比较分支,遇到两边都是非数字就逐字符比,遇到类型错位就按约定处理。整个过程只读取字符,不分配任何对象。

using System; using System.Collections.Generic; public sealed class NaturalStringComparer : IComparer<string>, IComparer<string?> { public static readonly NaturalStringComparer Ordinal = new(ignoreCase: false); public static readonly NaturalStringComparer OrdinalIgnoreCase = new(ignoreCase: true); private readonly bool _ignoreCase; private NaturalStringComparer(bool ignoreCase) => _ignoreCase = ignoreCase; public int Compare(string? a, string? b) { if (ReferenceEquals(a, b)) return 0; if (a is null) return -1; if (b is null) return 1; int i = 0, j = 0; int la = a.Length, lb = b.Length; while (i < la && j < lb) { char ca = a[i], cb = b[j]; if (IsAsciiDigit(ca) && IsAsciiDigit(cb)) { // 先找出两个数字块的完整范围 int endA = i; while (endA < la && IsAsciiDigit(a[endA])) endA++; int endB = j; while (endB < lb && IsAsciiDigit(b[endB])) endB++; // 在块内跳过前导零 int sa = i; while (sa < endA && a[sa] == '0') sa++; int sb = j; while (sb < endB && b[sb] == '0') sb++; int lenA = endA - sa; int lenB = endB - sb; if (lenA != lenB) return lenA < lenB ? -1 : 1; for (int k = 0; k < lenA; k++) { char x = a[sa + k], y = b[sb + k]; if (x != y) return x < y ? -1 : 1; } // 数值相等,用原始块长度区分前导零 if (endA - i != endB - j) return (endB - j) < (endA - i) ? -1 : 1; i = endA; j = endB; continue; } if (ca != cb) { if (_ignoreCase) { char ua = char.ToUpperInvariant(ca); char ub = char.ToUpperInvariant(cb); if (ua != ub) return ua < ub ? -1 : 1; } else { return ca < cb ? -1 : 1; } } i++; j++; } // 主循环结束,剩余长度决定先后 int remA = la - i, remB = lb - j; if (remA != remB) return remA < remB ? -1 : 1; // 完全等价时用 Ordinal 兜底,绝不返回 0 return string.CompareOrdinal(a, b); } private static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9; }

这版有几个细节是我改了好几轮才定下来的。

第一个是数字块范围的计算顺序。必须先确定块的完整范围,再在范围内跳前导零,不能先跳零再找范围。因为如果整块都是零,比如"000",跳零之后指针会直接越过整块,后面算长度就乱了。我第一次写的时候就是这么错的,导致"a000"和"a0"比较时结果随机。

第二个是数字块比较之后的指针推进。必须推进到endA和endB,也就是原始块的末尾,而不是跳零之后的位置。否则前导零会被重复处理,逻辑就崩了。

第三个是前导零相等时的处理。(endB - j) < (endA - i) ? -1 : 1的意思是:A 的原始块更长(前导零更多),则 A 排前面。这样001, 002, 01, 02, 1, 2会形成比较自然的顺序。

第四个是string.CompareOrdinal(a, b)收尾。这里必须用CompareOrdinal而不是string.Compare,因为后者默认走文化敏感比较,可能返回 0(比如某些组合字符和预组合字符被视为相等),那严格弱序就破了。

最后要说清楚一个理论上的局限:当两边的块类型错位时(A 当前位置是数字,B 当前位置是字母),这版实现会走逐字符比较分支,只比一个字符就推进一位,这可能导致块边界错位,严格来说不保证传递性。实践中这种字符串极少(要求同一位置一个数字一个字母,且后续还有多个块),遇上了也基本不会触发异常。如果你要处理的确实是那种混乱的混合编号,用 3.1 的 chunk 版本更保险,因为它是基于块序列的字典序,天然满足传递性。

3.3 第三版:直接上内置 NumericOrdering

如果你的项目是 .NET 5 及以上,而且不需要精细控制前导零、大小写这些细节,那真的没必要自己写。内置方案一行搞定,而且它的 ICU 实现在边界情况上处理得比手写版更细致。

using System; using System.Collections.Generic; using System.Globalization; public static class NaturalComparers { public static readonly StringComparer Default = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering | CompareOptions.IgnoreCase); public static readonly StringComparer CaseSensitive = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); }

用法很自然,list.OrderBy(x => x, NaturalComparers.Default)或者Array.Sort(arr, NaturalComparers.Default)都行,因为它返回的类型是StringComparer,本身就实现了IComparer<string>。

要注意的是CultureInfo.InvariantCulture这个参数。传CultureInfo.CurrentCulture也能用,但排序结果会随机器语言环境变化,跨机器不一致。业务上如果没有“按当地语言习惯排”的硬需求,一律用InvariantCulture。另外CompareOptions.IgnoreCase在这条路径上是有效的,会被 ICU 正确处理,不像手写版只能近似模拟。

还有一个必须验证的点:这个 flag 在你的目标运行环境里是否真的生效。写完之后跑一个"file10"和"file2"的对比测试,确认返回值是正数。如果返回负数,说明运行环境回退到了 NLS,flag 被忽略了。这种情况在容器、精简运行时、或者通过配置文件强制旧行为的场景里都可能出现。别问我怎么知道的,我因为这个问题排查过一个跨平台服务的排序不一致,最后发现是容器基础镜像的全球化配置差异。

3.4 在各种排序入口里正确接入

比较器写好了,接进去的方式也有讲究,不同容器的行为差别很大。

使用场景推荐写法关键注意点
LINQ 查询source.OrderBy(x => x, comparer)稳定排序,相等元素保留原序
数组排序Array.Sort(arr, comparer)不稳定,必须有 tie-breaker
泛型列表list.Sort(comparer)同上,且会修改原列表
有序字典new SortedDictionary<string, T>(comparer)返回 0 即视为同一键,会覆盖
有序集合new SortedSet<string>(comparer)返回 0 即视为重复,会被丢弃
WPF 表格CollectionViewSource+CustomSort必须设在视图层,不是数据源

SortedDictionary这一项我要重点强调。它的键比较用的是你传进去的比较器,返回 0 就认为键重复。如果你的比较器在"a01"和"a1"上返回 0(去掉前导零后数值相等),那么后插入的那个会直接覆盖先插入的值,而且不会抛任何异常。这个 bug 极难发现,因为字典大小看起来是对的,只是某几个键的值不对。所以给字典用的比较器,tie-breaker 一定要做到位,保证任意两个不同字符串都返回非零。我在 3.2 版里用string.CompareOrdinal(a, b)收尾,就是为了这个。

Array.Sort和List<T>.Sort用的是内省排序,它对比较器的要求是“严格弱序”,也就是必须满足自反性、反对称性、传递性。如果你的比较器偶尔返回 0 偶尔返回非 0(比如因为前导零处理不彻底),排序过程中就可能抛ArgumentException: Failed to compare two elements in the array,或者更糟糕——排完了但顺序是错的。这就是为什么代码里那么多地方都要保证“值相等时还有第二判据”。

还有一个实用技巧:如果你需要在一次排序里做多级排序,比如先按数值排再按原名排,不要试图在一个比较器里塞完,直接用 LINQ 的ThenBy:

var sorted = files .OrderBy(f => f.Name, NaturalStringComparer.OrdinalIgnoreCase) .ThenBy(f => f.LastWriteTimeUtc) .ToList();

ThenBy只在主比较返回 0 时才执行次级比较,逻辑清晰,也不容易出错。前提还是主比较器要能返回 0,所以这里的比较器和我前面“绝不返回 0”的建议是冲突的——这两种用法要分开:给 LINQ 多级排序用的,可以返回 0;给SortedDictionary用的,必须不返回 0。我的做法是提供两个静态实例,一个带兜底、一个不带,命名上区分清楚。

3.5 性能基准:数字不会骗人

我拿一批模拟的机位日志文件名(约 5 万个,形如cam_007_20240315_0412.log)在这台开发机上跑了几组实测,量级如下:

实现5 万条排序耗时相对开销
默认字符串排序(基线)约 45 ms1.0x
正则分块版(3.1)约 780 ms17x
手写扫描版(3.2)约 95 ms2.1x
内置 NumericOrdering(3.3)约 130 ms2.9x
平台 API 版本(Windows)约 110 ms2.4x

这组数字不是让你背的,具体会随数据形态和 CPU 大幅波动,但相对关系很稳定。可以看到正则版慢了一个数量级,主要开销在每次比较都要Regex.Matches两遍,而且分配了大量临时对象,触发 GC。手写扫描版只比默认排序慢一倍出头,这个代价在任何业务场景都是可以接受的。

如果你有更极端的性能要求,还有两条路可走。一是在排序前先把每个字符串分块一次,缓存成结构体数组,然后用缓存做比较。这能把分块开销从“每次比较”降到“每个元素一次”,但要多分配一份内存,属于典型的空间换时间。二是用Span<char>加切片,配合MemoryExtensions.CompareTo,避免 substring 分配。这两种我都试过,收益在 5 万条这个量级上大概能再省 30% 左右,但代码复杂度明显上升,除非你有几十万条的排序需求,否则不建议。

提示:.NET 7之后可以考虑[GeneratedRegex]源生成器,它能把正则编译期生成成 IL,比RegexOptions.Compiled的运行时编译更省启动开销。但正则本身的分块思路不变,性能提升有限,想快还得靠扫描版。

4. 常见问题与排查技巧实录

4.1 问题速查表

这张表是我这些年积累的高频问题,遇到症状直接从表里找原因,比翻代码快。

症状大概率原因处理方式
Array.Sort抛Failed to compare two elements比较器不满足严格弱序,值相等时返回不稳定结果加 Ordinal 兜底,保证不同字符串不返回 0
排序结果在开发机和线上不一致用了文化敏感比较,或NumericOrdering在某环境静默失效全部改InvariantCulture,加冒烟测试验证 flag 生效
SortedDictionary元素莫名变少比较器把不同键判为相等,后者覆盖前者给字典用带兜底的比较器,或改用Dictionary后手动排键
前导零的文件顺序还是乱的数字块跳过前导零后没推进到原块末尾检查指针推进用的是endA还是sa
超出long的长编号排错用了数值解析,溢出或精度丢失改长度优先 + 逐位比较,不解析
小数点版本号排错通用比较器把.当扩展名分隔符写专门的版本号比较器,按段解析
全角数字或阿拉伯数字排错用了char.IsDigit但按 ASCII 算值显式限定 ASCII 数字,其余走文本比较
UI 点列头排序没生效比较器挂在数据源,没挂在ICollectionView设CustomSort,并处理SortDirection

4.2 三个我踩过的坑,都是真金白银

第一个坑是文化敏感的传递性崩坏。早期我用string.Compare(a, b, StringComparison.CurrentCulture)做文本块比较,在中文环境下跑了很久都没事,直到有一批带重音符号的字符串进来,Array.Sort开始随机抛异常。原因是某些字符组合在文化敏感比较下既不是严格小于也不是严格大于,而是“等价但不等同”,导致a==b、b==c但a<c。改成Ordinal之后世界清静了。这件事之后我定了个规矩:只要不是明确要按语言习惯排,一律 Ordinal。

第二个坑是前导零的歧义导致字典丢数据。有个配置项服务用SortedDictionary<string, Config>存客户端上报的节点编号,编号形如node_001、node_1。当时的比较器把前导零去掉了,node_001和node_1被判为相等,于是后上报的配置覆盖了先上报的,运营那边发现配置“丢失”但日志里明明有。查了两个小时才定位到是比较器返回 0 的问题。教训就是:任何可能进字典的字符串比较器,必须保证一对一。

第三个坑是排序稳定性引发的“刷新一次变一次”。有个列表 UI 用List<T>.Sort(),同一批数据每次刷新顺序不一样,用户投诉说“列表会自己跳”。原因是比较器对一些等价元素返回 0,而不稳定排序不保证这些元素的相对位置。改成OrderBy之后问题消失,因为 LINQ 的OrderBy是稳定排序。所以选排序 API 之前先问自己:我需要稳定吗?需要的话,OrderBy永远比Sort安全。

4.3 测试用例清单:写完先跑这二十个

比较器的正确性很难靠肉眼验证,必须靠用例。我整理了一份清单,每次改实现都跑一遍,覆盖了我知道的所有边界。建议你把它写成 xUnit 的[Theory],用[InlineData]喂数据,Assert.True(comparer.Compare(a, b) < 0)来断言。

类别输入 A输入 B期望 A 在前
基础数字file2.txtfile10.txt是
前导零img_001img_1是
同值不同零img_0001img_001是
超长数字n_99999999999999999999n_100000000000000000000是
数字优先a1ab是
大小写File1file2是(忽略大小写模式)
纯数字0077是
空串"""a"是
nullnull"a"是
特殊字符a-1a_1依 Ordinal 而定
负数-10-5否(通用版不支持负数,需专用比较器)
小数v1.10v1.2否(需版本号比较器)
全等abcabc相等
长前缀aaaa1aaaa2是
后缀位数x9x10是
混排cam_007_0412.logcam_007_1000.log是
交错误位a1bab1需确认实际行为
中英文混排第1章第10章是
纯文本applebanana是
尾部空块aa0是

除了逐对断言,我还建议加一个三元组传递性测试:随机生成一千组字符串,对每个三元组 (a, b, c) 验证“若 a≤b 且 b≤c,则 a≤c”。这个测试能快速抓出比较器的逻辑漏洞,比逐个用例覆盖得更全。我一般用固定随机种子,保证结果可复现。

[Fact] public void Compare_IsTransitive() { var rnd = new Random(20240315); var strings = Enumerable.Range(0, 200) .Select(_ => RandomName(rnd)) .ToArray(); foreach (var a in strings) foreach (var b in strings) foreach (var c in strings) { int ab = NaturalStringComparer.OrdinalIgnoreCase.Compare(a, b); int bc = NaturalStringComparer.OrdinalIgnoreCase.Compare(b, c); if (ab <= 0 && bc <= 0) { int ac = NaturalStringComparer.OrdinalIgnoreCase.Compare(a, c); Assert.True(ac <= 0, $"传递性破坏: {a} <= {b} <= {c} 但 {a} > {c}"); } } }

这个测试跑起来是 800 万次比较,几秒钟的事,但能挡住 90% 的实现错误。我给团队定的规矩是:改比较器必须先跑这个测试,跑不过就不许提 PR。

最后分享一个经验:如果你的项目里同时有文件名排序、版本号排序、编号排序三种需求,别指望一个比较器打天下。文件名走自然排序,版本号走Version.TryParse或者自己按.切段,编号走纯数值排序。三套逻辑分开,各自简单,谁出问题改谁,比在一个巨型if-else里互相干扰要好维护得多。我见过一个比较器为了兼容所有场景,代码长到两百多行,最后谁都不敢动,只能每次发现新 case 就往上加一行补丁——那才是真正的技术债。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进