ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

字符串比较函数终极版:ASCII差值、差异定位与多语言避坑

字符串比较函数终极版:ASCII差值、差异定位与多语言避坑

比较字符串大小并输出 ASCII 码差值,这个需求表面看像课后练习,但我在好几个真实项目里都写过它,而且每次写的理由都不一样:一次是给素材文件名做自然排序,一次是做数据同步时的差异定位,还有一次是给一个文本编辑器算光标偏移量。真正让我下决心把它做成一个“终极版”的,是前段时间帮人排查一段排序代码——逻辑写得挺漂亮,结果顺序怎么调都不对。追到最底层,问题出在一个手写的compare函数上,它用char直接相减来算 ASCII 差值,而输入里混进了中文,char在那台机器上是有符号的,0xE4被当成 -28,于是所有中文全被排到了数字和字母前面。

这类坑不新鲜,但每次遇到都有人栽。所以后来我把这套东西彻底重写了一遍,做成一个能同时回答三个问题的“终极版”:谁大谁小、差在第几位、差了多少。同时把 C 的有符号字符、Java 的 UTF-16 码元、JS 的代理对、Go 的字节序这几处容易翻车的地方全兜住。这篇内容适合三类人看:写过字符串比较但从没细究过返回值语义的;正在做排序规则、差异对比这类需求的;以及被中文或 emoji 排序坑过的。代码我会给完整可跑的版本,坑我会写清楚为什么是坑。

1. 需求拆解:一个比较函数到底要回答几个问题

1.1 返回值语义的三种流派

“比较字符串大小”这句话最含糊的地方在于,它压根没说清楚要返回什么。我翻过不少代码库,实际落地下来大致分三派。

第一派是布尔派,只回答“A 是否大于 B”,返回true/false。Python 的"abc" < "abd"、JavaScript 的a < b、Go 里直接用<运算符都是这一派。优点是语义干净、不可能被误用;缺点是信息量太少,你没法知道它们差在哪一位,也没法把这个结果直接喂给排序函数做三路比较。

第二派是符号派,返回 -1 / 0 / 1。Go 的strings.Compare、Java 的Integer.signum包装、大部分手写的比较器都是这个路子。这是最契合排序算法需求的形态,因为qsort、Arrays.sort、std::sort这些排序接口内部只看返回值的符号,从不关心具体数值大小。

第三派是差值派,直接返回首个不同字符的码值之差。C 标准库的strcmp在多数实现上就是这一派——注意我说的是“多数实现”,不是“标准规定”,这个区别后面会专门讲。差值派的信息量最大,但坑也最多,因为它把“大小关系”和“数值差异”混在了一个整数里,调用方很容易把差值当成别的什么东西去用。

这三派并不是互斥的。我最终的方案是把三个答案打包成一个结构体一起返回,cmp字段给排序用,diff字段给差异定位用,pos字段给调试和高亮用。多返回两个字段的成本几乎为零,但能省掉后面无数次“这俩到底差在哪”的重复排查。

1.2 终极版要兜住的边界清单

在动手写之前,我先把所有能想到的边界情况列了一张表。这张表后来成了我的测试用例清单,也建议你照抄一份。

边界场景典型输入容易出什么错
完全相等的串"abc"vs"abc"返回 0,但被误判为“差值为 0 即相等”
前缀相同、长度不同"abc"vs"ab"差值算成 0,和“完全相等”混为一谈
一方是空串""vs"a"越界访问,或者返回未初始化的值
双方都是空串""vs""循环压根不执行,直接返回垃圾值
含非 ASCII 字符"中"vs"a"有符号char导致符号反了
含补充平面字符"😀"vs"中"UTF-16 码元比较把 emoji 排到了汉字前面
组合字符 / 变体"é"两种编码方式视觉相同但码点不同,比较结果非零
超长串几十 KB 的文本逐字节比较性能塌陷
嵌入式文本带\0二进制块strcmp提前截断

这里面最阴险的是第二行和第六行。第二行会让“相等”和“差值为 0”这两件事撞车,第六行则是同一份代码在不同语言里给出不同排序结果,而且差异只在特定字符上才暴露,测试覆盖不到就永远发现不了。

2. 字典序比较的底层逻辑

