☰
C# 数字字符串排序:从字典序陷阱到自然排序比较器
2026/11/2 17:19:24 网站建设 项目流程

排序这件事,写过几年代码的人大概都被它绊过一次。最常见的场景是这样的:列表里明明是一批编号item1、item2、item10,程序排完序之后,item10硬是挤到了item2前面,用户看着一脸问号,测试提了单,你去查发现代码里只写了一行OrderBy(x => x)——那行代码没有任何 bug,它只是忠实地按字典序干活。C# 里对含数字的字符串做排序,麻烦就在于"字符串比较"和"人类对数字的直觉"是两套规则,前者按字符码位逐位比,后者按数值大小比。这篇就把 C# 数字字符串排序这件事从头拆一遍:默认比较到底在比什么、补零和转数值这两条捷径各自死在哪里、怎么手写一个能上生产的自然排序比较器、以及我在代码评审和线上问题里反复见到的几个坑。

1. "10" 排在 "9" 前面:默认比较到底在比什么

动手改之前得先搞清楚现状。很多人以为list.Sort()或者OrderBy(x => x)是按"字符顺序"排的,这个说法不算错但太粗,C# 里字符串比较其实有三套互不相同的规则,选错了哪一套,结果都会跟你预期不一样。

1.1 字典序的本质是逐位比较码位,而不是理解语义

字符串比较的第一原则是:从左往右,逐字符比较,第一个不相等的字符决定整串的大小。"10"和"9"比,第一位是'1'和'9','1'的码位是 49,'9'是 57,'1' < '9',比较在第一个字符处就结束了,后面的'0'根本没参与。所以"10" < "9"是字典序的必然结果,不是 bug。

理解这个机制有一个非常实用的推论:只要参与比较的数字位数不同,字典序就会出错。反过来说,如果所有数字位数相同("001"、"010"、"009"),字典序和数值序的结果完全一致。后面所有方案,本质上都是在想办法把"位数不同"这个问题绕过去或者修正掉。

还有一层容易忽略的:string的==运算符是序数比较(ordinal),而CompareTo默认是文化敏感比较(culture-sensitive)。也就是说在一批字符串里a == b为 false,但a.CompareTo(b) == 0完全可能为 true 的情况是存在的,比如某些文化设置下"coop"和"co-op"会被认为相等。这个不一致性会在"排序"和"去重"同时出现时咬你一口。

1.2 同一组数据,三种比较方式给出三种顺序

我习惯在动手写比较器之前,先跑一遍下面这个小测试,看清当前环境的行为:

string[] data = { "file10.txt", "file2.txt", "file1.txt", "File3.txt" }; var ordinal = data.OrderBy(x => x, StringComparer.Ordinal).ToArray(); var invariant = data.OrderBy(x => x, StringComparer.InvariantCulture).ToArray(); var current = data.OrderBy(x => x, StringComparer.CurrentCulture).ToArray(); Console.WriteLine(string.Join(", ", ordinal)); Console.WriteLine(string.Join(", ", invariant)); Console.WriteLine(string.Join(", ", current));

在英文环境下,Ordinal和InvariantCulture给出的顺序通常一样,都是File3.txt, file1.txt, file10.txt, file2.txt——注意大写F排在小写f前面,因为'F'是 70、'f'是 102。而CurrentCulture在大多数语言环境下会把大小写混排,得到file1.txt, file2.txt, File3.txt, file10.txt这一类的顺序。

这三种法的适用边界很清楚:

比较方式规则适合场景坑
StringComparer.Ordinal纯码位,最快标识符、Key、哈希、协议字段大小写敏感,A永远在a前
StringComparer.InvariantCulture固定文化规则需要跨环境结果一致的数据不等于 Ordinal,也不等于用户所在文化
StringComparer.CurrentCulture跟随当前系统区域直接展示给用户的文本换台机器结果就变

最要命的是第三行。.NET 在 Windows 上依赖系统的 NLS,在 Linux/macOS 上依赖 ICU,同一个CurrentCulture比较在两套实现下的排序结果在边缘用例上并不保证一致。如果你的排序结果会被持久化(比如存成一个排序列、或者生成一份固化的导出文件),用CurrentCulture就是在给自己埋一颗跨环境不一致的雷。

