全文检索看起来是"老问题":给一段长文本建索引,回答"模式 P 出现了几次、在哪些位置、上下文是什么"。倒排索引(inverted index)在词粒度上无敌,但它要求先分词,对 DNA 序列、二进制字节流、列式存储的字典列这类"没有词边界"的数据无能为力。而把每个后缀都排序存下来(后缀数组)虽然能精确回答子串查询,却要 O(n) 空间且查询要二分 O(m·log n)。2000 年 Paolo Ferragina 与 Giovanni Manzini 提出的 FM-index,用一个巧妙的观察把后缀数组"压缩"进了它自己变换出的 BWT 字符串里——只存 BWT 加几个小辅助结构,就能在接近 n·Hk 的极低空间下,完成 count / locate / extract 三类查询,且完全随机访问。本文从空间困境讲起,逐层剖析 BWT、LF 映射、反向搜索与位级压缩,并给出一份可运行的 Python 教学版实现。

一、全文索引的空间困境:为什么不能把每个后缀都存下来

子串查询最朴素的做法是后缀数组:把所有 n 个后缀排字典序,存下每个后缀在原文的起始位置。查询模式 P(长度 m)时,在排序后的后缀数组上二分,找到"以 P 为前缀"的后缀区间 [sp, ep],区间长度就是出现次数,区间里每个值就是出现位置。这没错,但代价是:

  • 空间 O(n log n) 比特:每个后缀位置要用 log n 位,n 个就是 n·log n 位。对 3Gb 的人类基因组,这要几百 MB 仅存下标,还不算原文。
  • 查询 O(m log n) 时间:每次二分都比较 m 个字符。
  • 随机访问昂贵:要取回某次出现的上下文(extract),还得回原文切片,意味着原文得另外存着。

FM-index 的灵魂问题是:后缀数组里的"顺序信息"能不能用更小的结构表达,使得我们既不必存整个数组、又能还原出任意区间与位置?答案是把后缀数组喂进 Burrows-Wheeler Transform,再看变换后字符串的局部性。

二、后缀数组(Suffix Array):O(n) 空间定位子串起点

先把后缀数组说清楚,因为它就是 FM-index 的"压缩对象"。给定文本 S(长度 n),其后缀数组 SA 是一个整数数组,SA[i] 表示字典序第 i 小的后缀在 S 中的起始下标。例如 S = "banana$"('$' 是小于所有字母的哨兵,标记串尾):

S        = b a n a n a $
后缀      起始  字典序
$         6     最小
a$        5
ana$      3
anana$    1
banana$   0
na$       4
nana$     2     最大
SA = [6, 5, 3, 1, 0, 4, 2]

任意子串查询都可以转成对 SA 的区间查询:模式 P 出现的位置,恰好就是"以 P 为前缀"的那些后缀的起始下标,也就是 SA 里一段连续区间。难点只在于:这段区间怎么在不二分、不逐字符比较的情况下快速求得?FM-index 用 BWT 解决了它。

三、BWT 变换:把重复字符搬进相邻列

Burrows-Wheeler Transform 的构造非常直白:把文本的所有循环移位排成矩阵,按字典序排序,取每一行的最后一个字符拼起来,就是 BWT。对后缀数组视角,这等价于"取每个后缀的前一个字符"——因为 SA 已经把后缀排好序了。我们称排好序后的首列为 F、末列(BWT)为 L:

行排序后的后缀F(首列)L = BWT(上一行末字符)
0$$a
1a$an
2ana$an
3anana$ab
4banana$b$
5na$na
6nana$na

BWT 的魔力在于局部性:原文中连续出现的相同字符,其"前一个字符"往往也相近,于是排好序后 L 列会出现长串同质字符(这里的 "nn"、两个 "a")。这种同质块是后续压缩(游程、位图)能大显身手的根。注意 BWT 是可逆的:仅由 L 就能还原出原文(因为 F 就是 L 排序后的结果),这正是"压缩而不丢信息"的前提。

四、从 BWT 矩阵到 LF 映射:Last-to-First 重排

