有一天,小林要给图书馆做一台“联想检索机”:用户在搜索框输入“人工智能”和“医疗”,机器要找出哪几本书同时讲这两个主题。每本书都有一个主题索引页,就像一本通讯录。倒排索引可以理解为“关键词 → 书号列表”。问题于是变成:给两个按书号排好序的列表,如何快速找出共同书号?最笨的办法是两本“名单”一页页对照;聪明点的办法是在名单上每隔几页贴一个“定位标签”,一次跳过不相干的一大段。这个贴标签的技巧,就是跳表指针。本文将沿着这条线索,从布尔查询、合并算法、跳表指针讲到工程优化与实战,让零基础读者也能看透搜索引擎的“求交集”内功。

岗位关联

对 AI 从业者来说,合并算法与跳表指针是倒排索引查询引擎的核心加速器,掌握它能帮助你设计更高效的信息检索与搜索推荐系统。

目录
  1. 可视化演示
  2. 1. 布尔查询基础
  3. 2. 线性合并算法
  4. 3. 跳表指针优化
  5. 4. 查询处理流程
  6. 5. 性能优化策略
  7. 6. 工程实践权衡
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示倒排链表合并算法、合并顺序优化、跳表指针加速和性能对比,含数据流背景和发光动画,交互切换各策略。

本节配有交互动效(进入视口后自动加载,约 3KB)。

1. 布尔查询基础

假设小林面前摆着两张表:一张写着“人工智能”出现过的书号,一张写着“医疗”出现过的书号。他想找两门学科都讲过的书,本质就是让两个书号集合取交集。搜“人工智能 OR 医疗”则是求并集,搜“人工智能 NOT 医疗”则是求差集。这就是布尔查询:一个查询条件通过逻辑运算符组合多个词项,最后得到满足条件的文档集合。

在搜索引擎后台,这种“词 → 书号列表”的映射结构叫倒排索引。倒排索引像书后那几页索引:你查“机器学习”,它会告诉你第 3、17、42 页出现过,只不过这里变成“包含该词的文档编号”。这些文档编号并不是乱序的,而是按编号升序排列。看似简单,这个“有序”是后面所有合并算法能够加速的前提。

有了倒排索引,布尔查询就落到集合运算上:每个词项对应一个文档ID集合,查询表达式决定对这些集合做交、并、差。实际系统中,这些集合不是内存中的随机集合,而是磁盘上或内存里紧凑有序的数组;数组长度等于该词的文档频率。因此,“求交集”效率就直接决定了查询延迟。

回到通讯录的比喻:Alice 的名单是“AI书单”,Bob 的名单是“医疗书单”,共同好友就是两个名单中同时出现的名字。倒排索引保证了名单按姓氏(文档ID)排好序,接下来就看怎么高效找共同好友了。

q=t1∧t2,R=I(t1)∩I(t2)\displaystyle q = t_1 \wedge t_2,\qquad R = I(t_1) \cap I(t_2)
公式把“AI AND 医疗”翻译成集合语言:先取出两个词各自的文档ID列表,再求交集,得到的就是同时包含两个词的所有文档ID。
  1. 查询表达式查询条件 q 由词项 t_1 和 t_2 用逻辑与 \wedge 连接,表示“同时包含”。
    q=t1∧t2\displaystyle q = t_1 \wedge t_2
  2. 倒排列表I(t) 表示词项 t 对应的文档ID集合,即倒排索引中这个词的完整列表。
    I(t1), I(t2)\displaystyle I(t_1),\ I(t_2)
  3. 交集运算\cap 把两个集合中相同的文档ID挑出来,结果 R 就是符合条件的书号列表。
    R=I(t1)∩I(t2)\displaystyle R = I(t_1) \cap I(t_2)

2. 线性合并算法

两个升序名单找共同好友,最直观的方法是双指针扫描:Alice 和 Bob 各拿一支笔指向当前名字。如果 Alice 的名字靠前,Alice 往后翻一页;如果 Bob 的名字靠前,Bob 往后翻一页;如果两个名字一样,就记下来,然后两人同时翻页。整个过程两支笔都只向前移动,绝不回头,因此不会漏掉任何共同好友。