1.3 为什么文化敏感比较会给出"看起来随机"的结果

文化敏感比较不只是"忽略大小写"这么简单,它会做语言层面的处理:把ä和a归到附近、把连字符和空格在某些位置忽略掉、按语言习惯处理缩写。这套规则对自然语言文本很友好,但对"2024-01-15"这种结构化字符串就是灾难——连字符的权重、数字的权重都被语言规则重新定义过了。

我在一个项目里见过真实的问题:文件名排序在开发机上正常,到了另一台机器上"v1.10"和"v1.9"的顺序反了过来。排查了半天发现是两台机器的区域设置不同,一个是en-US,一个是某个欧洲语言,对这个点号的处理权重不一样。从那次之后,凡是涉及编号、版本号、文件名的排序,我一律走Ordinal系,绝不让区域设置参与决策。

2. 补零、转数值、分段比:三条路各能走多远

搞清楚默认行为之后,能选的方案其实就三类:把数字补成等长让字典序变正确、把数字解析成数值直接比、把字符串切成段分别比。这三条路没有绝对的好坏,只有适用边界,选错了不是慢一点的问题,是结果错的问题。

2.1 左侧补零:一行代码解决 90% 的纯数字场景

如果整批字符串都是纯数字(比如订单号、序号),最快的办法是在排序时临时补齐位数:

var sorted = list .OrderBy(s => s.PadLeft(16, '0'), StringComparer.Ordinal) .ToList();

PadLeft(16, '0')把"9"变成"0000000000000009",把"10"变成"0000000000000010",等长之后字典序和数值序完全一致。这个方案的优势是简单到不需要解释,而且因为全程都是字符串比较,没有解析开销。

但它有几个明确的死穴,必须知道:

  • 位数上限写死在代码里。上例的 16 意味着超过 16 位的数字会失效,"10000000000000000"(17 位)和"9000000000000000"排序就会出错。如果你用的是list.Max(s => s.Length)动态算最大长度,那就得先遍历一遍拿最大值,而且后续插入更长的数据时这个"最大长度"会失效。
  • 每次比较都在分配新字符串。PadLeft返回新实例,OrderBy内部会调用 keySelector 的次数是 n 次(LINQ 会缓存 key),这一块还好;但如果换成List.Sort((a, b) => ...)里做补零,那就是 O(n log n) 次分配,10 万条数据接近 170 万次字符串分配,GC 压力非常明显。
  • 只对纯数字串成立。"item9"和"item10"补零之后是"0000item9"和"000item10",长度是齐了,但补的位置完全错误,结果反而更乱。
  • 负数没救。-9和-10补零之后是-000009和-000010,'-'相同、'0'相同,然后'9' > '0',结果是-10 < -9——数值上确实-10 < -9,所以这个碰巧是对的?不对,因为正数"9"和"10"补零后9 > 10是错误的比较基准被修正了,而负数的符号在最前面,"-"的码位小于任何数字,导致所有负数都排在正数前面,这倒是符合直觉,但-000009和-000010的比较结果是-9 < -10,错了。这个坑很隐蔽,实测一次就明白。

如果数字段是嵌在字符串中间的,补零方案还有一个变体值得推荐:预先生成排序键。

public static string BuildSortKey(string input, int width = 12) { var sb = new StringBuilder(input.Length + 16); int i = 0; while (i < input.Length) { if (input[i] >= '0' && input[i] <= '9') { int start = i; while (i < input.Length && input[i] >= '0' && input[i] <= '9') i++; int len = i - start; if (len < width) sb.Append('0', width - len); sb.Append(input, start, len); } else { sb.Append(input[i]); i++; } } return sb.ToString(); }

这个方法的妙处在于:排序键只生成一次,之后就是一个纯字符串排序,甚至可以存进数据库的普通字符串列里,让数据库自己排序。成本是每条数据多占一份内存,以及那个width上限——超过 12 位的数字段会退化成原样比较。我在做日志文件名、导出文件名排序时经常用这招,比每次比较都跑一遍解析逻辑要划算得多。

2.2 转成数值再比:int.Parse 的天花板在哪