2.1 逐字符比较的完整决策路径

字符串比较的本质是字典序,也就是按位置从左往右逐个字符比,第一个分出胜负的位置就决定整体结果,后面的字符再大也没用。这个规则和查纸质字典一模一样:先比第一个字母,一样就比第二个,直到分出大小。

写代码的时候,这条规则展开成一条清晰的决策路径。第一步,取两个串当前位置的字符,如果相同就同时后移一位继续;第二步,如果不同,就用这两个字符的码值相减得到差值,同时根据差值的正负定下cmp;第三步,如果其中一个串先走到头,说明它是另一个串的前缀,此时短的那个更小。第三步是最容易被忽略的,因为很多人写循环的时候用的是while (*p1 && *p2),循环退出时默认返回 0,于是"abc"和"ab"就被判成了相等。

我在第一版代码里就犯过这个错,当时的测试用例只覆盖了长度相同的串,上线之后做版本号排序,"1.2"和"1.2.0"被判成相等,排序结果稳定但错得离谱。后来我把“前缀相同”这个分支单独拎出来,作为一条独立的返回路径,问题才算根治。

从复杂度上看,最坏情况是比较到两个串中较短那个的末尾,时间复杂度是O(min(n, m)),空间复杂度O(1)。但这里有个容易被忽视的细节:最坏情况只发生在两个串高度相似时,实际业务里大部分比较在头几个字符就能分出胜负,所以平均性能远好于最坏值。这也是为什么很多实现选择逐字节来,而不是先算哈希。

2.2 ASCII 差值的三种口径与选择

“输出 ASCII 码差值”这句话同样有歧义,我这里必须把三种口径摆清楚,因为选错口径会导致完全不同的结果。

第一种是首个不同字符的差值,也就是code(a[i]) - code(b[i]),其中i是第一个不同的位置。这是最符合直觉的口径,也是 C 实现里最常见的行为。它的优点是计算简单、一次遍历就能拿到,缺点是遇到“一个是另一个的前缀”时差值为 0,语义上和“相等”撞车。

第二种是逐位差值求和。把每一位的码值差累加起来,听起来像是能反映“整体差异程度”,但实际用起来很危险:"ab"和"ba"的差值加起来正好是 0,可它们根本不相等。这个口径我见过有人在相似度计算里用,结果闹过笑话。除非你明确知道自己在做什么,否则别用。

第三种是只输出位置,差值另算。也就是说比较函数只返回cmp和pos,差值由调用方按需从原始串里取。这种设计最灵活,但调用方要多写两行,而且容易在下标计算上出错。

我的终极版选了第一种,但加了一条明确的规则:diff字段只在pos位置上确实存在两个不同字符时才有意义;如果是因为长度不同导致的分歧,diff固定为 0,此时必须看cmp才能判断大小。这条规则我会写进接口注释,也会写进测试用例,让它变成契约的一部分,而不是靠调用方猜。

这里还有一个容易被忽略的数值细节:差值的范围。ASCII 字符的码值是 0 到 127,所以理论差值范围是 -127 到 127,int8理论上装得下。但如果输入不是纯 ASCII,比如混进了 UTF-8 多字节序列的单个字节,0x00到0xFF的范围就会让差值扩大到 -255 到 255,int8直接溢出。所以我在实现里一律用int32存差值,虽然多占几个字节,但省掉了一次溢出排查。

2.3 前缀相同为什么必须单独处理

单独把这条拎出来讲,是因为它是这类代码里最高频的 bug 来源,而且症状隐蔽。

假设你的实现是这样的:循环条件是“两个指针都没到结尾且当前字符相同”,循环结束后直接返回两个当前字符的差。那么这个实现在"abc"和"ab"上会怎么做?循环比完前两位就退出了,此时 A 串的指针指向'c',B 串的指针指向'\0'。如果直接相减,'c' - 0 = 99,返回 99。看起来结果是对的,大小关系也对,但它同时也是一个合法的差值——调用方完全没法区分这是“第三位不同”的差值,还是“长度不同”的占位值。

更麻烦的是另一头:"ab"和"abc"会得到0 - 99 = -99。同样是“长度不同”,两个方向的返回值不对称,差值绝对值相等这一点纯属巧合,一旦字符码值不同就不成立了。

