土法炼钢兴趣小组的算法知识备份

BWT 与 FM-index:从 bzip2 到基因组比对

目录

1994 年,Michael Burrows 和 David Wheeler 在 DEC 的一份技术报告里提出了一种令人 拍案叫绝的字符串变换方法:把原始字符串的所有循环旋转按字典序排列,取最后一列, 就得到了一个对压缩极其友好的新序列。这个变换后来以他们的名字命名为 Burrows-Wheeler Transform(BWT)。

十年后,Paolo Ferragina 和 Giovanni Manzini 在 BWT 的基础上构建出了 FM-index, 一种支持在压缩空间内完成精确模式匹配的全文索引结构。这项工作不仅拿到了 ESA 2000 的最佳论文奖,更在 2009 年被 Heng Li 引入基因组学领域——BWA 工具的 诞生彻底改变了二代测序数据的比对范式。

本文将从 BWT 的定义出发,层层递进地讲解其可逆性证明、在 bzip2 中的压缩流水线、 FM-index 的查询机制、小波树对 rank 查询的加速、SA 采样的定位策略,以及在基因组 比对中的实际应用。文中还包含一份完整的 C 语言实现,约 200 行,可直接编译运行。

一、BWT 的定义

给定一个长度为 n 的字符串 T,我们在末尾追加一个字典序最小的终止符 $ (不出现在原始字母表中),得到 T$ = T[0..n]。BWT 的构造过程如下:

  1. 生成 T$ 的所有 n+1 个循环旋转(cyclic rotation)。
  2. 将这些旋转按字典序排序,形成一个 (n+1) x (n+1) 的矩阵 M,称为 BWT 矩阵(也叫 Burrows-Wheeler 矩阵)。
  3. 取矩阵 M 的最后一列 L,即为 BWT(T)。

以字符串 banana 为例:

T$ = banana$

所有循环旋转:           排序后(BWT 矩阵 M):
  banana$                  $banana          -> L[0] = a
  anana$b                  a$banan          -> L[1] = n
  nana$ba                  ana$ban          -> L[2] = n
  ana$ban                  anana$b          -> L[3] = b
  na$bana                  banana$          -> L[4] = $
  a$banan                  na$bana          -> L[5] = a
  $banana                  nana$ba          -> L[6] = a

BWT("banana") = "annb$aa"

下图展示了完整的变换流程:

BWT 变换过程

我们把 BWT 矩阵的第一列记为 F 列(First column),最后一列记为 L 列(Last column)。F 列就是 T$ 中所有字符排序后的结果,可以直接从 字符频次得到;L 列就是 BWT 的输出。

形式化定义。 令 SA[0..n] 为 T$ 的后缀数组,则:

BWT[i] = T$[SA[i] - 1]    若 SA[i] > 0
BWT[i] = T$[n]  = $        若 SA[i] = 0

换言之,BWT 的每个位置记录的是后缀数组中对应后缀的前一个字符。这个等价定义 揭示了 BWT 与后缀数组之间的深层联系:给定后缀数组,BWT 可以在 O(n) 时间内 计算出来;反过来,给定 BWT,也可以在 O(n) 时间内恢复后缀数组(通过 LF-mapping 反复迭代)。

二、BWT 的可逆性证明

BWT 最令人惊叹的性质是:仅凭最后一列 L,就可以完整地恢复原始字符串 T。 这在直觉上很不可思议——我们丢弃了矩阵中绝大部分信息,只保留了一列, 却没有任何信息损失。

证明依赖于一个关键引理:LF-mapping 性质

引理(LF-mapping)。 对于 BWT 矩阵中的任意字符 c,c 在 L 列中第 k 次出现 (从 0 计数)所在的行,与 c 在 F 列中第 k 次出现所在的行,对应的是同一个字符 c 在 T$ 中的同一次出现。

也就是说,相同字符在 F 列和 L 列中保持相同的相对顺序。

证明。 考虑 L 列中出现的某个字符 c。L[i] = c 意味着 BWT 矩阵第 i 行的 循环旋转以 c 结尾。将 c 移到该旋转的开头,得到的就是一个以 c 开头的旋转。 由于 BWT 矩阵是按字典序排列的,所有以 c 开头的旋转在矩阵中是连续的 (它们构成了 F 列中 c 的那一段)。