纯数字场景下最直观的做法是把字符串转成数字:

var sorted = list.OrderBy(s => long.Parse(s)).ToList();

它能完美解决"9"和"10"的顺序问题,也不受位数对齐影响。但这里有三个坑,一个比一个深。

第一个坑是溢出。int.Parse的上限是 2147483647,10 位数。订单号、时间戳、雪花 ID、身份证号里的数字段,随手就超了。换成long能到 19 位,再往上就得BigInteger。而BigInteger的解析和比较开销比long高一个数量级,用在高频比较里不划算。

第二个坑是前导零和符号的语义丢失。"007"和"7"解析出来都是 7,OrderBy会因为 key 相等而保留原始顺序(OrderBy是稳定排序),但如果换成不稳定的List.Sort,两者的相对位置就不确定了。更麻烦的是" 12"(带空格)这种脏数据,int.Parse默认允许前后空白,"12 "也能解析成功,你会以为数据是干净的。

第三个坑是它只能处理"整串都是数字"的情况。"item10"送去int.Parse直接抛FormatException。有些代码为了绕过这一点会写正则提取数字,那就掉进了第四个坑——每次比较都跑一次正则。这一点放在第 4 节展开。

2.3 分段自然排序:把字符串拆成"文字块"和"数字块"

真正能同时处理item9/item10、v1.9/v1.10、第2章/第10章这类混合结构的,只有自然排序(Natural Sort):把字符串扫描成若干交替的"文字段"和"数字段",遇到数字段就按数值比,遇到文字段就按字符串比。

这个思路的核心洞察是:数字串之间的数值比较,其实不需要真的转换成数字。两个数字串比大小,先去掉前导零,然后比长度——长度长的数值一定大;长度相同就逐位比字符。整个过程不涉及任何解析,也就不存在溢出问题,多长的数字都能正确处理。

这条规则可以用一句话概括:"123"和"45"比,3 位对 2 位,3 > 2,所以"123" > "45";"123"和"456"比,位数相同,逐位比'1' < '4',所以"123" < "456"。位数比较 + 逐位比较,这两步覆盖了所有情况。

三种方案的对比:

方案混合字符串超长数字前导零每次比较开销实现复杂度
PadLeft补零不支持受 width 限制天然处理中(有分配)极低
Parse转数值不支持溢出风险语义丢失中高极低
生成排序键支持受 width 限制天然处理只在建键时中
分段自然排序支持完全支持可自定义低(无分配)高

注意最后一行"无分配"这一点。手写的分段比较器全程用string.Compare(strA, indexA, strB, indexB, length, comparison)这个重载做子区间比较,不切片、不Substring,十万级数据排序下来几乎没有额外 GC 压力,这是它相比正则方案最大的优势。

3. 从零写一个能上生产的自然排序比较器

网上流传的自然排序实现大多是为了演示,真正拿去用往往在边界上翻车:null 进不去、"007"和"7"排反了、全角数字被当成数字但算出来的值是错的、遇到两个连续数字段就死循环。这一节写一个我实际在用的版本,逐段解释每个决策。

3.1 主循环骨架:先分块,再分发

整体结构是一个双指针扫描:两个字符串各有一个位置指针,同时向前推进。每一步先判断当前字符是不是 ASCII 数字,如果两边都是数字就进入数字段比较,否则进入文字段比较,比完之后把指针推到本段末尾。

public int Compare(string? x, string? y) { if (ReferenceEquals(x, y)) return 0; if (x is null) return -1; if (y is null) return 1; int i = 0, j = 0; while (i < x.Length && j < y.Length) { bool digitX = IsAsciiDigit(x[i]); bool digitY = IsAsciiDigit(y[j]); if (digitX && digitY) { int endX = ScanDigits(x, i); int endY = ScanDigits(y, j); int cmp = CompareDigitRun(x, i, endX, y, j, endY); if (cmp != 0) return cmp; i = endX; j = endY; continue; } int textEndX = ScanNonDigits(x, i); int textEndY = ScanNonDigits(y, j); int common = Math.Min(textEndX - i, textEndY - j); int result = string.Compare(x, i, y, j, common, _comparison); if (result != 0) return result; if (textEndX - i != textEndY - j) return (textEndX - i).CompareTo(textEndY - j); i = textEndX; j = textEndY; } return (x.Length - i).CompareTo(y.Length - j); }

