Rabin-Karp算法 java_第 2 章 字符串

第 2 章 字符串

字符串处理是算法领域里非常重要的内容,其中有些是关于文字处理的,比如语法检查,有些则关于子字符串(子串);或者更笼统地说,是关于模式(pattern)查找的。随着生物信息学的发展,出现了 DNA 序列问题。本章中将介绍一系列我们认为的重要算法。

在计算机系统内,一个字符串可以用一个字符的列表来表示。但一般情况下,我们会使用 str 类型,这个类型在 Python 语言中类似于一个列表。对于使用 Unicode 编码方式的字符串,每个字符可以使用两个字节来编码。一般情况下,字符仅用一个字节表示,并用 ASCII 码来编码:0 ~ 127 的每个整数代表一个不同的字符,编码按顺序排列,如 0 ~ 9、a ~ z、A ~ Z。同样,如果一个字符串只包含大写字母,我们可以使用 ord(s[i]) – ord('A') 这样的计算方式找到第 i 个字符在字母表中的位置。反过来说,第 j 个(从 0 开始编号)大写字母可以使用 chr(j+ord('A')) 找到。

说到子串,也就是字符串的子字符串的时候,一般要求字符必须是连续的 1,这与更通常的子序列(子字)的定义不同。

1即中间没有空格。——译者注

2.1 易位构词

定义

如果对调字符,使得单词 w 变成单词 v,那么 w 就是 v 的易位构词。假设有一个集合包含了 n 个最大长度为 k 的单词,现在要找到所有的易位构词。

输入:le chien marche vers sa niche et trouve une limace de chine nue pleine de malice qui lui fait du charme

输出:{une nue}, {marche charme}, {chien chine niche}, {malice limace}.

句子的意思是:“一条狗走向狗窝时遇到一条顽皮的鼻涕虫,被吸引了过去。”其中某些单词,如 chien(狗)和 niche(窝)、limace(鼻涕虫)和 malice(顽皮)等,都是字母相同而顺序不同的单词,输出得到的是输入句子中所有易位构词的集合。

复杂度

以下算法能在平均时间 O(nk logk) 内解决问题。而在最坏情况下,由于使用了字典,所需时间复杂度是 O(n2k logk)。

算法

算法的思路是计算每个单词的签名。两个单词能得到相同的签名,当且仅当它们互为易位构词。这个签名不过是包含了相同字母的另一个单词,是把要计算签名的单词中的所有字母按顺序排列后得到的。

算法使用的数据结构是一个字典,将每个签名与拥有这一签名的所有单词的列表对应起来。

def anagrams(w):

w = list(set(w)) # 删除重复项

d = {} # 保存有同样签名的单词

for i in range(len(w)):

s = ''.join(sorted(w[i])) # 签名

if s in d:

d[s].append(i)

else:

d[s] = [i]

# -- 提取易位构词

reponse = []

for s in d:

if len(d[s]) > 1: # 忽略没有易位构词的词

reponse.append([w[i] for i in d[s]])

return reponse

2.2 T9:9 个按键上的文字

输入:2 6 6 5 6 8 7

输出:bonjour

应用

按键式移动电话提供了一种有趣的输入方法,通常被称作 T9 输入法。26 个字母分布在数字 2 ~ 9 的按键上,就像图 2.1 展示的一样。为了输入一个单词,只需按对应的数字键就可以了。但是,有时输入一个相同的数字序列却可能得到不同的单词。在这种情况下,就需要用字典来推测最有可能出现的单词,并把这些单词摆放在候选词的首位。

e8938d1e6abf494631cc8a9933a94890.png

图 2.1 一个移动电话键盘上的按键

定义

这个问题实例的第一部分是一个字典结构,由一系列键值对 (m, w) 组成,其中 m 是一个由 26 个小写字母中部分字母组成的单词,w 是这个单词的权重。问题实例的第二部分由输入序列为 2 ~ 9 的数字组成。对于每个输入序列,只需要显示字典中权重最高的一个单词。假设有一个数字序列使用 T9 输入法,根据图 2.1 中的对应关系,输出单词为 m,而输入数字序列 t 是通过将单词 m 中的每个字母都替换为相关数字得来的。s 是输入数字序列 t 的前缀,这时,我们就可以定义单词 m 与 s 相关。比如单词 bonjour(你好)与数字序列 26 相关,也和数字序列 266 或 2665687 相关。