把这个过程搬到倒排列表上,就是线性合并算法。维护两个下标 i 和 j,分别指向两个列表当前元素。当 A[i] < B[j] 时,说明 A[i] 不可能出现在另一个列表中,i++;当 B[j] < A[i] 时,j++;当 A[i] == B[j] 时,把该文档ID加入结果,并同时前进。这个过程持续到其中一个列表走完为止,因为剩下的元素不可能再匹配。

这个算法没有使用任何额外数据结构,只需要两个列表各扫描一遍。要强调一点:它靠的是两个列表都已按文档ID升序排列。如果列表乱序,就得退化为哈希或排序,代价大得多;如果其中一个列表短、另一个很长,线性合并仍会从头看到尾,因此才有后面跳表指针的用武之地。

用通讯录的话说,两个人把名单从头到尾各翻一遍,翻完就得出共同好友。这个方法稳定、简单,是搜索引擎中所有更高级合并算法的“地基”。

T(n)=O(a+b)\displaystyle T(n) = O(a + b)
这里的 a 和 b 是两个倒排列表的长度。线性合并的比较次数最多是两者之和,每一轮至少会让一个指针前进,所以不会出现“重复比较”的浪费。
  1. 列表长度设第一个列表长度为 a,第二个列表长度为 b。
    a=∣I(t1)∣,b=∣I(t2)∣\displaystyle a = |I(t_1)|,\quad b = |I(t_2)|
  2. 最坏情况最坏时两个列表的元素会交替比较,但每个元素最多被比较一次。
    a+b\displaystyle a + b
  3. 渐进复杂度当 a、b 都很大时,运行时间随总长度线性增长,记作 O(a+b)。
    T(n)=O(a+b)\displaystyle T(n) = O(a + b)

3. 跳表指针优化

线性合并虽好,但遇到很长的名单就吃亏:如果 Alice 有十万个名字,Bob 只有五个名字,Alice 可能要把整本名单翻完才结束。设想在 Alice 的名单上每隔几页贴一个标签:“本段最后一个名字是 XX”。Bob 一看到标签,就知道这一段不可能有共同好友,Alice 可以直接翻过整段。这就是跳表指针的思路。

在倒排列表上实现跳表指针,就是每隔 w 个文档ID建立一个“跳跃指针”,指针指向若干位置之后。合并时,如果当前元素仍然小于另一个列表的当前元素,并且跳跃之后的位置依然不超过对方当前元素,就直接把 i 推进 w 个位置,从而跳过中间一段。例如 A[i] = 1、B[j] = 5,而 A[i+w] = 5,那么下标 1 到 i+w-1 之间的元素都小于 B[j],不可能匹配,可以安全跳过。

跳表指针的核心是跳跃条件:只有当“跳过去也不会错过候选”时才跳。它不会改变结果正确性,只会减少无效比较。最坏情况下,如果没有合适的跳跃机会,算法退回逐指针移动,复杂度仍为 O(a+b);平均情况下,跳跃可以把比较次数降到一个与列表长度和跳跃步长相关的小得多的值。

回到通讯录:Alice 的名单越厚,跳表标签越有用;Bob 的名单很短时,Alice 跳几次就能和 Bob 对完。于是“贴标签”这个看似土办法,成了搜索引擎里减少查询延迟的重要武器。

Cavg=O(b+aw)\displaystyle C_{\text{avg}} = O\left(b + \frac{a}{w}\right)
公式中 b 是较短列表的长度,a 是较长列表的长度,w 是跳跃步长。跳跃指针让长列表不必每次只走一步,而是“大步流星”地掠过不可能匹配的区域。
  1. 较短列表长度合并时较短列表 b 几乎每个元素都要比较一次,因此保持不变。
    b\displaystyle b
  2. 跳跃次数长列表每前进 w 个位置才需要一次跳跃比较,因此跳跃次数约为 a/w。
    aw\displaystyle \frac{a}{w}
  3. 平均复杂度两者相加,得到总比较次数约为 b + a/w,用大 O 表示即 O(b + a/w)。
    Cavg=O(b+aw)\displaystyle C_{\text{avg}} = O\left(b + \frac{a}{w}\right)

4. 查询处理流程