所以我的做法是把长度判断和字符判断彻底分开。字符循环只负责找第一个不同的位置,如果找到了,走字符差值分支;如果循环走完还没找到,走长度分支,diff置 0,cmp由长度差决定。两条路径互不干扰,调用方看pos是否等于min(len_a, len_b)就能反推是哪条路径,逻辑上一清二楚。

3. 分语言实现:每一门语言的坑都不一样

3.1 C/C++:有符号 char 与 strcmp 的返回值契约

C 是这门生意的源头,坑也最经典。

先说char的符号问题。C 标准没有规定char是有符号还是无符号,这由编译器决定。x86 上的 GCC 和 Clang 默认是有符号的,ARM 上很多工具链默认无符号。这就意味着同一段代码换个平台编译,结果可能不一样。当字符串里只有 ASCII 字符时,符号问题不会暴露,因为 0 到 127 在两种情况下都是正数。但只要混进 UTF-8 的中文字节,比如0xE4,有符号char会把它解释成 -28,于是比较结果直接反了。

修法很简单,也比较固定:比较和相减之前一律转成unsigned char。

long d = (long)(unsigned char)a[i] - (long)(unsigned char)b[i];

注意判断相等的时候可以不转,因为a[i] == b[i]比的是值相等,符号不影响;只有涉及大小和减法的地方必须转。这个细节很多文章会混着写,容易让人以为处处都要转。

再说strcmp的返回值。C 标准原文说的是“返回一个大于、等于或小于零的整数”,从来没有承诺过返回差值的具体数值。所以理论上strcmp(a, b) == -1是不可移植的写法。glibc 的实现确实返回差值,但换个 libc 就未必,某些实现会返回-1/0/1,还有些实现为了优化会返回任意正负数。我在代码里见到if (strcmp(a, b) == -1)一定会改成if (strcmp(a, b) < 0),这不是风格问题,是正确性问题。

还有一个容易踩的坑是memcmp。它和strcmp的区别是遇到\0不停止,适合比较二进制块。但它同样只保证符号,不保证数值。另外memcmp的比较是按unsigned char来的,这一点比strcmp的实现更标准,所以在处理含高位字节的数据时反而更安全。

如果你需要的是“我能自己控制差值的精确值”,那就别用标准库,自己写循环。我最终的 C 实现大概三十行,附在下面这段里:

#include <stddef.h> typedef struct { int cmp; /* -1 / 0 / 1 */ long diff; /* 首个不同位置的 unsigned char 差;前缀相同时为 0 */ size_t pos; /* 首个不同位置;全同或前缀关系时为较短串长度 */ } str_cmp_result; str_cmp_result cmp_bytes(const char *a, const char *b) { str_cmp_result r; size_t i = 0; for (;;) { unsigned char ca = (unsigned char)a[i]; unsigned char cb = (unsigned char)b[i]; if (ca == cb) { if (ca == '\0') { /* 同时到结尾:完全相等 */ r.cmp = 0; r.diff = 0; r.pos = i; return r; } i++; continue; } r.diff = (long)ca - (long)cb; r.cmp = r.diff > 0 ? 1 : -1; r.pos = i; return r; } }

这段代码把“同时到结尾”合并进了相等分支,避免了空串比较时走不到任何返回路径的问题。我特意把\0的判断放在相等之后,是因为\0相等就意味着两个串同时结束,这是唯一的合法出口。

3.2 Java:compareTo 的长度差混进了字符差

Java 的String.compareTo返回值设计,在我看来是“差值派”里最需要小心处理的一个。它的源码逻辑非常简洁:取两个串长度的较小值作为上限,逐位比较字符,不同就返回c1 - c2,全部相同则返回len1 - len2。

问题就出在这个“全部相同则返回长度差”上。这个返回值既可能是字符差,也可能是长度差,调用方拿着一个 5 根本分不清是“第几位字符差了 5”还是“第一个串比第二个串长 5”。好在排序接口只看符号,所以作为比较器它是称职的;但如果你想用它做差异定位,就必须自己重新遍历一遍。