复杂度为 O(nk) 的算法

字典初始化的时间复杂度为 O(nk),而每次查询的时间复杂度为 O(k)。这里的 n 是字典中的单词数量,k 是单词长度的上限。

在第一时间,对于字典中某个单词的每个前缀 p2,我们要查找将 p 作为前缀的所有单词的总权重,并把总权重存入一个 freq(频率)字典中。接下来,我们在另一个字典 prop[seq] 中存储赋予每个给定的 seq 序列的前缀列表。遍历 freq 中的所有键,可以确定权重最大的前缀。此处的关键就是 word_code 函数,它能为给定单词提供相关的输入数字序列。

为方便阅读,以下算法实现的时间复杂度是 O(nk2)。

t9 = "22233344455566677778889999"

# 分别对应abcdefghijklmnopqrstuvwxyz 这26 个字母

def letter_digit(x):

assert 'a' <= x and x <= 'z'

return t9[ord(x)-ord('a')]

def word_code(words):

return ''.join(map(letter_digit,words))

def predictive_text(dico): # dico 意为字典

freq = {} # freq[p] = 拥有前缀p 的单词的总权重

for words,weights in dico:

prefix = ""

for x in words:

prefix += x

if prefix in freq:

freq[prefix] += weights

else:

freq[prefix] = weights

# prop[s] = 输入 s 时要显示的前缀

prop = {}

for prefix in freq:

code = word_code(prefix)

if code not in prop or freq[prop[code]] < freq[prefix]:

prop[code] = prefix

return prop

def propose(prop, seq):

if seq in prop:

return prop[seq]

else:

return "None"

2这里可以理解为,每次用 T9 输入法输入一个新数字的时候,由于尚未输入完成,输入数字序列的前几个数字就是整个输入序列的前缀。——译者注

2.3 使用字典树进行拼写纠正

应用

如何把单词存入一个字典来纠正拼写呢?对于某个给定的单词,我们希望很快在字典中找到一个最接近的词。如果把字典里的所有单词存在一个散列表里,单词之间的一切相近性信息都将丢失。所以,更好的方式是把这些单词存入字典树,字典树也叫前缀树或排序树(trie tree)。

定义

一棵保存了某个单词集合的树称为字典树。连接一个节点及其子节点的弧线用不同字母标注。因此,字典中的每个单词与树中从根节点到树节点的路径相关。每个节点都是标记,用于区分相关字母组合究竟是字典中的单词,还是字典中单词的前缀(图 2.2)。

878fae72857c69e195b93e1610346a84.png

图 2.2 字典树

字典树存储着法语单词 as、port、pore、pré、près 和 prêt(但没有重音符号)。图中的虚线圈表示子节点 3 ;实线圈代表字典中一个完整的单词。右边是一个前缀树代表的相同字典 4。

拼写纠正

利用上述数据结构,我们很容易在字典中找到一个与给定单词距离为 dist 的单词。这里的距离以编辑距离(levenshtein distance)来定义,本书 3.2 节有详细介绍。查找方式是只需模拟每个节点的拼写操作,然后使用参数 dist-1 进行递归调用。

变种

若某个节点只有一个子节点,就可以合并多个节点,这种结构更精简。这种节点用单词标记,而不是用字母标记。图 2.2 右侧的结构更节省内存和遍历时间,被称为前缀树(patricia trie)。

from string import ascii_letters # 在python2 中需要引用letters 库

class Trie_Node:

def __init__(self):

self.isWord = False

self.s = {c: None for c in ascii_letters}

def add(T, w, i=0):

if T is None:

T = Trie_Node()

if i == len(w):

T.isWord = True

else:

T.s[w[i]] = add(T.s[w[i]], w, i + 1)

return T

def Trie(S):

T = None

for w in S:

T = add(T, w)

return T

def spell_check(T, w):

assert T is not None

dist = 0

while True: # 尝试用越来越长的距离来查找

u = search(T, dist, w)