关键观察:对于 L 列中的两个相同字符 L[i] = L[j] = c(i < j),将它们各自移到 开头后,所得到的两个旋转的第二个字符开始的后续部分分别等于原来第 i 行和第 j 行 的完整旋转。由于第 i 行在第 j 行之前(字典序更小),移位后以 c 开头的两个旋转 也保持了相同的相对顺序。因此 c 在 L 列中的第 k 次出现映射到 c 在 F 列中的第 k 次出现。证毕。

有了 LF-mapping,我们可以定义映射函数:

LF(i) = C[c] + Occ(c, i)

其中:

逆变换算法。 从 L 列恢复 T 的过程如下:

算法 InverseBWT(L):
    计算 C[] 和 Occ 表
    i = 0                  // F 列中 $ 所在行(即第 0 行)
    for j = n-1 downto 0:
        T[j] = L[i]
        i = LF(i)
    return T

从 F 列中 $ 所在的行(第 0 行)开始,每次读取 L[i] 得到原始字符串的一个字符(倒序), 然后通过 LF-mapping 跳到下一行。这个过程恰好遍历了整个字符串,因为每行 被恰好访问一次。

时间复杂度。 如果预先构建 Occ 表,逆变换只需 O(n) 时间。

三、BWT 与数据压缩:bzip2 流水线

BWT 本身并不压缩数据,但它把相似的上下文聚集在一起,使得输出中相同字符 倾向于连续出现。这种”聚簇效应”是 BWT 在压缩领域的核心价值。

bzip2 的压缩流水线由四个阶段组成:

原始数据
  |
  v
[1] BWT(Burrows-Wheeler 变换)
  |     将相似上下文聚集,产生大量连续重复字符
  v
[2] MTF(Move-To-Front 编码)
  |     将连续重复转化为大量小整数(0 和 1 居多)
  v
[3] RLE(游程编码)
  |     压缩连续的零
  v
[4] 熵编码(Huffman 编码)
        最终压缩

BWT 的聚簇效应

为什么 BWT 的输出对压缩友好?考虑英文文本中的 th。所有后缀以 h 开头 且前一个字符为 t 的位置,在 BWT 矩阵中对应的行是连续的(因为它们的后缀 排在一起),而这些行的 L 列值都是 t。类似地,he 前面经常出现 tin 前面经常出现某些固定字符。这种局部聚簇使得同一字符在 L 列中大量 连续出现。

Move-To-Front 编码

MTF 维护一个初始为 [0, 1, 2, …, 255] 的字符列表。对于输入的每个字符, 输出该字符在列表中的位置,然后把它移到列表头部。如果输入中相同字符连续出现, MTF 的输出就是大量的 0。

BWT 输出:  a a a a b a a a c c a a
MTF 输出:  97 0 0 0 98 1 0 0 99 0 2 0

游程编码与 Huffman

RLE 对连续的零进行特殊编码,大幅减小数据量。最后的 Huffman 编码根据符号 频率分配变长码字,完成最终压缩。

bzip2 的块大小默认为 900KB,这意味着 BWT 矩阵的构建在每个块内独立进行。 块大小越大,BWT 的聚簇效果越好,但内存消耗也更大(BWT 矩阵的排序需要 约 8 倍块大小的内存)。

解压流程

解压是压缩的精确逆过程:

压缩数据 -> Huffman 解码 -> RLE 解码 -> MTF 逆变换 -> BWT 逆变换 -> 原始数据

BWT 逆变换利用上节介绍的 LF-mapping 在 O(n) 时间内完成,不需要重建 整个 BWT 矩阵。

四、FM-index:全文索引的革命

2000 年,Ferragina 和 Manzini 发表了 FM-index(Full-text index in Minute space),在 BWT 的基础上构建了一种支持精确模式匹配的压缩索引。

FM-index 的核心思想:BWT 矩阵的 F 列和 L 列之间的 LF-mapping 关系, 不仅可以用于逆变换,还可以用于字符串搜索。

数据结构组成

FM-index 由以下组件构成:

  1. BWT 数组 L[0..n]:文本 T$ 的 BWT 变换结果。
  2. C 数组C[c] = F 列中字典序严格小于 c 的字符总数。对于大小为 σ 的字母表,C 数组只需 O(σ) 空间。
  3. Occ 表(occurrence table)Occ(c, i) = L[0..i-1] 中字符 c 的 出现次数。这是支持 rank 查询的核心。

C 数组的计算

// 统计字符频率
int freq[SIGMA] = {0};
for (int i = 0; i <= n; i++)
    freq[L[i]]++;

// 前缀和得到 C 数组
C[0] = 0;
for (int c = 1; c < SIGMA; c++)
    C[c] = C[c-1] + freq[c-1];

