参考:
http://en.wikipedia.org/wiki/Suffix_tree
http://marknelson.us/1996/08/01/suffix-trees/
http://en.wikipedia.org/wiki/Generalised_suffix_tree
http://en.wikipedia.org/wiki/Longest_common_substring_problem
与小郁合作的第一个课题是求多个字符串的最长公共子串,这一算法一般在生物学上用于基因比对,以从中发现物体与物体之间的最大相似,目前效率最高的算法是与suffix-tree相关的,这个可以参考文中开头的参考部分。我们的第一个任务是根据字符串的suffix-tree,构造算法写出多个字符串的lcs(最短公共子串),我设计了以下思路:
(1) 构造第一个字符串的suffix-tree
(2) 在(1)构造的树的基础上继续构造第二颗树的suffix-tree,当每次遇到字符串的某尾时,我们就在叶结点里加入字符串的信息。
(3) 不断重复步骤(2)直到所有的字符串构造完,这样,如果这颗树的某个叶结点代表的后缀是某个字符串的子串,这个叶结点就含有了该字符串的信息,同时包括该后缀子串是从这个字符串什么位置开始的。
(4) 通过递归调用遍历该颗后缀树,可以求出所有结点的相关信息,对于叶结点来说,这些信息本身通过步骤(2)后已经有了,对于那些不是叶结点的结点来说,他们本身代表后缀的前缀(从根结点到他们结点上走一遍形成的字符串是某些字符串的前缀),与这些结点相关的信息来自于他所有的孩子。例如,叶结点
A包含字符串(1,3,5)的信息,即该叶结点代表的后缀是字符串1,3,5的子串,叶结点B包含字符串(2,4,6)的信息,即该叶结点代表的后缀是字符串2,4,6的子串,如果结点C仅有A B两个孩子,则结点C包含(1,2,3,4,5,6)这些字符串的信息。遍历的过程可以计算出每个结点相关的字符串的数目,那些数目达到m
(字符串个数)的结点所代表的即为公共子串。
(5) 第二次遍历该后缀树,可以找出lcs。
假设字符串个数为m,而所有字符串的长度之和为n的时候,这个算法原则上可以达到O(n)级,因为构造后缀树花掉O(n)时间,遍历树花掉O(mn)时间,由于m<<n,可以视为常数,在实现该算法的过程中,我发现当要求的字符串达到一定数量,字符串达到一定长度的时候,内存就不够用了,很显然,由于构造的整颗树信息都在内存之中,如果要处理大批量的数据,这无疑是该算法的一个瓶颈,同时,如果考虑到用外部存储,算法另外要改造,还要加上内外存数据交换,这个问题就变得非常复杂了。
对于在很多情况下,物体与物体的相似可能我们只需要一个近似值,是不是有更好的办法呢。德国的老板提供给小郁这样的一种思路,看下面的图:
算法举例
0110001
11001010
001100110
|
|
|
0 |
|
|
|
|
|
1 |
|
|
|
(1,1) (1,4) (1,5) (1,6) (2,3) (2,4) (2,6) (2,8) (3,1) (3,2) (3,5) (3,6) (3,9) |
(1,2) (1,3) (1,7) (2,1) (2,2) (2,5) (2,7) (3,3) (3,4) (3,7) (3,8) |
|||||||||||
|
00 |
|
|
10 |
|
|
|
01 |
|
|
11 |
|
(1,4) (1,5) (2,3) (3,1) (3,5) |
(1,3) (2,2) (2,5)(2,7) (3,4) (3,8) |
(1,1) (1,6) (2,4) (2,6) (3,2) (3,6) |
(1,2) (2,1) (3,3) (3,7) |
|||||||||
000 |
|
100 |
|
010 |
|
110 |
001 |
|
101 |
011 |
|
111 |
(1,4) 删除 |
(1,3) (2,2) (3,4) |
(2,3)删除 |
(1,2) (2,1) (3,3) (3,7) |
(1,5) (2,3) (3,1) (3,5) |
(2,5) 删除 |
(1,1) (3,2) (3,6) |
删除 |
|||||
|
0100 |
1100 |
|
|
0110 |
1110 |
0001 |
1001 |
|
1011 |
0011 |
|
|
删除 |
(1,2) (2,1) (3,3) |
|
|
(1,1) (3,2) (3,6) 删除 |
删除 |
(1,4) 删除 |
(2,2) (3,4) 删除 |
|
删除 |
(3,5) 删除 |
|
01100删除 |
11100删除 |
|
|
|
|
|
|
|
|
|
上面是求3个字符串的lcs, 为说明简单,我们所列的字符串只包含了0和1两种字符,表格的第一行列出了只含1个字符的子串,表格的第二行列出了包含对应该字符的所有字符串的位置信息,如(1,1)表示0字符在第一个字符串的第1个位置出现过。第三行把字符的子串扩展到了两个字符,如在0前加入0,1形成00和10,在1前加入0,1形成01和11,同时,第四行给出了第三行对应子串的位置信息。如此下去,其实可以把子串和他的位置信息看做一个结点,这样,这个表格可以看作一颗树,如00和10可以看作0的孩子。000和100又可以看作是00的孩子。再看000结点,由于该子串只在字符串1的第4个位置出现过,已经不再是公共子串,所以该结点不再有孩子了。显然,红色标注的1100便是lcs。
这个算法思想非常简单,而且容易实现,根据以上描述,很显然是一个穷举算法,是不是需要也要在内存中构造整颗树呢?当数据量很大的时候,是否能够找出近似值呢。我和小郁经过多次讨论,最终对算法做了如下设计。
首先我们设计了lcslocation类,主要是把所有与输入的所求字符串的信息都放入此类,lcslocation里有三个属性,分别为a b c,a里包含所有的输入字符串信息,b里包含所有字符串含有的公共字符,在上面的表中,含有的公共字符就是0,1,如果第一个字符串含有字符2,而第二个不含有字符2,则2是不会出现在以上表格中的。
另外,针对树,我们设计了下面lcstree和lcsnode类,lcstree并不是真正上面的树,而是一颗抽象树,重点封装了算法的过程。Lcsnode是上面子串代表的节点的一个抽象。我们采用的仍然是以上穷举遍历的过程,不过,为了能够尽快接近结果,我们并不是进行上述类似的广度遍历。从00,10这层节点开始,我们设置了遍历的宽度,假如是3,即我们按照一定的条件对第一层节点生成的所有孩子,并把满足条件的孩子(节点子串仍包含所有字符串的位置信息,仍然是公共子串,否则让它死亡)进行排序(一般是位置信息的丰富程度),挑出3个最有可能生成lcs的节点放入队列,其余的压入栈中,后面的遍历是把所有队列中的节点取出来,让他们生孩子,然后在所有的孩子中挑出最有可能生成lcs的节点入队列,其余入栈,在这个算法的过程中,如果队列中已经没有3个孩子了,就从栈(还可以规定宽度,抛弃一些可能性不大的节点)中挑出来补足。
同时,在这个过程中,如果父节点能够生出满足条件的孩子,显然,孩子表示的子串比父结点表示的更长,父节点就没有保存的必要了,我们始终保持lcs指针指向当前最长的公共字串,如果从队列中弹出的节点的字串长度大于当前的lcs,就让lcs指针指向它。
该遍历直到栈和队列空为止,或者超过规定的时间。
很显然,该算法不需要构造整棵树,同时又能够尽快接近真正的lcs,而且lcs指针始终指向当前lcs,还可以在限定的时间内退出,输入当前的lcs,虽然可能是真正lcs。
为了能够尽快接近真正的lcs,挑选满足条件的孩子大有文章可做,我们也试验了多种挑选方法,在此,我把数据结构中的锦标赛排序和堆排序都用上了,小郁在我把算法实现后得出了多组测试数据。
以下是算法中一些重要数据结构c++描述
主要类:
//与lcs中所有字符串相关信息整理为此类,作为lcs算法的输入数据
class LcsLocation{
......
private:
//lcs中字符串个数
int strcnt;
//字符串数组,用以存储每个字符串含有的字符
//(不重复,如"abacd" 对应 "abcd")
string *a;
//是a数组中所有字符串的公共字符(不重复),若干字符串的lcs中的字符必然出现在b中
string b;
//二位数组,第一维表示某个字符串,第二维表示b中的某个字符,
//该二位数组指出所有字符在每个字符串中的位置信息;
set<int> **c;
......
......
}
//lcs算法的核心类,封装了算法所有核心的数据结构
class LcsTree{
......
public:
void setChildNumber(int childnumber);
void setLcsLocation(LcsLocation *lcslocation);
//根据lcslocation,构建初始化的树,生成树的根节点,
//与该根节点关联的是所有的公共字符的位置信息(从lcslocation的c中获得)
//同时把该根节点入队列
void init(LcsLocation &lcslocation);
//根据type从栈或队列中取出一个lcsnode并生成相关的子node
//在此过程中如果从栈或队列中取出的lcsnode的level值比当前lcs的level大,即当前lcs指向取出的lcsnode
void genechildnode(int type);
//求公共字串的核心过程
//遍历该树(即根据栈或者队列中的节点信息,生成它的子节点并进行相关处理)
void tranverse();
//遍历是否结束,即是否栈和队列中已无可处理的节点,此时表示整棵lcstree树已经遍历完成
bool isfinTran();
LcsNode *getLcs();
......
private:
//存放当前的lcs,遍历结束后,即为最长公共字串
LcsNode *lcs;
//与树相关的队列与栈,暂存还没有生成子节点的节点
LcsQueue<LcsNode> lcsqueue;
LcsStack<LcsNode> lcsstack;
//含有公共字符的个数(即lcslocation中的b的字符个数)
int childnumber;
int originallevel;
LcsLocation *lcslocation;
//暂存树遍历过程中的中间节点
vector<LcsNode *> childvect;
......
......
};
//lcstree中的节点
class LcsNode {
......
public:
//节点层号
int level;
//节点中的公共字符串
string lcsstr;
//是否终结点,所谓的终结点是该节点代表的字串已被确认为非公共字串
bool isendnode;
bool ischoose;
//该节点附带的与节点中字符串相关的位置信息
set<locinfo, loccompare> *nodeinfoptr;
//做相应标记(在对暂存于向量数组中的lcsnode按照某种条件排序后执行)
void setChoose(int vecwidth, int lcswidth, bool value);
......
......
};
算法的思路是这样的:
void lcsmain() {
根据输入得到lcslocation;
lcstree.init(lcslocation);//初始化lcstree
start = time(NULL);
while (!lcstree.isfinTran() && duration < USE_TIME) {//遍历lcstree直到遍历完毕或者超时
lcstree.tranverse();
finish = time(NULL);
duration = (double) finish - start;
}
//输出求得的lcs信息
lcstree.outputLcsNode(*lcstree.getLcs());
}
在这个实践课题的许多环节中,德国教授和小郁多次讨论算法的改进过程,并对设计测试时的一些细节进行了点评,非常严谨地指出了很多实现细节中的关键问题,实非目前大多国内大学的教授所能比,让我感到由衷地敬佩。