if u is not None:

return u

dist += 1

def search(T, dist, w, i=0):

if i == len(w):

if T is not None and T.isWord and dist == 0:

return ""

else:

return None

if T is None:

return None

f = search(T.s[w[i]], dist, w, i + 1) # 相关

if f is not None:

return w[i] + f

if dist == 0:

return None

for c in ascii_letters:

f = search(T.s[c], dist - 1, w, i) # 插入

if f is not None:

return c + f

f = search(T.s[c], dist - 1, w, i + 1) # 替换

if f is not None:

return c + f

return search(T, dist - 1, w, i + 1) # 删除

3这个路径的字母组合只是一个正确拼写的单词前缀。——译者注

4右侧的树合并了只有一个子节点的路径,经过优化的结构更简洁,效率更高。——译者注

2.4 KMP(Knuth-Morris-Pratt)模式匹配算法

输入:lalopalalali lala

输出:    ^

定义

给定一个长度为 n 的字符串 s 和一个长度为 m 的待匹配模式字符串 t,我们希望找到 t 在 s 中第一次出现时的下标 i。当 t 不是 s 的子串时,返回值应该是 -1。

复杂度:O(n+m),见参考文献 [19]。

穷举算法

这种算法用来测试所有 t 在 s 中可能出现的位置,并逐个比较字符,检查 t 是否与 s[i,…, i + m-1] 相关。最坏情况下的时间复杂度是 O(nm)。下面演示了使用穷举算法的对比查找过程。每一行对应选择的一个 i,并用字母标识出在选定 i 时的相关字符,若字符不相关就用 × 来标记。

e803886f3bf10ef7ce4f4ed1b597530b.png

在处理 i 后,我们能了解字符串 s 的大部分内容。利用这些信息,就不必对例子中的 t[0] 和 s[1] 进行比较了。

算法

我们把两个字符串 x 和 y 的重叠部分称为最长单词,这个最长单词既是 y 的严格后缀,又是 x 的严格前缀。在发现 s[i ] 和 t[j ] 有差异的时候,我们把 t 向 s 的尾部移动(从 0 到 i-1),以便进行后续比较。由于 s[0,…, 1] 的前缀是 t[0,…, j-1](最后 j 次比较已经证明了 s[i-j,…, i-1] 和 t[0,…, j-1] 相等),因此 t 向后移动的距离仅由 t 来决定。

我们可以通过预先计算来确定 t 向后偏移的距离。用 r[j ] 来记录 j 减去自身与 t[0,…, j-1] 的重叠部分的差值。下面的程序展示了具体实现方式。为了分析复杂度,我们把计算 r 的代码和字符串匹配的代码分开:第一部分代码的复杂度是Θ(m),第二部分代码的复杂度是Θ(n)。每当 s[i ] = t[j ] 时,都需要把 j 增加 1;而每次两者不相等的时候,要把 j 减少 1,因为 r[j ] < j。既然 s[i ] 和 t[j ] 最多只有 n 次相等,而且 j 永远是非负值,那么两者不相等的次数最多也只有 n。5

def knuth_morris_pratt(s, t):

assert t != ''

len_s = len(s)

len_t = len(t)

r = [0] * len_t

j = r[0] = -1

for i in range(1, len_t):

while j >= 0 and t[i - 1] != t[j]:

j = r[j]

j += 1

r[i] = j

j = 0

for i in range(len_s):

while j >= 0 and s[i] != t[j]:

j = r[j]

j += 1

if j == len_t:

return i - len_t + 1

return -1

变种

在不增加复杂度的情况下,增加一个很小的改动可以生成一个大小为 n 的布尔型数组 p,它指明了对于每个位置 i,t 是否是 s 在 i 位置的一个子串。概括地说,我们可以计算出一个整数数组 p,它判断了对于每个位置 i 是否有最大的 j,使得长度为 j 的 t 的最大前缀字符串是 s 在 i 结尾的子字符串。这个算法将在后面介绍。