Occ 表的存储

朴素方案是存储一个 (σ x (n+1)) 的二维数组,空间 O(σn)。对于 DNA 序列 (σ = 5,包括 $ACGT),这是可以接受的。对于一般文本(σ = 256), 需要更紧凑的方案。

常见的做法是分块存储:每隔 k 个位置存一个完整的 Occ 快照(checkpoint), 查询时从最近的 checkpoint 开始扫描。k 的选择是空间与时间的权衡:

五、Backward Search:O(m) 精确匹配

FM-index 的精确匹配算法称为 backward search(逆向搜索)。给定模式串 P[0..m-1],算法从右向左逐字符扫描 P,在每一步维护一个 SA 区间 [lo, hi), 表示 BWT 矩阵中所有以当前已处理后缀为前缀的行的范围。

算法 BackwardSearch(P[0..m-1]):
    lo = 0
    hi = n + 1
    for i = m-1 downto 0:
        c = P[i]
        lo = C[c] + Occ(c, lo)
        hi = C[c] + Occ(c, hi)
        if lo >= hi:
            return "未找到"
    return "出现 hi - lo 次,SA 区间 [lo, hi)"

正确性说明。 初始时 [lo, hi) = [0, n+1) 表示整个 SA 范围。处理 P[m-1] 后,区间缩小为所有以 P[m-1] 开头的后缀。每处理一个新字符 P[i],区间 进一步缩小为所有以 P[i..m-1] 为前缀的后缀。

每一步的更新公式基于 LF-mapping 的性质:

时间复杂度。 m 次迭代,每次一次 C 查询(O(1))和两次 Occ 查询。 如果 Occ 查询为 O(1)(通过预计算或 popcount),则总时间为 O(m)。 这个复杂度与文本长度 n 无关,是 FM-index 最令人惊艳的特性。

对比后缀数组。 后缀数组 + LCP 数组的二分查找需要 O(m log n) 时间, FM-index 将其降到了 O(m)。

搜索示例

在 BWT(“banana”) = “annb$aa” 上搜索模式 “ana”:

初始:lo=0, hi=7

处理 P[2]='a': lo = C['a'] + Occ('a',0) = 1 + 0 = 1
               hi = C['a'] + Occ('a',7) = 1 + 3 = 4
               区间 [1,4)  -> 3 个以 'a' 开头的后缀

处理 P[1]='n': lo = C['n'] + Occ('n',1) = 5 + 0 = 5
               hi = C['n'] + Occ('n',4) = 5 + 2 = 7
               区间 [5,7)  -> 2 个以 "na" 开头的后缀

处理 P[0]='a': lo = C['a'] + Occ('a',5) = 1 + 1 = 2
               hi = C['a'] + Occ('a',7) = 1 + 3 = 4
               区间 [2,4)  -> 2 个以 "ana" 开头的后缀

结果:"ana" 在 "banana" 中出现 2 次。

六、小波树与 Rank 查询

当字母表较大时(如 ASCII 的 σ=256,或 Unicode),朴素 Occ 表的空间消耗 变得不可接受。小波树(wavelet tree) 是解决这个问题的优雅方案。

基本结构

小波树是一棵二叉树,每个节点对应字母表的一个子集。根节点对应整个字母表, 左子树对应字典序较小的一半字符,右子树对应较大的一半。每个节点存储一个 位向量(bit vector),其中第 i 位表示该节点序列中第 i 个字符属于左子集(0) 还是右子集(1)。

字母表 {a, b, c, d}
分割:{a,b} | {c,d}

        root: [a,b,c,d,a,c,b,a,d]
        bits: [0,0,1,1,0,1,0,0,1]
       /                          \
  {a,b}: [a,b,a,b,a]         {c,d}: [c,d,c,d]
  bits:  [0,1,0,1,0]         bits:  [0,1,0,1]
   /       \                   /       \
 {a}:[a,a,a] {b}:[b,b]    {c}:[c,c] {d}:[d,d]

Rank 查询

要计算 Occ(c, i)(即 rank_c(i)),从根节点开始向下遍历:

  1. 在当前节点的位向量上做 rank 查询,更新位置 i。
  2. 如果 c 属于左子集,向左走,i = rank_0(i)。
  3. 如果 c 属于右子集,向右走,i = rank_1(i)。
  4. 到达叶节点时,i 就是答案。

树高为 O(log σ),每层做一次位向量 rank(O(1) 时间),所以总时间为 O(log σ)。

