寻找任意子字符串的最小汉明距离的最快方法是什么?

7
给定一个长字符串L和一个短字符串S(限制是L的长度必须大于或等于S的长度),我想要找到S和任何与S长度相等的L的子字符串之间的最小汉明距离。我们称这个函数为minHamming()。例如,
minHamming(ABCDEFGHIJ, CDEFGG) == 1.
minHamming(ABCDEFGHIJ, BCDGHI) == 3.
显然地,枚举L的每个子字符串需要O(S.length * L.length)的时间。有没有聪明的方式能够在次线性时间内完成呢?我要用不同的S字符串搜索相同的L,所以对L进行一些复杂的预处理是可以接受的。
编辑:修改后的Boyer-Moore算法是一个好主意,只是我的字母表只有4个字母(DNA)。

1
如果S比L短,你的约束条件应该是(L.length >= S.length)吗? - DaveR
“几个不同”是多少?相对/绝对地,|L| 和 |S| 有多大?~~~~ - ShreevatsaR
3个回答

17

或许让人惊讶的是,使用快速傅里叶变换(FFT),可以在O(|A|nlog n)的时间内解决此类问题,其中n是较大序列L的长度,|A|是字母表的大小。

这里有一篇Donald Benson的免费论文PDF,描述了它的工作原理:

摘要:将您的字符串S和L分别转换为多个指示向量(每个字符一个向量,因此DNA的情况下为4),然后卷积相应的向量以确定每种可能的对齐的匹配计数。关键是在“时间”域中进行卷积,通常需要O(n²)的时间,但可以使用“频率”域中的乘法来实现,其仅需要O(n)时间,以及将领域之间进行转换所需的时间。使用FFT,每次转换只需要O(nlog n)的时间,因此总时间复杂度为O(|A|nlog n)。为了获得最大速度,使用了有限域FFT,它只需要整数算术。

注意:对于任意的SL,该算法显然比直接的O(mn)算法在|S||L|变大时有巨大的性能优势,但如果S通常比log|L|短(例如在使用小序列查询大型数据库时),那么这种方法显然不会提供加速。
更新于21/7/2009:更新以说明时间复杂度也线性依赖于字母表的大小,因为必须为字母表中的每个字符使用单独的指示向量对。

不适合我的情况,因为S与L相比非常小,但足够有趣,值得点赞。 - dsimcha
更新说明时间复杂度也与字母表大小成线性关系。 - j_random_hacker
谢谢Daniel!是的,FFT对我来说就像一种魔法,好像你不应该能够这么快地计算出什么 :) 对我来说,其他那些看起来不可能的事情包括后缀树/数组可以在线性时间内构建和查询 :) - j_random_hacker
1
不错! @dsimcha:如果S非常小——只有log|L|个字母那么为什么要费心呢?请注意,你有一个Ω(|L|)的下限(你需要查看L中的所有字母)……所以你是否处于|L|足够小但|L|*log|L|太大的状态? - ShreevatsaR

2

修改后的Boyer-Moore算法

我找到了一些旧的Python实现Boyer-Moore算法,并修改了匹配循环(在该循环中将文本与模式进行比较)。不是在两个字符串之间发现第一个不匹配就退出,而是简单地计算不匹配数量,但记住第一个不匹配

current_dist = 0
while pattern_pos >= 0:
    if pattern[pattern_pos] != text[text_pos]:
        if first_mismatch == -1:
            first_mismatch = pattern_pos
            tp = text_pos
        current_dist += 1
        if current_dist == smallest_dist:
           break

     pattern_pos -= 1
     text_pos -= 1

 smallest_dist = min(current_dist, smallest_dist)
 # if the distance is 0, we've had a match and can quit
 if current_dist == 0:
     return 0
 else: # shift
     pattern_pos = first_mismatch
     text_pos = tp
     ...

如果此时字符串没有完全匹配,通过恢复值回到第一个不匹配的点。这可以确保找到最小距离。
整个实现相当长(~150 LOC),但我可以根据要求发布它。核心思想已经概述了,其他一切都是标准的Boyer-Moore算法。
在文本上进行预处理是另一种加速的方法,可以在字符位置上建立索引。您只想从至少发生两个字符串之间单个匹配的位置开始比较,否则Hamming距离就是|S|。
import sys
from collections import defaultdict
import bisect

def char_positions(t):
    pos = defaultdict(list)
    for idx, c in enumerate(t):
        pos[c].append(idx)
    return dict(pos)

该方法仅创建一个字典,将文本中的每个字符映射到其出现次数排序后的列表。
比较循环与朴素的 O(mn) 方法基本相同,除了我们不是每次将比较开始位置增加 1,而是根据字符位置进行增加。
def min_hamming(text, pattern):
    best = len(pattern)
    pos = char_positions(text)

    i = find_next_pos(pattern, pos, 0)

    while i < len(text) - len(pattern):
        dist = 0
        for c in range(len(pattern)):
            if text[i+c] != pattern[c]:
                dist += 1
                if dist == best:
                    break
            c += 1
        else:
            if dist == 0:
                return 0
        best = min(dist, best)
        i = find_next_pos(pattern, pos, i + 1)

    return best

实际的改进在于find_next_pos函数:
def find_next_pos(pattern, pos, i):
    smallest = sys.maxint
    for idx, c in enumerate(pattern):
        if c in pos:
            x = bisect.bisect_left(pos[c], i + idx)
            if x < len(pos[c]):
                smallest = min(smallest, pos[c][x] - idx)
    return smallest

对于每个新的位置,我们找到S中出现在L中的字符的最低索引。如果没有这样的索引了,算法将终止。
find_next_pos肯定很复杂,可以尝试通过仅使用模式S的前几个字符或使用集合来确保不会检查两次来改进它。
讨论
哪种方法更快主要取决于您的数据集。字母表越多样化,跳跃就越大。如果L非常长,则预处理的第二种方法可能更快。对于非常短的字符串(例如您的问题),朴素的方法肯定是最快的。
DNA
如果您的字母表非常小,可以尝试获取字符二元组(或更大)的字符位置,而不是单个字符。

没错,很抱歉所有的代码示例都是用Python编写的,但那只是我最熟悉的语言。如果有需要,请随时询问以获得更多解释! - Torsten Marek
2
个人认为,人们应该始终使用他们最熟悉的语言发布代码。即使没有其他原因,这也可以让其他人了解一些关于那种语言的知识。 - Daniel C. Sobral
如果代码示例清晰易懂,则给予+1,如果允许的话,我会再次+1为使用小写字母的二元组或更大n-gram的想法。毕竟DNA的4-gram类似于8位字节。你只需要考虑一下从n-gram返回真实序列的转换即可。 - RBerteig

-1

就大O而言,你陷入了困境。从根本上讲,你需要测试目标中的每个字母是否与子字符串中的每个可选字母匹配。

幸运的是,这很容易并行化。

你可以应用一种优化方法,即保持当前位置不匹配的运行计数。如果它大于迄今为止最低的汉明距离,那么显然你可以跳到下一个可能性。


1
-1 抱歉。实际上,对于这个问题存在更快的算法(请参见我的帖子),尽管它们不一定显而易见。 - j_random_hacker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接