5第一步预处理计算了带匹配的模式字符串 t 的每个子字符串的最大前缀和后缀的公共元素长度,即 t 本身包含的重复字符和字符组合。这样一来,每次匹配失败时,带匹配字符串不是通过简单地向后移动一位来继续查找,而是根据预先算好的前缀和后缀的公共元素表来跳过一定数量的字符,以此直接匹配 t 中重复的字符串或字母组合,从而提高效率。比如,字符串 t 是 ABCAD,字符串 s 是 DEABCABABCADE。t 中两个 A 重复出现,第一次 ABCAD 匹配 ABCAB 在最后一个字符 D 和 B 比较时失败,此时,我们准确地知道匹配失败的字符 D 的前一个字符 A 匹配成功了,即 ABCA 都匹配成功了,那么我们就不再需要比较 s 中的其他字母。也就是说,不是将 t 中的 A 和 s 中的 B 比较,而是直接用已经匹配成功的 t 中的 A 来和 s 中的 A 对齐。再次强调,由于 t 中有两个 A 重复,而其他字符都不是 A,那么我们希望匹配 s 中的 A 时,只能用 t 中的两个 A 中的一个来对齐 s 中的 A,这样就跳过了一定不相等的 B 和 C 等字符。——译者注

2.5 最大边的 KMP 算法

查找一个字符串的最大边,也可以帮助我们解决字符串的模式匹配问题。这一算法的基本思想与 KMP 模式匹配算法相同,但使用了更多技巧,因此实现方式也更简洁。

定义

当字符串 w 的某个子字符串同时是 w 本身的严格前缀和严格后缀时,我们把该子字符串称作字符串 w 的边,且将最大边记为 β(w)。举例来说,字符串 abababa 的边有 aba、a 和空字符串ε。对于一个给定字符串 w = w0,…, wn- 1,现在要计算 w 的每个前缀的最大边,也就是计算这些边的长度,因为 w 的前缀的边同时也是 w 的前缀。因此,我们也可以快速找到前缀长度的序列 li = |β(w0,…,wn-1)|。

关键测试

按照边的思路来观察一个递归结构:假设 u 是 v 的边,而 v 是 w 的边,那么 u 同时也是 w 的边。用 β 对一个字符串 w 进行迭代运算,就能得到 w 所有的边。比如,对于 w = abaababa,可以得到 β(w) = aba,β(β(w)) = a,以及 β3(w) = ε。

算法

假设已知字符串 w 的前 i 个前缀的最大边,即已知前缀 u = w0,…,wi-1(也就是说,子字符串 w0,…, wi-1 拼接而成的字符串 u)。让我们先考虑前缀 ux(更明确地说,x 表示字符 wi):其最大边的形式一定是 vx,其中 v 是 u 的边(图 2.3)。我们用 vj = β j(u) 来记录字符串 u 按长度降序排列的第 j 条边,并用 kj 来记录这条边的长度。为此,要从最大边 v1 = β(u) 开始,在 u 的边的序列 ( vj) 中寻找 v。为了检测一个已经是 ux 的后缀的候选边 vjx 是否是 ux 的边,只需确认 vjx 是否是 ux 的前缀即可。然而,既然 vj 已经是 u 的前缀(即一条边),那么只需要测试紧接着 vj 后续(也就是在位置 kj = |vj|)的字符是否是 x。这就又回到了测试

5e4bccfc6f2aad039f928134f5177168.gif。如果满足条件,那么就找到了 β(ux)= vjx,对 ux 的最大边计算也就完成了。否则,就要测试下一条 vj+1 = β(vj),其长度为

6be7c0cfd8a16e60952c41675669c6a7.gif,因为 vj 恰恰是 w 的前缀且长度为 kj。如果任何比较都没有得到想要的结果,就可以确认 β(ux) = ε。因为在每次迭代中,我们进行的唯一一次测试,也就是计算 kj+1,只依赖于当前 kj 的边长。算法的实现只需用一个变量 k 来确定数组 l。

e2f47777116c35c106896e0290ea396f.png

图 2.3 KMP 字符串匹配算法变种的一个计算步骤

一旦已知 u 的所有边,我们就知道 ux 的最大边形式一定是 vx,其中 v 是 u 的边。如果图中问号代替的字符是 x,那么 v = β(u),否则就要在 β(u) 更短的边中查找 v。

复杂度