位向量的 Rank 实现

位向量上的 rank 查询是整个结构的基石。经典实现使用两层索引:

大块(superblock):每 256 位一个,存储前缀 popcount(绝对值)
小块(block):    每 64 位一个,存储相对于大块的 popcount(相对值)
查询时:rank_1(i) = superblock[i/256] + block[i/64] + popcount(word & mask)

利用硬件 popcount 指令(x86 的 POPCNT,GCC 的 __builtin_popcountll), 每次 rank 查询只需常数时间。

空间分析

小波树总共存储 n * ceil(log σ) 位的位向量,加上 rank 的辅助结构 (每个位向量额外 o(n) 位)。总空间为 n * log σ + o(n log σ) 位, 接近信息论下界 n * H_0(T)。

对于 FM-index,使用小波树后,backward search 的每步时间从 O(1) 变为 O(log σ),总时间为 O(m log σ)。在实践中,对于 DNA 序列 (σ = 4 或 5),log σ 只有 2-3,几乎可以忽略。

七、C 语言实现:BWT 构建与 FM-index 搜索

下面给出一份完整的 C 语言实现,包含 BWT 的朴素构建、FM-index 的查询, 以及逆变换。代码可以直接用 gcc -O2 -o bwt bwt.c 编译运行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_N    100000
#define SIGMA    256

/* --------------- 后缀数组(朴素构建) --------------- */

static int sa[MAX_N + 1];
static int sa_n;
static const char *sa_text;

static int sa_cmp(const void *a, const void *b)
{
    int i = *(const int *)a;
    int j = *(const int *)b;
    return strcmp(sa_text + i, sa_text + j);
}

static void build_suffix_array(const char *t, int n, int *out_sa)
{
    sa_text = t;
    sa_n = n;
    for (int i = 0; i <= n; i++)
        out_sa[i] = i;
    qsort(out_sa, n + 1, sizeof(int), sa_cmp);
}

/* --------------- BWT 构建 --------------- */

static char bwt[MAX_N + 2];

static void build_bwt(const char *t, int n, int *suf_arr, char *out_bwt)
{
    for (int i = 0; i <= n; i++) {
        if (suf_arr[i] == 0)
            out_bwt[i] = t[n]; /* 即 '$' */
        else
            out_bwt[i] = t[suf_arr[i] - 1];
    }
    out_bwt[n + 1] = '\0';
}

/* --------------- FM-index 结构 --------------- */

typedef struct {
    int  n;                     /* 文本长度(含 $) */
    char *L;                    /* BWT 数组 */
    int  C[SIGMA];              /* C 数组 */
    int  (*occ)[SIGMA];         /* Occ[i][c] = L[0..i-1] 中 c 的出现次数 */
} FMIndex;

static void fm_build(FMIndex *fm, const char *bwt_str, int n)
{
    fm->n = n;

    /* 复制 BWT */
    fm->L = (char *)malloc(n + 1);
    memcpy(fm->L, bwt_str, n + 1);

    /* 分配并计算 Occ 表 */
    fm->occ = (int (*)[SIGMA])calloc((size_t)(n + 2), sizeof(int[SIGMA]));

    /* occ[0][c] = 0 对所有 c */
    for (int i = 0; i < n + 1; i++) {
        unsigned char c = (unsigned char)fm->L[i];
        for (int a = 0; a < SIGMA; a++)
            fm->occ[i + 1][a] = fm->occ[i][a];
        fm->occ[i + 1][c]++;
    }

    /* 计算 C 数组 */
    int freq[SIGMA] = {0};
    for (int i = 0; i <= n; i++)
        freq[(unsigned char)fm->L[i]]++;

    fm->C[0] = 0;
    for (int c = 1; c < SIGMA; c++)
        fm->C[c] = fm->C[c - 1] + freq[c - 1];
}

static void fm_free(FMIndex *fm)
{
    free(fm->L);
    free(fm->occ);
}

/* Backward search: 返回匹配次数,lo/hi 为 SA 区间 */
static int fm_search(const FMIndex *fm, const char *pattern,
                     int *out_lo, int *out_hi)
{
    int m = (int)strlen(pattern);
    int lo = 0;
    int hi = fm->n + 1;

    for (int i = m - 1; i >= 0; i--) {
        unsigned char c = (unsigned char)pattern[i];
        lo = fm->C[c] + fm->occ[lo][c];
        hi = fm->C[c] + fm->occ[hi][c];
        if (lo >= hi)
            return 0;
    }

    if (out_lo) *out_lo = lo;
    if (out_hi) *out_hi = hi;
    return hi - lo;
}

