给定一个字符集合 must [0,...,m-1 ] 和一个字符串str [0,...,n-1 ]。假定 n <= m 。找出 str 中包含 must 中所有字符的最短子串。
例如:给一个字符串s1,和一个小串s2,求算法能在s1中找到包含s2里所有字符的最小子串。比如:
s1 = “ADOBECODEBANC”
s2 = “ABC”
最小子串是 “BANC”,要求O(N)的算法。
1、最直接和简单的算法当然是暴力搜索(brute-force search):
minimal := str [0,...,n-1 ]
for i from 0 to n-1 do
search smallest kisuch 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] 就是我们要寻找的。
观察3:如果 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,则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 )。
另外一种思路:使用一个256元素大小的Hash表存储字符出现的个数.初始时将Hash中在s2中出现了的元素初始化为0,其余初始化为-1.
两指针p1,p2都指向s1首字符,在p2向后移动过程中,如果遇到了hash值为0时则count计数加1,如果count的值等于s2的长度时证明p1~p2之间已经有了一个子串为s2了,此时记录下他们的位置和距离。继续把p1向后移,注意更新hash值和count,直到找到最短的
int findMinString(char *s1,char *s2)
{
int hash[255];
for(int i=0;i<255;i++)
{
hash[i]=-1;
}
//将字符集s2中的字符对应的数组元素初始化为0,s2中的字符在hash中的值>=0
for(char *p=s2;*p!='\0';p++)
{
hash[*p]=0;
}
char *p1=s1;
char *p2=s1;
char *min_p1=s1;
char *min_p2=s1;
int minlen=INT_MAX;
int count=0;//标识已经是否有了一个串中包含字符集s2
int len_s2=strlen(s2);
while(*p2!='\0' || len_s2==count)
{
if(count<len_s2)
{
if(hash[*p2]==0)//第一次遇到与s2中某字符相等的字符
{
count++;
hash[*p2]++;
}
else if(hash[*p2]>0)//多次遇到与s2中某字符相等的字符
{
hash[*p2]++;
}
p2++;
}
if(count==len_s2)//即找到了一个串中包含s2中所有字符的串
{
if(p2-p1<minlen)//如果发现更小的则替换
{
min_p1=p1;
min_p2=p2;
minlen=p2-p1;
}
hash[*p1]--;
if(hash[*p1]==0)//如果p1指向的字符在p1到p2之间只出现了一次
{
count--;
}
p1++;
}
}
while(min_p1<=min_p2)
{
cout<<*min_p1;
min_p1++;
}
cout<<endl;
return minlen;
}