有趣的是,这个算法的复杂度呈线性下降:实际上,while 循环迭代的次数永远都不会超过当前边长度 k,而每次 k 在 for 循环中最多只增加 1。

def maximum_border_length(w):

n = len(w)

L = [0] * (n + 1)

for i in range(1, n):

k = L[i]

while w[k] != w[i] and k > 0:

k = L[k]

if w[k] == w[i]:

L[i + 1] = k + 1

else:

L[i + 1] = 0

return L

变种

借助最大边的列表,我们能解决很多与字符串和单词相关的问题:计算平方子串;确定回文前缀;判断两个单词 x 和 y 是否共轭,也就是说,格式是否满足对于单词 u 和 v 有 x = uv 且 y = vu ;检测一个单词 x 的最小周期 6,即单词 x 和 z 有最大的 k 值,令 zk = x。

关键测试

如果字符串 u 呈周期性,则 u 的最大边是 zk-1,其中 k 是当存在一个字符 z 并使得 u = zk 成立时的最大整数(图 2.4)。

def powerstring_by_border(u):

L = maximum_border_length(u)

n = len(u)

if n % (n - L[-1]) == 0:

return n // (n - L[-1])

return 1

cdaf6e9060cb58e7aa534a7bcce9e1f9.png

图 2.4 已知一个周期性字符串 u 的最大边,就能找到其最小周期。假设 n 是字符串 u 的长度,如果 n-li 能把 n 整除,那么该字符串就是周期性的。而且,若对于字符 z 有 u = zk,那么其中 k 的最大值是 n/(n-ln)

应用:在 s 中匹配模式字符串 t

我们选择一个字符 #,它既不在 s 中也不在 t 中。我们关注的是字符串 t#s 的前缀最长边的长度:首先要注意的是,因为存在字符 #,这一长度绝对不会超过 t 的长度。但是,每当该长度达到|t|时,说明我们在 s 中找到了一次 t 的存在。因此,我们可以使用动态规划算法确定字符串 t#s 的所有带有前缀 u 的列表 li = |β(u)|(图 2.5)。

3eb24d7e1b1ff5be57f77a938d8dfe00.png

图 2.5 查找最大边算法的一次迭代。t 在 s 中每出现一次,都对应着在最大边 l 的长度列表中一条长度为|t|的边

备注

所有编程语言的标准类库都会提供一个在字符串 haystack 中查找模式 needle 的方法 7。在 Java 8 中,该方法在最坏情况下的时间复杂度是Θ(nm),效率低得惊人。读者可以测试一下,用这个方法计算当 n 变化时,在字符串 02n 中查找 0n 1 所需的时间 8。

6最小子字符串重复的最多次数即为最小周期。——译者注

7正如在草堆中查找一根针。——译者注

8在 2n 个 0 构成的字符串中查找 n 个 0 拼接一个 1 的字符串,这就是前面所说的最坏情况。作者提出这个问题是为了提示读者,在竞赛时使用 Java 8 的方法可能会降低效率。——译者注

2.6 字符串的幂

输入

输出

abcd

=(abcd)1

aaaa

=a4

ababab

=(ab)3

应用

假设你获得一个周期信号的取样结果,需要找到该信号的最短周期。此问题可以简化为确定一个最短周期,使得输入内容总是这一最短周期的多次重复。

定义

设一个字符串 x,找到一个最大整数 k,使得存在一个字符串 y,令 x = yk。这里 y 的 k 次幂被定义为字符串 y 拼接 k 次。问题至少有一个结果,因为 x = x1。

解 k 除以长度 m 等于 x,同时对于 p = m/k,x 中每个字符都应该与下标较远的字符 p 相等,此时 x 被视为圆周字符串。在圆周字符串 x 中,最后一个字符串之后的字符被定义为字符串 x 的第一个字符。转动一次字符串 x 会把其第一个字符删掉,并将该字符添加到字符串尾部。当转动操作的执行次数等于字符串 x 中的字符个数时,字符串 x 变换后仍是字符串 x。

线性时间复杂度的算法