实际用户输入可不是“给我求交集”这么直接。小林在搜索框输入“人工智能 AND 医疗”,系统要先把这个字符串拆成词项,再判断有没有“AND”“OR”“NOT”这类逻辑符号,接着去词典里查“人工智能”和“医疗”分别对应哪个倒排列表,最后把两个列表按合并算法求交集,再把结果按相关性排好序返回。整个过程像拿到一份“点菜指令”:先读懂菜名,再去后厨取食材,最后按菜谱组合出成品。

这个流程可以分成四步:查询解析、词项查找、结果合并、结果排序。查询解析把表达式变成结构化语法树;词项查找通过词典(词典常用哈希或 B+ 树)拿到每个词项的倒排列表地址;结果合并根据语法树执行对应的交集/并集/差集;结果排序则综合词频、文档长度等因素给文档打分。合并算法处在核心位置,因为数据量最大的环节就是倒排列表的交并运算。

工程上,这四步可能分散在不同模块:解析器和调度器负责前两步,合并器负责第三步,排序器(learning to rank 等)负责第四步。延迟瓶颈通常出现在第三步,也就是倒排列表的读取与合并。因此,前面讲的跳表指针、索引压缩等优化,主要都是在为“第三步”减负。

这就像小林把用户的“找共同好友”请求拆成:名字是什么、通讯录在哪、怎么对名单、最后怎么排顺序。每一步都清晰可优化,而核心优化点正好落在“怎么对名单”上。

Query→parseTree→lookupPostings→mergeResult\displaystyle \text{Query} \xrightarrow{\text{parse}} \text{Tree} \xrightarrow{\text{lookup}} \text{Postings} \xrightarrow{\text{merge}} \text{Result}
这条链路把查询处理拆成四个清晰阶段:解析生成语法树,查词典取出倒排列表,合并列表得到候选集,最后排序返回。倒排列表合并是其中最核心的一环。
  1. 解析解析用户查询字符串 Query 经过语法解析变为表达式树 Tree。
    Query→Tree\displaystyle \text{Query} \to \text{Tree}
  2. 词项查找表达式树的每个叶子节点是词项,通过词典查找得到其倒排列表 Postings。
    Tree→Postings\displaystyle \text{Tree} \to \text{Postings}
  3. 合并返回对多个倒排列表执行合并算法,得到最终结果集 Result。
    Postings→Result\displaystyle \text{Postings} \to \text{Result}

5. 性能优化策略

名单厚、查询多的时候,光靠跳表指针还不够。图书馆里常用的办法:把名字缩写以减少纸张面积、提前排除不常查的人、把热门查询结果贴在柜台、让多个管理员分头翻。对应到倒排索引系统,就是索引压缩、剪枝优化、缓存策略和并行查询。

索引压缩让倒排列表在内存和磁盘里占用更少空间,常见做法是用相邻文档ID之间的差值(delta encoding)代替完整 ID,再用变长编码压缩小数值;读取时虽要解码,但减少了 I/O。剪枝优化是在合并前先剔除明显不满足条件的列表,例如用文档频率估计候选集大小,优先选择最短的倒排列表作为“种子”。缓存策略则是把热点查询的结果或倒排列表片段缓存起来,避免重复合并。并行查询则把不同词项的读取和子查询的合并分发到多线程/多核上。

这些优化不是互相孤立的:跳表指针减少了比较次数,压缩减少了 I/O 字节,缓存减少了重复计算,并行摊薄了延迟。设计时通常先用最短列表约束候选集,再配合跳表指针或者 Block-Max 这类高级结构,让合并过程尽早聚焦。对 AI 从业者而言,理解这些优化意味着知道系统瓶颈在哪里,而不是盲目堆机器。

在通讯录里,跳表标签越多,翻得越快,但贴标签的纸也越多。工程优化正是在“少翻页”和“少贴纸”之间寻找甜蜜点。

Mskip=aw×s\displaystyle M_{\text{skip}} = \frac{a}{w} \times s
公式表示跳表指针额外占用的内存:a/w 是指针数量,s 是每个指针的存储大小。任何优化都有代价,压缩和剪枝就是在“计算时间、内存、I/O”之间做取舍。
  1. 指针数量长列表长度为 a,每隔 w 个元素建一个跳跃指针,总指针数约为 a/w。
    aw\displaystyle \frac{a}{w}
  2. 指针大小每个指针通常保存目标位置或目标文档ID,占用 s 字节。
    s\displaystyle s
  3. 额外内存两者相乘得到总额外内存 M_{\text{skip}},这就是“空间换时间”的成本。
    Mskip=aw×s\displaystyle M_{\text{skip}} = \frac{a}{w} \times s