/* --------------- BWT 逆变换 --------------- */

static void inverse_bwt(const FMIndex *fm, char *out_text)
{
    int n = fm->n;

    /* 从 F 列中 '$' 所在的行(即第 0 行)开始 */
    int i = 0;
    for (int j = n - 1; j >= 0; j--) {
        out_text[j] = fm->L[i];
        unsigned char c = (unsigned char)fm->L[i];
        i = fm->C[c] + fm->occ[i][c];
    }
    out_text[n] = '\0';
}

/* --------------- 定位:通过后缀数组回查 --------------- */

static void fm_locate(const FMIndex *fm, const int *suf_arr,
                      const char *pattern)
{
    int lo, hi;
    int count = fm_search(fm, pattern, &lo, &hi);

    printf("Pattern \"%s\": %d occurrence(s)", pattern, count);
    if (count > 0) {
        printf(" at position(s):");
        for (int i = lo; i < hi; i++)
            printf(" %d", suf_arr[i]);
    }
    printf("\n");
}

/* --------------- 主程序 --------------- */

int main(int argc, char *argv[])
{
    const char *text = (argc > 1) ? argv[1] : "banana";
    int n = (int)strlen(text);

    /* 在末尾添加 $ */
    char *t = (char *)malloc(n + 2);
    memcpy(t, text, n);
    t[n] = '$';
    t[n + 1] = '\0';

    printf("Text: %s\n", text);
    printf("T$:   %s\n", t);

    /* 构建后缀数组 */
    build_suffix_array(t, n, sa);

    printf("SA:  ");
    for (int i = 0; i <= n; i++)
        printf(" %d", sa[i]);
    printf("\n");

    /* 构建 BWT */
    build_bwt(t, n, sa, bwt);
    printf("BWT:  %s\n", bwt);

    /* 构建 FM-index */
    FMIndex fm;
    fm_build(&fm, bwt, n);

    /* 测试搜索 */
    const char *patterns[] = {"ana", "an", "na", "ban", "xyz", NULL};
    for (int i = 0; patterns[i]; i++)
        fm_locate(&fm, sa, patterns[i]);

    /* 逆变换验证 */
    char *recovered = (char *)malloc(n + 1);
    inverse_bwt(&fm, recovered);
    printf("Recovered: %s\n", recovered);
    printf("Match: %s\n", strcmp(text, recovered) == 0 ? "YES" : "NO");

    /* 清理 */
    fm_free(&fm);
    free(t);
    free(recovered);
    return 0;
}

编译与运行:

gcc -O2 -o bwt bwt.c
./bwt banana

预期输出:

Text: banana
T$:   banana$
SA:   6 5 3 1 0 4 2
BWT:  annb$aa
Pattern "ana": 2 occurrence(s) at position(s): 3 1
Pattern "an": 2 occurrence(s) at position(s): 3 1
Pattern "na": 2 occurrence(s) at position(s): 4 2
Pattern "ban": 1 occurrence(s) at position(s): 0
Pattern "xyz": 0 occurrence(s)
Recovered: banana
Match: YES

代码说明

后缀数组构建。 这里使用了 qsort + strcmp 的朴素 O(n^2 log n) 方法, 仅用于演示。生产环境应使用 SA-IS 算法(O(n) 时间,O(n) 空间)或 DC3/Skew 算法。

Occ 表。 预计算了完整的二维 Occ 表,每个位置存储所有字符的累计频次。 这在 σ 较小时(如 DNA 的 5 个字符)非常高效,但对于大字母表 需要换用小波树或分块存储。

BWT 构建。 在实际应用中,BWT 通常直接从后缀数组推导,而后缀数组 由 SA-IS 在线性时间内构建。libdivsufsort 是广泛使用的高效实现。

八、SA 采样与位置定位

FM-index 的 backward search 返回的是 SA 区间 [lo, hi),告诉我们模式串 出现了多少次。但要知道每次出现的具体位置,还需要查询后缀数组的值 SA[i]。

如果完整存储后缀数组,空间为 O(n log n) 位,这与 FM-index 的压缩目标矛盾。 解决方案是 SA 采样(SA sampling)

采样策略

每隔 d 个位置存储一个后缀数组值。对于未被采样的位置 i,通过反复应用 LF-mapping 向前走,直到到达一个被采样的位置 j:

SA[i] = SA[j] + steps

