最近一直在更新 Typescript
数据结构与算法系列,在学习中对 JS
的 sort
方法产生了好奇,Array.prototype.sort()
的用法肯定都比较熟悉:
1 | arr.sort([compareFunction]); |
sort
方法的参数为一个可选的排序回调函数 compareFunction
,回调函数中有两个用于比较的参数。如果省略,元素按照转换为的字符串的各个字符的 Unicode
位点进行排序。整个 sort
方法会返回排序后的数组,但是原数组已经原地排序。
但是这个 Array.prototype.sort()
的逻辑是什么,用到的排序方法是什么,时间和空间复杂度是多少都不清楚,本篇从 ECMA262
标准,V8
引擎源码来探究 sort
方法。
ECMA262
既然要排序,肯定会用的到大小比较,在 ECMA262
中对大小的关系的定义翻译如下:
大小关系比较原文
比较 x < y
(其中 x
和 y
是值)会产生 true``,false
或 undefined
(表示有至少一个操作数是 NaN
)。除 x
和 y
外,该算法还使用名为 LeftFirst
的布尔标志作为参数。该标志用于控制对 x
和 y
执行具有潜在副作用的操作的顺序。这是必需的,因为 ECMAScript
指定了表达式的从左到右计算。 LeftFirst
的默认值为 true
,表示先在 x
参数位置计算表达式。如果 LeftFirst
为 false
,则情况相反,必须现在 y
上执行操作。比较执行如下:
- 如果
LeftFirst
标志为true
, 则- 让
px
为?ToPrimitive(x, number)
. - 让
py
为?ToPrimitive(y, number)
.
- 让
- 否则,
- 让
py
为?ToPrimitive(y, number)
. - 让
px
为?ToPrimitive(x, number)
.
- 让
- 如果
Type(px)
为String
并且Type(py)
为String
, 则- 如果
IsStringPrefix(py, px)
为true
, 返回false
. - 如果
IsStringPrefix(px, py)
为true
, 返回true
. - 令
k
为最小的非负整数,这个非负整数能使px
上k
索引处的代码单元与py
上k
索引处的代码单元不同。 - 让
m
为px
上k
索引处的代码单元整数值. - 让
n
为py
上k
索引处的代码单元整数值. - 如果
m < n
, 返回true
. 否则, 返回false
.
- 如果
- 否则,
- 如果
Type(px)
为BigInt
并且Type(py)
为String
, 则- 让
ny
为!StringToBigInt(py)
. - 如果
ny
为NaN
, 返回undefined
. - 返回
BigInt::lessThan(px, ny)
.
- 让
- 如果
Type(px)
为String
并且Type(py)
为BigInt
, 则- 让
nx
为!StringToBigInt(px)
. - 如果
nx
为NaN
, 返回undefined
. - 返回
BigInt::lessThan(nx, py)
.
- 让
- 让
nx
为!ToNumeric(px)
. - 让
ny
为!ToNumeric(py)
. - 如果
Type(nx)
与Type(ny)
相同, 返回Type(nx)::lessThan(nx, ny)
. - 断言:
Type(nx)
为BigInt
并且Type(ny)
为Number
, 或Type(nx)
为Number
并且Type(ny)
为BigInt
. - 如果
nx
或ny
为NaN
, 返回undefined
. - 如果
nx
为-∞𝔽
或ny
为+∞𝔽
, 返回true
. - 如果
nx
为+∞𝔽
或ny
为-∞𝔽
, 返回false
. - 如果
ℝ(nx) < ℝ(ny)
, 返回true
; 否则返回false
.
- 如果
注意:字符串的比较使用的是简单的代码单元值的字典顺序。没有尝试使用
Unicode
规范中定义的更复杂的,面向语义的字符或字符串相等性定义以及整理顺序。因此,根据 Unicode 标准规范相等
的字符串值可能会被测试为不相等
。实际上,该算法假定两个字符串都已经处于规范化形式。另外请注意,对于包含补充字符的字符串,UTF-16 代码单元
值序列的字典顺序与代码点
值序列的字典顺序不同。
大小关系比较总结
将 x < y
上面的步骤进行下简单总结:
- 如果
x
和y
都为String
类型,则- 如果
x
是y
或y
是x
的前缀,则短的这个小 - 否则比较
x
和y
的代码单元
值的大小
- 如果
- 否则
x
和y
都转化为数字
类型后再比较大小,如果转换过程中出现了NaN
,就返回undefined
(但其实在其他章节中提到过,要将undefined
变为false
)
注意:
JS
的编码格式为UTF-16
,所以用16
位二进制来表示所有的字符,当代码点过大时,比如部分汉字和各种emoji
表情文字,就需要2
个16
位二进制来表示,也就是用2
个代码单元来表示。(具体学习可见阮老师的博客)。
理解了 ECMA262
给出的标准,就能理解下述代码中的大小关系比较
了:
1 | /** |
理解了大小比较
的原理,可以继续向下推进了。在 ECMA262
标准中也找到了 Array.prototype.sort()
部分,下面我把继续它翻译一下,大部分内容都比较学术,对原文不感兴趣可以跳到重点分析部分:
Array.prototype.sort(comparefn)
排序数组中的元素。排序必须稳定
(也就是说,相等的元素必须保持其原始先后顺序)。如果 comparefn
不是 undefined
,则它应该是一个接受两个参数 x
和 y
的函数
,如果 x < y
该函数返回负数,如果 x > y
则返回正数,否则返回 0
。
当调用 sort
方法时,将执行以下步骤:
- 如果
comparefn
不为undefined
,并且IsCallable(comparefn)
返回false
, 抛出TypeError
错误. - 让
obj
为?ToObject(this value)
. - 让
len
为?LengthOfArrayLike(obj)
. - 让
items
为新的空List
. - 让
k
为0
. - 循环, while
k < len
,- 让
Pk
为!ToString(𝔽(k))
. - 让
kPresent
为?HasProperty(obj, Pk)
. - 如果
kPresent
为true
, 则- 让
kValue
为?Get(obj, Pk)
. - 添加
kValue
到items
中.
- 让
- 设
k
为k + 1
.
- 让
- 让
itemCount
为items
的元素数目. - 由具体
引擎实现自己决定
的排序方法并调用SortCompare
来对items
进行排序。如果调用返回了一个异常完成
,则停止进一步调用SortCompare
或停止算法中的后续步骤,然后返回该完成。 - 让
j
为0
. - 循环, while
j < itemCount
,- 执行
?Set(obj, !ToString(𝔽(j)), items[j], true)
. - 设
j
为j + 1
.
- 执行
- 循环, while
j < len
,- 执行
?DeletePropertyOrThrow(obj, !ToString(𝔽(j)))
. - 设
j
为j + 1
.
- 执行
- 返回
obj
.
排序顺序是指排序完成后,对 整数索引
小于 len
的 obj
属性值的先后顺序。sort
函数的结果由以下方式确定:
如果下述任何条件为真,则排序顺序由具体引擎实现自己决定
:
如果
comparefn
不为undefined
,并且对items
元素不是一致比较函数(参见下文)。如果
comparefn
为undefined
,并且SortCompare
不是一致比较函数。如果
comparefn
为undefined
,并且ToString
的所有应用(传递任意特定值参数给SortCompare
),不会产生相同的结果。
除非以上将排序顺序指定为引擎实现自己决定
,否则在执行上述算法的步骤 8
之后,items
必须满足以下所有条件:
- 必须存在一些小于
itemCount
的非负整数组成的数学排列π
,在排列中,对于每个小于itemCount
的非负整数j
,元素old[j]
与new[π(j)]
相同。 - 然后,对于所有的小于
itemCount
的非负整数j
andk
, 如果SortCompare(old[j], old[k]) < 0
, 则π(j) < π(k)
.
这里的 old[j]
是指执行步骤 8
之前的 items[j]
,而 new[j]
是指是指执行步骤 8
之后的 items[j]
。
comparefn
函数是一个一致比较函数,一致比较的定义如下:
规定 a
, b
和 c
都是集合 s
中的值(值可能相等),a <CF b
表示 comparefn(a, b) < 0
; a =CF b
表示 comparefn(a, b) = 0
; a >CF b
表示 comparefn(a, b) > 0
。
- 传参一对特定的值
a
和b
来调用comparefn(a, b)
始终返回相同的值v
。此外,Type(v)
为Number
, 并且v
不为NaN
。 注意,这也意味着对于给定一对值a
和b
,条件a <CF b
,a =CF b
和a >CF b
中必有且只有一个为真。 - 调用
comparefn(a, b)
不会修改obj
或obj
原型链上的任何对象. a =CF a
(自反性)- 如果
a =CF b
, 则b =CF a
(对称性) - 如果
a =CF b
且b =CF c
, 则a =CF c
(=CF
的传递性) - 如果
a <CF b
且b <CF c
, 则a <CF c
(<CF
的传递性) - 如果
a >CF b
且b >CF c
, 则a >CF c
(>CF
的传递性)
NOTE 1
以上条件的充分必要条件是comparefn
将集合S
划分为等价类并且确保这些等价类完全有序。
NOTE 2
sort
函数是故意泛型的;它不需要它的this
值是一个Array
对象。因此,可以将其作为一个方法转移到其他类型的对象中。
SortCompare(x, y)
抽象操作 SortCompare
接受参数 x
和 y
。它也可以访问 sort
方法当前调用的 comparefn
参数。调用时,它将执行以下步骤:
- 如果
x
和y
都为undefined
, 返回+0𝔽
. - 如果
x
为undefined
, 返回1𝔽
. - 如果
y
为undefined
, 返回-1𝔽
. - 如果
comparefn
不为undefined
, 则- 让
v
为?ToNumber(?Call(comparefn, undefined, « x, y »))
. - 如果
v
为NaN
, 返回+0𝔽
. - 返回
v
.
- 让
- 让
xString
为?ToString(x)
. - 让
yString
为?ToString(y)
. - 让
xSmaller
为执行Abstract Relational Comparison
xString < yString
的结果. - 如果
xSmaller
为true
, 返回-1𝔽
. - 让
ySmaller
为执行Abstract Relational Comparison
yString < xString
的结果. - 如果
ySmaller
为true
, 返回1𝔽
. - 返回
+0𝔽
.
NOTE 1
因为不存在的属性值总是大于undefined
属性值,而undefined
总是大于任何其他值,所以undefined
属性值在排序时总是在结果的末尾,接下来是是不存在的属性值。
NOTE 2
由步骤5
和6
中的ToString 抽象操作
执行的方法调用有可能导致SortCompare
不能表现为一致比较函数
。
sort 规范 总结
ECMA262
的相关规范的原文很复杂,但是两节整体来讲,可以突出这么几个重点:
先分析SortCompare(x, y)
,假设比较的第一个参数为 x
,比较的第二个参数为 y
:
- 如果只有
x
为undefined
,返回1
;如果只有y
为undefined
,返回-1
;如果都为undefined
,返回+0
(根本不会执行回调函数comparefn
) - 如果传入了回调函数
comparefn
,则执行回调函数并数字化后返回(如果为NaN
返回+0
) - 如果没传回调函数
comparefn
,就把x
和y
字符串化,然后执行字符串间的大小关系比较
,如果x
小返回1
,如果y
小返回-1
,相等返回+0
再分析 Array.prototype.sort(comparefn)
的原文,可以发现:
sort
会修改原数组sort
要返回修改后的数组sort
排序算法的选择是JS
引擎自己决定的sort
函数是泛型的,可以用到别的类数组对象中comparefn
得是个一致比较函数,具备自反性,对称性和传递性
所以综合起来看,也会得到另外一个结论:如果被比较的值为 undefined
,根本不会执行比较操作就会返回单次比较结果,而且排序完成后所有的 undefined
放在最后。
V8 引擎实现
既然 ECMA262
规定了引擎在实现 sort
的排序算法时可以自己选择,现在就深入源码,看看使用量最大的 V8 怎么实现的。
V8
引擎是现在改为了谷歌自研的 tq
语言来实现,sort
的实现页足足有 1400
行,实现的核心算法是 timsort
排序方法。看的头疼,翻译一篇国外文章 Tim sort, the fastest sort used in v8 and Python
Tim sort, the fastest sort used in v8 and Python
Tim Sort
是在 2002
年由 Python 的主要贡献者之一 Tim Peters 为改进 Python list
排序性能而创建的,是通过对现实世界数据的分析来实现的一种算法组合。最好时间复杂度为 O(n)
,最坏时间复杂度为 O(nlogn)
,比快排的最坏时间复杂度 O(n²)
优秀的多。
二分插入排序和归并排序
在实现的时候,如果数据量较小使用二分插入排序
,下图是插入排序
和二分插入排序
:
当数据量较大时,就会使用归并排序
的思想,将长 list
分解为更小的 runs
,然后统一合并,如下图:
分割为 runs
传统归并算法
中,常将 2
个数据分为一组 run
。但 Tim
实验发现,适当提高 run
的初始大小会有明显的性能提升,但每组也不能太大,将分组定为 32~64
左右比较合适。
与此同时,他还发现在现实世界的数据中,有许多数据片段是按升序
或降序
排序的,不需要额外排序。因此他决定以一个“自然
”的大小来划分 run
—— 初始 runs
的大小不是一个固定的数字,而是一个最小的长度要求 —— “min run
”。
以下是划分的准则:
- 通过比较前两个
item
设置一个descending flag
,如果只有一个item
,则设置为false
。 - 循环剩下的
items
,并检查它们是否仍然以升序
或严格降序
排列,直到反转。 - 现在有了一个
升序
或严格降序
排列的run
。如果run
是严格降序,则反转它。严格
的原因是为了保持算法的稳定性。 - 然后检查这个
run
的长度是否满足“min run
”的要求。如果不满足,则补偿剩下的items
以达到最小长度,并从补偿项开始执行二分插入排序。
下面是 js
实现的伪代码:
1 | let nremaining = list.length; |
为了使归并操作
更加平衡,最好有 2的幂次方
个 runs
。因此 Tim 将总长度不断除以 2
,直到这个值小于 64
,并向上或向下取整得出 min run
:
1 | function merge_compute_minrun(n) { |
平衡归并
经典的归并排序
只是简单地将相邻的 2
个 runs
合并。因为需要挨个比较两个有序数组中的元素的大小,然后将合并后的 list
存储在内存中,所以它将占用相当多的内存空间。Tim 发现有一些方法可以处理这种不必要的内存浪费。
为了达成目标,需要解决的第一个问题是以下哪两个 runs
合并性能最优?
假设有 3
个 runs
:
- A: 1000 个元素
- B: 2000 个元素
- C: 1000 个元素
很显然先合并 A
和 C
将更加平衡。但是存在一个问题,如果 A、 B 、C
中都存在一个相同元素,那么首先将 A
和 C
合并,再去和 B
合并时将改变该元素的顺序并影响算法稳定性
。
因此,Tim sort 仍然只合并相邻
的 2
个 runs
,但是用了更精巧的一种方式:
比较 3
个相邻 runs
的长度。如果它们满足以下规则:
1 | A > B + C |
也就意味着 B
和 C
是相对较小的 runs
。所以首先合并较小的 runs
,然后就与 A
的长度更接近了,再继续合并。
经过上述操作之后,如果仍然剩了一些 runs
,那么就按正常的顺序来合并。
使用 gallop 模式归并
当尝试合并大型 runs
时,因为他们都是有序
的,所以很可能很久才能找到正确的放置位置。例如:
- A: [0, 2, 4, 6, 8, 10, …, 100]
- B: [57, 77, 97, …]
就拿 B - 57
与 A - 0
进行比较,57
将比较 58/2 = 29
次来找到正确的位置。然后下一个 B - 77
,需要再比较 (78-58)/2 = 10
次。
不仅比较本身毫无意义,还会额外浪费很大的内存空间。因此,Tim sort 使用 gallop
来加速这个过程。与二分搜索
相比,虽然复杂度是相同的,但是 gallop
更擅长寻找距离开始搜索位置不远
的数据。
它首先选择一个位置(不一定是首或尾位置)开始搜索,然后根据起始元素
与目标数据
的大小比较
来选择搜索方向。如果一次搜索失败,下一次它将通过乘 2
来尝试更远的位置。
例如,如果从 A[7]
开始搜索,而 A[7]
比目标数据小,那么下一步比较 A[9]
,然后是 A[11]
,A[15]
,A[23]
,A[35]
… 直到发现一个大于等于目标数据
的元素。假设 A[35]
较大 ,那么就知道目标数据肯定位于 A[23]
和 A[35]
之间的某个位置。
一旦得到这个粗略的范围,然后就切换到二分搜索
,找到精确的位置。
下面是伪代码:
1 | function gallop_left( |
何时使用 gallop 模式
刚开始合并时,两个 runs
之间可能会有很大的间隔。例如仍然是下面两个数组:
- A: [0, 2, 4, 6, 8, 10, …, 100]
- B: [57, 77, 97, …]
所以在这里可以使用 gallop_left(b[0], a, 0)
(从左侧某位置开始),来快速定位 B[0]
应该在 A
中的位置;然后使用 gallop_right(a[a.length - 1], b, b.length - 1)
(从右侧某位置开始),来定位 A[0]
应该在 B
中的位置。这样就可以缩短
所需比较数据的大小,后再真正需要进行比较
和合并
。
gallop_left
和 gallop_right
的实现基本一致,只是对边界情况有一些不同的处理。
下面是伪代码:
1 | function merge(ms /* MergeState */, a, b) { |
Gallop
模式很棒,但只有在 runs
的长度存在较大差距时才高效,否则只会降低性能。因此 Tim sort 引入了一些使用 Gallop 模式
的规则。
在 Python 中,只有当一个数据比较 7
次但仍然没有找到它应该放置的位置时,才会进入 Gallop 模式
。更有趣的事情是,如果发现合并 runs
之后继续
进入了 Gallop 模式
,“7 次
” 这个约束条件将变小
。如果没有触发 Gallop 模式
,那么就重置一下约束条件。
下面是伪代码:
1 | const MIN_GALLOP = 7; |
实际上还有一个函数叫做 merge_hi
。因为与 merge_lo
非常相似,所以跳过它。
总结
从 ECMA262
关于 sort
的规定 到 大小关系比较
的原理,再到 V8
引擎实现原理,从头到尾梳理了一遍 Array.prototype.sort
,可以回答开头的几个问题:
ECMA262
规范中Array.prototype.sort
的逻辑是什么?
先定义一个大小比较的标准SortCompare
,这个SortCompare
要具有自反性,对称性和传递性;再定义sort
需要修改原数组并返回修改后的数组;最后定义由引擎开发者自己决定用什么排序算法来实现。Array.prototype.sort
排序方法是什么?
不是常见的排序算法中的任何一种,而是学习了 python 的排序实现,使用了 Tim 实现的Tim sort
算法,汇集了各种优化。- 复杂度是多少?
最好时间复杂度为O(n)
,平均时间复杂度O(nlogn)
,最坏时间复杂度为O(nlogn)
,比最常用的快速排序的最坏时间复杂度O(n²)
要更优秀;空间复杂度为O(n)
,并且为稳定排序算法。
前端记事本,不定期更新,欢迎关注!