6. 工程实践权衡

最后,小林要决定给哪些名单贴标签、隔几页贴一个。如果名单只有十来个名字,直接线性翻完就够了,贴跳表指针纯属浪费;如果有几十万名字,跳表指针能省下大量时间。跳跃步长 w 的选择也很有意思:w 太小,指针太多、存储开销大;w 太大,跳跃太稀疏、加速效果差。经验上,当跳表指针越过的元素数接近名单长度的平方根时,检查次数往往最少。

除了步长,工程中还要看倒排列表的更新频率。通讯录如果每天都有新人加入,标签位置可能频繁失效;搜索引擎里文档增删改同样会改变倒排列表长度。因此静态索引可以一次构建大量跳表指针,动态索引则要控制指针数量,或用分段合并来降低维护成本。实际系统如 Lucene,会为每个段(segment)维护自己的倒排列表和跳表信息,查询时合并多个段的候选结果。

场景选型总结成一句话:短列表朴素合并,长列表加跳表;内存充足时多用跳跃,内存紧张时先用压缩;静态索引可大胆建指针,动态索引要谨慎更新。这也是所有信息检索系统设计中的通用心法:没有银弹,只有根据数据分布和延迟要求做出的工程权衡。

回到最初的图书馆:小林不必给每本通讯录都贴标签,也不必把标签贴得密密麻麻。真正专业的做法,是根据名单长度、纸张成本、更新频率决定怎么加速——这正是“跳表指针”从理论走向工程的关键一步。

---

wopt≈a\displaystyle w_{\text{opt}} \approx \sqrt{a}
经验上,每个跳跃指针覆盖 w 个元素,当 w 约为列表长度 a 的平方根时,单次扫描的检查次数最少;这是从“每跳一次付出一次代价、跳过一段节省一段”的平衡中推出来的。
  1. 列表长度长倒排列表长度为 a,是跳表指针的主要服务对象。
    a\displaystyle a
  2. 开平方对 a 开平方得到 \sqrt{a},作为步长的经验参考值。
    a\displaystyle \sqrt{a}
  3. 近似最优w_{\text{opt}} 表示经验上的较优步长,实际还需根据内存与更新频率微调。
    wopt≈a\displaystyle w_{\text{opt}} \approx \sqrt{a}

AI 应用场景

场景一:Elasticsearch 中的布尔查询加速

阶段:检索召回 / 查询解析与结果合并

解决的问题:在 Elasticsearch 中搜索“AI AND 医疗”,系统需要合并两个词项的倒排列表。传统方式把两个列表读进内存逐项比较,响应延迟高。利用倒排列表的有序性和跳表指针,系统只读取部分文档ID,就能快速得到交集。

具体案例:某医疗知识库在 ES 中维护了数亿篇文档,用户输入“深度学习 AND 影像诊断”时,通过 Lucene 的跳表(skip data)机制,跳过大量不可能命中的文档ID,查询 P99 延迟从 120ms 降到 45ms。

场景二:数据库索引合并

阶段:数据检索 / 多条件过滤

解决的问题:数据库执行 WHERE age > 20 AND city = '上海' 时,若两个字段分别有索引,优化器可以合并两个索引扫描结果。倒排索引与 B+ 树索引的合并思想类似,都依赖有序列表和跳表指针减少无效访问。

具体案例:在用户画像系统中,查询“最近 7 天活跃 AND 兴趣含 AI”分别从活跃索引和标签索引得到两个用户ID有序列表,利用跳表合并后,只需遍历约 15% 的用户ID就得到结果,避免全量扫描。

场景三:文档相似度计算

阶段:向量/词项召回后的精排前过滤

解决的问题:计算两篇文档相似度时,通常要找出它们共同包含的词项。把每篇文档的词项映射到倒排列表,再对两组列表求交集长度,跳表指针能快速筛掉无共同词项的文档对。

具体案例:每日新闻去重系统中,先用 MinHash 粗筛,再用倒排交集精确验证,跳跃合并减少 40% 的候选比较时间。

Python 演示