其中 steps 是从 i 到 j 经过的 LF-mapping 步数。

算法 LocateSA(i):
    steps = 0
    while i 不是采样位置:
        i = LF(i)
        steps++
    return sampled_SA[i] + steps

空间与时间权衡

采样间隔 d SA 采样空间 定位一个位置的最坏时间
1 O(n log n) O(1)
32 O(n log n / 32) O(32)
64 O(n log n / 64) O(64)
128 O(n log n / 128) O(128)
log^2 n O(n log n / log^2 n) O(log^2 n)

在实际基因组比对中,d = 32 或 d = 64 是常见选择。对于人类基因组 (n ≈ 3 x 10^9),完整 SA 需要 12 GB(每个值 4 字节), d = 32 的采样只需约 375 MB。

ISA 采样

另一种策略是采样逆后缀数组(ISA):每隔 d 个文本位置存储其在 SA 中的 排名。ISA 采样的好处是它与 BWT 的某些操作(如从文本位置出发的查询)更兼容。 BWA 同时使用了 SA 采样和 ISA 采样。

九、基因组比对:BWA-MEM 算法概述

BWA(Burrows-Wheeler Aligner)是目前使用最广泛的短读段比对工具之一。 BWA-MEM 是其最新的算法变体,适用于 70bp 到数 Mbp 的读段。

基因组比对的挑战

二代测序(NGS)每次运行产生数亿条短读段(reads),每条长 100-300bp。 这些读段需要被比对到参考基因组(如人类基因组,约 30 亿碱基对)上。 核心挑战在于:

BWA-MEM 的工作流程

BWA-MEM 的比对分为三个主要阶段:

阶段一:种子生成(Seeding)。 使用 FM-index 的 backward search 在参考基因组的 BWT 上寻找 超级最大精确匹配(SMEM,Super-Maximal Exact Match)。 SMEM 是读段与参考基因组之间的最长精确匹配片段,且在两端都无法继续延伸。

算法从读段的每个位置出发,利用 backward search 逐步延伸,记录每个位置能 达到的最长匹配。当匹配长度低于阈值(默认 19bp)时跳过。

阶段二:种子延伸(Extension)。 对每个种子(SMEM),使用带 affine gap penalty 的 Smith-Waterman 算法 进行局部比对延伸。BWA-MEM 使用 SSE2 指令集加速的条带化(banded) Smith-Waterman 实现(ksw 库)。

阶段三:输出与后处理。 生成 SAM/BAM 格式的比对结果,包括: - 比对位置和方向 - CIGAR 字符串(描述比对关系) - 比对质量(MAPQ) - 配对信息(paired-end reads)

FM-index 在 BWA 中的具体使用

BWA 对参考基因组构建 FM-index 的过程:

参考基因组 -> SA-IS 构建后缀数组 -> 推导 BWT -> 构建 Occ 表 -> SA 采样

人类基因组的 FM-index 占用约 3.2 GB 内存(BWT + Occ 表 + SA 采样), 远小于完整后缀数组的 12 GB。

在搜索过程中,BWA 使用的 Occ 表采用 128 位为一个块 的分块存储方案, 每个块存储 4 个 DNA 碱基的累计频次。利用 __builtin_popcountll 指令, 每次 Occ 查询只需约 10 个时钟周期。

为什么不用哈希或后缀数组?

方案 索引大小(人类基因组) 查询时间 适用场景
FM-index ~3.2 GB O(m) 内存受限、长精确匹配
后缀数组 ~12 GB O(m log n) 内存充裕
哈希表(k-mer) ~16-32 GB O(1) 每个 k-mer 极速比对(minimap2)

FM-index 的优势在于空间紧凑且支持变长模式匹配(不限于固定 k-mer 长度)。 但在三代长读段时代,minimap2 基于 minimizer 的哈希索引因其更好的种子 生成策略而逐渐成为主流。

十、性能基准:FM-index 对比后缀数组

理论复杂度的差异在实际运行中表现如何?下面给出一组基准测试的典型结果。

索引构建

操作 后缀数组(SA-IS) FM-index(含 SA 采样)
构建时间(1 GB 文本) ~180 秒 ~220 秒(含 Occ 表)
构建内存 ~5 GB ~6 GB
磁盘大小 ~4 GB ~1.5 GB

FM-index 的构建需要先构建后缀数组,因此构建时间略长。但磁盘占用显著更小。

查询性能