第二个问题是compareTo比的是char,也就是 UTF-16 码元。对于基本多文种平面内的字符(大部分汉字、拉丁字母都在这里面),一个字符对应一个码元,行为符合直觉。但遇到 emoji 这类需要代理对的字符,一个字符占两个码元,比较就退化成码元比较。举个具体例子:"\uD83D\uDE00"(也就是 😀)和"\u4E2D"(中)比较,第一位是0xD83D对0x4E2D,0xD83D更大,所以 emoji 被判为大于“中”。可按真实码点看,😀 是 U+1F600,中是 U+4E2D,前者确实更大,结论碰巧一致。真正的错位发生在和\uE000到\uFFFF之间的私用区字符比较时——那些字符的码点小于代理对的高位,但 UTF-16 码元比较会判 emoji 更大,和按码点比的结果正好相反。这个例子有点极端,但确实有项目在私有区放图标字体,然后排序就乱了。

要绕开这个坑,得改用codePointAt手动遍历:

public record CmpResult(int cmp, int diff, int pos) {} public static CmpResult cmpByCodePoint(String a, String b) { int i = 0, j = 0, pos = 0; while (i < a.length() && j < b.length()) { int ca = a.codePointAt(i); int cb = b.codePointAt(j); if (ca != cb) { int d = ca - cb; return new CmpResult(Integer.signum(d), d, pos); } i += Character.charCount(ca); j += Character.charCount(cb); pos++; } int ra = a.codePointCount(0, a.length()); int rb = b.codePointCount(0, b.length()); int d = ra - rb; return new CmpResult(Integer.signum(d), 0, Math.min(ra, rb)); }

charCount这个调用千万别省,它的作用是根据码点值判断应该前进 1 还是 2 个char,漏掉就会在代理对上产生重复比较。

3.3 JavaScript:UTF-16 码元让 emoji 排错了位置

JS 的情况和 Java 同源,因为两者内部都是 UTF-16。但 JS 更隐蔽的地方在于,字符串比较可以通过<直接做,看起来特别自然。

"😀" > "\uE000" // true,但按码点应该是 false

原因就是刚才说的代理对。"\uE000"的码点是 57344,而 😀 的第一个码元是0xD83D(55357),后者更小,所以按码元比 😀 应该更小;但按码点比 😀 是 128512,更大。结论完全相反。

另一个常见误区是用charCodeAt。这个方法返回的就是 UTF-16 码元值,名字里虽然有个 char,但和“字符”不是一回事。要拿真实码点得用codePointAt。而codePointAt有个小脾气:它对代理对的第二个半位也会返回一个值,而不是undefined,所以遍历时还是要手动跳位。

我的 JS 实现用Array.from先把字符串拆成码点数组,虽然多了一次内存分配,但换来的是代码可读性和正确性,对于比较这种调用频次不算特别高的场景,我觉得这笔交易划算:

function cmpByCodePoint(a, b) { const ca = Array.from(a); const cb = Array.from(b); const n = Math.min(ca.length, cb.length); for (let i = 0; i < n; i++) { const x = ca[i].codePointAt(0); const y = cb[i].codePointAt(0); if (x !== y) { const d = x - y; return { cmp: Math.sign(d), diff: d, pos: i, unit: 'codepoint' }; } } const d = ca.length - cb.length; return { cmp: Math.sign(d), diff: 0, pos: n, unit: 'codepoint' }; }

如果你的场景明确只处理 ASCII 且对性能敏感,那用charCodeAt逐位比完全没问题,但请务必在函数名或注释里写清楚“仅限 ASCII”,否则下一个人接手时一定会栽。

3.4 Python:没有现成的差值,自己写反而最省心

Python 3 的str内部按 Unicode 码点存储,比较语义非常干净:"a" < "b"就是逐码点比,"中" > "a"也是按真实码点算,没有任何代理对的困扰。这一点比 JS 和 Java 舒服太多。

但 Python 没有内置的“返回差值”函数。cmp内置函数在 Python 3 里已经被移除了,现在想拿差值只能自己写。好在写起来很短,而且可以顺带把结构化的返回值一起做出来:

