(更新:我已经重写了这个答案的开头,包括对复杂性的讨论以及一些替代方法和潜在风险的讨论。)
(简短的回答,我能想到的在 O(nm) 方法之上的唯一真正改进是观察到我们通常不需要计算表中的所有n次m条目。我们可以只计算我们需要的那些单元格。但实际上它可能非常好,具体取决于数据集。)
澄清问题:我们有一个S
长度n
字符串和一个T
长度字符串m
。允许的最大间隙是k
- 这个间隙也将在字符串的开头和结尾强制执行。间隙是两个匹配字符之间的不匹配字符数 - 即如果字母相邻,则间隙为0
,而不是1
。
想象一个有n+1
行和m+1
列的表格。
0 1 2 3 4 ... m
--------------------
0 | ? ? ? ? ? ?
1 | ? ? ? ? ? ?
2 | ? ? ? ? ? ?
3 | ? ? ? ? ? ?
... |
n | ? ? ? ? ? ?
首先,我们可以定义行r
和列中的条目c
是一个二进制标志,它告诉我们 of 的第一个字符是否是 的第一个r
字符的有效S
子k
序列。(不用担心如何计算这些值,甚至这些值是否有用,我们只需要先明确定义它们。)c
T
然而,这个二进制标志表并不是很有用。不可能轻松地将一个单元格计算为附近单元格的函数。相反,我们需要每个单元存储稍微多一点的信息。T
除了记录相关字符串是否为有效子序列外,我们还需要在我们的子字符串(带有字符的子字符串)的末尾记录连续不匹配字符的个数c
。例如,如果are的第一个r=2
字符和S
are"ab"
的第一个c=3
字符,那么这里有两种可能的匹配:第一个字符显然相互匹配,但可以与后者匹配。因此,我们可以选择留下一个或T
"abb"
b
b
最后零unmatched b
s。我们在表中记录哪一项?
答案是,如果一个单元格有多个有效值,那么我们取最小的一个。我们希望在匹配字符串的其余部分的同时让自己的生活尽可能轻松,这是合乎逻辑的,因此最后的间隙越小越好。警惕其他不正确的优化 - 我们不希望匹配尽可能多的字符或尽可能少的字符。那会适得其反。但是,对于给定的一对字符串S,T
,找到使最后间隙最小化的匹配项(如果有任何有效匹配项)是合乎逻辑的。
另一项观察是,如果字符串S
比 短得多T
,则它无法匹配。k
这显然也取决于。S
可以覆盖的最大长度是rk
,如果小于c
,那么我们可以很容易地标记(r,c)
为-1
。
(可以做出任何其他优化语句吗?)
我们不需要计算此表中的所有值。不同可能状态的数量为 k+3。它们以“未定义”状态 ( ?
) 开始。如果这对(子)字符串无法匹配,则状态为-
。如果匹配是可能的,那么单元格中的分数将是一个介于 0 和 k 之间的数字,包括最后的不匹配连续字符的最小可能数量。这给了我们总共 k+3 个状态。
我们只对表格右下角的条目感兴趣。如果f(r,c)
是计算特定单元格的函数,那么我们只对 感兴趣f(n,m)
。可以根据附近的值计算特定单元格的值。我们可以构建一个递归算法,将r
和c
作为输入,并根据附近的值执行相关的计算和查找。如果此函数查找f(r,c)
并找到 a ?
,它将继续计算它,然后存储答案。
存储答案很重要,因为算法可能会多次查询同一个单元格。而且,某些单元格永远不会被计算。我们刚开始尝试计算一个单元格(右下角),并根据需要进行查找、计算和存储。
这是“明显的” O(nm) 方法。这里唯一的优化是观察到我们不需要计算所有单元格,因此这应该使复杂度低于 O(nm)。当然,对于非常糟糕的数据集,您最终可能会计算几乎所有的单元格!因此,很难对此进行官方的复杂性估计。
最后,我应该说如何计算一个特定的单元格f(r,c)
:
- 如果
r==0
和c <= k
,那么f(r,c) = 0
。 空字符串可以匹配包含最多k
字符的任何字符串。
- 如果
r==0
和c > k
,那么f(r,c) = -1
。比赛时间太长了。
- 单元格只有两种其他方式可以具有成功状态。我们首先尝试:
- 如果
S[r]==T[c]
和f(r-1,c-1) != -1
,那么f(r,c) = 0
。这是最好的情况 - 没有尾随差距的匹配。
- 如果这不起作用,我们尝试下一个最好的方法。如果
f(r,c-1) != -1
和f(r,c) < k
,那么f(r,c) = f(r,c-1)+1
。
这个答案的其余部分是我最初的基于 Haskell 的方法。它的一个优点是它“理解”它不需要计算每个单元,只在必要时计算单元。但这可能会导致多次计算一个单元格的效率低下。
*另请注意,Haskell 方法有效地解决了镜像中的问题 - 它试图从结尾的子字符串S
和T
最小的不匹配字符的前导字符串中构建匹配。我没有时间以它的“镜像”形式重写它!
递归方法应该有效。我们想要一个接受三个参数的函数,int K
、StringS
和 String T
。然而,我们不只是想要一个关于 S 是否是 T 的有效 k 子序列的布尔答案。
对于这种递归方法,如果 S 是有效的 k 子序列,我们还想通过返回从 T 的开头可以删除多少个字符来了解可能的最佳子序列。我们想找到“最好的”子序列。如果 S 和 T 不可能有 k-子序列,那么我们返回 -1,但如果可能,那么我们希望返回可以从 T 中提取的最少字符数,同时保留 k-子序列属性。
helloworld
l r d
这是一个有效的 4 子序列,但最大的间隔(最多)有四个字符 ( lowo
)。这是最好的子序列,因为它在开头 ( he
) 处只留下两个字符的间隙。或者,这是另一个具有相同字符串的有效 k 子序列,但它不是那么好,因为它在开始时留下了 3 个间隙:
helloworld
l r d
这是用 Haskell 编写的,但应该很容易用任何其他语言重写。我将在下面更详细地分解它。
best :: Int -> String -> String -> Int
-- K S T return
-- where len(S) <= len(T)
best k [] t_string -- empty S is a subsequence of anything!
| length(t_string) <= k = length(t_string)
| length(t_string) > k = -1
best k sss@(s:ss) [] = (-1) -- if T is empty, and S is non-empty, then no subsequence is possible
best k sss@(s:ss) tts@(t:ts) -- both are non-empty. Various possibilities:
| s == t && best k ss ts /= -1 = 0 -- if s==t, and if best k ss ts != -1, then we have the best outcome
| best k sss ts /= -1
&& best k sss ts < k = 1+ (best k sss ts) -- this is the only other possibility for a valid k-subsequence
| otherwise = -1 -- no more options left, return -1 for failure.
逐行分析:(Haskell 中的注释以 开头--
)
best :: Int -> String -> String -> Int
一个接受一个 Int 和两个字符串并返回一个 Int 的函数。如果不可能有 k 子序列,则返回值为 -1。否则它将返回一个介于 0 和 K(含)之间的整数,告诉我们 T 开始处可能的最小间隙。
我们只是按顺序处理案件。
best k [] t -- empty S is a subsequence of anything!
| length(t) <= k = length(t)
| length(t) > k = -1
上面,我们处理了 S 为空 ( []
) 的情况。这很简单,因为空字符串始终是有效的子序列。但是要测试它是否是一个有效的k-subsequence,我们必须计算 T 的长度。
best k sss@(s:ss) [] = (-1)
-- if T is empty, and S is non-empty, then no subsequence is possible
那条评论解释了它。这给我们留下了两个字符串都是非空的情况:
best k sss@(s:ss) tts@(t:ts) -- both are non-empty. Various possibilities:
| s == t && best k ss ts /= -1 = 0 -- if s==t, and if best k ss ts != -1, then we have the best outcome
| best k sss ts /= -1
&& best k sss ts < k = 1+ (best k sss ts) -- this is the only other possibility for a valid k-subsequence
| otherwise = -1 -- no more options left, return -1 for failure.
tts@(t:ts)
匹配一个非空字符串。字符串的名称是tts
。但是在 Haskell 中还有一个方便的技巧,允许您为字符串中的第一个字母 ( t
) 和字符串的其余部分( ) 命名ts
。这里ts
应该作为复数形式大声朗读t
——s
这里的后缀是“复数”的意思。我们说有一个t
和一些ts
,它们一起构成完整的(非空)字符串。
最后一段代码处理两个字符串都非空的情况。这两个字符串称为sss
and tts
。但是为了省去我们编写head sss
和tail sss
访问字符串的第一个字母和字符串剩余部分的麻烦,我们只需@(s:ss)
告诉编译器将这些数量存储到变量s
和ss
. 例如,如果这是 C++,您将获得与char s = sss[0];
函数的第一行相同的效果。
最好的情况是第一个字符匹配s==t
并且字符串的其余部分是有效的 k-subsequence best k sss ts /= -1
。这允许我们返回 0。
如果当前完整字符串 ( sss
) 是其他字符串 ( ts
) 的剩余部分的有效 k 子序列,则唯一的其他成功可能性。我们将其加 1 并返回,但如果差距太大,则例外。
不要改变最后五行的顺序非常重要。它们按分数“好”的降序排列。我们希望首先测试并返回最好的可能性。