问题变为寻找最小的 p(p ≥ 1),使得字符串 x 在进行 p 次转动后仍等于 x。这里要使用圆周字符串算法中的一个经典技巧:在字符串 xx(x 后接 x)中查找 x 第一次出现的位置——当然,要去掉第 0 个位置(图 2.6)。

63f046e25daef4b1340e55267947552f.png

图 2.6 如果字符串 x 在 4 次转动后仍得到 x,那么字符串 x 的最小周期是 4

def powerstring(x):

return len(x) // (x + x).find(x, 1)

2.7 模式匹配算法:Rabin-Karp 算法

复杂度:一般为 O(n+m),最差情况为 O(nm)。

算法

Rabin-Karp 算法(见参考文献 [17])与 KMP 模式匹配算法基于完全不同的思路。为了在大字符串 s 中找到字符串 t,应该在 s 上滑动一个长度为 len(t) 的窗口,然后判断这个窗口的内容是否与 t 相等。逐个对比字符串所需的时间成本太高,所以需要计算当前窗口内容的散列值。比较窗口内容和字符串 t 的散列值,速度会更快。当两个字符串的散列值吻合时,再进行耗时较长的逐个比较字符串操作(图 2.7)。因此,为了得到更好的时间复杂度,我们需要一个高效的方法来获取到当前窗口内容的散列值,这就要用到滑动散列函数(hash function)。

e13024e2d46f3f7610c16b056415fb03.png

图 2.7 Rabin-Karp 算法的思路是首先比较 t 和 s 中窗口的散列值,然后再逐个比较字符串

如果散列函数值范围为 {0, 1,…, p-1},而且选择得当,那么“发生碰撞”的概率能达到 1/p。所谓碰撞,指的是两个长度相等、顺序一致的独立字符串 s 和 t 被提取出同样的散列值。在这种情况下,当前算法的平均时间复杂度是 O(n+m+m/p)。算法实现方式使用的是 p = 231-1,因此在实际情况下,算法时间复杂度是 O(n+m)9,而最坏情况下的算法时间复杂度是 O(nm)。

计算滑动窗口散列值的方法

散列函数首先把包含 m 个字符的字符串变换成包含 m 个整数的序列 x0,…, xm-1,并与字符的 ASCII 码对应,整数介于 0 至 127 之间 10。因此,散列函数值有如下多线性表示法:

cd6b41f237a33c2b09d64723eaf88641.gif

其中所有操作都被一个大质数 p 进行了取模运算(modulo)。在实际操作中要特别注意,所有计算值都应能被一个 64 位机调用,其中一个机器字(处理器的寄存器)应当处在 -263 到 263-1 之间。最大的中间临时变量是 128·(p-1)=27·(p-1),这也是算法实现中选择 p < 256 的原因。

这一散列函数的多项式形式可在常数时间内通过 x0、xm 和 h(x0,…, xm-1) 计算 h(x1,…, xm) 值:抽取第一个字符等价于抽取多项式的第一项,字符串左移等价于多项式乘以 128,修改最后一个字符等价于添加多项式的一项。于是,让窗口在字符串 s 上移动,更新窗口内字符串的散列值并与字符串 t 进行比较,都可以在常数时间内完成。注意,把字符串向右移动等价于字符串乘以 128 对 p 取模的倒数,其运算时间也是常数 11。

在以下代码中,在散列值中加 DOMAIN*PRIME 是为了保证计算结果是正值或空值。这在 Python 语言中并不是必要的,但在 C++ 等其他语言中,取模运算可能会得到负的返回值。

PRIME = 72057594037927931 # < 2^{56}

DOMAIN = 128

def roll_hash(old_val, out_digit, in_digit, last_pos):

val = (old_val - out_digit * last_pos + DOMAIN * PRIME) % PRIME

val = (val * DOMAIN) % PRIME

return(val + in_digit) % PRIME

算法的实现从逐个比较长度为 k 的子串开始,即从在字符串 s 中位于 i 的字符和在字符串 t 中位于 j 的字符开始,比较后面的 k 个字符。

def matches(s, t, i, j, k):

for d in range(k):

if s[i + d] != t[j + d]:

return False

return True

接下来实现真正意义上的 Rabin-Karp 算法,首先计算 t 的散列值和 s 第一个窗口中字符串的散列值,然后将 s 中的所有子字符串循环。