from dataclasses import dataclass from functools import cmp_to_key @dataclass(frozen=True) class CmpResult: cmp: int diff: int pos: int unit: str = "codepoint" def _sign(x: int) -> int: return (x > 0) - (x < 0) def cmp_str(a: str, b: str) -> CmpResult: n = min(len(a), len(b)) for i in range(n): ca, cb = ord(a[i]), ord(b[i]) if ca != cb: d = ca - cb return CmpResult(_sign(d), d, i) d = len(a) - len(b) return CmpResult(_sign(d), 0, n)

(x > 0) - (x < 0)这个写法是 Python 里取符号的惯用手法,比写 if-else 短,而且不会有分支预测的顾虑。用ord()取码点比用codecs或者encode转字节要快得多,因为ord是 C 层实现,不产生中间对象。

排序的时候配合cmp_to_key用:

items = ["第10章", "第2章", "第1章"] items.sort(key=cmp_to_key(lambda x, y: cmp_str(x, y).cmp))

注意这里必须传.cmp而不是整个CmpResult对象。给 dataclass 加个__lt__也能让对象直接参与排序,但那会把“数据容器”和“可比较对象”两种角色混在一起,我不太推荐。

3.5 Go:字节序和码点序要分清楚

Go 的字符串本质是[]byte,用<比较走的是字节序,strings.Compare也是字节序。这和 Python 的码点序在结论上是一致的,因为 UTF-8 是保序编码。

所谓保序,意思是码点越大的字符,它的 UTF-8 字节序列按字典序也越大。这个性质成立的原因在于 UTF-8 的前导字节设计:单字节的 0 到 127 以0开头,两字节的以110开头,三字节的以1110开头,四字节的以11110开头。首字节的高位前缀随着码点范围递增而单调递增,后续字节的取值区间也严格不重叠。所以按字节比和按码点比,谁大谁小的判断永远一致。

但差值不一样。同一对字符,按字节算出来的差值和按码点算出来的差值可以差十几倍。比如“中”(U+4E2D)和a(U+0061),码点差是 20013 - 97 = 19916,而按 UTF-8 首字节算,0xE4是 228,228 - 97 = 131。所以如果你的接口约定里写了“返回码点差”,实现上用了字节相减,那就是个静默的错误,测试用例如果只覆盖 ASCII 完全发现不了。

Go 里的写法我建议明确标注单位:

type CmpResult struct { Cmp int Diff int Pos int Unit string } func sign(x int) int { if x > 0 { return 1 } if x < 0 { return -1 } return 0 } func CmpByRune(a, b string) CmpResult { ra, rb := []rune(a), []rune(b) n := len(ra) if len(rb) < n { n = len(rb) } for i := 0; i < n; i++ { if ra[i] != rb[i] { d := int(ra[i]) - int(rb[i]) return CmpResult{sign(d), d, i, "codepoint"} } } d := len(ra) - len(rb) return CmpResult{sign(d), 0, n, "codepoint"} } func CmpByByte(a, b string) CmpResult { n := len(a) if len(b) < n { n = len(b) } for i := 0; i < n; i++ { if a[i] != b[i] { d := int(a[i]) - int(b[i]) return CmpResult{sign(d), d, i, "byte"} } } d := len(a) - len(b) return CmpResult{sign(d), 0, n, "byte"} }

把两个版本都提供出来,让调用方自己选,比在文档里用一段模糊的描述去解释“我们的差值是什么单位”要靠谱得多。CmpByByte里a[i]是byte也就是uint8,天然无符号,所以不需要像 C 那样做强转,这是 Go 的一个小优势。

4. 终极版实现与测试设计

4.1 接口契约:用结构体把三个答案一起返回

前面几节把各语言的实现都过了一遍,现在把设计收敛一下。终极版的核心不是代码写得多花哨,而是接口契约写得足够死,让调用方不需要猜。

我的契约是这样的五点。第一,cmp字段永远只取 -1、0、1 三个值,0 表示两个串在比较口径下完全相等,不存在“差值为 0 但不等”的情况。第二,pos是首个不同位置的索引,如果两个串是前缀关系,它就等于较短串的长度;两个串完全相同时,它也等于长度。第三,diff只在pos < min(len_a, len_b)时有意义,此时它等于该位置上两个字符的码值之差;否则固定为 0。第四,diff一定用 32 位以上整数存,杜绝溢出。第五,unit字段显式说明是字节还是码点,绝不靠约定俗成。

