包含所有指定字符的最小子串(shortest substring containing all given characters)

问题描述:给定一个字符集合 must [0,...,m-1 ] 和一个字符串str [0,...,n-1 ]。假定 n >= m 。找出 str 中包含 must 中所有字符的最短子串。

最直接和简单的算法当然是暴力搜索(brute-force search):

minimal := str [0,...,n-1 ]

for i from 0 to n-1 do

       search smallest ki such that str [i,...,ki ] contains all characters from must .

       //寻找最小的 ki ,使得str [i,...,ki ] 包括所有must 中的字符

       if str [i,...ki ] is shorter than minimal , then update minimal to str [i,...,ki ]

      //如果该子串比当前已知的最知子串还短,则设置当前最小子串为该子串

可以看出,该算法的复杂度为 O(nk ) 。如果 k =O(n ),则为O(n2 )。那么,有没有更好的算法呢?一般来说,暴力算法会包含很多不必要的或重复的计算。这些不必要的计算有时可以通过剪枝的方法来避免。接下来,我们就来研究如何避免不必要的计算。

首先,如果 str [i ] 不在 must 中,则可以安全的跳过以 str [i ] 开始的子串(注意:这里所说的以 str [i ] 开始的子串,是指从 str 的位置 i 开始的子串,即 str [i,...,k ],而不是以 str [i ] 这个字符开头的子串;下同。)这是基于如下观察:

观察1: 如果 str [i,...,ki ] 是最短的包含 must 中所有字符的子串,则 str [i ] 和 str [ki ] 都在 must 中。

其次,我们还有

观察2: 如果 str [i,...,ki ] 是包含 must 的子串,并且 str [i ] str [i,...,ki ] 中不止出现一次,则 str [i+1,...,ki ] 也包含 must 中的所有字符。

基于观察2,在暴力算法中,当然从 i 转到 i+1 时,如果能以某种快速的方法知道 str [i ] 在 str [i,...,ki ] 中不只出现一次,则无须搜索以 str [i+1 ] 开始的符合要求的子串:因为 str [i+1,...,ki ] 就是我们要寻找的。

如果 str [i,...,ki ] 是包含 must 的子串,但 str [i ] 只在 str [i,...,ki ] 出现一次呢?显然,str [i+1,...,ki ] 包含了除 str [i ] must 中所有的字符。如果我们在 str [ki +1,..., n-1 ] 中寻找第一个出现的 str [i ],记为 ki+1 。则

观察3: str [i+1,...,ki+1 ] 是以 str [i+1 ] 开头的包含 must 的最短子串。

基于这以上三个观察,我们可以减少暴力算法中的多少不必要的计算呢?以下我们先给出剪枝后的算法。

step 1: 首先,从 i := 0 ,我们找到第一个包含 must 的以str [0 ] 开始的最小子串:str [0,...,k0 ]。显然,这也是当前我们所知的最短子串。设 j := k0

step 2: 接下来,假定我们已经知道 str [i,...,j ] 包含了 must

step 3: 考虑当前 i 。 1) 如果 str [i ] 不在 must 中,根据观察1,我们继续向前移动 i := i + 1 ;2) 如果 str [i ] 在 must 中,且在 str [i ] 在 str [i,...,j ] 中不止出现一次,根据观察2,继续向前移动 i := i + 1 ;3) 否则,如果 str [i,...,j ] 比 mininal 还要短,则我们找到了更短的子串,设置 mininal := str [i,...,j ],然后向前移动 j 直到 str [j ] == str [i ] 或者 j > n-1 。如果 j > n-1 ,则结束程序;否则,回到 step 3。

注意到我们在 step 3 中的 1)和 2)并没有更新 mininal 。这是因为那是多余的。如果发生了1),则在之后的某一刻一定会发生2)或者3)(这里假定 must 不为空);如果发生了2),则在之后的某一刻会发生3)。而由于基于以上三个观察的剪枝是安全的,即不会影响到解的正确性,所以,上述算法也是正确的。事实上,该算法的正确性也可以通过归纳法得以证明。上面的描述基本上就是遵从了归纳法的描述框架。

那么,剪枝后的算法复杂度呢?注意到 step 3 的循环中,我们每次都至少向前移动 i j 一步,因此,该循环最多执行 2n 次。如果我们使用一个哈希表来记录 str [i,...,j ] 中每个在 must 中的字符的出现次数,就可以在O(1)的时间内决定是分支到1)2)3)中哪一支。而显然,在1)和2)分支中,时间为 O(1);而在 3)中,除掉移动 j 的步骤,时间也为 O(1),而移动 j 的步骤已经被计算到循环所需要的次数中了。因此,整个算法的复杂度为 O(n )。

把上述算法描述转成 c/c++ 代码也是相当直观的,因此也没必要给出代码了。只不过,在实现时,也注意一些细节,否则很容易出现bugs。其实,这句话,对任何代码实现都是对的:)

P.S.  网页中排版算法和表达式真不方便啊,准备找找能从 latex 转成网页的工具。。。


你可能感兴趣的:(算法,框架,search,工具,n2,bugs)