def rabin_karp_matching(s, t):

hash_s = 0

hash_t = 0

len_s = len(s)

len_t = len(t)

last_pos = pow(DOMAIN, len_t - 1) % PRIME

if len_s < len_t :

return -1

for i in range(len_t): # 预计算

hash_s = (DOMAIN * hash_s + ord(s[i])) % PRIME

hash_t = (DOMAIN * hash_t + ord(t[i])) % PRIME

for i in range(len_s - len_t + 1):

if hash_s == hash_t : # 逐个比较字符

if matches(s, t, i, 0, len_t):

return i

if i < len_s - len_t:

hash_s = roll_hash(hash_s, ord(s[i]), ord(s[i+len_t]), last_pos)

return -1

Rabin-Karp 算法比 KMP 模式匹配算法的效率略低,根据我们的测试结果,前者的运算时间是后者的 3 倍。但 Rabin-Karp 算法的优势在于,能在多个变种问题中应用自如。

变种 1:匹配多个模式

利用 Rabin-Karp 算法,在给定字符串 s 中查找 t 的问题可以拓展为在字符串 s 中查找一个字符串集合 τ 的问题,其中 τ 的所有字符串长度必须一致。为解决问题,仅需把 τ 中所有字符串的散列值存入字典 to_search,然后检测 s 每个窗口的散列值能否在字典 to_search 中找到相关值。

变种 2:公共子串

给定字符串 s、t 和一个长度值 k,寻找一个长度为 k 的字符串 f,令 f 同时是 s 和 t 的子字符串。为解决问题,首先考虑字符串 t 中长度为 k 的所有子串。这些子串都可以通过与 Rabin-Karp 算法类似的方式获得,即在 t 上滑动宽度为 k 的窗口,把获得的散列值存入字典 pos。每次获得一个散列值的时候,将其与窗口位置关联。

然后,对于在字符串 s 中每个长度为 k 的子串 x,检查其散列值 v 是否存在于字典 pos 中,如果存在,再将 x 与 t 中位于 pos[v] 位置的所有子串逐一比较。

使用这一算法时,需要选择恰当的散列函数。如果 s 和 t 的长度都是 n,为了让字符串 s 和 t 各自 O(n) 个窗口中的一个窗口相互碰撞次数为常数,需要选择函数 p∈Ω(n2)12。读者可以参看参考文献 [17] 来获得更细致的解答。

变种 3:最长公共子串

给定两个字符串 s 和 t,寻找其最长公共子串,这个问题也可以采用上述算法,并以二分查找最长距离 k 的思路来解决。算法的时间复杂度是 O(n logm),其中 n 是 s 和 t 的总长度,而 m 是优化子串的长度。

def rabin_karp_factor(s, t, k):

last_pos = pow(DOMAIN, k - 1) % PRIME

pos = {}

assert k > 0

if len(s) < k or len(t) < k:

return None

hash_t = 0

for j in range(k): # 存入散列值列表

hash_t = (DOMAIN * hash_t + ord(t[j])) % PRIME

for j in range(len(t) - k + 1):

if hash_t in pos:

pos[hash_t].append(j)

else:

pos[hash_t] = [j]

if j < len(t) - k:

hash_t = roll_hash(hash_t, ord(t[j]), ord(t[j + k]), last_pos )

hash_s = 0

for i in range(k): # 预计算

hash_s = (DOMAIN * hash_s + ord(s[i])) % PRIME

for i in range(len(s) - k + 1):

if hash_s in pos: # 此散列值是否存在于s 中?

for j in pos[hash_s]:

if matches(s, t, i, j, k):

return(i, j)

if i < len(s) - k:

hash_s = roll_hash(hash_s, ord(s[i]), ord(s[i + k]), last_pos)

return None

9因为 p 值很大,使得 m/p 值小到可以忽略。——译者注

10128 的乘方可以用二进制位移运算,不会耗费太多时间。——译者注

11假设从左向右移动窗口,那么每次移动窗口都要移除字符串 t 最左边的字符,并在最右边添加字符。用多项式表示该操作,等同于添加多项式的项,并将全部项乘以 128。——译者注