这五条看着啰嗦,但每一条都是从实际踩坑里换来的。尤其是第三条,只要调用方记住“diff为 0 不代表相等”,就能避免绝大部分误用。我在结构体上加了一个辅助方法is_equal(),内部只判断cmp == 0,这样即使有人手滑用了diff == 0也能在 code review 时被抓出来。

另外一点经验:不要把CmpResult做成可哈希、可比较的对象。有些语言里加个__eq__或者实现Comparable看起来很优雅,但一旦有人拿它去当字典键或者直接比较,就会引入“两个差值不同但 cmp 相同的结果是否相等”这种语义争议。老实用字段访问,别搞花活。

4.2 核心实现(含一份可直接跑的 Python 版)

下面这份 Python 实现是我现在项目里的主力版本,加了大小写折叠选项,也处理了组合字符的常见场景。可以直接复制去跑。

from dataclasses import dataclass, field from functools import cmp_to_key import unicodedata @dataclass(frozen=True) class CmpResult: cmp: int diff: int pos: int unit: str = "codepoint" def is_equal(self) -> bool: return self.cmp == 0 def __str__(self) -> str: if self.is_equal(): return "<equal>" arrow = ">" if self.cmp > 0 else "<" return f"pos={self.pos} {arrow} diff={self.diff} ({self.unit})" def _sign(x: int) -> int: return (x > 0) - (x < 0) def cmp_str( a: str, b: str, *, ignore_case: bool = False, normalize_form: str | None = None, ) -> CmpResult: """ ignore_case: 折叠大小写后再比较 normalize_form: 传 'NFC' 或 'NFD' 会先做归一化,用于处理 同一个字符的不同编码方式(如带重音的 e) """ if normalize_form: a = unicodedata.normalize(normalize_form, a) b = unicodedata.normalize(normalize_form, b) if ignore_case: a = a.casefold() b = b.casefold() n = min(len(a), len(b)) for i in range(n): ca, cb = ord(a[i]), ord(b[i]) if ca != cb: d = ca - cb return CmpResult(_sign(d), d, i) d = len(a) - len(b) return CmpResult(_sign(d), 0, n)

这里有几个设计取舍值得说明。

第一,ignore_case用的是casefold()而不是lower()。lower()只做简单的小写映射,而casefold()做了更激进的折叠,能处理德语 ß 这类特殊字符。代价是casefold之后字符串长度可能变短,所以pos对应的是折叠后的位置——这一点我在注释里写明了,因为确实有同事踩过这个坑,拿着pos去原串里取字符,结果取错了。

第二,normalize_form做成可选参数而不是默认开启。归一化本身是有成本的,而且会改变字符串内容,如果调用方只是想比较原始字节,开了归一化反而坏事。把它做成显式开关,让调用方自己决定。

第三,大小写折叠放在归一化之后。这个顺序是有讲究的:先归一化能让字符表示统一,再折叠能保证折叠规则作用于标准形态。反过来做的话,某些特殊字符折叠之后可能变成非标准形态,又要再归一化一次。

配套的排序辅助函数也写一下:

def cmp_key(a: str, b: str, **kw) -> int: return cmp_str(a, b, **kw).cmp # 用法 names = ["文件10", "文件2", "文件1"] names.sort(key=cmp_to_key(lambda x, y: cmp_str(x, y).cmp)) # 结果:['文件1', '文件10', '文件2'] —— 纯字典序,不是自然排序

注意最后这个结果的注释。字典序排出来的文件10在文件2前面,因为比较到第二位时'1'小于'2'。很多人以为“比较字符串大小”就能解决版本号排序,其实不是。版本号需要的是自然排序,得先把数字段拆出来单独比较,那是另一个话题。我之所以在这里标注,是因为我见过不止一个项目把字典序当自然排序用,然后奇怪为什么v1.10排在v1.9前面。

4.3 测试用例设计:从空串到组合字符

测试用例这块,我按“容易出错的程度”排了个序,建议直接抄成表格驱动测试。

用例AB期望 cmp期望 diff期望 pos
完全相等abcabc003
首字符不同abcbbc-1-10
中间字符不同abcabd-1-12
前缀关系(长在前)abcab102
前缀关系(短在前)ababc-102
空串对非空``a-100
空串对空串````000
大小写Aa-1-320
数字与字母9A-180
汉字与字母中a1199160
补充平面与汉字😀中11084990

