Navigation
Share
Breadcrumb

基于Gestalt Pattern Matching的SequenceMatcher代码详解

[Ash]

SequenceMatcher是difflib中的一个类,其中包含几个函数:

初始化函数:初始化函数主要是传入需要做比较的两个sequence,并且利用__chain_b函数对sequence b(通过set_seq2传入)做了索引处理,因此想要1 to many sequence做对比的话,保持b不变,只用set_seq1去修改sequence a效率比较高,因为不用多次做索引。

  • init
  • set_seqs
  • set_seq1
  • set_seq2
  • __chain_b

主要计算函数:

  • find_longest_match:在指定范围内找到最长的连续匹配子序列
  • get_matching_blocks:返回所有匹配块的列表
  • ratio:计算序列的相似度 (0到1之间)
  • quick_ratio:快速计算相似度的上界估计
  • real_quick_ratio:最快速计算相似度的上界估计

其他无关紧要的:

  • get_opcodes:返回将序列 a 转换为序列 b 的操作列表
  • get_grouped_opcodes:将操作码分组,每组包含最多 n 行上下文

原理

核心算法:Gestalt Pattern Matching

SequenceMatcher基于Gestalt Pattern Matching算法,这是Ratcliff和Obershelp在1980年代提出的一种模式匹配方法。该算法的核心思想是:

  • 递归查找最长公共子序列 (LCS)
  • 分而治之:在找到LCS后,递归处理LCS左右两侧的子序列
  • 累积所有匹配块

但是SequenceMatcher是其变体,一些算法有所改变,比如使用队列+循环模拟递归,避免长文本的深度递归问题。

def find_longest_match(self, alo=0, ahi=None, blo=0, bhi=None):
    a, b, b2j, isbjunk = self.a, self.b, self.b2j, self.bjunk.__contains__
    if ahi is None:
        ahi = len(a)
    if bhi is None:
        bhi = len(b)
    besti, bestj, bestsize = alo, blo, 0
    
    # !!!!!核心:动态规划!!!!!
    j2len = {}                              # j2len[j] = 以序列b的位置j结尾的最长匹配子序列长度
    nothing = []
    for i in range(alo, ahi):
        j2lenget = j2len.get                # 优化技巧,后面直接使用j2lenget,避免重复引用
        newj2len = {}
        for j in b2j.get(a[i], nothing):    # 在 b 里找依次找 a
            # a[i] matches b[j]
            if j < blo:                     # b是有上下界的,可能从中间开始匹配,通过定义blo
                continue
            if j >= bhi:
                break
            k = newj2len[j] = j2lenget(j-1, 0) + 1      # 动态规划思想,直接取以b[j-1]结尾的最长匹配子序列长度,加上1,与 j2len = newj2len 相互合作
            if k > bestsize:
                besti, bestj, bestsize = i-k+1, j-k+1, k
        j2len = newj2len                    # 这句话是重点,j2len并不是往后append,而是直接替换,也就是说只会保留当前阶段的状态,所以每一次通过j2lenget(j-1, 0)得到的都是上一个循环中的状态,保证了字符串的连续性

    # !!!!!!!!!!!!!!!!!!!!

    # 以下是用于扩展垃圾元素(junk)和流行元素(popular,出现频率>1%),这两类都不包含在b2j中,所以不能被以上的逻辑检测到
    while besti > alo and bestj > blo and \
            not isbjunk(b[bestj-1]) and \
            a[besti-1] == b[bestj-1]:
        besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
    while besti+bestsize < ahi and bestj+bestsize < bhi and \
            not isbjunk(b[bestj+bestsize]) and \
            a[besti+bestsize] == b[bestj+bestsize]:
        bestsize += 1


    while besti > alo and bestj > blo and \
            isbjunk(b[bestj-1]) and \
            a[besti-1] == b[bestj-1]:
        besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
    while besti+bestsize < ahi and bestj+bestsize < bhi and \
            isbjunk(b[bestj+bestsize]) and \
            a[besti+bestsize] == b[bestj+bestsize]:
        bestsize = bestsize + 1

    return Match(besti, bestj, bestsize)

从核心代码中可以看出该算法强调LCS的连续性,也就是说比如对于a=“abca”, b=“acba”,得到的LCS是1,而不是3(aca)。

垃圾字符处理机制

SequenceMatcher引入了"垃圾字符"概念,这是其相比其他算法的重要创新:

  • 用户定义垃圾:通过isjunk函数,用户可以指定哪些字符应被视为垃圾(如空格、制表符)
  • 自动垃圾识别:当序列长度≥200时,出现频率超过1%的字符会被自动标记为垃圾
  • 智能匹配策略:算法首先忽略垃圾字符找到核心匹配,然后在两端扩展匹配垃圾字符 这种设计使得算法在处理包含大量重复元素(如代码中的空行、HTML中的标签)的文本时表现出色。

Ratio计算

核心计算ratio的方法:

$$ ratio = 2\cdot \frac{matches}{length} $$

其中:

  • match:所有匹配块的字符总数
  • length:两个序列的总长度(len(a)+len(b))

代码提供了三种计算ratio的方式:

  • ratio:通过get_match_blocks函数得到,经过了完整算法的ratio
  • quick_ratio:不考虑顺序,只把两个sequencial当成两个集合取交集
  • real_quick_ratio:只是用长度做数学计算,完全不看序列内容
比较:
- 精确性:ratio > quick_ratio > real_quick_ratio(ratio最精确,real_quick_ratio最粗糙)

- 速度:  ratio < quick_ratio < real_quick_ratio(ratio最慢,real_quick_ratio最快)

- 值的大小关系:real_quick_ratio ≥ quick_ratio ≥ ratio(都是上界或精确值,满足单调性)