排序矩阵有个关键不变量:第 i 行的首字符,在第 i-1 行里是作为末字符出现的,且它在该字符的"第几个"保持不变。换句话说,把 L 中第 i 个位置的字符,映射回 F 中"同字符的第几个",得到的新行号就叫 LF(i)(Last-to-First)。对 "banana$":

L = "annb$aa"   F = "$aaabnn"(F 就是 L 排序)
C['$']=0  C['a']=1  C['b']=4  C['n']=5   # C[c]:严格小于 c 的字符总数
rank('a', i) = L[0..i] 中 'a' 的出现次数

LF(i) = C[ L[i] ] + rank( L[i], i-1 )
LF[0]=C['a']+0=1
LF[1]=C['n']+0=5
LF[2]=C['n']+1=6
LF[3]=C['b']+0=4
LF[4]=C['$']+0=0
LF[5]=C['a']+1=2
LF[6]=C['a']+2=3
LF = [1, 5, 6, 4, 0, 2, 3]

LF 映射的深层含义是 SA[LF[i]] = (SA[i] - 1) mod n:它把"后缀起点减 1"这个操作编码进了 BWT 的局部性里。也就是说,只要能算 LF,就能沿着后缀数组往"前一个字符"走,而完全不需要真的存 SA。这正是 FM-index 把 O(n log n) 位压缩到接近 n·Hk 的核心。

五、LF 映射与字符计数:用 rank 回答"前面有几个 c"

实现 LF 只需要两样东西:C[c](字符频率前缀和,常数大小)和 rank(c, i)(BWT 前 i 个位置里 c 出现了几次)。rank 看似要扫描,实则可以用累计出现数组 O(1) 查到——这是 FM-index 唯一需要"额外存"的结构。工程上有两级优化:

  • 原始 occ 数组:对每个字符维护一个长度为 n 的前缀计数数组,O(1) 查询、O(n·Σ) 空间。
  • Wavelet Tree / 位图 + RRR:把每个字符的出现位图压成稀疏位图,用 select/rank 在 O(log Σ) 时间内回答,空间降到接近 n·H0(零阶熵)。这就是"压缩"二字落地的位置。

只要 rank 够快够小,LF 就是 O(1) 操作,整个索引的查询骨架就立住了。

六、反向搜索(Backward Search):从模式末尾倒着走

怎么不求区间地直接算出"以 P 为前缀的后缀区间"?技巧是从模式的最后一个字符往回处理。每加入一个字符 c(从后往前),区间 [sp, ep] 被收窄到"在 BWT 的 [sp, ep] 这一段里,字符 c 所占据的那段新区间"。公式只用 C 和 rank:

sp, ep = 0, n-1
for c in reversed(P):                 # 从模式末尾往前
    lo = rank(c, sp-1)                # BWT[0..sp-1] 中 c 的个数
    hi = rank(c, ep)                  # BWT[0..ep]  中 c 的个数
    if hi <= lo: return 空            # 区间内没有 c,模式不存在
    sp = C[c] + lo
    ep = C[c] + hi - 1
# 结束后 [sp, ep] 即"以 P 为前缀"的后缀在 SA 中的区间

用 "ana" 在 "banana$" 上走一遍:起始 sp=0,ep=6。先处理 'a':lo=0, hi=3 → sp=1,ep=3(所有以 'a' 开头的后缀);再处理 'n':在 [1,3] 内 'n' 的 lo=0,hi=2 → sp=5,ep=6("na$"、"nana$");最后处理 'a':在 [5,6] 内 'a' 的 lo=1,hi=3 → sp=2,ep=3。落点区间 [2,3] 正好对应后缀 "ana$" 与 "anana$",出现次数 2。整个过程没有比较任何原文字符,只做了 3 次 O(1) 的 rank/LF 计算。

七、三种核心查询:count / locate / extract