操作 后缀数组(二分查找) FM-index(backward search)
存在性查询(m=20) ~0.8 μs ~0.3 μs
存在性查询(m=100) ~4.2 μs ~1.2 μs
计数查询 同上 同上
定位(d=32) +0 +~0.5 μs per hit
内存占用 ~4 GB ~1.5 GB

FM-index 在存在性和计数查询上有明显优势,但定位操作因 SA 采样的开销 而略逊色。在大多数应用中(如基因组比对),计数和存在性查询远多于定位, 因此 FM-index 的综合性能更优。

Cache 行为

FM-index 的 backward search 在访问 Occ 表时存在较差的缓存局部性—— 每步可能跳到 Occ 表的任意位置。相比之下,后缀数组的二分查找虽然也有 随机访问,但最终几步通常在缓存行内。

实际工程中,通过将 BWT 和 Occ 表交错存储(interleaved layout), 可以改善缓存命中率。BWA 和 Bowtie2 都采用了这种优化。

十一、工程实践中的陷阱

在实际部署 BWT 和 FM-index 时,有许多教科书不会提到的工程细节。 下表汇总了常见的陷阱及其应对方案。

陷阱 表现 应对方案
$ 符号冲突 输入文本包含 \0$ 使用不出现在输入中的哨兵值,或用特殊编码
后缀数组构建溢出 n > 2^31 时 SA 值溢出 int 使用 64 位整数,或采用 40-bit 压缩存储
Occ 表内存爆炸 σ=256 时 Occ 表占 256n 字节 分块存储 + popcount,或使用小波树
缓存不友好 backward search 随机访问 Occ 表 BWT/Occ 交错存储,对齐到缓存行
多核并行困难 FM-index 查询是顺序依赖的 不同查询之间并行(query-level parallelism)
磁盘 I/O 瓶颈 大基因组的索引加载很慢 内存映射(mmap),预分配 huge pages
逆向搜索方向 backward search 从右向左,与直觉相反 也可以构建反向文本的 FM-index 实现正向搜索
浮点下溢(压缩) 算术编码的精度问题 bzip2 使用 Huffman 而非算术编码避免此问题
超长重复序列 导致 SA 区间巨大,定位极慢 设定定位数量上限,或使用阈值过滤
字母表假设 假设 σ 很小但实际更大 运行前检查实际字母表大小,动态选择策略
块边界处理(bzip2) BWT 在块边界处中断 每块独立构建 BWT,块大小影响压缩比
内存对齐 SIMD 指令要求特定对齐 使用 posix_memalign 或 aligned_alloc

十二、真实世界的应用

bzip2

bzip2 是 BWT 在压缩领域最成功的应用。由 Julian Seward 于 1996 年发布, 至今仍是 Linux 发行版中的标准工具。

bzip2 的核心参数是块大小(-1-9,对应 100KB 到 900KB)。 更大的块意味着更好的压缩比,但也需要更多内存:

压缩内存   ≈ 块大小 x 8
解压内存   ≈ 块大小 x 4

bzip2 的压缩比通常优于 gzip(DEFLATE 算法),但劣于 xz(LZMA 算法)。 在压缩/解压速度上,bzip2 也慢于 gzip。bzip2 的主要优势在于解压速度 相对合理(因为 BWT 逆变换是线性的),以及良好的压缩比/内存权衡。

值得一提的是,bzip2 的后缀数组排序曾是其最大的性能瓶颈。早期版本使用 qsort,后来切换到了更高效的算法(类似于 Bentley-Sedgewick 的多键快排)。

Bowtie / Bowtie2

Bowtie 是 2009 年发表在 Genome Biology 上的短读段比对工具, 是 FM-index 在基因组学中的第一个大规模应用。Bowtie 使用 FM-index 支持精确匹配和允许少量错配的近似匹配(通过回溯搜索)。

Bowtie2 是其后续版本,增加了对间隔(indel)的支持,使用类似 BWA 的 种子-延伸策略。Bowtie2 的 FM-index 实现针对 DNA 的 4 字符字母表 做了深度优化:Occ 表使用 2-bit 编码存储 DNA 碱基,每 128 个碱基 一个 checkpoint。

BWA / BWA-MEM

BWA 由 Heng Li 开发,是目前引用量最高的生物信息学工具之一 (截至 2025 年超过 60,000 次引用)。BWA-MEM 算法自 2013 年以来 成为短读段比对的事实标准。

BWA 的 FM-index 实现有几个值得注意的工程决策:

其他应用

十三、我的一些思考