几个关键决策解释一下。

为什么文字段和数字段分开扫描,而不是逐字符比?因为逐字符比会丢失"段"的语义。"v1"和"v001"逐字符比,第二位'1'对'0',直接判"v1" > "v001",看起来没问题;但"v2"和"v010"逐字符比,'2' > '0'判"v2" > "v010",而数值上 2 < 10,错了。只有把数字段整体抽出来按数值规则比,才是正确的。

为什么用string.Compare(strA, indexA, strB, indexB, length, comparison)而不是Substring?Substring每个字符都要分配新字符串,O(n log n)次比较就是上百万次分配。这个重载直接在原字符串上比较指定区间,零分配。代价是需要先处理长度不等的情况——先比公共长度部分,相等再比剩余长度。

为什么最后返回的是剩余长度差?循环结束时,至少有一个指针到了末尾。如果x先到头,说明x是y的前缀(或者剩余部分更短),前缀应该排前面,返回负数,正确。这个收尾写法比return x.Length.CompareTo(y.Length)更准确,因为它考虑的是"未比较的剩余部分长度"。

块类型不同时怎么处理?代码里如果一边是数字一边不是,会落进文字段分支,此时ScanNonDigits对数字那边返回原位置(长度 0),对非数字那边会扫出一段长度大于 0 的区间。公共长度是 0,string.Compare返回 0,接着比较区间长度,长度 0 的一方胜出。结果就是数字段排在文字段前面,"a1"排在"ab"前。这个行为跟资源管理器的习惯一致,属于明确的设计选择,不是巧合。

3.2 数字段比较:用位数代替解析

这段是整个比较器的灵魂,二十行代码解决所有数值比较问题:

private static int CompareDigitRun(string x, int startX, int endX, string y, int startY, int endY) { int p = startX; while (p < endX - 1 && x[p] == '0') p++; int q = startY; while (q < endY - 1 && y[q] == '0') q++; int lenX = endX - p; int lenY = endY - q; if (lenX != lenY) return lenX.CompareTo(lenY); for (int k = 0; k < lenX; k++) { int d = x[p + k].CompareTo(y[q + k]); if (d != 0) return d; } int zerosX = p - startX; int zerosY = q - startY; return zerosX.CompareTo(zerosY); }

while (p < endX - 1 && x[p] == '0') p++;这个循环条件是endX - 1而不是endX,意思是"至少保留一位"。所以"000"会被压缩成"0",而不会变成一个空串导致lenX = 0。这是必须的,否则"0"和"000"比较时长度都是 0,会走到后面的逐位循环里访问越界。

跳过前导零之后的长度就是"有效位数",有效位数多的数值大,这一步覆盖了所有不同位数的比较。位数相同就逐位比字符——注意这里直接用char.CompareTo,因为'0'到'9'的 Unicode 码位是连续的,字符顺序和数值顺序完全一致,不需要转成int。

最后三行处理的是"数值相等但前导零数量不同"的情况。"7"和"007"数值上都是 7,按常规排序它们会被认为相等,稳定排序下保持输入顺序。但对文件名来说,通常希望不带零的排在前面,所以返回zerosX.CompareTo(zerosY),零少的胜出。这个选择是可争议的,如果你的业务里"007"必须排在"7"前面(比如它有独立编号语义),把这两个参数调换即可——但一定要在注释里写清楚,否则半年后没人知道这行为是有意为之还是 bug。

3.3 文字段比较:大小写和文化的取舍

文字段的比较直接决定了"File1"和"file2"谁在前,而且这个选择对用户体验影响很大。

我在构造函数里把StringComparison暴露出来,默认用OrdinalIgnoreCase:

