[TOC]
saisxx (顶层API)
saisxx
是整个 sais.hxx
库提供给外部使用的公共接口函数。它本身不执行任何复杂的算法逻辑,其主要作用是作为一层封装,进行参数的合法性检查,处理一些简单的边界情况,并调用内部的核心实现 saisxx_private::suffixsort
。
原理与设计思路解析
API 设计:
- 函数签名
template<typename string_type, typename sarray_type, typename index_type>
表明它是一个高度通用的模板函数。它可以接受任何满足随机访问迭代器 (Random Access Iterator) 条件的类型作为输入字符串T
和输出数组SA
。例如,T
可以是const char*
、std::string
或std::vector<unsigned char>
;SA
可以是int*
或std::vector<long>
。 k = 256
的默认参数,表明它默认处理的是8位字符(如ASCII, UTF-8字节流)的字符串。
- 函数签名
参数校验与断言:
assert(...)
: 在函数开头有一系列static_assert
(在C++11之前通常用assert
模拟)来在编译期或调试时检查传入的整数类型index_type
和savalue_type
是否满足算法内部的要求(例如,它们必须是有符号整数,并且范围相同)。if((n < 0) || (k <= 0)) { return -1; }
: 对输入的字符串长度n
和字符集大小k
进行运行时的合法性检查,确保它们是有效的。
边界情况处理 (Edge Cases):
if(n <= 1) { ... }
: 算法对长度为0或1的字符串进行了特殊处理。- 如果
n == 0
,后缀数组为空,直接返回成功。 - 如果
n == 1
,后缀数组只有一个元素0
,将其设置后返回成功。
- 如果
- 处理这些简单的边界情况可以避免让核心的递归算法去处理这些琐碎的、可能引发问题的输入,简化了核心算法的逻辑。
调用核心实现:
return saisxx_private::suffixsort(T, SA, 0, n, k, false);
- 所有准备工作完成后,它就将参数“透传”给位于
saisxx_private
命名空间下的suffixsort
函数。false
参数告诉suffixsort
只需要计算后缀数组,而不需要计算BWT。
代码与注释
1 | /** |
suffixsort SA-IS 三阶段调度器
suffixsort
是 SA-IS 算法的“总指挥”。它本身不执行具体的排序逻辑,而是负责编排和调度整个算法的三个核心阶段。它还包含了复杂的内存管理策略,以在给定的内存空间内高效地完成排序。
原理与设计思路解析
该函数清晰地展示了 SA-IS 算法的分治流程:Stage 1
(规约), Stage 2
(递归), Stage 3
(诱导)。
/* stage 1: reduce the problem ... */
(第一阶段:问题规约)- 目标: 识别出所有的 LMS (Leftmost S-type) 后缀,对它们进行初步排序,然后将每个 LMS 子串“重命名”为一个新的整数,从而将原始问题规约 (reduce) 成一个规模更小、字符集可能也更小的新问题。
- 内存管理 (
flags
): 函数开头的大量if-else
和flags
设置,是在进行精密的内存布局规划。SA
数组是一个巨大的内存块,suffixsort
试图将它“一地多用”:一部分用作最终的SA输出,一部分用作递归调用的SA,还有一部分用作基数排序所需的桶数组C
和B
。flags
变量记录了最终采用的内存布局方案。这是一个极致的内存优化,旨在尽可能避免代价高昂的new
操作。 - 调用
stage1sort
: 这是第一阶段的实际执行者。它完成LMS后缀的识别、初步排序和重命名工作。 - 返回:
stage1sort
返回两个关键值:m
(LMS后缀的总数) 和name
(唯一LMS子串的数量)。
/* stage 2: solve the reduced problem ... */
(第二阶段:递归求解)- 目标: 解决第一阶段规约产生的新问题。
- 递归条件:
if(name < m)
。这个条件是递归的核心。- 如果
name == m
,意味着所有 LMS 子串都是唯一的,它们在第一阶段的初步排序后,其相对顺序就已经完全确定了。因此,无需递归,可以直接跳到第三阶段。 - 如果
name < m
,意味着存在内容相同的 LMS 子串,它们的相对顺序尚未确定。此时,必须通过递归来解决。
- 如果
- 准备递归输入:
RA = SA + m + newfs;
: 在SA
数组的后半部分开辟一块空间RA
。for(...) { RA[j--] = SA[i] - 1; }
: 将第一阶段生成的“新字符串”(即 LMS 子串的名称序列)从SA
的中间部分提取并复制到RA
中。
- 递归调用:
if(suffixsort(RA, SA, newfs, m, name, false) != 0) ...
: 核心递归调用。它调用suffixsort
自身,但输入变成了更短的、由“名称”组成的字符串RA
。递归的输出(排好序的“名称”的后缀数组)会被写入SA
数组的前m
个位置。
- 结果映射: 递归返回后,需要将排好序的“名称”映射回它们所代表的原始 LMS 后缀。
for(i=0; i<m; ++i) { SA[i] = RA[SA[i]]; }
:RA
在这里被当作一个查找表,RA[SA[i]]
能找到第i
小的名称所对应的原始LMS后缀的位置。
/* stage 3: induce the result ... */
(第三阶段:诱导排序)- 目标: 利用在第二阶段已完全排好序的 LMS 后缀“骨架”,构建出完整的后缀数组。
- 调用
stage3sort
: 这是第三阶段的实际执行者。它接收SA
数组前m
个位置的已排序 LMS 后缀,并执行完整的两轮(一轮L-type,一轮S-type)诱导排序,最终将结果填充到整个SA
数组中。
代码与注释
1 | /* saisxx_private namespace */ |
stage1sort (第一阶段) 问题规约:识别与命名 LMS 子串
stage1sort
是 SA-IS 算法的问题规约阶段。它的核心任务是扫描原始字符串,找出所有 LMS (Leftmost S-type) 后缀,对它们进行初步排序,然后将每个 LMS 后缀所代表的子串进行“重命名”,最终生成一个规模更小的新问题,交给第二阶段(递归)去解决。
原理与设计思路解析
这个函数本身也包含多个精巧的步骤来完成其复杂的任务。
第一步:识别 LMS 后缀并放入桶中 (初步排序)
- 目标: 找出所有 LMS 后缀,并利用基数排序的思想,将它们放入各自首字符对应的桶中。
- 从右到左扫描:
for(; 0 <= i;)
循环从字符串的末尾向前扫描。 - L/S 类型判断:
do { c1 = c0; } while((0 <= --i) && ((c0 = T[i]) >= c1));
这一段是在判断后缀的 L/S 类型。当循环从T[i] >= c1
(L-type) 的状态跳出时,说明遇到了一个 S-type 后缀。 - LMS 识别:
do { c1 = c0; } while((0 <= --i) && ((c0 = T[i]) <= c1));
紧接着的这一段是在跳过一个连续的 S-type 区域。当它跳出时,说明又遇到了一个 L-type 后缀。那么,前一个位置j = i
就是一个...L S...
模式的转折点,因此后缀j
是一个 LMS 后缀。 - 放入桶中:
getBuckets(C, B, k, true);
在扫描前,已经将B
数组设置成了各个桶的结束边界指针。*b = j; b = SA + --B[c1];
: 当找到一个 LMS 后缀j
,其首字符为c1
时,就将j
放入c1
桶当前可用的末尾位置,然后将该桶的写指针--B[c1]
向前移动一格。
- 结果: 这一步结束后,
SA
数组的后半部分(从右向左填充)存储了所有的 LMS 后缀,并且它们已经按照首字符进行了排序。
第二步:对 LMS 后缀进行诱导排序 (
LMSsort1
/LMSsort2
)- 目标: 对第一步初步排序的结果进行细化。仅仅按首字符排序是不够的,还需要考虑后续字符。
LMSsort1
/LMSsort2
执行一次局部的、不完整的诱导排序。它们的作用是:利用 LMS 后缀之间的诱导关系,对这些 LMS 后缀自身进行一次更精确的排序。这足以将内容完全相同的 LMS 子串聚集在一起。
第三步:重命名 (
LMSpostproc1
/LMSpostproc2
)- 目标: 比较排序后相邻的 LMS 子串,为每个唯一的子串分配一个整数“名称”。
LMSpostproc1
/LMSpostproc2
(post-process) 负责这个过程。- 压缩 SA 数组: 首先,它将排序后的 LMS 后缀紧凑地移动到
SA
数组的前m
个位置。 - 比较与命名: 然后,它遍历这
m
个 LMS 后缀,逐一比较当前 LMS 子串与上一个 LMS 子串的内容是否相同。- 如果不同,就分配一个新的名称 (
++name
)。 - 如果相同,就使用与上一个相同的名称。
- 如果不同,就分配一个新的名称 (
- 存储新字符串: 它巧妙地将这些“名称”存储在
SA
数组的中间部分(SA[m + (p >> 1)] = name;
)。这样,一个规模为m
的新问题(由整数名称构成的字符串)就被原地构造出来了。 - 返回: 函数最终返回 LMS 后缀的总数
m
和唯一名称的数量name
,这两个值将决定第二阶段是否需要递归。
代码与注释
1 | /* saisxx_private namespace */ |
stage3sort (第三阶段) 诱导排序:构建完整 SA
stage3sort
是 SA-IS 算法的最终合成阶段。在第一阶段规约问题、第二阶段递归求解之后,我们已经得到了一个完全排好序的 LMS 后缀列表(存储在 SA
数组的前 m
个位置)。stage3sort
的任务就是利用这个已排序的“骨架”,通过两轮高效的线性扫描,诱导出所有其他 L-type 和 S-type 后缀的正确位置,从而构建出最终的、完整的后缀数组。
原理与设计思路解析
这个函数是“诱导排序”思想最纯粹、最直接的体现。它包含两个步骤:首先是准备工作,然后是核心的诱导排序。
第一步:准备阶段 (放置 LMS 后缀骨架)
- 目标: 将已排序的 LMS 后缀从
SA
数组的前m
个位置,移动到它们各自首字符对应的桶的末尾。这是为后续的诱导扫描做准备,因为诱导过程需要从这些已知的“锚点”开始。 if(1 < m)
: 只有当存在 LMS 后缀时才需要此步骤。getBuckets(C, B, k, 1);
: 计算每个桶的结束边界。do { ... } while(0 <= i);
: 这个循环从后向前遍历已排序的 LMS 后缀列表(位于SA[0...m-1]
)。SA[--j] = p;
: 对于每个 LMS 后缀p
,它被放置到其首字符c1
对应桶的当前可用末尾位置j
。- 结果: 这一步结束后,
SA
数组大部分是空的(被0
填充),只有那些已排序的 LMS 后缀被精准地放置在了它们桶的末尾,构成了诱导排序的“骨架”。
- 目标: 将已排序的 LMS 后缀从
第二步:执行完整诱导排序 (
induceSA
或computeBWT
)- 目标: 执行两轮线性扫描,填充
SA
数组的其余所有空位。 if(isbwt == false) { induceSA(...) } else { pidx = computeBWT(...) }
: 根据调用者是否需要计算 Burrows-Wheeler Transform (BWT),选择调用不同的函数。我们主要关注induceSA
。induceSA
的内部工作 (两轮扫描):- 第一轮 (诱导 L-type):
- 扫描方向: 从左到右 (
for(i = 0; i < n; ++i)
) 扫描整个SA
数组。 - 诱导逻辑: 当扫描到一个已放置的后缀
j
(j > 0
) 时,算法会考察它的前一个后缀j-1
。 - 如果后缀
j-1
是 L-type (T[j-1] >= T[j]
),算法就会将j-1
放入其首字符对应桶的当前可用开头位置。 - 由于是从左到右扫描,桶的写指针也是从左到右移动,这保证了所有 L-type 后缀在其桶内是按字典序排列的。
- 扫描方向: 从左到右 (
- 第二轮 (诱导 S-type):
- 扫描方向: 从右到左 (
for(i = n - 1; 0 <= i; --i)
) 扫描整个SA
数组。 - 诱导逻辑: 当扫描到一个已放置的后缀
j
(j > 0
) 时,再次考察j-1
。 - 如果后缀
j-1
是 S-type (T[j-1] <= T[j]
),算法就会将j-1
放入其首字符对应桶的当前可用末尾位置。 - 由于是从右到左扫描,桶的写指针也是从右向左移动,这保证了所有 S-type 后缀在其桶内也是按字典序排列的。
- 扫描方向: 从右到左 (
- 第一轮 (诱导 L-type):
- 目标: 执行两轮线性扫描,填充
完成: 经过这两轮扫描,
SA
数组中的所有空位都被正确填充,一个完整的、排好序的后缀数组就构建完成了。
代码与注释
1 | /* saisxx_private namespace */ |
induceSA 核心诱导排序引擎
induceSA
是 SA-IS 算法中执行最终诱导排序的核心引擎。它接收一个部分填充(只包含已排序的 LMS 后缀“骨架”)的 SA
数组,并通过两轮方向相反的线性扫描,高效地、确定性地将其余所有 L-type 和 S-type 后缀填充到它们的正确位置,从而完成整个后缀数组的构建。
原理与设计思路解析
这个函数是“诱导排序”思想最直接的体现。它基于一个关键的推论:一旦后缀 i
的排名确定,其前一个后缀 i-1
的排名也可以被高效地推导出来。
第一轮:诱导 L-type 后缀 (从左到右扫描)
/* compute SAl */
: 这里的SAl
指的是“S A Left”,即对 L-type 后缀进行排序。- 准备:
getBuckets(C, B, k, false);
计算每个桶的起始边界,因为我们将从左到右填充。 - 扫描:
for(i = 0; i < n; ++i)
从头到尾扫描整个SA
数组。 - 诱导逻辑:
- 当扫描到
i
位置时,取出SA[i]
中存储的后缀索引j
。注意,此时的j
可能是负数标记,需要~j
恢复。 - 如果
j > 0
,说明它不是第一个字符的后缀,那么我们就可以考察它的前一个后缀j-1
。 - 获取
j-1
的首字符c0 = T[j-1]
和j
的首字符c1 = T[j]
。 - 判断类型: 如果
c0 < c1
(T[j-1] < T[j]
),说明后缀j-1
是 S-type,我们忽略它(S-type 在第二轮处理)。如果c0 >= c1
(T[j-1] >= T[j]
),说明后缀j-1
是 L-type。 - 放置: 对于 L-type 的后缀
j-1
,算法会查找c0
桶的当前写指针b
,并将j-1
放入*b
的位置,然后将写指针b++
向前移动一格。
- 当扫描到
- 为何有效? 因为我们是从左到右(即按字典序从小到大)扫描
SA
数组的。这意味着我们总是先处理排名较小的后缀。对于 L-type 后缀,其排名主要由首字符决定。因此,按照桶的顺序、从左到右填充,可以保证所有 L-type 后缀都被正确地排序。
第二轮:诱导 S-type 后缀 (从右到左扫描)
/* compute SAs */
:SAs
指的是“S A Right”,即对 S-type 后缀进行排序。- 准备:
getBuckets(C, B, k, true);
重新计算每个桶的结束边界,因为我们将从右到左填充。 - 扫描:
for(i = n - 1; 0 <= i; --i)
从尾到头扫描整个SA
数组。 - 诱导逻辑:
- 与第一轮类似,取出后缀索引
j
。 - 如果
j > 0
,考察前一个后缀j-1
。 - 判断类型: 如果
c0 > c1
(T[j-1] > T[j]
),说明后缀j-1
是 L-type,我们忽略它(L-type 在第一轮已经处理完毕)。如果c0 <= c1
(T[j-1] <= T[j]
),说明后缀j-1
是 S-type。 - 放置: 对于 S-type 的后缀
j-1
,算法查找c0
桶的当前写指针b
,并将j-1
放入*--b
的位置(先将写指针b
向左移动一格,再写入)。
- 与第一轮类似,取出后缀索引
- 为何有效? S-type 后缀的排序依赖于其后续的后缀。从右到左扫描(即按字典序从大到小)
SA
数组,可以确保当我们处理后缀j
时,所有比它大的后缀都已就位。这保证了在诱导j-1
时,它的排序依据是可靠的,从而保证了所有 S-type 后缀也被正确排序。
位标记
~j
的使用
在这个函数中,~j
被用来同时编码后缀索引和 L/S 类型信息,以减少一次额外的查表。*b++ = ((0 < j) && (T[j - 1] < c1)) ? ~j : j;
: 在第一轮中,如果诱导出的j
本身是 S-type,就用负数标记它。*--b = ((j == 0) || (T[j - 1] > c1)) ? ~j : j;
: 在第二轮中,如果诱导出的j
本身是 L-type,就用负数标记它。- 最后,
SA[i] = ~j;
将所有标记恢复为正数索引。
代码与注释
1 | /* saisxx_private namespace */ |
LMSsort1 (第一阶段) LMS 子串的初步诱导排序
LMSsort1
的任务是在 stage1sort
识别出所有 LMS 后缀并将它们(按首字符)放入桶中之后,对这些 LMS 后缀进行一次更精确的排序。它本身就是一个微缩版的诱导排序,但目的不是构建完整的SA,而是将内容完全相同的 LMS 子串聚集在一起,为后续的“重命名”阶段做准备。
原理与设计思路解析
LMSsort1
也遵循与 induceSA
相同的两轮扫描诱导逻辑,但操作的对象和目标都更加有限。
上下文:
- 此时,
SA
数组的后半部分(从右向左)填充着按首字符初步排序的 LMS 后缀。数组的其他部分都是0
。
- 此时,
第一轮:诱导 L-type 后缀 (从左到右)
/* compute SAl */
: 与induceSA
类似,这一步诱导 L-type 后缀。- 扫描:
for(i = 0; i < n; ++i)
从左到右扫描SA
数组。 - 诱导逻辑:
- 当扫描到一个位置
i
,如果SA[i]
中有一个有效的后缀索引j
(此时只有LMS后缀是有效的),它会考察其前一个后缀j-1
。 - 如果
j-1
是 L-type,就将j-1
放入其首字符对应桶的开头。
- 当扫描到一个位置
- 关键区别: 在
stage1sort
的这个阶段,SA
数组非常稀疏,大部分都是0。这次扫描的目的不是填充整个SA,而是在SA
的前半部分,临时地、不完整地放置一些由 LMS 后缀诱导出的 L-type 后缀。
第二轮:诱导 S-type 后缀,并筛选出 LMS 后缀** (从右到左)**
/* compute SAs */
: 这一步诱导 S-type 后缀。- 扫描:
for(i = n - 1; 0 <= i; --i)
从右到左扫描SA
数组。 - 诱导逻辑:
- 当扫描到一个位置
i
,如果SA[i]
中有一个有效的后缀索引j
(无论是原始的 LMS 还是第一轮诱导出的 L-type),它都会考察其前一个后缀j-1
。 - 如果
j-1
是 S-type,就将j-1
放入其首字符对应桶的末尾。
- 当扫描到一个位置
- 核心目标 (筛选): SA-IS 算法的一个巧妙之处在于,只有 LMS 后缀才是 S-type 且其前一个后缀是 L-type。在第二轮从右向左的诱导过程中,当一个 S-type 后缀
j-1
被放置时,如果它的前一个后缀j-2
是 L-type,那么j-1
就是一个 LMS 后缀。通过这次诱导,所有原始的 LMS 后缀会被重新、但更精确地排序并放置到SA
的前半部分。 - 结果: 这一轮结束后,所有原始的 LMS 后缀被“提纯”并排序后,紧凑地排列在
SA
数组的开头部分。其他在扫描过程中产生的临时 L-type 和 S-type 后缀都被丢弃了(被SA[i] = 0
清除)。
代码与注释
1 | /* saisxx_private namespace */ |
LMSpostproc1 (第一阶段) LMS 子串的重命名
LMSpostproc1
(LMS Post-processing 1) 的任务是对 LMSsort1
精确排序后的 LMS 后缀进行**“重命名” (Renaming)。它会比较相邻的 LMS 子串,为每个唯一**的子串分配一个整数“名称”,并将这些名称打包成一个新的、更短的字符串。这个新字符串就是下一阶段递归算法的输入。
原理与设计思路解析
这个函数是实现问题规模规约 (Reduction) 的关键。它通过两个步骤完成任务:压缩和命名。
第一步:压缩 (Compact)
- 上下文:
LMSsort1
执行完毕后,所有排好序的 LMS 后缀被紧凑地放置在SA
数组的开头部分,但它们之间可能仍然夹杂着一些被标记的(负数)或无效的(0)条目。 - 目标: 将所有有效的、排好序的 LMS 后缀无缝地移动到
SA
数组的最前端SA[0...m-1]
。 for(i = 0; (p = SA[i]) < 0; ++i) { SA[i] = ~p; ... }
: 这个循环首先恢复所有被负数标记的 LMS 后缀。if(i < m) { for(j = i, ++i;; ++i) { ... } }
: 这个循环则是一个经典的双指针压缩算法。指针j
是写指针,指针i
是读指针。它将i
找到的有效 LMS 后缀~p
写入到j
的位置,从而消除所有无效条目。
- 上下文:
第二步:存储 LMS 子串长度
- 目标: 在后续的命名阶段,需要快速比较两个 LMS 子串是否相等。为了进行比较,我们必须知道每个 LMS 子串的长度。
for(; 0 <= i;)
: 这一段代码再次从右到左扫描原始字符串T
,用与stage1sort
几乎完全相同的方式重新识别出所有的 LMS 后缀。SA[m + ((i + 1) >> 1)] = j - i;
: 这是一个非常巧妙的内存复用技巧。- 当识别出一个 LMS 后缀
i+1
时,它与前一个 LMS 后缀j
之间的距离j-i
就是 LMS 子串T[i+1 ... j]
的长度。 - 这个长度信息被存储在了
SA
数组的中间部分SA[m + ...]
。 (i + 1) >> 1
是一个空间优化,它假设 LMS 后缀的分布大致是每两个字符一个,所以可以用p/2
作为索引来存储与 LMS 后缀p
相关的信息。
- 当识别出一个 LMS 后缀
第三步:比较与命名 (Compare and Name)
- 目标: 遍历压缩好的、排好序的 LMS 后缀列表
SA[0...m-1]
,为每个唯一的 LMS 子串分配一个从1
开始递增的整数“名称”。 for(i = 0, name = 0, q = n, qlen = 0; i < m; ++i)
: 这个主循环遍历所有m
个 LMS 后缀。p = SA[i], plen = SA[m + (p >> 1)], diff = true;
: 获取当前 LMS 后缀p
及其长度plen
。if((plen == qlen) && ...)
: 比较当前 LMS 子串T[p...p+plen-1]
与上一个唯一的 LMS 子串T[q...q+qlen-1]
。for(j = 0; (j < plen) && (T[p + j] == T[q + j]); ++j) { }
: 逐字节比较。if(diff != false) { ++name, q = p, qlen = plen; }
: 如果两个子串不同,则增加name
计数器,并将当前子串p
记录为新的“上一个唯一子串”。SA[m + (p >> 1)] = name;
: 核心重命名步骤。在存储 LMS 子串长度的同一位置,用计算出的新“名称”name
覆盖掉原来的长度值。
- 目标: 遍历压缩好的、排好序的 LMS 后缀列表
最终结果:
LMSpostproc1
执行完毕后,SA
数组的内存布局变为:SA[0 ... m-1]
: 存储着紧凑的、排好序的 LMS 后缀的原始位置。SA[m ... m+(n/2)]
: 存储着一个由整数“名称”构成的、长度为m
的新字符串。这个新字符串就是第二阶段递归的输入。
- 函数返回唯一名称的数量
name
。
代码与注释
1 | /* saisxx_private namespace */ |