前七行是基础用例,必须全绿。第八到十行是踩坑用例,尤其是最后两行,差值那几列的数字不是随手写的,是真实码点算出来的:中是 U+4E2D 也就是 20013,减去a的 97 得 19916;😀是 U+1F600 也就是 128512,减去 20013 得 108499。如果你的实现跑出来不是这两个数,说明中间某处按字节算了。

我特意把“前缀关系”拆成两行,因为很多实现只在一个方向上正确。比如循环条件写成“A 没到头且字符相等”的实现,在abcvsab上能给出正确结果,在abvsabc上就返回 0 了。

还有一类用例不在表里但必须测:同一个字符串的两个方向。cmp_str(a, b).cmp和cmp_str(b, a).cmp必须互为相反数,diff也必须互为相反数。这个性质叫反对称性,听起来理所当然,但只要有任何一个分支漏了取反,就会被破坏。我一般会在测试里加一段随机串的模糊测试,跑一万组随机输入验证这条性质,成本很低,收益很高。

4.4 性能:先定位置再算差值的分块策略

逐字节比较在长串上会有点吃不消。假设你在做一个大文本的差异对比,每次比较要扫几十 KB 的公共前缀,纯 Python 循环会慢到没法用。

思路是先用批量比较快速跳过相同的部分,找到第一个不同的位置,再在那个位置算差值。批量比较用什么?底层能走到 SIMD 指令的memcmp是最划算的。

def first_diff_offset(a: bytes, b: bytes) -> int: """返回首个不同字节的下标;完全相同或前缀关系时返回较短长度。""" n = min(len(a), len(b)) block = 64 i = 0 while i + block <= n and a[i:i + block] == b[i:i + block]: i += block while i < n and a[i] == b[i]: i += 1 return i

Python 里bytes切片比较虽然是纯 Python 层写法,但==底层会走到 C 的memcmp,64 字节一块的情况下比逐个a[i] == b[i]快一个数量级以上。实测下来,10 KB 公共前缀的定位从大约 3 毫秒降到 0.2 毫秒左右,差不多 15 倍。

如果是 C 环境,那就更直接:

str_cmp_result cmp_chunked(const char *a, const char *b, size_t la, size_t lb) { size_t n = la < lb ? la : lb; size_t i = 0; const size_t BLK = 64; while (i + BLK <= n && memcmp(a + i, b + i, BLK) == 0) i += BLK; while (i < n && a[i] == b[i]) i++; if (i < n) { long d = (long)(unsigned char)a[i] - (long)(unsigned char)b[i]; str_cmp_result r = { d > 0 ? 1 : -1, d, i }; return r; } long d = (long)la - (long)lb; str_cmp_result r = { d > 0 ? 1 : (d < 0 ? -1 : 0), 0, n }; return r; }

这个版本要求调用方传进来正确的长度,所以只适合你自己在内部使用;对外的接口还是用strlen版本,安全第一。另外注意内层while (i < n && a[i] == b[i])这里直接比较char是没问题的,因为判断的是值相等;只有算差值那一行必须转unsigned char。

分块大小 64 是个经验值。太小了批量比较的优势不明显,太大了在差异位置靠前时会浪费一次比较。我试过 32、64、128、256 几档,在常见文本上 64 和 128 差别不大,但 64 更省一点内存带宽,就定了 64。如果你的数据特征是“差异通常出现在很靠前的位置”,那分块反而会增加开销,这时候直接逐字节更合适——所以我把两个版本都留着,按场景选。

5. 线上踩坑实录与排查速查表

5.1 典型症状对照表

下面这张表是我这几年攒下来的,症状、根因、修法三列,遇到问题先来这里对着找。

