寻找包含给定字符集合的最小子串

来源:互联网 发布:编辑杂志的软件 编辑:程序博客网 时间:2024/05/19 23:01

奉献几篇很早前写给朋友的稿子,后来由于其它原因无法出版就压了箱底。

今天拿出来晒晒太阳,看官觉得能入眼的话,就看看吧~


寻找包含给定字符集合的最小子串

现代的信息处理中,计算机发挥着极其重要的作用。而信息主要以字符串的形式显示在我们面前,所以对字符串的处理在程序领域中有很多的研究,我们在程序中也常会用到字符串和它的相关算法。

想想小学的时候,老师布置的词组造句的作业,我们能否写个程序自动帮老师去判断呢?

我们将这个问题抽象出来:给定一个字符串和一个字符集合,判断字符集合是否都在字符串中出现过;同时再求该字符串的最小子串(子串的长度最小,长度一样时取字典序最小),使得这个子串同样包含字符集合中的所有元素,字符均为小写英文字母。最小子串可能有多个,找到任意一个即可。

例如:

字符串S="abcdefg",字符集合D={'c','f'},那这个最小子串为S'="cdef"。
字符串S="cfcf",字符集合D={'c','f'},那这个最小子串为S'="cf"。

解法一

首先我们先规定下最小子串的判断标准:首先判断长度是否最小,其次如果长度一样,则根据字典序大小进行判断。需要注意的是,我们使用C++ STL中的std::string类的关系运算符默认是按字典序进行字符串大小比较的。所以我们需要定义minstr函数来比较之前定义的最小子串。minstr函数见代码清单1。

string minstr(string obj1, string obj2)

{

    if (obj1.length() == obj2.length())

       return min(obj1, obj2);

    return obj1.length() > obj2.length() ? obj2 : obj1;

}

代码清单1  最小子串比较函数

因为只有当字符集合D里所有字符都在字符串S中出现过,最小包含字串才有可能存在,所以我们先来实现如何判断D中字符是否都在S中出现的问题。对于这个问题,我们只需要先遍历字符串,得到它的字符集合Ds,然后判断D是否是Ds的子集就可以了。理论上很简单,但在实际编码时有个问题,就是如何实现这个字符集合及其相关操作。如果你熟悉STL的话,可以使用std::set模板类及其相关函数,但是这些函数的内部实现都太重太复杂了,我们更倾向于找到轻量级的解决方案。由于我们的集合是针对英文字符而言的,英文字母总共只有26个,所以我们可以直接开大小为26的数组来模拟集合(当然也可以用位来进行,虽然操作会复杂点,但是用位运算来实现集合比较会很简单)。即使要考虑中文字符,开大小为65536的数组也足够了(MBCS字符集的中文字符占2字节,2^16=65536)。我们用is_subset函数来判断之前提到的子集问题,见代码清单2。

bool is_subset(bool subSet[26], bool set[26])

{

    for (int i = 0; i < 26; i ++) {

       if (subSet[i] && !set[i])

           return false;

    }

    return true;

}

代码清单2  子集判断函数

有了代码清单2后,我们就可以用两个for循环来实现求最小完全包含子串的函数(见代码清单3)。其中最外面的for循环枚举最小子串长度,第二个for循环枚举子串起始位置,不停调用is_subset函数去判断该子串是否满足条件。当然了,还需要一个for循环对bool数组进行赋值。

string min_substr(string S, string D) // 字符串S,字符集合D

{

    string ret;

    bool Sset[26], Dset[26]; // 数组模拟集合

    memset(Dset, 0, sizeof(Dset));

    for (int i = 0; i < D.length(); i ++)

       Dset[D[i]-'a'] = true; // 字符集合D初始化

    for (int i = D.length(); i <= S.length(); i ++) {

       for (int j = 0; j <= S.length() - i; j ++) {

           memset(Sset, 0, sizeof(Sset));

           for (int k = 0; k < i; k ++)

               Sset[S[j+k]-'a'] = true; // 字符集合初始化

           if (is_subset(Dset, Sset))

              ret = ret.empty() ? S.substr(j, i) : minstr(ret, S.substr(j, i)); // substr(offest,length)是取子串函数

       }

    }

    return ret;

}

