字符串匹配
2012-08-24 22:04 ggzwtj 阅读(1622) 评论(0) 编辑 收藏 举报在字符串的计算过程中,总是有重复计算的部分,而正是这些部分导致了计算复杂度的上升。
后缀数组
1、倍增算法:
在构造后缀数组的过程中需要两个数组:rank和suffix。rank保存的是这个字符的排名,而suffix则是保存的则是排名对应的位置。举个例子:
为了增加效率就要减少重复劳动,为了减少重复劳动就要在这次的比较中用上上次比较的结果(这个地方是不是和KMP很像)。怎么用上呢?比如长度为4的字符串C可以分成两个长度为2的字符串A和B,在将C与其他串比较的时候,只有在A相同的时候再去比较B。当然也可以说把C分成1:3的两个串A和B,但是这样的话,在知道A相同的时候怎么知道字符串之间B部分的关系呢?
在第一次比较的时候发现,suffix的前一个是正确的了,在第二次比较的时候前两个是正确的,在第三次比较的时候前4个是正确的,这样在log(N)次比较之后就得到了排序,也就得到了后缀数组。
在迭代的过程中有个从前考虑还是从后考虑的问题:
- 从前考虑:第K+1次的排序中A部分是有序的;
- 从后考虑:第K+1次的排序中B部分是有序的;
其实在排序的过程中,A部分只是充当桶的作用,那么他们的顺序又有什么关系呢?而真正决定第K+1次排序结果的应该是B部分,也就是每次从后考虑。这样,创建后缀数组的代码就写成这个样子了:
public class Test { public static void main(String[] args){ String str = "ababcab "; int length = str.length(); int[] rank = new int[length], nrank = new int[length]; int[] suffix = new int[length], nsuffix = new int[length]; int[] count = new int[0x0000ffff]; for(int i = 0; i < length; i++){ count[str.charAt(i)]++; } for(int i = 1; i < 0x0000ffff; i++){ count[i] += count[i-1]; } for(int i = 0; i < length; i++){ suffix[--count[str.charAt(i)]] = i; } rank[0] = 0; for(int i = 1; i < length; i++){ if(str.charAt(suffix[i]) != str.charAt(suffix[i-1])){ rank[suffix[i]] = rank[suffix[i-1]] + 1; }else{ rank[suffix[i]] = rank[suffix[i-1]]; } } for(int i = 1; i < str.length(); i <<=1){ for(int j = 0; j < length ; j++){ nrank[rank[suffix[j]]] = j; } for(int j = length - 1; j >= 0; j--){ if(suffix[j] - i >= 0) nsuffix[nrank[rank[suffix[j]-i]]--] = suffix[j]-i; } for(int j = i - 1; j >= 0; j--){ nsuffix[j] = suffix[j]; } nrank[nsuffix[0]] = 0; for(int j = 1; j < length; j++){ if(rank[suffix[j]] != rank[suffix[j-1]] || rank[suffix[j]+i] != rank[suffix[j-1]+i]){ nrank[suffix[j]] = nrank[suffix[j-1]]+1; }else{ nrank[suffix[j]] = nrank[suffix[j-1]]; } } int[] temp = nrank; nrank = rank; rank = temp; temp = suffix; suffix = nsuffix; nsuffix = temp; } for(int i = 0; i < length; i++){ System.out.println(str.substring(suffix[i])); } } }
当然,我们并不是因为后缀数组是个可爱的东西所以才费尽心机地去构造它,而是要用它来解决实际的问题,比如:
- 最长公共前缀;
- 最长重复子串(不重叠);
- 至少重复K次的最长子串(可重叠);
- 最长回文子串;
- 最长公共子串;
- 长度不小于K的公共子串的个数;
- 至少出现在K个串中的最长子串;
这里就要引入height数组,用来保存排序之后的相邻后缀的最长公共前缀的长度。这里就看出来排序的重要性了,因为在有序的字符串组里面,相邻的前缀相同的长度最长。那这里在计算的时候要什么样的顺序呢?
- 下标顺序:如果成功的匹配了3位,那么不考虑第一个字母剩下的当然也是匹配的!
- 排序顺序:虽然两个字符串可能有比较长的公共前缀?
谁让在height数组中保存的是排序之后的顺序的后缀,但是还是要根据下标的顺序来计算,因为这样的话上次比较过的在下次的计算中就不需要再次比较了(是不是有点想起等差数列)。那么初始化height数组的代码可以写成这样:
int[] height = new int[length]; height[0] = 0; for(int i = 0, h = 0; i < length; i++){ if(h > 0){ h--; } if(rank[i] == 0){ height[0] = 0; }else{ int j = suffix[rank[i]-1]; while(str.charAt(i+h) == str.charAt(j+h)){ h++; } height[rank[i]] = h; } }
因为在height数组中是按照rank排序的,那么也就是说两个相邻的后缀的前缀是最相近的,在计算任意两个后缀的公共前缀的时候,只要计算它们路过的rank中的最小值即可。那么上面的七个问题就很好解决了(不过也是要一点点小思考的)。这里有个问题,如果频繁地去问你后缀I和后缀J的最大公共前缀是多长的时候怎么办呢?这里就需要用到RMQ:
要提高效率的时候还是要减少不必要的计算,在上面排序的时候是通过利用上次的结果。好的,还有一种就是空间换时间的说法,如果我们把所有I和J都枚举一次并保存在一个二维数组中,那么以后来的查询都是O(1)的,但是这样的代码是预处理的复杂度绝对不会低于O(N^2),空间的复杂度暂不考虑。而在后缀数组处理的时候多是大数据量,在这样的数据量下如果使用O(N)的复杂度不太合适吧?所以我们最起码要使用O(NLogN)的。那么做法也很简单,还是利用二进制来切割,代码如下:
public class Test { public static void main(String[] args){ String str = "abcdabcd"; int length = str.length(); char[][] maxCh = new char[length][]; maxCh[0] = new char[length]; for(int i = 0; i < length; i++){ maxCh[0][i] = str.charAt(i); } for(int i = 1; (1<<i) <= length; i++){ maxCh[i] = new char[length+1-(1<<i)]; for(int j = 0; j <= length-(1<<i); j++){ maxCh[i][j] = (char) Math.max(maxCh[i-1][j], maxCh[i-1][j+(1<<(i-1))]); } } int x = 0, y = 3; int level = (int) (Math.log(y-x)/Math.log(2)); System.out.println((char)Math.max(maxCh[level][x], maxCh[level][y-(1<<level)])); } }
貌似上面的查询是O(1)的,但是要用到log,当然这个计算量比加减法大多啦,有没有更好的方法计算出来一个数的最高位呢?
2、分治算法(DC3):
使用基数排序对当个的字符排序的时候的复杂度是O(N),同样是排序当然也有理由去找后缀的O(N)的解决方法。在倍增的解决方法中已经知道后缀之间的关系:相邻的后缀之间只差一个字母。那么如果我们间隔地取出后缀(比如取出偶数位的后缀),完成之后再与前一个字母合并就变成了剩余的后缀(奇数位的后缀),显然通过一次奇数排序之后就能得到奇数位的rank数组,合并不就得到了总体上的rank?比如根据奇偶拆开之后分别排序完成之后为:
在分的过程中,相当于把问题变小了,从N变成了N/2。这样F(N)=F(N/2)+F(N/4)+F(N/8)+……,而合并的过程是O(N)的,那么总体上的复杂度也就是O(N)了。下面来看合并的过程:其实这是一个归并的过程,因为偶数序列和奇数序列都已经是有序的了,所以在归并的过程中就不需要比较了,需要比较的是两个分别在奇偶序列中的,比如:
由于在两个不同的序列中,那么比较这两个最直观的方法就是逐位比较了,那么复杂度重新回归到最笨的方法:O(N^2)。好了,现在认识到问题的关键了:分的有问题,我们应该在比较的时候使得两个有在同一个序列中的,如果这样分呢:
在有些情况下会如愿,比如:
比较第一个字符就可以了,剩下的部分因为在同一个序列中,所以大小关系是知道的。但是在这种情况下:
就不行了。这样很容易发现了问题的关键:从不同颜色的位置开始,有颜色重叠的部分就OK了。如果两个序列是按照不同的间隔取的,那么一定会有重叠的部分,比如:
比如在比较2和3开始的两个后缀:
可以发现,在前三个字符中一定有一个位置是重复的。好了,剩下的问题就是如何写代码了:
class DCArray{ int[] array; int length; int start; int maxValue; public DCArray(int[] array, int length, int start, int maxValue){ this.array = array; this.length = length; this.start = start; this.maxValue = maxValue; } public int get(int i){ return array[i+start]; } public int set(int i, int v){ array[i+start] = v; return v; } public String toString(){ String ret = "start:"+start+"\n"; ret += "length:"+length+"\n"; for(int i = 0; i < length; i++){ ret += get(i) + " "; } ret += "\n"; return ret; } } public class Test { public static final int maxn = 65535; static int[] strArray = new int[maxn*3]; static int[] rankArray = new int[maxn*3]; static int[] nrankArray = new int[maxn*3]; static int[] suffixArray = new int[maxn*3]; // i is equal with j? public static boolean isEqual(DCArray str, int i, int j){ return str.get(i) == str.get(j) && str.get(i+1) == str.get(j+1) && str.get(i+2) == str.get(j+2); } // i is bigger than j? public static boolean isBigger(int k, DCArray str, int i, int j){ if(k == 2) return str.get(i) > str.get(j) || (str.get(i) == str.get(j) && isBigger(1, str, i+1, j+1)); else return str.get(i) > str.get(j) || (str.get(i) == str.get(j) && value[i+1] > value[j]); } // to be continuous. public static int f(int x, int tb){ return x/3+((x%3==1)?0:tb); } // reduction. public static int g(int x, int tb){ return x<tb?x*3+1:(x-tb)*3+2; } // Radix Sort. static int[] count = new int[maxn*3]; static int[] value = new int[maxn*3]; public static void sort(DCArray str, DCArray before, DCArray after){ int i = 0; for(i = 0; i < before.length; i++) value[i] = str.get(before.get(i)); for(i = 0; i < before.maxValue; i++) count[i] = 0; for(i = 0; i < before.length; i++) count[str.get(before.get(i))]++; for(i = 1; i < before.maxValue; i++) count[i] += count[i-1]; for(i = before.length-1; i >= 0; i--) after.set(--count[value[i]], before.get(i)); } // dc3 static DCArray before, after; public static void dc3(DCArray str, DCArray suffix){ int tbc = 0, i, j, tb = (str.length+1)/3, ta = 0, p; str.set(str.length, 0); str.set(str.length+1, 0); for(i = 0; i < str.length; i++) if(i%3 != 0) before.set(tbc++, i); before.length = after.length = tbc; str.start += 2; sort(str, before, after); str.start--; sort(str, after, before); str.start--; sort(str, before, after); DCArray newStr = new DCArray(str.array, tbc, str.start+str.length, str.maxValue); DCArray newSuffix = new DCArray(suffix.array, tbc, suffix.start+suffix.length, suffix.maxValue); // after to newStr. newStr.set(f(after.get(0), tb), 0); for(i = 1, j = 0; i < tbc; i++) newStr.set(f(after.get(i), tb), isEqual(str, after.get(i), after.get(i-1))?j:++j); // Recursion. if(j < tbc) dc3(newStr, newSuffix); else for(i = 0; i < tbc; i++) newSuffix.set(newStr.get(i), i); // 3*x+1,3*x+2 is all right, build 3*x for(i = 0, ta = 0; i < tbc; i++) if(newSuffix.get(i) < tb) before.set(ta++, newSuffix.get(i)*3); if(str.length%3 == 1) before.set(ta++, str.length-1); before.length = after.length = ta; sort(str, before, after); for(i = 0; i < tbc; i++) value[before.set(i, g(newSuffix.get(i), tb))] = i; for(i = 0, j = 0, p = 0; i < ta && j < tbc; p++){ if(isBigger(before.get(j)%3, str, before.get(j), after.get(i))){ suffix.set(p, after.get(i++)); }else{ suffix.set(p, before.get(j++)); } } for(; j < tbc; p++)suffix.set(p, before.get(j++)); for(; i < ta; p++)suffix.set(p, after.get(i++)); } // build the strArray. static DCArray buildStrArray(String str){ int i, j; for(i = 0; i < 0x0000ffff; i++) value[i] = -1; for(i = 0; i < str.length(); i++) value[str.charAt(i)] = 1; for(i = 0, j = 0; i < 0x0000ffff; i++) if(value[i] >= 0) value[i] = j++; for(i = 0; i < str.length(); i++) strArray[i] = value[str.charAt(i)]; return new DCArray(strArray, str.length(), 0, 100); } public static int[] work(String str){ DCArray input = buildStrArray(str); for(int i = 0; i < input.length; i++){ System.out.print(input.get(i)); } System.out.println(); DCArray suffix = new DCArray(suffixArray, input.length, input.start, input.maxValue); before = new DCArray(rankArray, input.length, 0, input.maxValue); after = new DCArray(nrankArray, input.length, 0, input.maxValue); dc3(input, suffix); int[] result = new int[suffix.length]; for(int i = 0; i < suffix.length; i++) result[i] = suffix.get(i); return result; } public static void main(String[] args){ work("01235041324501325042153051423054213504213540"); } }
3、MF算法:
貌似最快的线性算法,看起来很复杂的样子,一个代码下载地址:http://download.csdn.net/detail/tiandyoin/1607178
4、从后缀树构造:
建好后缀树之后,遍历一次得到的即是suffix数组。
后缀树
有了上面的后缀数组的概念,后缀树就很好懂了:一个具有m个字符的字符串S的后缀树就是一个有向树,有m个叶子节点,这些叶子节点从1到m编号,每一个非叶子节点至少有两个子节点(路径压缩),每条边都用S的非空子串来表示。对于任何叶子节点i,从根节点到该叶子节点所经历的所有的边构成了从i开始的后缀。
网上几篇文章:
MISSISSIPPI$字符串构建后缀树的过程如下:
再次插入的时候发现根节点处已经有一个S,那么就没有必要生成新的分支,但同时,S处也就有了一个隐藏的后缀:
再加入I的时候,发现隐藏的后缀“藏不住”了,需要在这个位置裂开:
直到MISSISSI的过程和上面的过程相同,这时候后缀树变成了:
这时又发现和上面相同的情况,需要裂开:
到最后的时候我们会发现,现在知道的隐藏节点只有一个了,如下:
注:黑色的节点为叶子。
先不要关心隐藏节点,来看一些简单的问题,这棵树有多大?
非根的内部节点的子节点个数大于等于2(否则可以将其合并),所以可以这样认为:在叶子个数相同(N)的情况下,后缀树的总节点个数大于等于完全二叉树,而完全二叉树的总结点个数小于2N,所以后缀树总体上占用的空间是O(N)的。
在上面的处理中有两个需要操作的节点:
- 根节点;
- 最后的一个隐藏节点;
在最后的一个隐藏节点的branch中找到不到当前处理的字符的时候,就需要将该节点裂开了。那么之前的隐藏节点怎么办?
- 如果s1...sn是一个隐藏后缀,那么可能还存在另一个隐藏后缀:si...sn。那么如何找到所有的之前的隐藏节点?设存在si...sn是一个最长的从根节点出发能和s1...sn匹配的串,那么si...sn也一定是隐藏节点。
证明:考虑处理si的时候:当在根节点发现没有该字符,那么需要在该位置加上一个新的节点,此时隐藏节点向后移动一位。当处理si+1的时候,该字符在隐藏节点的后一位,那么当然也在刚加入的字符的后一位。
- 如果s1...sn是最长的一个隐藏后缀,那么所有的隐藏后缀都是以sn结尾的。
证明:假如存在一个非sn结束的隐藏后缀,比如为...sm,那么在sm+1的时候发现不匹配了,该隐藏后后缀也就消失了。也就是说,在sn之前加入的后缀都已经在叶子上了,而在加入新的字符的时候叶子节点仍然在叶子上。
- 如何快速地找到更短的一个隐藏后缀?
最笨的方法是从根节点开始查找,这样的话复杂度又毫无疑问地增加到O(N)。但是,会发现这里“重复的比较”和TRIE中“重复的比较”几乎是完全相同的,所以也可以用类似的方法来提高效率,也就是很多地方都提到的“后缀指针”。
知道了这些,我们来看一下隐藏节点的操作:
1、当前处理的字符和隐藏后缀后面的字符是匹配的;
1.1、如果根节点没有该字符的子节点;
在根节点下创建一个新的子节点;
1.2、隐藏后缀向后移动一位;
1.3、继续大循环;
2、当前处理的字符和隐藏后缀后面的字符不匹配;
2.1、隐藏后缀在一条边上?
将这条边裂开
2.2、将当前处理的字符加到隐藏后缀的后面的一个新的节点上;
2.3、找到最长的另一个隐藏后缀,继续2.1的处理;
优化、原理都说完了,下面开始尝试写一个代码,后缀树中的节点的定义如下:
package suffixtree; import java.util.HashMap; import java.util.Map; public class Node { public static final int DEFALUT_END = 0x0FFFFFFF; public Map<Character, Node> branch; public Node link, father; public int start, end; public String toString(){ String ret = "start:" +start +", "; ret += "end:" + end + "\n"; return ret; } // 初始化一个节点。 public Node(){ start = -1; end = Node.DEFALUT_END; //length = 0; link = father = null; branch = null; } // 将THIS分割变成:FATHER->NEWNODE->THIS. public Node split(int newEnd, String str){ if(newEnd >= this.end){ return null; } Node newNode = new Node(); newNode.start = this.start; newNode.end = newEnd; newNode.father = this.father; newNode.father.addBranch(str.charAt(newNode.start), newNode); this.start = newNode.end+1; this.father = newNode; newNode.addBranch(str.charAt(this.start), this); return newNode; } // 根据字符CH找到对应的分支。 public Node getBranch(char ch){ if(branch == null) return null; else return branch.get(ch); } // 把一个节点插入到THIS.BRANCH中。 public Node addBranch(char ch, Node node){ if(this.branch == null) this.branch = new HashMap<Character, Node>(); branch.put(ch, node); return node; } public int size(){ return start == -1? 0 : end - start + 1; } }
把构建后缀树的代码如下:
package suffixtree; public class SuffixTree { private static final int MAXN = 1000; Node root; Node curr; int currStart; String content; // 根据字符串【content】构造后缀树。 public SuffixTree(String content){ this.content = content; root = new Node(); root.end = -1; root.link = root; curr = this.root; currStart = 0; for(int i = 0; i < content.length(); i++){ insert(i); } } // 处理新的字符。 public void insert(int index){ Node temp; // curr正在一个已经存在的节点上。 if(currStart == curr.end - curr.start){ if((temp = curr.getBranch(content.charAt(index))) != null){ curr = temp; currStart = 0; }else{ updateBeforeSuffixs(index); } }else{ if(content.charAt(index) == content.charAt(curr.start+currStart+1)){ currStart++; return; }else{ curr = curr.split(curr.start+currStart, content); currStart = curr.end - curr.start; updateBeforeSuffixs(index); } } } // 循环处理所有的后缀。 public void updateBeforeSuffixs(int index){ Node temp, next; int start, end; if((temp = curr.getBranch(content.charAt(index))) == null){ temp = new Node(); temp.start = index; temp.father = curr; curr.addBranch(content.charAt(index), temp); if(curr == root){ currStart = 0; return; } next = curr.father.link; start = curr.start; // if father's link is root, start++ to make next_suffix is smaller than curr_suffix. if(next == root) start++; end = curr.end; while(start + (temp = next.getBranch(content.charAt(start))).size() <= end+1){ start += temp.size(); next = temp; } if(start != end + 1){ next = temp.split(temp.start + end - start, content); } curr.link = next; curr = next; updateBeforeSuffixs(index); }else{ curr = temp; currStart = 0; } } public static void main(String[] args){ SuffixTree suffixTree = new SuffixTree("MISSISSIPPI$"); } }
对了,可以看到后缀树构建的过程是从左往右遍历的,这样就可以在线构造后缀树。
KMP
在查找字符串的时候经常会提到KMP算法,所谓查找字符串,不如:
在abcabfghijklmn中是否有abcabc。
其中abcabc被称为模板串。
在不做任何思考的时候比较方法为:
第一步:
a、b、c、a、b、f、g、h、i、j、k、l、m、n
a、b、c、a、b、c
在循环发现不匹配的字符的时候,第二步:
a、b、c、a、b、f、g、h、i、j、k、l、m、n
a、b、c、a、b、c
……
那么每次匹配的失败的时候都需要倒回到第一个开始重新匹配吗?当然不是!那退回到什么地方呢?看一个例子来说明:
i
a、b、c、a、b、f、g、h、i、j、k、l、m、n
a、b、c、a、b、c
j
很不幸在匹配到最后一个的时候失败了。i需要动吗?当然不需要,因为总体上向后移动,不可能在i之前找到匹配了。那j应该移动多少位呢?
假设模板串为s1...si...sn,在si+1处匹配失败,这时候移动到x+1位:
- 为了不从头比较:需要s1...sx与si-x+1...si-1是完全相同的。
- 为了不漏掉匹配:上条镇南关的相同的串的长度必须最大。
对于上面例子中的模板串移动后的位置如下:
i
a、b、c、a、b、f、g、h、i、j、k、l、m、n
a、b、c、a、b、c
j
知道这些之后就很快写出KMP的代码了,网上一搜一大堆,这里给出一个连接:http://www.cnblogs.com/haogj/archive/2010/08/15/1799930.html
TRIE
如果把TRIE换成TREE就很很好理解了吧,只有小写英文字母的TRIE就是一颗26叉树。比如有单词:
- abcb;
- abac;
- bca;
建好的TRIE的结构为:
TRIE建立的代码和建树的代码没什么两样,而用数组还是动态地分配节点实现随个人喜好吧。
AC自动机
会发现有了KMP这种思想之后,很多东西都是一种变形而已,比如AC自动机。KMP算法是在数组(一维)上加速比较,而AC自动机则是企图在多个模式的时候加速匹配(在TRIE上做一点小修改)。在KMP中模式的每个位置都有一个next数组的值,同理,在TRIE中每个节点上增加一个字段用来存放匹配失败时候的“next”,还是用TRIE中的例子:
与TRIE不同的地方就是多出来的红色的线,而这些线构造方法和以及使用方法与KMP中没有什么两样,这里就不在赘述。
--------------------------------
个人理解,欢迎拍砖。