python68 行
def linear_merge(a, b):    """朴素线性合并,返回两个升序列表的交集。"""    i = j = 0    result = []    while i < len(a) and j < len(b):        if a[i] == b[j]:            result.append(a[i])            i += 1            j += 1        elif a[i] < b[j]:            i += 1        else:            j += 1    return result  def skip_merge(a, b, w=3):    """带跳表指针的合并:w 表示每隔多少元素设置一个跳跃指针。"""    i = j = 0    result = []    while i < len(a) and j < len(b):        if a[i] == b[j]:            result.append(a[i])            i += 1            j += 1        elif a[i] < b[j]:            # 只有跳过去也不会超过 b[j] 时才跳,否则安全前进一步            if i + w < len(a) and a[i + w] <= b[j]:                i += w            else:                i += 1        else:            if j + w < len(b) and b[j + w] <= a[i]:                j += w            else:                j += 1    return result  def compare_count(a, b, w=3):    """统计两种算法的比较次数,便于理解跳跃的收益。"""    cnt = 0    i = j = 0    while i < len(a) and j < len(b):        cnt += 1        if a[i] == b[j]:            i += 1            j += 1        elif a[i] < b[j]:            if i + w < len(a) and a[i + w] <= b[j]:                i += w            else:                i += 1        else:            if j + w < len(b) and b[j + w] <= a[i]:                j += w            else:                j += 1    return cnt  if __name__ == '__main__':    postings_a = [1, 3, 5, 7, 11, 15, 18, 22, 26, 30]    postings_b = [5, 11, 18, 30]    print('线性合并结果:', linear_merge(postings_a, postings_b))    print('跳跃合并结果:', skip_merge(postings_a, postings_b, w=2))    print('线性比较次数:', compare_count(postings_a, postings_b, w=1))    print('跳跃比较次数:', compare_count(postings_a, postings_b, w=2))
目的:用 Python 实现线性合并与跳表指针合并,并统计比较次数,直观展示跳跃如何减少无效比较。
  1. if a[i] < b[j]:列表升序时,较小的元素不可能出现在另一个列表当前元素之后,所以先移动它。
  2. if i + w < len(a) and a[i + w] <= b[j]:跳跃的“安全检查”:跳过这段后不会错过候选,才允许大步前进。
  3. i += w一次跳过 w 个元素,把 O(a+b) 的比较次数降为 O(b+a/w)。
  4. compare_count用一个计数器把两种策略的代价量化,方便对比。

常见误区

误区有人以为倒排列表上的跳跃指针就是 Redis 里的 skiplist,或者以为跳表指针会动态插入层数。

正解倒排列表的跳表指针只是“稀疏跳跃”思想的特例:在有序数组上每隔 w 个元素存一个指针,合并时直接跳过一段。它不是完整的动态跳表,不会动态插入/删除层。两者的共同点是利用“跳过中间节点”减少比较,但底层结构和操作很不同。

为何常见:都叫“跳”,都用于加速有序结构查找,名字相似导致混淆。

误区小林一开始认为找交集只能把两个列表从头到尾挨个比,跳表会漏答案。

正解只要跳跃后的元素仍然不超过另一列表当前元素,被跳过的元素就绝对不可能成为交集。这个结论依赖两个列表都是按相同键升序排列。跳跃不是猜测,而是基于有序性做出的剪枝,所以不会漏。

为何常见:看到“跳过”就觉得漏了某些元素,没理解有序性保证。

误区既然跳表能加速,那么每个位置都加指针,查询一定最快。

正解指针越多,存储和缓存占用越大,甚至可能让倒排列表超过 CPU 缓存,读取开销反而变大。w 的选择存在一个平衡点,经验上接近 sqrt(a);还要考虑更新时维护指针的成本。

为何常见:只看到时间收益,忽略空间成本和维护成本。

误区以为布尔查询只要用哈希集合就能求交集,不需要排序。

正解如果不排序,双指针合并和跳表指针全部失效;虽然可以用哈希集合 O(min(a,b)) 平均求交集,但会付出额外哈希内存和随机访问开销,且不利于磁盘顺序读取和压缩。

为何常见:现代编程语言哈希表太方便,初学容易忽略排序带来的系统级收益。

面试题

Q1. 倒排索引中,同一个词项的文档ID列表通常采用什么顺序存储?

