比较字符串大小并输出 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 测试用例设计:从空串到组合字符
测试用例这块,我按“容易出错的程度”排了个序,建议直接抄成表格驱动测试。
| 用例 | A | B | 期望 cmp | 期望 diff | 期望 pos |
|---|---|---|---|---|---|
| 完全相等 | abc | abc | 0 | 0 | 3 |
| 首字符不同 | abc | bbc | -1 | -1 | 0 |
| 中间字符不同 | abc | abd | -1 | -1 | 2 |
| 前缀关系(长在前) | abc | ab | 1 | 0 | 2 |
| 前缀关系(短在前) | ab | abc | -1 | 0 | 2 |
| 空串对非空 | `` | a | -1 | 0 | 0 |
| 空串对空串 | `` | `` | 0 | 0 | 0 |
| 大小写 | A | a | -1 | -32 | 0 |
| 数字与字母 | 9 | A | -1 | 8 | 0 |
| 汉字与字母 | 中 | a | 1 | 19916 | 0 |
| 补充平面与汉字 | 😀 | 中 | 1 | 108499 | 0 |
前七行是基础用例,必须全绿。第八到十行是踩坑用例,尤其是最后两行,差值那几列的数字不是随手写的,是真实码点算出来的:中是 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 iPython 里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 多,谁都不认错。后来在协议里加了个字段说明单位,才把这事了结。跨语言场景下,“差多少”这个问题没有统一答案,必须显式约定。
最后分享一个小习惯:我现在写任何比较函数,第一件事是把返回值做成结构体或者多返回值,而不是单个整数。多写几行代码的成本,和“上线后发现排序错了再回滚排查”的成本比起来,基本可以忽略。这个习惯救过我至少两次。