后缀BWT与循环移位BWT及快速计算
摘要
解释Burrows-Wheeler变换的两种变体(循环移位和后缀)以及快速计算方法,面向数据压缩爱好者。
<p><a href="https://lobste.rs/s/suucvi/suffix_bwt_vs_cyclic_shift_bwt_fast">评论</a></p>
查看缓存全文
缓存时间: 2026/07/04 06:38
# 后缀 BWT 与循环移位 BWT,以及快速计算
来源:https://purplesyringa.moe/blog/suffix-bwt-vs-cyclic-shift-bwt-and-fast-computation/
2026年7月3日
> 目标读者:数据压缩爱好者。
Burrows-Wheeler 变换(https://en.wikipedia.org/wiki/Burrows%E2%80%93Wheeler_transform)接收一个字符串作为输入,并对其字符进行重排,使它们按上下文分组。该变换是可逆的,只需额外输入 O(1) 数据,这些特性使其在数据压缩和基因组比对中占有一席之地。维基百科没有告诉你的是,实际上 BWT 有两种变体,它们在性能特性和简单性上有着微妙的差异。这些差异似乎几乎没有被文档记录,因此这篇博文是我试图理清它们的一次尝试。
### 循环移位 BWT
我先介绍我称为“循环移位 BWT”的变体。以字符串 `bcacaba` 为例。我们写下该字符串的所有循环移位(或旋转),以及它们的偏移量:
```
bcacaba (0)
cacabab (1)
acababc (2)
cababca (3)
ababcac (4)
babcaca (5)
abcacab (6)
```
然后按字典序排序:
```
ababcac (4)
abcacab (6)
acababc (2)
babcaca (5)
bcacaba (0)
cababca (3)
cacabab (1)
```
BWT 的输出是每个循环移位开始位置之前的那个字符。例如,列表中第一个循环移位从索引 4 开始,所以我们写下输入中偏移量 3 处的字符,即 `c`。最终输出为 `cbcaaab`。(注意这等同于取上面表格的最后一列。)
排在一起的两个字符串很可能有较长的公共前缀,因此在实际数据中,它们之前的符号也往往匹配。例如,在给定文本中,如果字符串 `ender` 前面总是跟着 `g`、`b` 或空格,那么输出中就会有一个完全由这三个符号组成的长子序列。
该算法是可逆的:如果你知道 BWT 的输出,你可以恢复输入(直到一个循环移位)。具体做法如下:
- 准备工作:取 BWT 的输出(`cbcaaab`)并将其字符按字典序排序。这样就得到了上面表格的第一列:`aaabbcc`。
- 现在取输出的第一个字符 `c`。它是由某个循环移位 t‖"c" 的最后一个字符产生的。我们将寻找这个 `c` 之前的字符,即 t 的最后一个字符。
- 文本中有两个 `c`,所以有两个以 `c` 结尾的循环移位:t1‖"c" 和 t2‖"c"。由于我们取的是输出的第一个 `c`,而不是第二个,它一定属于字典序较小的那个移位 t‖"c" = t1‖"c" < t2‖"c"。
- 但我们还不知道哪个移位更大。不过,这并不重要,因为我们现在正在恢复字符串,而循环移位 BWT 的逆变换只能恢复到一个循环移位。因此,我们随意选取一个,比如 t1。由于 `c` 是输出中的第一个 `c`,它对应第一列中的第一个 `c`(在 `aaabbcc` 中位置为 5)。因此,该行的第一列是 `c`,它是导致该行成为字典序最小的候选者的原因:t1‖"c" 以 `c` 开头,而第二行的第一个字符 `c` 并不能与 t2 的 `b` 竞争。
- 让我们用同样的方式处理 `b`、`c` 等等。完整算法称为“last-to-first mapping”(LF mapping),因为你将输出中特定位置的字符映射到第一列中对应字符的第 k 次出现。最终我们得到 `bcacaba`——一个循环移位。
### 后缀 BWT
另一种变体称为“后缀 BWT”。它采用字符串的所有后缀,而不是循环移位。但后缀没有最后一个字符——除了空后缀(empty suffix)。所以我们需要一个技巧来实现“环绕”语义。对于字符串 `bcacaba`,我们显式地将空后缀放在最后,或者假定它在最前面(取决于约定)。对后缀进行排序,并输出每个后缀前面的字符:
```
[a] bcacaba
[ ] (empty suffix)
```
后缀 `bcacaba` 整体上相对于 `cacaba`、`acaba` 等进行比较。但我们也需要比较后缀的相对顺序,以确定 BWT 输出顺序。假设我们按字典序排序:
```
(empty suffix)
a
aba
acaba
ba
bcacaba
caba
cacaba
```
现在 BWT 输出是每个后缀前面一个字符:
```
(empty suffix): 前面没有字符——假设为某默认字符,比如 ''
a: 前面是 b
aba: 前面是 c
acaba: 前面是 c
ba: 前面是 a
bcacaba: 前面是 ''? 又是边界
caba: 前面是 a
cacaba: 前面是 b
```
所以我们得到了 `bccaaab`,以及一个 undefined。边界问题可以避免:某些实现约定空后缀放在开头,或者使用终止符 `$`。但无论如何,边界处始终是个问题。
在经典实现中,你通常会看到这样的解码算法:
```c
char decode(const char *bwt, size_t primary_index) {
// 第一步:计算字符计数
// 第二步:LF mapping,但这次跳过该 primary_index 而不是空后缀
}
```
循环移位 BWT 的主索引(primary index)就是给定循环移位在排序列表中的索引。在后缀 BWT 中,它是空后缀被放置的索引。如果使用空后缀(或 `$`),编解码器会跳过它。
### 细微差别
我用来强调边界问题的例子是 `"a"` 是否出现在文本中。在 `"bcacaba"` 中,`"a"` 出现了,所以后缀列表包含 `"a"`。但是,在循环移位 BWT 中,循环移位 `"a"` 并不存在,因为 `"a"` 不是循环移位。所以循环移位 BWT 处理 `"a"` 与 `"aa"` 的方式可能不同。
让我们更仔细地比较这两种排序。对于后缀:
```
a # 前面是 b
aba # 前面是 c
acaba # 前面是 c
ba # 前面是 a
bcacaba # 前面是 ? (边界)
caba # 前面是 a
cacaba # 前面是 b
```
对于循环移位(从偏移量 4 开始):
```
ababcac # 前面是 c (来自偏移量 3)
abcacab # 前面是 b
acababc # 前面是 c
babcaca # 前面是 a
bcacaba # 前面是 ? (边界)
cababca # 前面是 a
cacabab # 前面是 b
```
注意 `a` 作为后缀是有的,但作为循环移位却没有——它只作为 `ababcac` 的开头出现。这导致了 BWT 输出的不同。
如果我们天真地试图用后缀 BWT 来模拟循环移位 BWT,通过丢弃空后缀并假设最后一个字符(在 full suffix 前面)是某个值,就会遇到问题。假设我们在后缀列表的顶部插入空后缀,并说 full suffix 前面是某个字符(例如 `a`,因为它应该被环绕)。那么排序就变成了:
```
(empty): 前面是 ? -> 假设为 "a"
a: 前面是 b
aba: 前面是 c
acaba: 前面是 c
ba: 前面是 a
bcacaba: 前面是 ? 边界,但这里不关心
caba: 前面是 a
cacaba: 前面是 b
```
我们想要的是:在字典序上,`"a"` 作为后缀应该小于空后缀?或者空后缀小于所有?循环移位的排序是 `"a"`(实际上不存在)应该介于空后缀和 `"aba"` 之间?但事实上 `"a"` 不是循环移位,所以这是不可能实现的。
这种不一致会导致解码失败。具体来说,当你用后缀 BWT 输出 `bccaaab` 并告诉解码器 full suffix 在索引 4 时,解码器会错误地将倒数第二个字符(即 `a`)视为 full suffix 前面的字符。但实际上那个位置对应的 suffix 是 `ba`,而不是 `a`。这是因为编码时我们丢失了 `"a"` 作为循环移位的信息。
### 比较
鉴于这些细微差别,你可能会合理地认为每个人都使用循环移位 BWT,而后缀 BWT 是一个遗留物。但事实并非如此。虽然后缀 BWT 解码稍复杂且稍慢,但编码快得多。有多种快速方法用于排序后缀(https://en.wikipedia.org/wiki/Suffix_array),最实用的是线性时间的 SA-IS(https://zork.net/~st/jottings/sais.html)算法,但很少有用于循环移位的算法。新手第一个 O(n log n) 的后缀数组实现(https://cp-algorithms.com/string/suffix-array.html#on-log-n-approach)确实适用于循环移位,但我找不到文献中任何专注于循环移位的线性时间算法。所以大多数人这样做:导入 libsais(https://github.com/IlyaGrebnov/libsais),构建后缀数组,丢弃全后缀,插入空后缀,然后通过 `s[sa[i] - 1]` 构造后缀 BWT。或者直接调用 `libsais_bwt` 函数做同样的事情。在很多情况下,这完全没问题。你可以经常在字符串前加上一个比所有其他字符都小的 `^` 字符,这样全后缀就会在索引 0 处,你不需要调整 `i`。或者附加一个 `$` 字符,这样后缀 BWT 和循环移位 BWT 在 `$` 的位置上重合,效果几乎相同。
### 欺骗 SA-IS
但如果你确实需要循环移位 BWT 呢?也许你的字母表是满的,实现真正的后缀 BWT 解码器太昂贵,或者你的问题明确要求循环移位 BWT。在这种情况下,我们仍然可以通过一些调整使用 SA-IS。
实现环绕语义的最简单方法是加倍字符串。字符串 s‖s 中起始偏移量 i, j < |s| 的两个后缀与 s 的对应循环移位比较方式完全相同。所以你可以将 s 加倍,计算后缀数组,丢弃索引 ≥ |s| 的元素,然后从该列表中计算循环 BWT 和主索引。
如果你不想付出双倍代价,还有另一种方法。取 s 的字典序最小的循环移位,记为 s'。对于 `s = "bcacaba"`,最小循环移位是 `s' = "ababcac"`。s' 的后缀与它的循环移位比较方式完全相同:`"c" < "cac"` 并且 `"cababca" < "cacabab"`。这是因为 s' 在截断点处起到了隐式 `$` 终止符的作用。由于循环 BWT 对输入旋转不敏感,计算 s' 的后缀 BWT 就能得到正确结果。我是从 Christoph Diegelmann 的 StackOverflow 答案(https://stackoverflow.com/a/31491630/5417677)中学到这个的。最小循环移位可以通过在 s‖s 上运行 Duval 算法(https://cp-algorithms.com/string/lyndon_factorization.html#finding-the-smallest-cyclic-shift)找到。它是线性时间的,但常数比 SA-IS 小,因此速度提升很大。唯一的问题是主索引会被旋转 s 破坏。有两种方法可以恢复它。第一,我们可以构建 s' 的后缀数组而不是直接跳转到 BWT,然后找到 s 在该列表中的位置。这可行,但成本较高。另一种方法是直接按定义计算主索引,即字典序小于 s 的 s 的循环移位数。Z-函数(https://cp-algorithms.com/string/z-function.html)可以线性时间地为每个后缀 (s‖s)[i...] 找到它与 s‖s 的公共前缀长度,这正是确定循环移位 i 是否小于 s 的位置。或者,根据数据的不同,使用二次算法可能更快。
这是我的实现(https://github.com/purplesyringa/computercraft-programs/blob/df3551a486e239f7761312ba8989560229ddd241/initrd-ng/initrd-ng/src/bz.rs#L4-L58)。
### 结论
那么你应该使用哪一种?在大多数情况下,后缀 BWT 更好:高效的后缀 BWT 编码器实现更简单且拥有更高的顶级性能,而解码仅稍慢,且仅在字母表满时明显。我认为循环移位 BWT 在需要小巧解码器且不关心编码器大小时尤其出色。比如 Demo 场景(https://en.wikipedia.org/wiki/Demoscene)和一般的尺寸编码。在脚本语言中它也更重要,因为解码运行时不主要被内存访问主导,调整会明显减慢过程。
希望我没有在这篇文章中犯任何错误!这个话题我不太熟悉,所以如果我犯了错误,请随时给我发电子邮件。
相似文章
增量BPE分词
本文介绍了一种增量式字节对编码(BPE)分词算法,该算法处理每个字节的时间复杂度为 O(log^2 t),支持流式场景下的高效部分分词,并相比现有实现实现了加速。
快速字节潜在Transformer
本文介绍了用于字节级语言模型的BLT扩散(BLT Diffusion)和投机解码技术,在保持生成质量的同时,显著降低了生成延迟和内存带宽成本。
面向Transformer模型压缩的鲁棒B样条解耦方法
本文介绍了一种基于B样条的Transformer模型压缩解耦框架,并提出了一种鲁棒交替最小二乘算法(R-CMTF-BSD),该算法在Vision Transformer和Swin Transformer架构上实现了显著的参数减少,同时保持了具有竞争力的准确率。
PivCo-Huffman
本文介绍了PivCo-Huffman,一种利用小波树中的枢轴编码进行霍夫曼编码的新方法,实现了高性能的SIMD友好编码和解码。它始终优于最先进的霍夫曼编解码器,并展示了如何将ANS编码选择性地应用于偏斜节点,以接近ANS压缩比,同时保持高解压缩速度。
快速傅里叶变换 第一部分:库利-图基算法
本文详细推导了库利-图基快速傅里叶变换算法的数学原理,并解释了它如何降低离散傅里叶变换的复杂度。