单选题 · easy

  • A.随机顺序
  • B.升序排列← 正确答案
  • C.降序排列
  • D.插入顺序

答案:B。倒排列表必须按文档ID升序排列,这是双指针合并和跳表指针能够成立的前提。随机或插入顺序都会破坏有序性,导致无法直接使用合并算法。

Q2. 用双指针合并两个长度分别为 a、b 的有序倒排列表,最坏情况时间复杂度是?

单选题 · easy

  • A.O(a+b)← 正确答案
  • B.O(a×b)
  • C.O(a/b)
  • D.O(log a)

答案:A。线性合并中每个元素最多被比较一次,两个指针只前进不后退,因此最坏复杂度是两者长度之和 O(a+b)。乘法和除法都不符合“双指针顺序扫描”的模型。

Q3. 跳表指针在倒排列表合并中的核心作用是?

单选题 · easy

  • A.压缩存储
  • B.跳过无效区域← 正确答案
  • C.给结果排序
  • D.增加召回

答案:B。跳表指针利用有序性,在确认一段元素不可能匹配时直接跳过,从而减少比较次数。它不改变结果集合,也不负责排序或压缩。

Q4. 合并时若 A[i] < B[j],且 A[i+w] <= B[j],应该怎么做?

单选题 · mid

  • A.i 直接跳到 i+w← 正确答案
  • B.j 直接跳到 j+w
  • C.两者同时前进
  • D.停止合并

答案:A。因为 A[i+1] 到 A[i+w] 都不超过 B[j],而 B 列表后续元素只会更大,所以这些位置不可能出现在交集中,i 可以直接跳到 i+w。这是跳表指针的“安全跳跃”条件。

Q5. 查询“AI AND 医疗”的结果集合等价于两个倒排列表的?

单选题 · easy

  • A.并集
  • B.交集← 正确答案
  • C.差集
  • D.补集

答案:B。AND 表示同时包含两个词,因此结果只保留同时出现在两个倒排列表里的文档ID,即交集。并集对应 OR,差集对应 NOT。

Q6. 在倒排列表长度为 a 时,经验上跳跃步长 w 通常取多少?

单选题 · mid

  • A.sqrt(a)← 正确答案
  • B.log(a)
  • C.a/b
  • D.b/a

答案:A。当每个指针覆盖的步长接近 sqrt(a) 时,指针数量和跳跃次数之间取得平衡,单次扫描的检查次数较少。log(a) 是二分查找场景的经验值,不适用于顺序合并。

Q7. 下列哪些属于倒排索引查询的常见优化手段?

多选题 · mid

  • A.索引压缩← 正确答案
  • B.查询缓存← 正确答案
  • C.随机存储
  • D.并行查询← 正确答案

答案:ABD。压缩减少 I/O,缓存减少重复计算,并行摊薄延迟,都是常用优化。随机存储会破坏顺序读性能,不是优化手段。

Q8. 布尔查询处理流程的正确顺序是?

单选题 · mid

  • A.解析→查找→合并→排序← 正确答案
  • B.排序→解析→合并→查找
  • C.查找→解析→排序→合并
  • D.合并→查找→解析→排序

答案:A。系统先解析查询字符串,再通过词典查找倒排列表,然后合并得到候选结果,最后排序返回。顺序颠倒会导致没有语法树就无法查词,没有候选就无法排序。

Q9. 若长列表长度 a=10000,每 w=100 个元素设一个指针,每个指针占 8 字节,额外存储约为多少?

单选题 · hard

  • A.800字节左右← 正确答案
  • B.8000字节左右
  • C.80KB左右
  • D.8MB左右

答案:A。指针个数为 a/w = 100,每个指针 8 字节,共 800 字节。这是跳表指针的“空间换时间”成本,通常远小于列表本身。

Q10. 关于跳表指针与线性合并,下列说法正确的是?

多选题 · hard

  • A.短列表更适合跳跃
  • B.长列表跳跃收益大← 正确答案
  • C.步长越大跳跃次数越少← 正确答案
  • D.步长越小一定越快

答案:BC。列表越长,可跳过的无效区域越多,收益越明显;步长越大,跳跃次数 a/w 越少。但步长过小会浪费存储,不一定更快,因此 D 错;短列表直接线性合并反而更简单,A 错。