症状最可能的根因修法
中文排到了数字和字母前面char有符号,高位字节变负数比较前强转unsigned char
两个不同长度的串被判为相等循环退出时直接返回 0单独处理前缀相同的分支
strcmp(a,b) == -1在某些平台失效标准只保证符号,不保证数值改成strcmp(a,b) < 0
排序时抛“比较器违反约定”异常返回值溢出成 0,或比较不具传递性检查是否用了int8存差值
差值算出来是 131 但期望 19916按字节算差,按码点做期望统一口径,明确unit
emoji 排序位置不对UTF-16 码元比较,代理对错位改用codePointAt遍历
视觉相同的两个串比较不为 0组合字符编码方式不同先做 NFC 或 NFD 归一化
超长串比较卡顿逐字节扫描,没有批量跳过分块 memcmp 快速定位
二进制文本比较被截断用了strcmp,遇\0停止改用memcmp或带长度版本
折叠大小写后pos对不上原串casefold改变了字符串长度pos只在原串口径下有效,或者返回原串索引

倒数第三条和最后一条,我各踩过一次。第一次是在做文件哈希对比,输入是从网络读进来的二进制块,里面恰好有个0x00,strcmp一比就返回相等了,我还以为是传输层出了问题,查了大半天。后来换成带长度的比较才解决。第二次是做搜索高亮,先折叠大小写再比较,拿pos去原串里截取高亮范围,结果在德语文本上整体偏移了一位——因为ß折叠成ss之后长度变了。最后改成折叠前后各维护一套索引映射,才算干净。

5.2 几个不太容易想到的细节

第一,差值的符号和大小关系未必同源。用diff的正负去判断谁大谁小,在数学上没问题,但在实现上要小心:如果我为了省事,在“前缀关系”分支里diff置 0 但cmp置 ±1,那diff的符号就和cmp脱节了。这时候如果调用方写了if (result.diff > 0)来判断大小,就会在长度不同的串上翻车。所以契约里必须写死“判断大小只看cmp”,别让diff兼职。

第二,比较器必须满足传递性,否则排序会崩。有些语言(比如 Java 的TimSort)会在检测到比较器违反约定时直接抛异常,有些语言(比如 C 的qsort)则默默给出乱七八糟的结果。传递性的破坏往往来自“比较时依赖了外部可变状态”,比如在比较函数里读了当前 locale,而 locale 在多线程环境下被别的线程改了。所以我的建议是:比较函数要么纯函数,要么把所有依赖都作为参数显式传进去。

第三,locale对比较结果的影响比想象中大。C 的strcoll会按当前 locale 的排序规则来比较,在中文 locale 下可能按拼音排,在英文 locale 下按字母排。这个特性在做面向用户的展示时是优点,在做数据一致性校验时是灾难。我的原则是:内部逻辑一律用字节序或码点序,只有纯展示层才考虑locale相关排序,而且要在函数名里体现出来,比如compare_for_display这种,让人一眼看出它不是纯函数。

第四,别忽视length字段的类型。C 里size_t是无符号的,la - lb在la < lb时会下溢成一个巨大的正数。我见过有人写int d = strlen(a) - strlen(b);然后直接拿去当返回值,结果在 32 位平台上偶尔返回正数。正确写法是先比较再相减,或者转成有符号类型再减。这个坑在 Python 和 Java 里不存在,因为它们的减法自动处理符号,但从 C 风格语言转过来的人很容易带习惯。

第五,测试一定要包含“看起来一样但实际不一样”的用例。比如"é"有两种编码方式:U+00E9 单个码点,或者 U+0065 加 U+0301 组合。肉眼和大多数编辑器里看都是同一个字符,但码点序列完全不同,比较结果一定是非零。这种用例能帮你验证归一化开关是否真的生效,也能在上线前就暴露“用户搜不到自己输入的词”这类问题。

第六,如果这个比较函数要跨语言通信,把unit和cmp的取值规范写进接口文档。我见过一个前后端联调的场景,后端用 Java 返回 UTF-16 码元差,前端用 JS 拿codePointAt算码点差,两边对同一对字符串算出来的差值差了 500 多,谁都不认错。后来在协议里加了个字段说明单位,才把这事了结。跨语言场景下,“差多少”这个问题没有统一答案,必须显式约定。

最后分享一个小习惯:我现在写任何比较函数,第一件事是把返回值做成结构体或者多返回值,而不是单个整数。多写几行代码的成本,和“上线后发现排序错了再回滚排查”的成本比起来,基本可以忽略。这个习惯救过我至少两次。

返回列表