排序算法之:Burrows-Wheeler 变换(BWT)与数据压缩中的排序应用
题目描述
Burrows-Wheeler 变换(简称 BWT)并不是一个传统的排序算法,而是一种基于排序的数据变换算法。它通过对字符串的所有循环移位进行排序,然后提取排序后的特定列,生成一个易于压缩的新字符串。BWT 是可逆的,即变换后的数据可以无损地恢复为原始数据。它本身是无损数据压缩(如 bzip2 压缩工具)的前置关键步骤。本题要求你理解 BWT 的变换与逆变换原理,其中排序是实现变换的核心操作。
目标:给定一个字符串 S(通常附加一个结束标识符,如 $),实现 BWT 的编码(变换)和解码(逆变换)过程,并理解其如何提高数据的可压缩性。
解题过程循序渐进讲解
第一步:理解 BWT 的编码(变换)过程
BWT 编码的目标是将原始字符串重新排列,使得相同字符倾向于聚集在一起,从而便于后续的游程编码(RLE)或其它压缩算法处理。
-
输入与结束符:
- 输入一个字符串
S,例如"banana$"。这里的$是一个特殊的、在原始字符串中不存在的结束符,它在排序时被定义为最小的字符(即 ASCII 值小于任何字母)。 - 添加
$的目的是为了在解码时能够唯一确定原始字符串的起始点。
- 输入一个字符串
-
生成循环移位矩阵:
- 为字符串
S(包含$)生成其所有可能的循环移位字符串。对于一个长度为n的字符串,有n个移位。 - 例如,对
"banana$":- 移位0:
banana$ - 移位1:
anana$b - 移位2:
nana$ba - 移位3:
ana$ban - 移位4:
na$bana - 移位5:
a$banan - 移位6:
$banana
- 移位0:
- 为字符串
-
对循环移位字符串进行排序:
- 这是 BWT 的核心排序步骤。我们将这
n个字符串视为一个n x n的矩阵(每行是一个移位字符串)。 - 对这个字符串列表进行字典序排序。在排序中,
$是最小的字符。 - 排序后的矩阵(行)如下:
$bananaa$bananana$bananana$bbanana$na$bananana$ba
- 这是 BWT 的核心排序步骤。我们将这
-
提取最后一列(L):
- 取排序后矩阵的最后一列字符,按顺序连接,得到 BWT 变换结果
L。 - 从上面排序后的行看最后一列:
$bananaa$bananana$bananana$bbanana$na$bananana$ba
- 因此,
L = "annb$aa"。这就是"banana$"的 BWT 编码结果。
- 取排序后矩阵的最后一列字符,按顺序连接,得到 BWT 变换结果
为什么这有助于压缩? 观察 L = "annb$aa",字符 'a' 和 'n' 分别聚集在了一起。在原始字符串 "banana$" 中,'a' 和 'n' 是分散的。这种“相似上下文字符聚集”的特性使得 L 的游程(连续相同字符)更长,用游程编码(RLE)压缩效率更高。
第二步:理解 BWT 的解码(逆变换)过程
逆变换的目标是从 BWT 结果 L 和原始结束符 $ 的位置信息,恢复出原始字符串 S。这个过程巧妙利用了排序的稳定性和首尾列对应关系。
-
已知条件:我们拥有变换结果
L(最后一列),并且我们知道原始字符串中$的位置(在编码时,$被加到末尾,但经过变换后,它在L中的位置是变化的)。实际上,在解码时,我们通常还需要知道原始字符串在排序矩阵中的行索引(即$所在的行号)。但在标准方法中,我们可以从L本身和$字符推断出来。这里我们明确:我们知道L = "annb$aa"。 -
构建“下一列”关系(关键洞察):
- 将
L视为排序矩阵的最后一列。设排序矩阵的第一列为F。如何得到F?很简单,对L中的字符进行排序即可得到F(因为F是排序矩阵的第一列,其字符和L完全一样,只是按字典序排列了)。 - 对
L = "annb$aa"排序:F = "$aaabnn"。 - 现在有一个关键性质:在排序矩阵中,同一行的
F中的某个字符,与L中的某个字符,在原字符串中是前后相邻的关系。具体来说,对于第i行,F[i]是原字符串中某个位置k的字符,L[i]是位置k+1的字符(循环意义下)。因此,从L[i]可以找到“下一个”字符是F[i]。
- 将
-
建立“前驱”映射(Last-First Mapping, LF Mapping):
- 我们需要一个更系统的方法。定义
next关系:对于F中的每个字符,我们需要知道它对应到L中的哪个相同字符。但由于字符可能重复,我们需要更精确的映射。 - 标准方法是:为
L和F中的每个字符记录其“出现次数排名”。 - 具体操作:
a. 计算F和L。
b. 对每个字符,记录它在F和L中出现的次序(从0开始)。例如,在F = "$aaabnn"中:
$(第0个),a(第0个),a(第1个),a(第2个),b(第0个),n(第0个),n(第1个)。
c. 现在,关键映射是:F中第r次出现的字符ch,在原始字符串中,它的下一个字符是L中第r次出现的字符ch。这个性质是因为排序是稳定的,相同字符在F和L中的相对顺序被保持。
- 我们需要一个更系统的方法。定义
-
解码步骤(从尾到头重建):
- 我们知道原始字符串以
$结尾。在F中,$只出现一次,且排在最前面(第0行,F[0]='$')。 - 根据 LF 映射,
F[0]='$'对应L[0]='a'。这意味着,在原始字符串中,$的前一个字符是L[0]即'a'。我们得到了倒数第二个字符'a'。 - 现在,我们需要找到这个
'a'在F中的位置。F中有三个'a',我们需要知道是第几个。查看L[0]='a',它是L中第0个'a'(因为L中'a'出现在位置0,5,6,第一个'a'在位置0)。所以,我们应该找F中第0个'a',它在F[1]。 F[1]的下一个字符是L[1]='n'。所以,原始字符串中,'a'的前一个字符是'n'。我们得到了倒数第三个字符'n'。- 重复这个过程:
L[1]='n'是L中第0个'n'(L中'n'在位置1,2)。对应F中第0个'n',在F[5]。F[5]的下一个字符是L[5]='a'。得到倒数第四个字符'a'。 - 继续:
L[5]='a'是L中第1个'a'(位置5)。对应F中第1个'a',在F[2]。F[2]的下一个字符是L[2]='n'。得到倒数第五个字符'n'。 L[2]='n'是L中第1个'n'。对应F中第1个'n',在F[6]。F[6]的下一个字符是L[6]='a'。得到倒数第六个字符'a'。L[6]='a'是L中第2个'a'。对应F中第2个'a',在F[3]。F[3]的下一个字符是L[3]='b'。得到倒数第七个字符'b'。L[3]='b'是L中第0个'b'。对应F中第0个'b',在F[4]。F[4]的下一个字符是L[4]='$'。得到'$',这是原始字符串的结尾(也是我们开始的起点)。解码完成。- 我们将得到的字符按逆序连接(因为我们是从后往前推导的):
b, a, n, a, n, a, $-> 反转后得到原始字符串"banana$"。
- 我们知道原始字符串以
第三步:算法实现要点
-
编码实现:
- 生成所有循环移位字符串。为了避免实际存储
n个长度为n的字符串(O(n²) 内存),可以只存储起始索引,然后通过切片或模运算来比较字符串。但为了清晰,初学时可先生成列表。 - 对起始索引列表进行排序,排序的比较函数是比较从该索引开始的循环字符串。
- 收集排序后每个索引的前一个字符(即
(index - 1) mod n位置的字符)作为L。
- 生成所有循环移位字符串。为了避免实际存储
-
解码实现(高效方法):
- 计算
F:对L排序。 - 构建
next数组(或称T数组):next[i]表示F[i]这个字符在L中的位置。构建方法:对每个字符,维护一个在L中出现的次数计数器,遍历F,对于F[i],找到它在L中第rank次出现的位置,其中rank是该字符目前在F中出现的次数。 - 从
$在F中的位置(即0)开始,反复应用:index = next[index],并将L[index]添加到结果中,直到再次遇到$为止(注意结果需反转)。
- 计算
总结
Burrows-Wheeler 变换的精髓在于利用排序对字符串的循环移位进行重排,使得相似上下文(即排序后相邻的行)的最后一个字符聚集,极大增强了数据的局部相关性,为后续压缩(如 MTF + 哈夫曼编码)创造了条件。其逆变换则巧妙地利用了排序的稳定性和首尾列之间的映射关系。整个算法的核心操作是排序,时间复杂度为 O(n log n),空间复杂度可以通过优化控制在 O(n)。