排序和列表去重,不留意外

为什么“10”会排在“2”前面,德语和瑞典语里变元音字母为何位置不同,哪些重复才真的算重复,以及为什么用随机比较函数打乱顺序是有偏的。

排序和去重看上去是你能对一个列表做的最简单的两件事,这也正是它们的结果为什么那么经常悄悄出错。两者都建立在一次比较之上,而“两行相等”或者“哪一行排在前面”并没有唯一的答案。

字典序不是数值序

文本的默认比较是逐字符地走过两个字符串,比较码位,在第一个不同的地方停下。这里面没有任何东西知道数字是什么。

input:   img1.png, img2.png, img10.png
sorted:  img1.png, img10.png, img2.png

img10.png 排到了第二位,因为在第四个字符处 1(U+0031)小于 2(U+0032),比较到此结束。同样的规则会把版本号 1.10 排在 1.9 之前,也会让一个装满截图的文件夹不再按拍摄顺序排列。

做法它做了什么需要留意
数值排序把每一行当作一个数字读取非数字的行总得有个去处,而千位分隔符会让解析失败
自然排序把连续的数字段当数字比较,其他段当文本比较011 比较结果相等,所以要靠一条决胜规则来定

数值比较还顺带修好了负数:按字典序,-10 落在 -1-2 之间,因为负号只不过是又一个带码位的字符。

大写排在小写前面,以及其他码位带来的意外

在 ASCII 里,大写字母占据 65 到 90,小写字母占据 97 到 122,所以每一个大写字母都排在每一个小写字母前面。

输入按码位排序不区分大小写排序
Zebra, apple, Apricot, bananaApricot, Zebra, apple, bananaapple, Apricot, banana, Zebra

数字排在所有字母之下,空格(32)排在所有可打印字符之下,空行又在那之下,所以升序排序会把空行和以空格开头的行全部聚到最顶上,降序排序则把它们聚到最底下。

行尾空白是同一个问题的不可见版本:apple apple 会挨着排,看上去像一个死活删不掉的重复项。不换行空格(U+00A0)更糟,它画出来和普通空格一模一样,排序时却排在所有字母之上;一个 Windows 文件只按换行符切分后残留下来的回车也是如此。如果两行看起来完全一样却仍然合并不掉,就去查隐藏字符。

同一个列表在两个国家排出来不一样

按码位排序还会把所有带重音的字符排在所有不带重音的字符之后。Zürich 排在 Zzz 之后,Ärger 排在 zebra 之后。没有哪种语言会这样排列自己的字母表。区域敏感的排序规则改为按语言规则比较,而这些规则彼此并不一致。

区域规则效果
德语,词典序äa 排,öo 排,üuApfel, Ärger, Azubi
德语,电话簿序äae 排,üueMüllerMueller 归在一起
瑞典语åäö 收尾字母表,排在 z 之后Apfel, Zebra, Ärger
西班牙语ñ 是一个独立的字母,排在 n 之后anzueloaño 之前

在 JavaScript 里这套东西住在 Intl.Collator 中:

const words = ["Zebra", "Ärger", "Apfel"];

words.sort(new Intl.Collator("de").compare);
// Apfel, Ärger, Zebra

words.sort(new Intl.Collator("sv").compare);
// Apfel, Zebra, Ärger

同一个对象也覆盖了前面那些问题。{ numeric: true } 会把连续的数字段当数字比较,也就是自然序;而 sensitivity 决定什么才算差异:"base" 忽略大小写和重音,"accent" 只忽略大小写,"case" 只忽略重音。不指定区域就会使用机器上的设置,这就是同一段代码在两台笔记本上返回两种顺序的原因。当输出必须在任何地方都一致时,请显式写明区域。

哪些重复才算重复

去重的定义程度,取决于它背后那个相等性判断的定义程度。看下面五行,它们渲染出来都是带尖音符的 cafe 这个词:

#实际存储的是什么
1café预组合的 é(U+00E9)
2cafée 加上组合尖音符(U+0301)
3Café大写 C,预组合
4café 末尾多一个空格
5café与第 1 行完全相同

四条都说得通的规则给出四种不同的答案:

规则存活下来的数量
精确匹配1, 2, 3, 44
先去首尾空白,再精确匹配1, 2, 33
去首尾空白并折叠大小写1, 22
去首尾空白、折叠大小写、规范化为 NFC11

它们没有一条是错的;它们回答的是不同的问题。规范化的重要性比看上去更大,因为来自 macOS 文件名的文本往往是分解形式(NFD),而来自 Windows 和网页表单的文本是预组合形式(NFC),所以一个从两个来源粘贴出来的列表会捡到一堆不可见的孪生项。NFKC 走得更远,它会把 这类兼容性连字也折叠成普通的等价形式。

按惯例是第一次出现的那一项胜出,所以如果那份不整洁的副本先被粘进来,存活下来的就是它。另外,删除重复项和找出唯一项是两个不同的请求:删除重复行工具会给每一种各留一个,而列表频次统计里的唯一项集合,是那个更小的、只出现过恰好一次的项目组。

稳定性,以及按两个键排序

稳定排序保证被比较函数判定为相等的条目,仍保持它们在输入中的先后顺序。多轮排序实现多键排序,靠的正是这一点:先按最不重要的键排,再按最重要的键排。先按姓名排、再按部门排,得到的是部门有序、且每个部门内部姓名有序的结果。不稳定的排序会在做第二轮时把第一轮的成果打乱,而结果看上去几乎是对的,这比看上去就是错的还要糟。

反转也有同样的陷阱,因为把一个已排序的列表翻过来,同时也会把每一组并列项翻过来。当你真正想要的是把整个顺序倒过来时才用列表反转工具,别把它当成降序排序的捷径。

各家实现并不相同。JavaScript 的 Array.prototype.sort 直到 ES2019 才被要求稳定;在那之前,数组超过一定大小时 V8 使用的是不稳定的快速排序,所以同一段代码处理十个项目和处理一万个项目时行为不同。Python 的 sorted 是稳定的,而 GNU 的 sort 除非加上 -s 否则不稳定。

打乱顺序不是一种排序

那个一行搞定的打乱写法 list.sort(() => Math.random() - 0.5) 是有偏的。排序假定它的比较函数描述的是一个自洽的顺序,而且只会去问它的算法所需要的那部分配对。一个每次调用都掷出一个新数字的比较函数破坏了这个假定,于是结果取决于算法和数组长度:项目倾向于停留在它们出发的位置附近,而某些排列出现的频率远高于其他排列。

正确的打乱使用 Fisher-Yates 算法,从末尾往前走,把每一项与一个在它本身或之前均匀选出的项目交换:

for (let i = list.length - 1; i > 0; i--) {
  const j = Math.floor(Math.random() * (i + 1));
  [list[i], list[j]] = [list[j], list[i]];
}

常见的错误变体是在循环里用 Math.random() * list.length,也就是每次都从整个数组里抽。那会产生 n 的 n 次方种等可能的抽取序列,而这个数无法被 n 的阶乘种排列整除,于是某些排列出现得更频繁。

随机和排序也可以正确地结合起来:先给每一项分配一个随机键,再按这个键排序,因为这样比较函数就是自洽的。出问题的做法是在比较过程中掷骰子。

各步骤的先后顺序

大多数列表问题都源于把对的操作做在了错的顺序上。

  1. 先去包裹。 从代码里复制出来的列表长这样:"apple",;引号和逗号是字符串的一部分,所以它永远匹配不上 apple。列表项去包裹工具会把它们剥掉。
  2. 去首尾空白并做规范化,好让相等真的意味着你以为的那个意思。
  3. 去重,而且匹配规则要有意识地选定,而不是照单接受默认值。
  4. 排序。 行排序工具涵盖文本序、数值序、长度序和随机序;带重音的数据要加上区域设置。
  5. 最后再重新塑形。 列表分组工具把结果切成固定大小的批次以适应每次请求的数量上限,而列表轮转工具在不改变内部顺序的前提下把所有内容整体移位。

在去首尾空白之前去重,会留下一堆近似重复项;在去重之前排序,则只是给一批即将被丢掉的行排了个序。