public sealed class NaturalStringComparer : IComparer<string?> { public static readonly NaturalStringComparer Default = new NaturalStringComparer(StringComparison.OrdinalIgnoreCase); private readonly StringComparison _comparison; public NaturalStringComparer(StringComparison comparison) => _comparison = comparison; // ... }

选OrdinalIgnoreCase而不是CurrentCulture的理由有三个。第一,结果是确定的,不会因为部署环境变;第二,比文化敏感比较快得多;第三,文件系统、URL、编号这类场景本来就不该受语言规则影响。真正需要语言规则的用户可见文本排序,那是另一个问题,应该走数据库的 collation 或 UI 层的语言服务,不该塞进这个比较器里。

但OrdinalIgnoreCase有个容易忽略的后果:它认为"abc"和"ABC"相等,Compare返回 0。如果你的比较器被用在SortedDictionary或SortedSet里,这两个 key 会被当成同一个,后插入的直接覆盖前者。这个行为在某些场景下是好事(大小写不敏感的去重),在某些场景下是数据丢失。用之前想清楚你的容器需要"排序"还是需要"唯一性",这是两个不同的需求。

如果要忽略大小写同时又要保证相等的元素有确定顺序,可以加一层兜底:

int result = /* 前面的大小写不敏感比较 */; if (result != 0) return result; // 忽略大小写相等,退回到序数比较保证确定性 return string.CompareOrdinal(x, y);

加这几行的代价是一次额外比较,收益是"abc"永远排在"ABC"前面,结果可复现、可测试。这个兜底我在生产代码里几乎都会加。

3.4 完整实现与边界用例

把前面几段拼起来,加上辅助函数,就是完整版本:

public sealed class NaturalStringComparer : IComparer<string?> { public static readonly NaturalStringComparer Default = new NaturalStringComparer(StringComparison.OrdinalIgnoreCase); private readonly StringComparison _comparison; public NaturalStringComparer(StringComparison comparison) => _comparison = comparison; public int Compare(string? x, string? y) { if (ReferenceEquals(x, y)) return 0; if (x is null) return -1; if (y is null) return 1; int i = 0, j = 0; while (i < x.Length && j < y.Length) { if (IsAsciiDigit(x[i]) && IsAsciiDigit(y[j])) { int endX = ScanDigits(x, i); int endY = ScanDigits(y, j); int cmp = CompareDigitRun(x, i, endX, y, j, endY); if (cmp != 0) return cmp; i = endX; j = endY; continue; } int textEndX = ScanNonDigits(x, i); int textEndY = ScanNonDigits(y, j); int common = Math.Min(textEndX - i, textEndY - j); int result = string.Compare(x, i, y, j, common, _comparison); if (result != 0) return result; if (textEndX - i != textEndY - j) return (textEndX - i).CompareTo(textEndY - j); i = textEndX; j = textEndY; } return (x.Length - i).CompareTo(y.Length - j); } private static int CompareDigitRun(string x, int sx, int ex, string y, int sy, int ey) { int p = sx; while (p < ex - 1 && x[p] == '0') p++; int q = sy; while (q < ey - 1 && y[q] == '0') q++; int lenX = ex - p, lenY = ey - q; if (lenX != lenY) return lenX.CompareTo(lenY); for (int k = 0; k < lenX; k++) { int d = x[p + k].CompareTo(y[q + k]); if (d != 0) return d; } return (p - sx).CompareTo(q - sy); } private static int ScanDigits(string s, int index) { while (index < s.Length && IsAsciiDigit(s[index])) index++; return index; } private static int ScanNonDigits(string s, int index) { while (index < s.Length && !IsAsciiDigit(s[index])) index++; return index; } private static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9u; }

IsAsciiDigit里那个(uint)(c - '0') <= 9u是性能小技巧:char相减得到int,转成uint后负数会变成一个巨大的正数,一次比较同时排除了"小于'0'"和"大于'9'"两种情况,编译出来的指令比两次比较更短。这种写法在高频调用的比较器里值得用。

实际跑一批数据验证一下:

string[] samples = { "file1.txt", "file10.txt", "file2.txt", "file02.txt", "v1.9.0", "v1.10.0", "v1.9.10", "第2章", "第10章", "a", "a1", "ab" }; Array.Sort(samples, NaturalStringComparer.Default); Console.WriteLine(string.Join(" | ", samples));

结果按顺序是:a、a1、ab、file1.txt、file2.txt、file02.txt、file10.txt、v1.9.0、v1.9.10、v1.10.0、第2章、第10章。

几个值得注意的点:file2.txt排在file02.txt前面(零少的在前);v1.9.0排在v1.9.10前面(第三段 0 < 10);v1.9.10排在v1.10.0前面(第二段 9 < 10);第2章在第10章前面,因为"第"和"章"是文字段、中间数字按数值比。

边界情况清单,建议写单元测试时逐条覆盖:

输入对期望结果说明
nullvs"a"null在前前置判断处理
""vs"a"""在前空串是任何串的前缀
"0"vs"000""0"在前零少者优先
"a"vs"a"0完全相等
"a1"vs"ab""a1"在前数字段优先于文字段
"123456789012345678901234567890"vs"99"前者在后纯位数比较,无溢出
"v1.5"vs"v1.50""v1.5"在前注意:按小数理解两者相等,但自然排序不这么看
"01"vs"1a""01"在前数字段相同,短串先结束

倒数第二行特别值得强调:"1.5"和"1.50"在自然排序下不相等,因为右半边的数字段"50"比"5"大。如果你的数据是真正的小数(金额、比率),自然排序不适用,必须走decimal.Parse再比。自然排序处理的是编号、版本号、序号这类"数字只是标识的一部分"的场景,不是数值。这个区分我在需求评审时踩过坑,被业务方拿着1.5和1.50的结果来问,解释了半小时才说清楚。

4. 落地时最常踩的五个坑

代码写对了只是第一步,把它接进业务代码里还有一堆细节。下面这几个是我在代码评审里反复拦下来的写法,有的会导致结果错,有的会导致偶发崩溃。

4.1 把 Compare 的返回值当布尔用

IComparer<T>.Compare返回的是三态整数:负数表示x < y,0 表示相等,正数表示x > y。契约里只保证符号有意义,不保证一定是 -1、0、1。string.Compare在某些情况下会返回两个字符码位的差值,比如 32、-15 这种。

所以下面这种写法是错的:

// 错误:把非零当 true if (comparer.Compare(a, b)) { /* ... */ } // 错误:判断大于等于 0 就认为 a 不小于 b if (comparer.Compare(a, b) >= 1) { /* a > b */ }

第二种尤其隐蔽,Compare返回 32 时确实进得去,返回 1 时也进得去,看起来"能用",但一旦某个实现的返回值是负数或者跳过了 1 直接给 2,逻辑就崩了。正确写法永远是:

int c = comparer.Compare(a, b); if (c < 0) { /* a < b */ } else if (c > 0) { /* a > b */ } else { /* 相等 */ }

还有个小优化:在一个函数里要对同一对元素做多次判断时,把Compare的结果存进局部变量,别重复调用。比较器里如果有分配(比如用了正则),重复调用就是白烧 CPU。

4.2 List.Sort 不稳定,OrderBy 稳定

这是我最常解释的一个问题。同一批数据,list.Sort(comparer)和list.OrderBy(x => x, comparer).ToList()得到的顺序可能不同,即使比较器完全正确。

原因在于排序算法。List<T>.Sort用的是内省排序(introsort),不稳定:两个比较结果为 0 的元素,最终顺序不确定。Enumerable.OrderBy用的是稳定排序,相等元素的原始相对顺序会被保留。

什么时候这个差异会咬人?举个具体例子:一批文件名里"a01.txt"和"a1.txt",比较器认为它们相等(如果实现里不处理前导零)。List.Sort排完之后谁在前谁在后,取决于分区过程,而且换一批数据、换一个 .NET 版本都可能变。如果你的业务要求"相等时按原始顺序",或者要求"相等时按入库时间",那就必须用稳定排序,或者自己加二级排序键:

list.Sort((a, b) => { int c = comparer.Compare(a.Name, b.Name); return c != 0 ? c : a.Id.CompareTo(b.Id); });

还有一个更隐蔽的风险:List<T>.Sort内部如果比较器抛出异常,列表会处于未定义状态。这不是危言耸听,Array.Sort的文档里明确写了这一点。如果你的比较器会调用int.Parse,而数据里混进了脏字符,FormatException一抛,整个列表就废了,后续代码拿到的可能是部分排序、甚至元素丢失的集合。所以比较器里绝不允许抛异常——所有解析逻辑要么用TryParse,要么干脆不解析。

4.3 char.IsDigit 和正则 \d 的 Unicode 陷阱

char.IsDigit('٣')(阿拉伯-印度数字三)返回true,char.IsDigit('3')(全角数字三)也返回true。如果你的代码用char.IsDigit判断是不是数字,然后用c - '0'算数值,全角字符算出来是一个巨大的数字,结果完全错乱。

// 错误示范:全角数字会算出错误的值 if (char.IsDigit(c)) { int v = c - '0'; /* ... */ } // 正确:只认 ASCII 0-9 if ((uint)(c - '0') <= 9u) { int v = c - '0'; /* ... */ }

正则里同样的坑:\d在 .NET 默认匹配所有 Unicode 十进制数字,[0-9]才只匹配 ASCII。写Regex.Split(input, @"(\d+)")的时候,全角数字也会被当成分隔点切出来,然后int.Parse在它上面抛异常。用@"([0-9]+)"能规避这个问题,但全角数字就变成了普通文字段,参与字符串比较——这通常是想要的行为,但得知道它发生了。

实际数据里这个问题的来源比想象中多:从 Excel 复制粘贴的编号、从某些输入法直接打出来的全角数字、从旧系统迁移过来的历史数据。我在一次数据导入里遇到过整批编号都是全角的,排序结果一团糟,最后是导入阶段统一做了全角转半角才解决。如果你的数据来源不可控,清洗环节比排序环节更值得投入。

4.4 自定义比较器塞进 ORM 查询会直接抛异常

OrderBy(x => x, new NaturalStringComparer())在内存里跑没问题,但如果x是IQueryable,ORM 的查询翻译器不认识你的比较器,运行时直接抛"无法翻译"的异常。这不是 bug,是设计如此——你的比较逻辑是用 C# 写的任意代码,SQL 里没有对应物。

能想到的绕过方式有三种,各有代价:

方式做法代价
内存排序先ToList()再排全表拉取,数据量大时内存和网络双爆
存储排序键入库时算好BuildSortKey,存一列需要额外列和写入开销,但排序在数据库侧完成
拆列存储把编号拆成"前缀 + 数字值"两列查询灵活,但表结构变复杂,改动量大

三种里我推荐第二种。加一列SortKey,写入时用第 2.1 节的BuildSortKey生成,索引建在这一列上,查询直接OrderBy(x => x.SortKey)。这样排序完全在数据库侧、走索引、分页正常,客户端不用关心任何逻辑。唯一要注意的是历史数据需要回填,以及width上限要留足余量。

4.5 null、空串、"0" 的排位与相等性判断

比较器里处理 null 看起来是小事,但位置放错就会 NRE。我见过把 null 判断写在循环内部的写法,x.Length在x为 null 时直接崩。null 判断必须在方法入口,在任何.Length访问之前。

空串的位置也要明确。""和"0"比,""长度 0,循环一次都不进,最后返回0.CompareTo(1) = -1,空串在前。这符合直觉。但""和""比,ReferenceEquals在字符串驻留的情况下可能为 true 直接返回 0,如果不是同一个实例,也会走到最后返回 0,结果一致,没问题。

"0"和"00"比,两者跳过前导零后都剩一位'0',逐位相等,最后比前导零数量,"0"零少,排前面。这个结果和"7"/"007"的规则一致,是统一的。

还有一个跟相等性相关的坑:排序相等不等于对象相等。用OrdinalIgnoreCase的比较器,"ABC"和"abc"比较结果为 0,但"ABC" == "abc"是 false,"ABC".Equals("abc")也是 false。如果代码里用Distinct()去重,Distinct()默认用的是EqualityComparer<string>.Default(序数比较),两者不会被去重;但如果用SortedSet<string>(comparer),它们会被认为是同一个元素。这个不一致性在某些流程里会导致"明明排序时算同一个,去重时算两个"的诡异现象,排查起来很费劲。

统一的办法是:需要大小写不敏感的场景,排序和去重都用同一个比较器,SortedSet<string>(NaturalStringComparer.Default)或者Distinct(NaturalStringComparer.Default)——注意Distinct的重载要求IEqualityComparer,而IComparer不满足,得额外实现一个包装。这是一个设计上不太优雅的地方,知道就行。

5. 十万条数据下的性能取舍

比较器写对之后还得写快。字符串排序的比较次数是O(n log n),十万条大约是 170 万次Compare调用,比较器里每多一次分配、每多一次解析,都会被放大到这个量级上。

5.1 五种方案的相对耗时

在一台普通办公机上用十万条混合编号(形如item12345.txt)做基准,各方案的相对耗时大致如下(以补零字符串排序为基准 1 倍,只给数量级,实际数值跟数据形态强相关):

方案相对耗时每次比较分配备注
预生成排序键 + Ordinal 排序1.0x建键时一次最快,适合持久化
PadLeft在比较器内补零1.5x每次比较 2 个分配拖累明显
手写分段比较器3x ~ 5x无支持混合串,综合最优
long.Parse后比较4x ~ 6x无仅纯数字,超 19 位崩
正则分段比较器30x ~ 60x每次比较多个千万别用在热路径

正则方案为什么慢这么多?因为它在每次比较里都要对两个字符串各跑一遍Regex.Split或者Matches,返回数组、分配子串、走正则引擎的状态机。170 万次比较就是 340 万次正则匹配,分配量轻松上 GB。我在一个项目里见过有人这么写,五万条数据排序要花好几秒,改成手写比较器后降到几十毫秒。

正则方案不是完全不能用,如果比较次数很少(比如几百条数据、只在启动时排一次),用正则换代码可读性是划算的。问题在于这个代码一旦被复制到高频路径上,性能就崩了,而它又"看起来能跑",非常容易漏掉。

还有一个小优化值得提:如果同一批数据要排多次、或者排序是在分页之后反复执行的,考虑把结果缓存起来,或者把排序键提前算好存进Dictionary<string, string>。比较器本身是无状态的,加缓存的成本很低:

private readonly ConcurrentDictionary<string, string> _keyCache = new(); private string GetKey(string s) => _keyCache.GetOrAdd(s, BuildSortKey);

十万条数据的内存占用大概是每条几十字节的键,十几 MB,换掉整个排序过程的开销,对长期驻留的服务来说是划算的。注意ConcurrentDictionary的GetOrAdd在并发下可能重复计算同一个 key,如果BuildSortKey很重,用Lazy<string>包一层能避免重复,但对十几毫秒级别的计算来说没必要。

5.2 按场景选方案的决策表

最后把选择逻辑整理成一张表,遇到具体问题时对号入座:

场景特征推荐方案理由
纯数字、位数固定、数据量小PadLeft或直接Parse一行搞定,不必上比较器
纯数字、位数可能超长(订单号、雪花 ID)分段比较器或数值位数比较避开溢出
混合编号(item10、v1.9)手写分段比较器唯一正确处理的方式
排序结果要存库、要分页预生成排序键存列排序在数据库侧完成
只需要排一次、数据量 < 1000正则分段,代码好读性能损失可忽略
需要和 UI 语言习惯一致CurrentCulture+ 自然排序接受跨环境不一致的风险

其中"排序结果要存库"这一条我想再强调。很多团队一开始图省事,在内存里排序然后分页返回,数据量小的时候完全没问题,等到数据涨到几十万条,内存爆了才回头改架构,成本高得多。只要这个排序字段将来可能要支持分页、筛选、导出,就在建库时留一个排序列,这是我在三个项目里都用血换来的教训。

另外还有一个不大不小的问题:如果业务里同时存在"文件系统看到的顺序"和"应用里排出来的顺序",两者必须一致,否则用户会觉得"文件夹里看着是对的,为什么列表里是乱的"。文件系统的排序规则由操作系统决定,Windows 资源管理器用的是一套特定的逻辑规则,你自己实现的自然排序在绝大多数常见情况下和它一致,但在特殊字符、重音符号、超长数字这些边缘上会有差异。如果不是必须像素级对齐,忽略这些差异是可以接受的;如果必须对齐,那就得在测试里加上对应的字符集做回归。

我个人的做法是把比较器封装成一个静态单例,在项目里所有涉及编号排序的地方统一引用,配上二十来

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询