杂记 › 技术笔记 › 升序按的不是码点:localeCompare('zh') 的拼音序与 3 种重复行数分母

升序按的不是码点:localeCompare('zh') 的拼音序与 3 种重复行数分母

一、6 行名单,排完两边都对不上

名单只有 6 行:苹果、香蕉、橘子、安卓、北京、上海。想按升序排一遍,拿到的是 安卓 北京 橘子 苹果 上海 香蕉。这既不是我心里按码点算出来的 上海 北京 安卓 橘子 苹果 香蕉,也不是笔画序——它是拼音序,ān běi jú píng shàng xiāng。

这份名单我最后是在文本排序去重里跑的,口径写死在模板第 170 行:比较器是 a.localeCompare(b, 'zh'),走浏览器 ICU 的中文 collation。文案那句「按 Unicode 编码从小到大」不成立,「中文最后」也站不住:苹果、zebra、Apple、香蕉 放同一列,升序给出 苹果 香蕉 Apple zebra,汉字整块排在拉丁字母前面,而码点序应是 Apple zebra 苹果 香蕉。

它也没排抖:重排 300 次结果 300/300 全等,两两比较的 16 格没有传递性矛盾——是个稳定但不同于码点序的全序。

二、item10 仍然排在 item2 前面

带编号的名单最容易起争议。item10、item2、item1、item21、item100 升序得到 item1 item10 item100 item2 item21。纯数字也按字符位:10、9、100、2、0 排成 0 10 100 2 9。

第 170 行没传 {numeric:true},也没有 sensitivity 和 caseFirst。表格里习惯看到 item2 排在 item10 前,这里不是排错,是自然序那一档没开,想要只能自己补零。

大小写的分工更微妙:banana、Banana、apple、Apple、cherry 勾不勾「区分大小写」,升序都是 apple Apple banana Banana cherry;去重一开,Apple、apple、APPLE、banana、Banana 取消勾选只剩 2 行、重复行数 3。第 122 行的 toLowerCase() 只进去重的键,不进第 170 行的比较器。

三、「按长度」数的是码元,不是字形也不是字节

第 181 行的 a.length - b.length 数的是 UTF-16 码元:ab、一个 4 字节的 emoji、a、苹果、abc、é 按长度升序得 a(1) ab(2) emoji(2) 苹果(2) é(2) abc(3)——1 个字形的 emoji 与 2 字节的 ab 等长,6 字节的「苹果」也等长,等长的行没有次级比较器。

四、去重认的是码点全等,而且不 trim

apple、带尾空格的 apple、带尾 Tab 的 apple、带前空格的 Apple,再加 1 行空行:5 行选保留首次,结果行数 5、重复行数 0。判「相同」用的是码点全等,首尾空白一个都不抹。

空行却由 trim 决定:a、空行、半角空格、U+3000、Tab、b 这 6 行,开启「去除空行」只剩 2 行,总行数也从 6 变成 2。切行只认 \n。Windows 粘贴安全,textarea 已把 CRLF 归一成 LF:4 行 apple、banana、apple、cherry 实测 总行数 4、结果行数 3、重复行数 1。

五、行名撞上 constructor 这种词,整行会消失

唯一会让数据静默蒸发的是行名。9 行输入:apple、constructor、toString、valueOf、hasOwnProperty、isPrototypeOf、__proto__、propertyIsEnumerable、banana,选保留首次,输出只剩 apple 和 banana 两行,统计却是 总行数 9、结果行数 2、重复行数 7。

根因在第 120–121 行的 const seen = {} 与 const duplicates = {}:普通对象字面量读键会顺原型链上去,第 128 行 if (!seen[k]) 对其中 6 个名字恒为假,__proto__ 读到 Object.prototype 也是真值。7 行全是这类名字时输出 0 行;4 行 constructor、constructor、toString、toString 选「仅显示重复项」输出 0 行、重复行数报 0。字段名清单和翻译 key 表最容易撞上;两份名单求交集是另一个问题。

六、同一段 x、x、x、y、y、z,重复行数有三个数

去重模式输出重复行数式子
保留首次 / 保留末次x y z3差值(第 130 行)
仅显示重复项x y2等于结果行数(第 154 行)
仅显示唯一项z5差值(第 164 行)

换成 7 行的 apple、Banana、apple、cherry、Banana、apple、date,三个数照旧是 3、2、5:问「被删掉几行」只有保留首次/保留末次对得上,仅显示重复项报的是重复值的种类数。总行数是第 114 行去除空行之后的 lines.length,不是输入框原始行数;选「不去重」时第三项不渲染(第 192 行)。

重复行数:同一段 x,x,x,y,y,z 保留首次 3 仅显示重复项 2 仅显示唯一项 5 图 1:三种去重模式的重复行数分母

七、没有上限,只有每次按键全量重算

模板 198 行 / 7897 字节,没有长度上限也没有格式白名单:单行 1000000 字符照常输出 1000000 字节。100000 行、669999 字节的输入在 Node v24.19 实测:不去重 68 ms、升序 710 ms、升序加保留首次 273 ms,两两 localeCompare 比不排序慢约 10 倍;输入框每次按键全量重算(第 53 行 oninput),大名单的体感是打字变卡而非报错。

耗时 ms:同一份 100000 行输入 不去重 68 升序加保留首次 273 升序 710 图 2:排序把这一步的耗时放大

八、随机打乱没毛病,毛病是不可复现

它是标准 Fisher-Yates(第 176–179 行):4 元素跑 2400 次,24 种排列全部出现,最少 84 次、最多 123 次(期望 100 次),分布没缺陷;麻烦在顺序取自 Math.random,两次点击不同也复现不回来,算法取舍在打乱名单那篇写过。

Q:想要码点序或者笔画序,能选吗?

不能。第 170 行的 locale 写死成 'zh',没有拼音/码点/自然序的口径选择,要 item2 在前只能补零。

Q:导出的行数比输入少,先查哪一处?

三处:空行被 trim 判掉(U+3000 和 Tab 也算空行);取消勾选「区分大小写」后 Apple 与 apple 合并;行名撞上 constructor 这类原型属性名时整行被判成重复——前两种是口径,最后一种是数据没了。

Q:报表要引用哪个重复行数?

问「删掉几行」用保留首次或保留末次(第 130 行的总行数减结果行数);问「几种值重复」用仅显示重复项,它等于结果行数(第 154 行)。同一段输入里是 3 和 2 两个数。

📎 相关文章

文本合并后行数对不上:缺尾换行、被吃掉的 BOM 和不可靠的顺序 2026-10-26 Excel 粘贴转 JSON:日期、长数字和精度在「粘贴那一刻」就已经变了 2026-10-26 条形码解码只吐原文:EAN-13 校验位、UPC-A 的前导零与 CODE128 的模 103 2026-10-26 二维码解码实测:纠错等级救不了遮挡,每模块 2 像素才是生死线 2026-10-26 字频还是词频:同一句中文,一份 23 项榜单和一份空表 2026-10-26