[toc]
sssort 递归核心:优化的子串排序算法
这是 divsufsort
算法的递归心脏,也是执行实际比较和排序工作的函数。当 sort_typeBstar
将 B* 后缀按照前两个字符分入不同的“桶”后,sssort
(Substring Sort) 就被调用来对每一个桶内部的后缀进行完全、正确的排序。
原理与设计思路解析
sssort
不是一个简单的排序函数,它是一个混合排序 (Hybrid Sort) 算法,结合了多种排序策略的优点,以在不同规模的数据上都达到最佳性能。它主要围绕一种优化的归并排序变体来实现。
核心策略:非递归的分块归并排序 (Non-Recursive Block Merge Sort)
标准的归并排序是递归的,这会带来函数调用的开销。sssort
采用了一种迭代式、自底向上的实现方式,避免了深层递归。分块 (Blocking):
- 算法首先将需要排序的区间
[first, last)
分成大小为SS_BLOCKSIZE
的小数据块。SS_BLOCKSIZE
是一个编译时常量,通常是一个经过优化的值(比如1024)。 - 对每一个小数据块内部,它使用一种更快的排序算法(见下一点)进行内部排序。
- 算法首先将需要排序的区间
归并 (Merging):
- 在所有小块都内部有序之后,算法开始进行一系列的归并操作。
- 它会成对地合并相邻的块:先将大小为
SS_BLOCKSIZE
的块合并成大小为2 * SS_BLOCKSIZE
的有序块,然后将这些块再合并成4 * SS_BLOCKSIZE
的有序块,以此类推,直到整个区间[first, last)
完全有序。 - 代码中的
for(b = a, k = SS_BLOCKSIZE, j = i; j & 1; ...)
和for(k = SS_BLOCKSIZE; i != 0; ...)
这两层循环,正是通过巧妙的位运算来控制这个“滚雪球”式的归并过程。
混合排序策略
块内排序 (
ss_mintrosort
):- 对于每个
SS_BLOCKSIZE
大小的数据块,算法使用 IntroSort (内省排序)。 - IntroSort 本身就是一种混合算法,它以快速排序开始,但在递归深度过深时(有退化为
O(n^2)
的风险),会自动切换到堆排序(保证O(n log n)
的最坏情况)。而在数据规模变得非常小时,它又会切换到插入排序(对于小数据最高效)。 ss_mintrosort
是 IntroSort 的一个修改版,专门用于比较后缀(通过ss_compare
)。
- 对于每个
归并策略 (
ss_swapmerge
,ss_inplacemerge
):ss_swapmerge
: 这是主要的归并函数。它需要一个额外的缓冲区buf
来辅助归并操作,以达到最高效率。ss_inplacemerge
: 当可用的外部缓冲区buf
不足时(bufsize < limit
),算法会回退到一种原地归并 (In-place Merge) 策略。原地归并虽然不需要额外的缓冲区,但其速度通常比使用缓冲区的归并要慢。
对
lastsuffix
的特殊处理lastsuffix
是一个标志,用于指示当前正在排序的这个桶是否包含了整个字符串的最后一个B*后缀。- 由于这个后缀的比较行为有特殊性(它后面没有更多字符),算法会先暂时将其排除在排序之外 (
++first
)。 - 在整个桶排序完成后,再通过一个简单的线性插入过程,将这个被排除的后缀放回到它在已排序序列中的正确位置。
总结一下 sssort
的完整流程:
- 处理
lastsuffix
的特殊情况。 - 决定是使用外部缓冲区还是回退到原地归并模式。
- 将待排序区间划分为
SS_BLOCKSIZE
大小的块。 - 使用
ss_mintrosort
对每个小块进行高效的内部排序。 - 通过一系列迭代式的
ss_swapmerge
或ss_inplacemerge
操作,自底向上地将所有已排序的小块合并成一个完全有序的序列。 - 最后,将
lastsuffix
(如果存在)插入回正确的位置。
代码解析
1 | /* Substring sort */ |
ss_compare DivSufSort 的核心比较函数
这是 divsufsort
算法中最底层、最核心的比较函数。在 sssort
及其调用的各种排序算法(如 ss_mintrosort
, ss_insertionsort
)内部,每当需要判断两个后缀的字典序大小时,最终都会调用这个 ss_compare
函数。它的性能直接决定了整个排序过程的效率。
原理与设计思路解析
ss_compare
的设计目标是:尽可能快地比较两个由 p1
和 p2
间接指向的后缀。它充满了针对性能的微观优化。
核心功能:多关键字后缀比较
depth
参数: 这是实现多关键字排序 (Multikey Sort) 的关键。它告诉函数不要从后缀的起始位置开始比较,而是从偏移depth
之后的第一个字符开始。这极大地减少了在递归排序中对已知相同前缀的重复比较。- 比较逻辑: 函数的主体是一个
for
循环,它逐字节地比较两个后缀,只要字符相同,就不断向后移动指针 (++U1, ++U2
)。当遇到第一个不相同的字符,或者其中一个后缀到达末尾时,循环终止。
关键优化:利用
PA
数组进行长度限制- 这是一个非常巧妙且不易理解的优化。函数的参数中有一个
PA
数组,这个数组存储的是 B* 后缀的原始位置。 U1n = T + *(p1 + 1) + 2
和U2n = T + *(p2 + 1) + 2
这两行代码是在预计算比较的边界。*(p1 + 1)
的含义: 在sssort
的调用上下文中,p1
指向一个存储 B* 后缀在PA
数组中索引的列表。因此,PA + *p1
指向 B* 后缀的原始位置,而PA + *(p1 + 1)
则指向下一个 B* 后缀的原始位置。- 为什么
+ 2
? B* 后缀的定义保证了它们之间的最小距离至少是1(因为B*前一个是A),并且比较通常关注前两个字符之后的差异。这个+2
提供了一个安全的、经过经验优化的比较上限。 - 最终效果: 这种设计使得比较不会无限制地进行到整个字符串的末尾。它实际上是在比较两个 B* 后缀直到下一个 B* 后缀出现之前的这部分子串。这是一种启发式的局部比较,它基于一个假设:如果两个 B* 后缀在这段“有代表性”的区域内都无法分出胜负,那么它们很可能是相同的,可以留到后续的排名阶段去处理。这避免了在非常长的公共前缀上浪费时间。
- 这是一个非常巧妙且不易理解的优化。函数的参数中有一个
返回值的含义
- 函数返回一个
saint_t
(有符号整数),其值遵循strcmp
的约定:- > 0: 后缀1 > 后缀2
- < 0: 后缀1 < 后缀2
- == 0: 后缀1 == 后缀2 (在比较范围内)
- 函数返回一个
INLINE
关键字INLINE
(通常是__inline__
或inline
的宏) 是给编译器的强烈建议,将这个函数的代码直接嵌入到调用它的地方(如ss_insertionsort
的循环内部),而不是通过常规的函数调用(这会有压栈、跳转等开销)。- 对于这种在最内层循环中被海量调用的、短小的函数,内联是至关重要的性能优化。
代码解析
1 | /* Compares two suffixes. */ |
ss_insertionsort 为小数据组优化的插入排序
这是 ss_mintrosort
在其混合排序策略的最后阶段调用的函数。当待排序的数据块规模变得非常小(小于等于 SS_INSERTIONSORT_THRESHOLD
)时,ss_mintrosort
会放弃复杂的快速排序或堆排序,转而调用这个高度特化的插入排序函数。
原理与设计思路解析
虽然名为插入排序,但它并非教科书上简单的实现。它经过了深度定制,以适应 divsufsort
算法的内部工作机制,特别是其对相等后缀组的处理。
核心策略:标准插入排序框架
- 算法的整体结构遵循经典的插入排序。它从右到左遍历数组(
for(i = last - 2; ...)
),将每个元素t = *i
看作是待插入的“牌”。 - 对于每张“牌”
t
,它向右进行一个内部循环(for(...; 0 < (r = ss_compare(...));)
),找到t
应该被插入的正确位置。 - 在寻找位置的过程中,它将所有比
t
大的元素都向右移动一格,为t
腾出空间。
- 算法的整体结构遵循经典的插入排序。它从右到左遍历数组(
关键优化与特殊设计
后缀比较而非整数比较:
- 排序的依据不是数组中存储的整数值本身,而是这些整数作为索引所指向的后缀子串。
- 所有的比较都通过
ss_compare(T, PA + t, PA + *j, depth)
来完成。这个函数会从第depth
个字符开始,比较后缀t
和后缀*j
的字典序。
相等后缀的识别与标记 (核心特性):
- 这是此函数最关键的特殊之处。它不仅仅在排序,它还在识别并标记那些彼此完全相等的后缀。
- 当
ss_compare
返回r == 0
时,意味着后缀t
和后缀*j
是完全相同的。 - 此时,代码执行
*j = ~*j;
。这个按位取反的操作会将一个非负整数变成一个负数。 - 这个负数就成了一个标记,告诉
divsufsort
的上层调用者:“这个位置的后缀*j
与它前一个位置的后缀是相同的”。这个信息对于后续的排名计算 (sort_typeBstar
中的阶段四) 至关重要。这是一种在原地(in-place)传递额外信息的、非常节省内存的技巧。
分组移动 (Group Shifting):
- 内部的移位循环
do { *(j - 1) = *j; } while((++j < last) && (*j < 0));
也不是一个简单的单元素移动。 (*j < 0)
这个条件检查的就是上一步留下的“相等标记”。- 这个
do-while
循环的含义是:“将*j
移到*(j-1)
,然后只要下一个元素*j
还是一个标记(负数),就继续移动”。 - 这实际上实现了对整个已标记的“相等后缀组”的一次性、连续的移动,而不是逐个移动组内元素,从而提高了效率。
- 内部的移位循环
为什么对小数据使用插入排序?
- 低开销: 快速排序和堆排序虽然在大数据量上表现优异,但它们的函数调用、分区或建堆操作本身有固定的开销。对于只有十几个元素的小数组,这些开销占比很高。
- 缓存友好: 插入排序是线性扫描,具有优秀的缓存局部性,在现代CPU上执行效率很高。
- 对近乎有序的数据高效: 当数据已经部分有序时,插入排序的性能接近
O(n)
。在递归排序的后期,小数据块往往已经具有一定的有序性。
代码解析
1 |
|
ss_heapsort & ss_fixdown 最坏情况保障:堆排序
这是 ss_mintrosort
内省排序算法的第三个组成部分,也是其性能稳定性的安全保障。当 ss_mintrosort
检测到快速排序的递归深度过深(有退化为O(n^2)
的风险)时,它会立即放弃快速排序,转而调用这个 ss_heapsort
函数来对当前数据块进行排序。
原理与设计思路解析
这组函数实现了一个经典的堆排序 (Heapsort) 算法。堆排序的核心是利用了“堆”这种数据结构的特性。
什么是堆 (Heap)?
- 在这里,我们讨论的是最大堆 (Max-Heap)。一个最大堆是一个概念上的二叉树,它满足一个性质:任何父节点的值都大于或等于其子节点的值。
- 在数组实现中,如果一个节点的索引是
i
,那么它的左子节点索引是2*i + 1
,右子节点是2*i + 2
。
ss_heapsort
(堆排序主流程)
堆排序主要分两个阶段:建堆 (Heapify):
for(i = m / 2 - 1; 0 <= i; --i) { ss_fixdown(Td, PA, SA, i, m); }
- 这一步是将一个无序的数组
SA
转换成一个满足最大堆性质的数组。 - 它从最后一个非叶子节点 (
m/2 - 1
) 开始,向前遍历到根节点 (0
),对每个节点都调用一次ss_fixdown
。ss_fixdown
会确保以i
为根的子树满足最大堆性质。当这个循环结束后,整个数组就变成了一个最大堆。 - 此时,数组的第一个元素
SA[0]
就是整个数组中的最大值(即字典序最大的后缀)。
排序 (Sorting):
for(i = m - 1; 0 < i; --i) { ... }
- 这个循环不断地从堆中取出最大元素,并放到它最终应该在的位置(数组的末尾)。
- 步骤 a:
SWAP(SA[0], SA[i])
(在divsufsort
的实现中,通过变量t
完成了交换)。将当前堆的根(最大元素)与堆的最后一个元素交换。这样,最大的元素就被放到了数组的末尾i
,这个位置就排好序了。 - 步骤 b:
ss_fixdown(Td, PA, SA, 0, i)
。交换后,新的根节点SA[0]
很可能破坏了堆的性质。因此,需要再次调用ss_fixdown
,从根节点开始,向下调整,让新的最大值“浮”到根部,从而在[0, i-1]
的范围内恢复堆的性质。 - 这个过程不断重复,每次都将当前最大的元素放到已排序部分的开头,堆的大小减一,直到整个数组排序完成。
ss_fixdown
(向下调整)- 这是维护堆性质的核心辅助函数,也叫 “heapify down” 或 “sift down”。
- 当一个节点
i
的值可能小于其子节点时,ss_fixdown
被调用。 - 它会比较节点
i
与其左右子节点的值,找出三者中的最大值。 - 如果最大的值不在节点
i
,它就将最大值的子节点与节点i
进行交换。 - 然后,它会以被交换下去的那个子节点为新起点,递归地继续向下调整,直到当前子树重新满足最大堆的性质。
比较的特殊性
- 和
ss_insertionsort
一样,这里的比较并不是简单的整数比较。 - 所有大小判断都是通过
Td[PA[SA[i]]]
来获取后缀在depth
偏移处的字符值。它只比较单个字符来维护堆的性质,这使得堆排序在这个多关键字排序的框架下非常高效。
- 和
为什么需要堆排序?
- 最坏情况保障: 堆排序的时间复杂度稳定在
O(n log n)
,它没有任何会导致性能退化的“病态”输入。 - 安全阀: 在
ss_mintrosort
中,它充当了一个“安全阀”。ss_mintrosort
可以尽情地使用平均性能极佳的快速排序,一旦发现有性能退化的风险,就立刻切换到堆排序,保证了整个排序过程的性能下限,使得算法既快速又稳健。
代码解析
1 |
|
ss_mintrosort 深度优化的内省排序
这是 sssort
内部调用的、用于对中等大小数据块(SS_BLOCKSIZE
)进行排序的核心算法。函数名 mintrosort
意为 Multikey Introspective Sort (多关键字内省排序),它是一种经过深度定制和优化的内省排序算法,专门用于对后缀数组进行排序。
原理与设计思路解析
标准的内省排序(IntroSort)结合了快速排序、堆排序和插入排序的优点。ss_mintrosort
在此基础上,针对后缀排序的特殊性进行了多项关键优化。
核心策略:混合排序 (Hybrid Sort)
这是一个非递归的实现,通过一个手动管理的栈 (stack
) 来模拟递归。快速排序 (Quicksort) 阶段:
- 算法的主体是基于三路快速排序 (3-Way Quicksort),也称为荷兰国旗问题的变体。这种排序方式非常适合处理有大量重复元素的场景(比如,很多后缀可能以相同的字符开头)。
- 它一次划分会将数据分为三部分:
< pivot
(小于主元),= pivot
(等于主元),> pivot
(大于主元)。 - 之后,它只需对
< pivot
和> pivot
两部分进行递归排序。对于= pivot
的部分,由于它们的当前字符都相同,只需将它们的递归深度depth
加一,然后对这整个部分进行下一轮排序。这被称为多关键字排序 (Multikey Sort),因为它有效地利用了字符串的结构,避免了不必要的重复比较。
堆排序 (Heapsort) 阶段 (最坏情况保障):
- 快速排序在处理特定数据(如已排序或逆序)时,有退化为
O(n^2)
的风险。 ss_mintrosort
通过一个limit
计数器来跟踪递归深度。如果limit
减到0(意味着递归太深),算法会立即切换到堆排序 (ss_heapsort
)。- 堆排序能保证
O(n log n)
的最坏情况时间复杂度,从而避免了快速排序的性能陷阱。
- 快速排序在处理特定数据(如已排序或逆序)时,有退化为
插入排序 (Insertion Sort) 阶段 (小数据优化):
- 当待排序的数据块大小小于等于一个阈值
SS_INSERTIONSORT_THRESHOLD
时,算法会切换到插入排序 (ss_insertionsort
)。 - 对于小规模数据,插入排序由于其简单的循环结构和良好的缓存局部性,通常比复杂的快速排序或堆排序更快。
- 当待排序的数据块大小小于等于一个阈值
关键优化与实现细节
非递归实现 (
stack
):- 为了避免深层递归导致的栈溢出风险,并可能获得更好的性能,该函数使用一个固定大小的数组
stack
和STACK_PUSH
/STACK_POP
宏来手动模拟函数调用栈。这使得算法的控制流变得复杂,但更健壮、更高效。
- 为了避免深层递归导致的栈溢出风险,并可能获得更好的性能,该函数使用一个固定大小的数组
多关键字处理 (
depth
):depth
参数是多关键字排序的核心。它告诉比较函数应该从后缀的第depth
个字符开始比较,而不是每次都从头开始。- 在处理
= pivot
的分区时,只需增加depth
并对整个分区进行下一轮排序,这大大提高了效率。Td = T + depth;
这一行就是用于快速定位到比较的起始字符。
主元选择 (
ss_pivot
):- 快速排序的性能严重依赖于主元 (pivot) 的选择。一个好的主元可以将数据均匀地划分。
ss_pivot
函数(此处未显示)通常会实现一种健壮的主元选择策略,如三数取中法 (Median-of-Three),以避免选择到最大或最小的元素。
复杂的分区逻辑 (
partition
):- 代码中的
partition
部分极其复杂。它不仅实现了三路划分,还进行了大量的交换 (SWAP
) 操作,以原地方式将< pivot
,= pivot
,> pivot
三个部分整理到位的正确位置。 for(e = first, f = b - s; ...)
等循环是在执行经典的块交换 (Block Swap) 操作,用于将不同的分区移动到最终位置。
- 代码中的
代码解析
1 | /* Multikey introsort for medium size groups. */ |
ss_pivot & median helpers 健壮的主元选择
这是 ss_mintrosort
内省排序算法中至关重要的一环。这组函数的核心任务是为快速排序的分区 (partition) 操作选择一个高质量的主元 (pivot)。一个好的主元选择是避免快速排序退化到 O(n^2)
最坏情况的关键。
原理与设计思路解析
快速排序的性能严重依赖于主元的选择。如果每次都选到最大或最小的元素,分区就会极度不平衡,导致算法性能急剧下降。为了解决这个问题,ss_pivot
实现了一种自适应的、基于采样的主元选择策略。
核心策略:中位数取样 (Median Sampling)
该策略的基本思想是:不随机选择主元,而是从待排序的序列中选取少数几个元素,然后用这几个元素的中位数作为最终的主元。这个中位数更有可能接近整个序列的真实中位数,从而产生更平衡的划分。自适应策略 (Adaptive Strategy)
ss_pivot
函数会根据待排序数组的大小t
,动态地选择不同的采样策略,在采样质量和计算开销之间取得平衡:小数组 (
t <= 32
): 三数取中 (ss_median3
)- 对于非常小的数组,随机选择导致性能退化的概率很低。
- 算法只取序列的第一个、中间、最后一个元素,然后调用
ss_median3
找出这三个数的中位数。这是一种开销极小且非常有效的策略。
中等数组 (
32 < t <= 512
): 五数取中 (ss_median5
)- 随着数组增大,从更多样本中选择可以得到更高质量的主元。
- 算法会从序列中均匀地选取五个元素(
first
,first+t/4
,middle
,last-1-t/4
,last-1
),然后调用ss_median5
找出这五个数的中位数。
大数组 (
t > 512
): 九数取中 / 中位数的中位数 (Median of Medians of Three
)- 对于非常大的数组,需要一种更稳健的策略。这里采用了一种被称为**“中位数的中位数”**的变体。
- 步骤 a: 将数组逻辑上分为开头、中间、末尾三个区域。
- 步骤 b: 在每个区域内部,都使用三数取中 (
ss_median3
) 找出一个局部的中位数。 - 步骤 c: 最后,再对这三个局部的中位数调用一次
ss_median3
,找出最终的主元。 - 这种方法从9个采样点中选出主元,极大地降低了选出“坏”主元的概率,为大规模数据的分区平衡提供了强有力的保障。
ss_median3
和ss_median5
的实现
这两个函数通过一系列精心安排的比较和交换 (SWAP
) 操作,以最少的比较次数找出3个或5个元素的中位数。它们本质上是小型的排序网络 (Sorting Network)。
代码解析
1 | /*---------------------------------------------------------------------------*/ |
ss_partition 特殊情况下的二路分区
这是一个非常特殊且高度优化的分区 (Partition) 函数,它并不是 ss_mintrosort
中主要的三路分区逻辑,而是一个在特定情况下被调用的二路分区辅助工具。
原理与设计思路解析
ss_partition
的设计目标是在一个已经部分有序的、并且只包含两种类型元素的序列中,快速地将这两类元素分开。
调用场景:
这个函数在ss_mintrosort
的两个特定、相对少见的场景中被调用:- 当
limit < 0
时,表明堆排序已经执行过,序列已经宏观有序。但可能存在一些相等的前缀组需要被进一步细分。 - 当三路分区后,所有元素都等于主元
v
时,需要根据**下一个字符(depth+1
)**来对这个等价类进行再次划分。
- 当
核心逻辑:Hoare 分区方案的变体
该函数实现了类似于Hoare 分区方案 (Hoare Partition Scheme) 的思想。- 它使用两个指针
a
和b
,分别从序列的两端向中间移动。 - 指针
a
从左向右 (++a
) 寻找一个“错位”的元素。 - 指针
b
从右向左 (--b
) 寻找另一个“错位”的元素。 - 一旦两个指针都找到了错位的元素,并且
a < b
,就将这两个元素交换。 - 当
b <= a
时,两个指针交错或相遇,分区完成。
- 它使用两个指针
特殊的比较条件 (
(PA[*a] + depth) >= (PA[*a + 1] + 1)
)- 这是理解此函数最关键、也最费解的部分。它并不是在比较后缀的内容。
- 上下文: 调用此函数的序列
[first, last)
中的所有后缀,在depth
深度上都具有相同的前缀。 PA
数组的含义:PA
数组存储的是 B* 后缀的原始位置。- 比较的真正含义: 这个条件实际上是在检查一个结构性属性。它在判断后缀
*a
是否是 B* 后缀*(a+1)
的直接前驱(即PA[*a] == PA[*(a+1)] - 1
)。更广义地说,它是在区分两种后缀:- 第一类: 那些是另一个 B* 后缀的直接前驱的后缀。
- 第二类: 所有其他后缀。
- 它利用了这个结构性特征来将序列一分为二,这是一种非常高效的划分方式,因为它完全避免了昂贵的字符串比较。
使用按位取反
~
进行标记*a = ~*a;
: 当指针a
找到一个满足条件的元素(第一类)时,它会先通过按位取反将其标记为负数。t = ~*b;
: 在交换之前,它会对从b
位置取出的元素*b
也进行一次取反。- 目的: 这种标记有两个作用:
- 它可能用于在分区结束后,快速地将所有被标记的元素恢复原状 (
if(first < a) { *first = ~*first; }
)。 - 更重要的是,这种标记方式与
ss_insertionsort
中的标记是兼容的,它们共同构成了divsufsort
内部一套复杂的、用于在原地传递额外信息的编码系统。
- 它可能用于在分区结束后,快速地将所有被标记的元素恢复原状 (
代码解析
1 | /** |
ss_ilg 快速整数对数计算
这个函数 ss_ilg
(Integer Logarithm) 是一个用于快速计算整数以2为底的对数(即 floor(log2(n))
)的辅助函数。在 ss_mintrosort
中,这个值被用来确定快速排序的最大允许递归深度 limit
,从而有效地防止算法性能退化。
原理与设计思路解析
计算对数通常是一个相对耗时的操作。为了在性能敏感的排序算法中避免这种开销,ss_ilg
采用了几种高度优化的、基于预计算和位运算的技巧。
核心策略:查表法 (Lookup Table)
该函数的核心是lg_table
这个静态常量数组。lg_table
的内容: 这个表预先存储了从0到255这256个整数的以2为底的对数。例如,lg_table[1]
=0,lg_table[2]
=1,lg_table[3]
=1,lg_table[4]
=2,lg_table[31]
=4,lg_table[32]
=5, …- 优势: 通过牺牲少量内存(256个整数),可以将一个可能需要循环或复杂指令的对数运算,转换成一次极快的数组索引操作。
编译时配置与自适应实现
函数通过预处理器宏#if
,根据编译时设置的SS_BLOCKSIZE
,选择最合适的实现方式:SS_BLOCKSIZE == 0
(TRSort 模式):- 在这种模式下,算法会调用另一个(此处未显示的)函数
tr_ilg(n)
。这表明trsort
算法有自己的一套整数对数计算实现,ss_ilg
只是直接复用它。
- 在这种模式下,算法会调用另一个(此处未显示的)函数
SS_BLOCKSIZE < 256
(小块模式):- 这是最直接的查表法。
- 由于
SS_BLOCKSIZE
(即ss_mintrosort
处理的最大数据块) 小于256,那么传入ss_ilg
的n
(即last - first
)也必然小于256。 - 因此,可以直接用
lg_table[n]
来查找结果,这是一步到位的最快方法。
SS_BLOCKSIZE >= 256
(大块模式 - 默认):- 这是最巧妙的实现。它将一个(通常是16位的)整数
n
拆分成高8位和低8位来处理,从而将256大小的查找表扩展到能处理更大的数。 return (n & 0xff00) ? ... : ...;
n & 0xff00
: 这个位运算检查n
的高8位是否不为0。- 如果为真 (e.g., n = 300 = 0x012C):
- 说明
n
至少是256。那么它的对数至少是8。 8 + lg_table[(n >> 8) & 0xff]
(n >> 8) & 0xff
: 右移8位,取出高8位的值(对于300,结果是1)。lg_table[1]
的结果是0
。- 最终结果是
8 + 0 = 8
,这正是floor(log2(300))
的正确结果。
- 说明
- 如果为假 (e.g., n = 100 = 0x64):
- 说明
n
小于256。 0 + lg_table[(n >> 0) & 0xff]
- 直接用
n
的低8位(也就是n
本身)查表。 lg_table[100]
的结果是6
,即floor(log2(100))
的正确结果。
- 说明
- 如果为真 (e.g., n = 300 = 0x012C):
- 这是最巧妙的实现。它将一个(通常是16位的)整数
为什么这个函数很重要?
limit = ss_ilg((saidx_t)(last - first))
这一行代码在ss_mintrosort
的开头和每次压栈时都会被调用。limit
的值直接关系到内省排序何时从快速排序切换到堆排序。- 一个快速、准确的
ss_ilg
实现,是保证ss_mintrosort
能够在不牺牲性能的情况下,又能获得最坏情况安全保障的基石。
代码解析
1 | // 预计算的整数对数查找表,存储了 0-255 的 floor(log2(n)) 的值。 |
ss_swapmerge 分治交换归并
- 这是
sssort
中负责归并两个已排序子区间的核心算法。它不是一个简单的归并,而是一种高度优化的、基于分治思想的原地归并 (In-place Merge) 变体。
原理与设计思路解析
标准归并排序需要 O(n)
的额外空间来合并两个子数组。ss_swapmerge
的设计目标是在有限的额外缓冲区 (buf
) 的帮助下,尽可能地完成原地归并,以减少内存消耗。它的核心思想来源于经典的块交换 (Block Swap) 和分治归并算法。
核心策略:找到中点并交换 (Find Median and Swap)
算法并没有从头到尾逐个元素比较。相反,它试图找到一个“分割点”,将问题分解成更小的、独立的子问题。寻找分割点:
for(m = 0, len = ..., half = ...)
这个循环是整个算法最精妙的部分。它在两个相邻的已排序子区间[first, middle)
和[middle, last)
的交界处,通过二分查找,寻找一个最佳的分割点。- 它要找到一个位置
m
,使得[middle-m, middle)
中的所有元素都大于[middle, middle+m)
中的所有元素。换句话说,middle-m
是[first, middle)
中第一个应该被移到[middle, last)
右边的元素,而middle+m
是[middle, last)
中最后一个应该留在原地的元素。 ss_compare(T, PA + GETIDX(*(middle + m + half)), ...)
这一行正是在执行二分查找的比较步骤。
块交换 (Block Swap):
- 一旦找到了这个分割点
m
,算法就知道[middle-m, middle)
这m
个元素(来自左边区间)和[middle, middle+m)
这m
个元素(来自右边区间)放错了位置。 ss_blockswap(lm, middle, m);
这一句执行一个块交换,将这两个大小为m
的数据块原地交换位置。- 交换之后,
[first, middle-m)
和[middle+m, last)
两个外围区域的元素都在它们的最终正确区间内了。
- 一旦找到了这个分割点
分治递归:
- 经过一次块交换,问题被分解成了两个更小的、独立的归并子问题:
- 归并
[first, middle-m)
和[middle-m, l)
- 归并
[r, middle+m)
和[middle+m, last)
- 归并
- 算法通过
STACK_PUSH
将这两个子问题中的一个(通常是较大的那个)压入手动管理的栈中,然后通过修改first
,middle
,last
指针,立即迭代处理另一个子问题。这就实现了分治递归。
- 经过一次块交换,问题被分解成了两个更小的、独立的归并子问题:
利用有限的缓冲区 (
buf
)- 当分治递归到子问题足够小,小到可以完全装入提供的缓冲区
buf
时 ((last - middle) <= bufsize
或(middle - first) <= bufsize
),算法就会切换到最高效的模式。 - 它会调用
ss_mergebackward
或ss_mergeforward
。这两个函数会利用buf
作为临时空间,执行一次标准的、线性的归并操作,这比复杂的分治交换要快得多。这正是缓冲区的价值所在。
- 当分治递归到子问题足够小,小到可以完全装入提供的缓冲区
处理相等后缀 (
MERGE_CHECK
和GETIDX
)- 与
ss_insertionsort
类似,归并过程也需要正确处理和传递那些被标记为“与前一个相等”的后缀(负数)。 GETIDX(a)
宏的作用是在比较前,临时地将被标记的负数~a
恢复成正数a
,以便能正确地用它作为PA
数组的索引。MERGE_CHECK(a, b, c)
宏在归并完成后被调用,它的任务是检查新形成的有序序列的边界处,如果边界两侧的后缀相等,就要正确地设置标记。check
变量通过位标志 (&1
,&2
,&4
) 来传递边界的状态信息。
- 与
代码解析
1 | /* D&C based merge. (Divide and Conquer based merge) */ |
ss_mergebackward 利用缓冲区的回溯归并
这是 ss_swapmerge
在其分治递归的基本情况 (Base Case) 下调用的高效线性归并函数之一。当待归并的右半部分 [middle, last)
的大小足以完全放入提供的缓冲区 buf
时,ss_swapmerge
会放弃复杂的分治交换,转而调用这个 ss_mergebackward
函数。
原理与设计思路解析
这个函数实现了一种回溯式 (Backward) 的标准归并算法,并针对 divsufsort
的内部数据结构(特别是对相等后缀的负数标记)进行了深度优化。
核心策略:回溯归并 (Merge Backward)
空间准备:
ss_blockswap(buf, middle, (saidx_t)(last - middle));
- 算法的第一步,也是最关键的一步,是将右半部分
[middle, last)
的全部内容移动到缓冲区buf
中。 - 这样一来,原数组中
[middle, last)
的区域就空闲了出来,可以作为最终合并结果的写入区域。
双指针回溯比较:
- 算法使用三个指针,都从右向左移动:
c
: 指向左半部分[first, middle)
的末尾。b
: 指向缓冲区buf
(即原右半部分) 的末尾。a
: 指向最终合并区域[first, last)
的末尾,作为写入指针。
for(...;;)
循环不断地比较*c
和*b
所指向的后缀。- 将较大的那个元素复制到
*a
的位置,然后将对应的源指针(c
或b
)和写入指针a
都向左移动一格。
- 算法使用三个指针,都从右向左移动:
收尾:
- 当其中一个源指针(
c
或b
)到达其区间的开头时,循环终止。 - 此时,只需将另一个源指针所在区间中所有剩余的元素(它们必然是最小的)全部复制到目标区域的剩余位置即可。
- 当其中一个源指针(
为什么是“回溯”?
- 因为写入指针
a
从last-1
开始,向first
移动。 - 这种方式的优点在于,写入操作不会覆盖尚未被比较过的源数据(因为源数据在写入指针的左边)。这使得归并操作可以在一个几乎原地的空间(只需要一个较小的缓冲区)内完成。
- 因为写入指针
对相等后缀标记的处理
- 这是此函数最复杂的部分。代码必须在归并的同时,正确地处理和传递那些被标记为负数的相等后缀。
if(*bufend < 0) ... else ...
: 在开始比较之前,代码会预先检查两个源指针指向的第一个元素是否是负数标记,并用一个位掩码x
记录下来。if(x & 1) { do { *a-- = *b, *b-- = *a; } while(*b < 0); x ^= 1; }
: 这是一个典型的处理逻辑。当要移动一个元素*b
时,代码会检查x
的相应位。如果为1,说明*b
是一个相等组的开始。于是,它会进入一个do-while
循环,将整个连续的负数标记组一次性地移动过去,然后清除标志位x
。*a-- = ~*b;
: 当比较发现两个后缀相等时 (r == 0
),它会在写入时,将其中一个标记为负数,以保持这个相等关系。
代码解析
1 | static INLINE |
ss_inplacemerge 原地归并
这是 ss_swapmerge
在无法使用足够大的外部缓冲区时所采用的备选策略。当 ss_swapmerge
的分治过程进行到最后,需要合并两个子区间,但剩余的可用缓冲区 buf
非常小甚至没有时,它就会调用这个 ss_inplacemerge
函数来完成最后的合并工作。
原理与设计思路解析
ss_inplacemerge
实现了一种原地归并 (In-place Merge) 算法。原地归并的目标是在不使用或只使用 O(1)
额外空间的情况下,合并两个相邻的有序序列。这通常比使用缓冲区的归并要慢,但能极大地节省内存。
这个函数实现的是一种基于循环旋转 (Rotation) 的原地归并算法。
核心策略:逐个插入与旋转
算法的整体思路可以看作是,不断地从右半部分[middle, last)
中取出元素,然后为它在左半部分[first, middle)
中找到正确的插入位置,最后通过一次旋转操作将元素放到位。选择元素:
for(;;)
主循环从右半部分的最大元素开始处理,即*(last - 1)
。
在左半部分寻找插入点:
for(a = first, len = ...)
这一段是一个二分查找。- 它的目标是在左半部分
[first, middle)
中,找到第一个大于或等于*(last - 1)
的元素。这个位置a
就是*(last - 1)
应该被插入的地方。
执行旋转 (
ss_rotate
):- 一旦找到了插入点
a
,我们就知道了[a, middle)
这个子区间的元素都比*(last - 1)
小,但它们现在的位置不对,应该被移动到*(last - 1)
的后面。 ss_rotate(a, middle, last)
这个函数调用执行一次块旋转操作。它会将[a, middle)
和[middle, last)
这两个序列进行三次反转,从而高效地将[middle, last)
移动到a
的位置,并将[a, middle)
移动到last
的位置。- 效果: 旋转完成后,
*(last-1)
(现在是新序列的某个位置)以及右半部分所有比它小的元素都被正确地归并到了左半部分。
- 一旦找到了插入点
缩小问题规模:
last -= middle - a;
: 旋转后,我们知道有middle - a
个元素从左半部分移动到了序列的末尾,所以我们将last
指针向左移动这么多,相当于缩小了待处理问题的规模。middle = a;
: 新的middle
指针就是刚刚找到的插入点a
。- 然后,
--last
将已经放到正确位置的最大元素排除在下一次迭代之外。 - 算法继续在缩小的
[first, last)
范围内重复这个过程,直到所有右半部分的元素都被正确地归并进来。
对相等后缀的处理
- 与之前的函数一样,它也需要正确处理负数标记。
if(*(last - 1) < 0) { x = 1; p = PA + ~*(last - 1); }
: 在比较前,先检查元素是否被标记,并获取其真实索引。if(r == 0) { *a = ~*a; }
: 在二分查找找到插入点时,如果发现插入点a
的后缀与待插入的后缀相等,就在*a
上设置负数标记。if(x != 0) { while(*--last < 0) { } }
: 在缩小last
范围时,如果当前处理的元素是一个相等组的开始,这个循环会跳过整个连续的负数标记组。
代码解析
1 | /*---------------------------------------------------------------------------*/ |
ss_blockswap & ss_rotate 底层内存块操作
这两个函数是 ss_swapmerge
和 ss_inplacemerge
等更高级算法能够实现原地 (in-place) 操作的基石。它们负责高效地移动内存块,而无需分配大量的额外空间。
ss_blockswap
原理与设计思路解析
ss_blockswap
的功能非常直接:交换两个不重叠、大小相同的内存块。
核心策略:逐元素交换 (Element-wise Swap)
- 该函数实现了一个简单的
for
循环。 - 在每次循环中,它交换
a
指针和b
指针当前指向的两个元素。 - 然后两个指针都向前移动一格,重复这个过程
n
次,直到两个块的所有元素都被交换完毕。 - 这是一种最基础、最直接的实现方式。
- 该函数实现了一个简单的
性能考量
INLINE
关键字建议编译器将这个循环直接内联到调用它的地方,以避免函数调用的开销。- 在现代编译器和CPU上,这种简单的循环通常能被很好地优化,可能会被自动向量化(使用SIMD指令)来一次性交换多个元素,从而达到很高的效率。
代码解析
1 | /** |
ss_rotate
原理与设计思路解析
ss_rotate
的功能要复杂得多:将一个序列 [first, last)
中的两个相邻子序列 [first, middle)
和 [middle, last)
进行位置互换。这等同于标准库中的 std::rotate
。
核心策略:Gries-Mills 算法 / 三次反转法 (Three Reversals)
这个函数实现了一种非常巧妙且高效的原地旋转算法,通常被称为 Gries-Mills 算法或三次反转法。但这里的具体实现看起来更像是一个基于块交换的迭代版本,也被称为**“手摇”算法 (Juggling Algorithm)** 的变体。让我们用一个更简单的三次反转法来理解其原理:
要将AB
旋转成BA
(其中A
是[first, middle)
,B
是[middle, last)
),只需三步:- 反转
A
->A^r B
- 反转
B
->A^r B^r
- 反转整个
A^r B^r
->(A^r B^r)^r
->B A
而代码中实现的“手摇”算法的原理是:
- 比较大小: 比较两个子序列
A
和B
的长度。 - 交换短序列:
- 如果
A
比B
短 (l < r
),就将A
与B
的末尾等长部分进行块交换。序列变为B_prefix A B_suffix
->B_suffix A B_prefix
。现在A
已经被移动到了正确的位置群组,问题规模缩小为对B_prefix
和B_suffix
进行旋转。 - 如果
B
比A
短 (l > r
),就将B
与A
的开头等长部分进行块交换。序列变为A_prefix A_suffix B
->B A_suffix A_prefix
。现在B
已经被移动到了正确位置,问题规模缩小。
- 如果
- 迭代: 在缩小后的序列上重复这个过程,直到其中一个子序列长度为0。
代码中的
if(l < r)
和else
块正是实现了这个逻辑,但它使用了更复杂的、看起来像“冒泡”的do-while
循环来移动元素,而不是直接调用ss_blockswap
,这可能是为了处理某些特定的内存访问模式或边界情况。if(l == r)
是一个特殊情况的优化,当两个块大小相同时,直接调用一次ss_blockswap
即可完成。- 反转
代码解析
1 | /** |
ss_isqrt 快速整数平方根
ss_isqrt
(Integer Square Root) 是一个高度优化的辅助函数,它的唯一目标是快速地计算一个整数的平方根并向下取整(即 floor(sqrt(x))
)。这个函数在 sssort
中被调用,用于决定在无法使用外部缓冲区时,应该采用多大的 limit
来进行原地归并。
原理与设计思路解析
计算平方根是一个计算密集型操作,尤其是在一个没有硬件浮点单元支持的环境中。为了避免在性能敏感的排序算法中引入昂贵的数学库函数调用,ss_isqrt
采用了多种技巧相结合的策略,包括查表法、位运算和牛顿迭代法。
核心策略:分段近似与迭代修正
该算法的基本思想是,不直接计算精确值,而是:- 快速估算: 首先通过查表和位运算,得出一个与真实平方根非常接近的初始估算值
y
。 - 迭代修正: 然后,使用牛顿-拉弗森法 (Newton-Raphson method) 对这个估算值进行一到两次迭代,使其快速收敛到精确的整数平方根。
- 快速估算: 首先通过查表和位运算,得出一个与真实平方根非常接近的初始估算值
lg_table
和sqq_table
:双表驱动
该函数依赖两个预计算的查找表:lg_table
(对数表): 我们之前已经分析过,它用于快速计算floor(log2(x))
。在这里,它被用来快速确定整数x
的最高有效位 (Most Significant Bit) 的位置,这可以告诉我们x
的大致数量级。sqq_table
(平方根-平方表):- 这个表是
ss_isqrt
的核心。sqq_table[i]
存储的是floor(sqrt(i * 2^6))
或类似的值,它本质上是一个对0-255范围内的数进行平方根运算并放大了的定点数表。 - 通过将输入
x
的最高几位提取出来作为索引去查sqq_table
,我们可以得到一个精度相当不错的平方根初始估算值。
- 这个表是
执行流程详解
最高有效位确定 (
e
):e = (x & 0xffff0000) ? ... : ...;
- 这一大段代码与
ss_ilg
的逻辑类似,它通过检查x
的不同字节段是否为零,并结合lg_table
,快速地计算出x
的最高有效位所在的比特位置e
。例如,如果x
是 1000 (二进制1111101000
),那么e
大约是 9。
分段处理与初始估算 (
y
):e >= 16
(大数):x >> ((e - 6) - (e & 1))
: 这是一个复杂的位移操作。它的目的是将x
的最高8个有效位对齐,并用这个结果去查sqq_table
。<< ((e >> 1) - 7)
: 将查表得到的结果根据e
的值进行相应的左移,将其“放缩”回正确的数量级,得到初始估算值y
。
e >= 8
(中数): 逻辑类似,但移位操作不同。e < 8
(小数): 对于小于256的数,可以直接查sqq_table
并通过右移得到一个足够好的近似值。
牛顿法迭代修正:
y = (y + 1 + x / y) >> 1;
- 这就是牛顿-拉弗森法求解平方根
y = sqrt(x)
的整数版本迭代公式。其基本思想是:如果y
是x
平方根的一个估算,那么x/y
会是另一个估算(一个偏大,一个偏小),它们的平均值(y + x/y) / 2
会是一个更好的估算。 - 对于大数,代码执行了一到两次这个迭代,这足以让初始估算值快速收敛到精确的整数平方根。
最终修正:
return (x < (y * y)) ? y - 1 : y;
- 由于整数运算的截断误差,最终得到的
y
可能是正确的floor(sqrt(x))
,也可能比正确值大1。 - 这一行代码通过一次最终的比较来进行修正,确保返回的是正确的向下取整的结果。
代码解析
1 | // 预计算的平方根查找表。 |