BWT 是我在学习字符串算法过程中遇到的最优美的构造之一。它的美在于 一种深刻的”无中生有”——通过一次看似毫无目的的矩阵变换,同时获得了 压缩友好性和可搜索性,这两个看起来毫不相关的性质。

我第一次实现 BWT 逆变换时,花了整整一个下午才真正理解 LF-mapping 为什么成立。那个”相同字符在 F 列和 L 列中保持相对顺序”的证明, 文字读起来很自然,但要在脑海中真正”看到”它,需要反复地画图、 举例、用手指在矩阵上一行一行地追踪。我建议每个学习 BWT 的人 都亲手在纸上做一遍这个过程。

FM-index 的 backward search 同样令人赞叹。传统的子串搜索 (如 KMP、Boyer-Moore)沿着文本从左到右滑动窗口,而 backward search 完全颠覆了这个思路:它不看文本本身,只操作一个经过变换的数组和 一些辅助表,就完成了搜索。更惊人的是,搜索时间只与模式串长度有关, 与文本长度完全无关。

从工程角度看,FM-index 的设计哲学值得玩味。它本质上是一种 “用计算换空间”的策略:通过复杂的编码和查表操作,在压缩表示上 直接完成原本需要在完整数据上进行的操作。这个思路在今天的内存墙时代 尤其有价值——当数据大到放不进内存时,压缩索引不只是节省了存储, 更是通过减少 I/O 而提升了实际性能。

在基因组学领域,BWT 和 FM-index 的影响怎么强调都不为过。 BWA 和 Bowtie 的出现使得在一台普通工作站上处理整个人类基因组 的测序数据成为可能。没有这些工具,二代测序革命不可能以如此快的 速度推进。

但技术总在演进。三代长读段测序(PacBio、Nanopore)的兴起使得 比对工具的设计发生了转变。minimap2 基于 minimizer 哈希的种子策略 在长读段场景下表现更好,因为长读段的高错误率使得 FM-index 的 精确匹配种子变得不够实用。不过,对于短读段和高精度的变异检测, BWA-MEM 仍然是首选。

BWT 和 FM-index 给我最大的启发是:好的算法设计往往来自对问题的 重新表述(reformulation)。BWT 把”压缩”重新表述为”排列使得相似 上下文聚集”;FM-index 把”搜索”重新表述为”在变换域上做区间收缩”。 这种能力——找到一个新的视角,使得原来困难的问题变得自然—— 是算法研究中最珍贵的素质。

最后说一个有趣的事实:BWT 和后缀数组之间存在一种”对偶”关系。 后缀数组记录的是”每个位置的后缀排在第几”,BWT 记录的是 “排在第几的后缀的前一个字符是什么”。看似不同的信息,却可以 互相推导,这是字符串算法中最精巧的对称性之一。

参考文献

  1. Burrows, M. and Wheeler, D. J. (1994). A Block-sorting Lossless Data Compression Algorithm. DEC Systems Research Center Technical Report 124.
  2. Ferragina, P. and Manzini, G. (2000). Opportunistic Data Structures with Applications. FOCS 2000, pp. 390-398.
  3. Ferragina, P. and Manzini, G. (2005). Indexing Compressed Text. Journal of the ACM, 52(4):552-581.
  4. Li, H. and Durbin, R. (2009). Fast and Accurate Short Read Alignment with Burrows-Wheeler Transform. Bioinformatics, 25(14):1754-1760.
  5. Li, H. (2013). Aligning Sequence Reads, Clone Sequences and Assembly Contigs with BWA-MEM. arXiv:1303.3997.
  6. Langmead, B. et al. (2009). Ultrafast and Memory-efficient Alignment of Short DNA Sequences to the Human Genome. Genome Biology, 10:R25.
  7. Grossi, R., Gupta, A. and Vitter, J. S. (2003). High-order Entropy-compressed Text Indexes. SODA 2003, pp. 841-850.
  8. Navarro, G. (2014). Wavelet Trees for All. Journal of Discrete Algorithms, 25:2-20.
  9. Nong, G., Zhang, S. and Chan, W. H. (2009). Linear Suffix Array Construction by Almost Pure Induced-Sorting. DCC 2009, pp. 193-202.
  10. Gagie, T., Navarro, G. and Prezza, N. (2020). Fully Functional Suffix Trees and Optimal Text Searching in BWT-Runs Bounded Space. Journal of the ACM, 67(1):1-54.

上一篇: AC 自动机 下一篇: 编辑距离与模糊匹配 相关阅读: - 后缀数组 - Huffman 编码与 DEFLATE


By .