反向搜索结束得到的区间 [sp, ep] 直接给出三大查询:

  • count:区间长度 ep - sp + 1,即出现次数。O(m) 时间,且不需要回原文。
  • locate:区间内每个行号 i 对应的后缀起点 SA[i]。由不变量 SA[LF[i]] = (SA[i] - 1) mod n,从行 i 沿 LF 一直走到 '$' 所在的首行(行 0),步数 k 满足 SA[i] = (SA[0] + k) mod n。SA[0] 是哨兵位置(恒为 n-1),所以每定位一个出现只需 O(n) 步——生产系统会对 SA 做稀疏采样(每隔 √n 或 log n 行存一个真值)把这一步压到 O(log n)。
  • extract:拿到某个出现的起点后,取回它的上下文子串。教学版直接回原文切片;压缩版则沿 LF 向前走 length 步逐字符拼回(注意 LF 给出的是"前一个字符",方向要反转)。
def count(self, pattern):
    sp, ep = self.backward_search(pattern)
    return max(0, ep - sp + 1)

def _sa_of_row(self, i):
    steps, pos = 0, i
    while pos != 0:              # 走回 '$' 所在的首行
        pos = self.lf(pos)
        steps += 1
    return (self.sa[0] + steps) % self.n   # SA[0] 即哨兵位置

def locate(self, pattern):
    sp, ep = self.backward_search(pattern)
    return sorted(self._sa_of_row(i) for i in range(sp, ep + 1))

def extract(self, sa_pos, length):
    return self.text[sa_pos: sa_pos + length]   # 教学版回原文切片

八、压缩骨架:Wavelet Tree 与 occ 的位级压缩

FM-index 之所以能"压缩",关键在于 rank 用的 occ 数组本身可以被压。最经典的落法是 Wavelet Tree:把字母表递归二分,每一层用一张位图标记"落到左半还是右半",rank 退化成在位图上数 1 的个数。位图再经 RRR(Raman–Raman–Rao)或 SDArray 编码压到接近零阶熵。这样整体空间:

# Wavelet Tree 上的 rank 示意(两层示例)
# 字母表 {a,b,n,$} 二分:左={$,a} 右={b,n}
bitmap = "0100110"          # 1 表示落在右半
# rank(c, i):先按位图定位在右半的第几个,再递归到对应子层
def wt_rank(c, i):
    if 叶子: return occ[c][i]
    bit = 1 if c in 右半 else 0
    k = rank1(bitmap, i) if bit else (i - rank1(bitmap, i))
    return wt_rank(子层字符(c), k)

配合对 SA 的稀疏采样(存 √n 个真值)与对原文可选的 Golomb/Rice 编码,FM-index 在实测中能把英文文本压到 约 1.2–2 字节/字符,同时保留全部子串查询能力——这比存完整后缀数组(约 4 字节/字符仅下标)加上原文要省得多。

九、空间账:为何能压到接近 1.2–2 字节/字符

把几类全文索引方案摊开对比,FM-index 在"子串随机访问 + 高压缩"这一格是唯一的:

方案空间(字节/字符)子串查询随机 extract典型用途
后缀数组(裸)~4(仅下标)O(m log n)需回原文教学、小规模
后缀树~8–20(指针密集)O(m)需回原文多模式、LLM
倒排索引依赖分词词级 O(1)不支持子串搜索引擎
FM-index(压缩)~1.2–2O(m)支持基因组、字节流

注意空间随文本熵走而非随 n 走:文本越有规律(DNA、日志、列式字典列),压缩比越高。这是它天然适合"高重复、无词界"数据的根本原因。

十、工程落地:BWA、Bowtie2 与列式存储的字典压缩

FM-index 不是论文玩具,它是现代生命科学与数据系统的地基:

  • 基因组比对:BWA、Bowtie2 用2 阶 FM-index(2FM-index)在数十 Gbps 的人类中做短读长比对,靠的就是 count/locate 在压缩空间上的 O(m) 查询。人类基因组 3Gb 的索引约 3–4GB,且能随机访问任意子串。
  • 字符串去重与模糊匹配:rsync 风格的 rolling hash、模糊搜索(近似 count)都建立在反向搜索的区间收窄上。
  • 列式存储字典压缩:ClickHouse、Parquet 的字典编码列在做"前缀/子串谓词"时,可直接对字典列建 FM-index,把 LIKE '%abc%' 变成一次 count 而非全列扫描。
  • 后缀自动机/后缀树退化场景:当内存紧张又必须保留子串查询时,FM-index 是后缀树的高压缩替代。