12碰撞次数过多会影响散列算法的性能,所以需要更大范围的值。——译者注

2.8 字符串的最长回文子串:Manacher 算法

输入:babcbabcbaccba

输出: abcbabcba

定义

如果字符串 s 的第一个字符等于最后一个字符,而第二个字符又等于倒数第二个字符,以此类推,那么该字符串就是一个回文字符串。“最长回文子串问题”就是要找到一个最长子串,同时该子串是一个回文子串。

复杂度

采用贪婪算法需要二次方的时间复杂度;采用后缀表算法需要的时间复杂度是 O(n logn);采用 Manacher 算法(见参考文献 [23])需要的时间复杂度是 O(n)。

算法

首先在输入字符串 s 的每个字符前后都添加 # 作为分隔符,在整个字符串的首尾添加 ^ 和 $ 字符,比如,abc 会被变换成^#a#b#c#$。变换后的字符串 s 用 t 来记录。这样做的好处是能够用相同方法找到长度为奇数和偶数的回文子串。注意,在使用这种转换方式时,所有回文子串都以分隔符 # 起始和结束。因此,每个回文子串的边界字符下标就拥有相同偶性 13,这样一来就很容易能将字符串 t 的解决方法转换为字符串 s 的解决方法。分隔符的存在方便了字符串边界字符的处理。

单词 nonne 包含一个长度为 2 的回文串 nn 和一个长度为 3 的回文串 non。在转换后,字符串都用分隔符 # 来开始和结束:

| ----- |

^#n#o#n#n#e#$

| --- |

算法的输出是一个数组 p,它能指出对于每个位置 i,是否存在某个最长半径 r,使得从 i-r 到 i+r 位置的子串是一个回文子串。贪婪算法如下:对于所有 i,初始化 p[i]=0,然后递增 p[i] 直至找到以 i 为中心的最长回文子串 t[i-p[i],…, i+p[i]]。

想要优化 Manacher 算法就要初始化 p[i]。已知一个以 c 为中心、r 为半径的回文子串,也就是说,子串的结尾是 d = c+r。而 j 是 i 相对于 c 的对称镜像(图 2.8)。p[i] 和 p[j] 之间有着很强的关联。在 i+p[j] 不超过 d 的情况下,我们可以用 p[j] 来初始化 p[i]。这一操作十分有效:假如以 j 为中心、p[j] 为半径的回文子串包含在以 c 为中心、d-c 为半径的回文子串的前一半中,那么它一定也存在于后一半中。

b2e053aadba72edfa49803babef49cc5.png

图 2.8 Manacher 算法。对于下标

在成功初始化 p[i] 后,需要更新 c 和 d,以保存用 d-c 最大值编写的回文子串的不变量。算法的时间复杂度呈线性,因为每次比较字符都会导致 d 的增加。

def manacher(s):

assert '$' not in s and '^' not in s and '#' not in s

if s == "":

return(0, 1)

t = "^#" + "#".join(s) + "#$"

c = 0

d = 0

p = [0] * len(t)

for i in range(1, len(t) - 1):

# -- 相对于中心c 翻转下标i

mirror = 2 * c - i # = c - (i-c)

p[i] = max(0, min(d - i, p[mirror]))

# -- 增加以i 为中心的回文子串的长度

while t[i + 1 + p[i]] == t[i - 1 - p[i]]:

p[i] += 1

# -- 必要时调整中心点

if i + p[i] > d:

c = i

d = i + p[i]

(k, i) = max((p[i], i) for i in range(1, len(t) - 1))

return((i - k) // 2, (i + k) // 2) # 输出结果

应用

一个人在城里漫步,他的智能手机记录下了所有移动路线。我们获取这些路线记录,并尝试在其中找到某段特定路程,即在两点间往返的相同路程。为解决这个问题,可以提取一个所有路口的列表,并在其中寻找回文子串。

13对于长度为奇数的回文串,如 aba,转换后^#a#b#a#$ 的边界字符 a 的下标 2 和 6 都是偶数,对于长度为偶数的回文串,如 abba,转换后^#a#b#b#a#$ 的边界字符 a 下标 2 和 8 也都是偶数。——译者注

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值