代码清单3  基于is_subset函数的算法

代码清单3的时间复杂度很容易计算,设N=Len(S),则时间复杂度为O(N^3)。这是高效的算法吗?当然不是,让我们来优化它吧。

在代码清单3中,因为我们是按照子串的长度来进行枚举的,所以在处理子串的过程中,存在着相当多的重复计算,比如字符串”abcdefg”,计算长度为3起始位置为0时子串为”abc”,偏移为0和1的两个字符’a’和’b’,在长度为2起始位置为0的子串”ab”时早已经被计算过,只不过那些状态在每次计算前被memset函数清空了。这样,假如我们能利用之前计算得出的结果,就能够避免这些重复计算,从而提高计算的效率,我们用left 和right指针来标记最小子串的左端和右端,left要始终小于等于right;right指针不停地往右移动,当S[left,right]包含D集合中所有字符时,它就是一个可能的答案。记录完答案后left就该右移一个字符,因为加入left不移动,之后得到的S[left,right]子串,即使满足条件,但它的长度肯定大于当前,就不可能是最小子串了。总结下left右移的条件:当S[left]在子串的其它位置出现过或者S[left]不在D集合中时,left就可以往右移动了,因为S[left]是一个多余的值。最后,对所有可能的答案进行minstr函数的比较,就能得出最小子串了。

例如,字符串S="aaba",字符集合D={'a','b'},程序运行流程为:

 

“ab”和”ba”两个可能答案经过比较后,最小子串为”ab”。瞧,这例子中N=4,总共也就运行了7步而已,比起代码3可省了不少计算,只要left和right指针各自扫描字符串一遍就可以了,这个算法能够做到时间复杂为O(N)!我们还需要一个更有效方法判断集合中的字符是否都出现了,我们用int记录D集合中的不同字符出现的个数,并且用int数组代替原来的bool数组来实现字符计数。详细算法见代码清单4。

string min_substr2(string S, string D) // 字符串S,字符集合D

{

    string ret;

    int Sset[26], Dset[26]; // int数组模拟集合,还有引用计数

    int Ds; // 字符集合D在字符串S中出现的不同个数

    memset(Dset, 0, sizeof(Dset));

    memset(Sset, 0, sizeof(Sset));

    Ds = 0;

    for (int i = 0; i < D.length(); i ++)

       Dset[D[i]-'a'] = 1; // 字符集合D初始化

    int l = 0;

    for (int r = 0; r < S.length(); r ++) {

       if (Dset[S[r]-'a'] == 1 && Sset[S[r]-'a'] == 0)

           Ds ++; // S中出现新的D集合中字符

       Sset[S[r]-'a'] ++;

       for (; l <= r; l ++) {

           if (Dset[S[l]-'a'] == 1 && Sset[S[l]-'a'] == 1) {

              if (Ds == D.length()) { // D集合中字符全部出现

                  ret = ret.empty() ? S.substr(l, r-l+1) : minstr(ret, S.substr(l, r-l+1));

                  Sset[S[l++]-'a'] --; // left右移

                  Ds --;

              }

              break;

           }

           Sset[S[l]-'a'] --;

       }

    }

    return ret;

}

 

代码清单4  基于left和right扫描的算法

总结

我们从朴素的方法开始摸索,寻找重复计算的数据,分析为何会出现重复,这个过程往往能让你发现问题的本质,再提出能避免重复计算的方案来降低时间复杂度。这样逐步优化的好处是因为朴素算法的正确性是不言而喻的,而新算法只是解决重复计算的问题,所以新算法的正确性也很容易被证明。

扩展问题

还不知您发现一个有趣的现象没,最小子串的第一个字符和最后一个字符在该子串中只会出现一次,即子串除去头尾后那两个字符不会再出现,这是为什么呢?这个问题的完整证明就留给读者您自己来完成吧。

 

 

 

原创粉丝点击