十一、与后缀数组 / 后缀树的取舍

FM-index 不是"更好",而是"在不同约束下更合适":

  • 要最小空间 + 能忍 O(m) 查询 → FM-index。它把后缀数组"隐含"在 BWT 里,用计算换存储。
  • 要 O(m) 查询且频繁做多种模式/子树操作(如后缀链接) → 后缀树/后缀自动机更直观,但指针密集、空间大。
  • 要最快的 locate → 裸后缀数组 + 原文,二分 O(m log n) 但实现最简单,适合内存宽裕的小数据。
  • 要词级检索 → 倒排索引仍是王者,FM-index 不擅长"词"语义。

实战里常见组合:用 FM-index 做底层压缩子串索引,上面再铺一层倒排或采样,兼顾空间与查询语义。

十二、结语

FM-index 的优雅在于它把"排好序的后缀数组"这一看似必须显式存储的结构,重新编码进文本自己的 BWT 变换之中:BWT 带来局部性、LF 映射把"前缀缩减"变成 O(1) 的 rank 查询、反向搜索让 count 完全不碰原文、稀疏采样的 SA 与 Wavelet Tree 把 locate/extract 也压进接近熵的空间。它证明了一件事——很多"必须存下来"的顺序信息,其实可以靠可逆变换 + 局部性 + 代数映射隐式表达。这也是为什么从人类基因组到列式存储的字典列,FM-index 始终是"高压缩、可随机访问子串索引"这一需求的第一选择。下面的教学实现把上述全部机制浓缩进约 40 行 Python,可直接跑通 count / locate / extract。

class FMIndex:
    def __init__(self, text):
        self.text = text
        self.n = len(text)
        self.sa = sorted(range(self.n), key=lambda i: text[i:])
        self.bwt = ''.join(text[(self.sa[i] - 1) % self.n] for i in range(self.n))
        from collections import Counter
        cnt = Counter(text)
        self.chars = sorted(cnt)
        self.C = {}
        tot = 0
        for c in self.chars:
            self.C[c] = tot
            tot += cnt[c]
        self.occ = {c: [0] * (self.n + 1) for c in self.chars}
        for i, ch in enumerate(self.bwt):
            for c in self.chars:
                self.occ[c][i + 1] = self.occ[c][i]
            self.occ[ch][i + 1] += 1

    def rank(self, ch, i):
        return self.occ[ch][i + 1] if i >= 0 else 0

    def lf(self, i):
        return self.C[self.bwt[i]] + self.rank(self.bwt[i], i - 1)

    def backward_search(self, pattern):
        sp, ep = 0, self.n - 1
        for ch in reversed(pattern):
            if ch not in self.C: return (0, -1)
            lo = self.rank(ch, sp - 1)
            hi = self.rank(ch, ep)
            if hi <= lo: return (0, -1)
            sp = self.C[ch] + lo
            ep = self.C[ch] + hi - 1
        return (sp, ep)

    def count(self, pattern):
        sp, ep = self.backward_search(pattern)
        return max(0, ep - sp + 1)

    def _sa_of_row(self, i):
        steps, pos = 0, i
        while pos != 0:
            pos = self.lf(pos)
            steps += 1
        return (self.sa[0] + steps) % self.n

    def locate(self, pattern):
        sp, ep = self.backward_search(pattern)
        return sorted(self._sa_of_row(i) for i in range(sp, ep + 1))

    def extract(self, sa_pos, length):
        return self.text[sa_pos: sa_pos + length]

# 端到端示例
idx = FMIndex("banana$")
print(idx.count("ana"))     # 2
print(idx.locate("ana"))    # [1, 3]
print(idx.extract(1, 3))    # "ana"
print(idx.count("na"))      # 2
print(idx.locate("na"))     # [2, 4]

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部