30. Substring with Concatenation of All Words
字符串的模式匹配一直是个很经典的问题,能写好几本书。单模式匹配有KMP/BM算法,多模式匹配有AC/WM算法等等,还有各种适用于特殊场景的算法,可以说已经被玩坏了。。。
我只稍微看过一点BM算法,很简单很优雅。其他的算法还有点复杂的。
刚看到这道题目的时候,第一感觉就是多模式匹配。给定一堆pattern,在字符串s中搜索。于是去google AC算法了,看得头晕。后来想想不对啊,这个pattern是有规律的,比如所有pattern长度是相同的,所有出现的字符也都是固定的,也许不必用那么复杂的算法。杀鸡焉用牛刀。
模式匹配算法的关键就是减少比较次数。最笨的办法就是index从0开始,依次去搜索是否符合某个pattern,然后index++,继续重复搜索。伪代码:
for (int i = 0; i < s.length(); i++) {
for (String p : patterns) {
if (s.substring(i).startsWith(p)) {
// 把i加到结果List里
}
}
}
在此基础上,应该如何减少比较次数?我采用了几个策略:
- pattern长度是固定的,所以当s“剩余”的长度小于pattern长度时,就不用再比较了。这个是比较容易想到的。
- 借鉴BM算法的“坏字符”策略。如果char[i]不是任何pattern中的字符,直接跳到i+1。另外还借鉴了BM算法一点:从pattern尾部开始比较,而不是头部。
- pattern是由长度相同的几个词排列而成。在从char[i]开始比较时,我们应该可以“预测”到char[i+1]是什么。比如输入的words是
["ab","cd"]
,当前字符是a,那么下一个字符必须是b。如果是c或d,就直接break,后面都不用再比较了。我会为可能的pattern建一个“索引”,以快速判断是否跳过。
这也是比较常见的解题思路了。先给出一个最笨的解法,然后在这个基础上去优化。很多问题都可以这样。当然也有些问题从开始就会用一个完全不同的思路,不能一概而论。
我的解法:
public class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> result = new ArrayList<Integer>();
if (s == null || words == null)
return result;
int wordCount = words.length;
int lenPerWord = words[0].length();
if (wordCount == 0 || s.length() < wordCount * lenPerWord) // 这个边界条件还是比较容易想到的
return result;
// 存储pattrn中出现过的所有字符
Set<Character> goodChars = new HashSet<Character>();
CharNode dummy = new CharNode();
dummy.c = 'x';
// 准备工作:建索引,顺便更新goodChars
// 这个索引的结构可能比较难理解,我关注的是一个字符的下一个字符可能是什么
// 比如输入是["ab","cd"],建好的索引就是dummy->a->b,dummy->c->d
// 代表第一个字符可能是a或c,如果第一个字符是a,那么下一个字符必须是b;如果第一个字符是c,下一个字符必须是d
// 因为可能有重复的输入,比如["ab","ab"],所以还要用一个count表示某个word出现了几次
// 换句话说,在匹配的时候,某个word可以被“使用”几次
for (int j = 0; j < words.length; j++) {
CharNode current = dummy;
for (int i = 0; i < words[j].length(); i++) {
char thisChar = words[j].charAt(i);
goodChars.add(thisChar);
if (!current.next.containsKey(thisChar)) {
CharNode tmp = new CharNode();
tmp.c = thisChar;
current.next.put(thisChar, tmp);
current = tmp;
} else {
current = current.next.get(thisChar);
}
}
current.count++;
}
//System.out.println(dummy);
// 准备工作结束,正式开始匹配
int lastEnd = 0;
// 注意i的终止条件。另外i不要在for语句里更新。
for (int i = 0; i <= s.length() - wordCount * lenPerWord;) {
// begin和end都是inclusive
int begin = i, end = i + wordCount * lenPerWord - 1;
// bad chars
int badChar = -1;
// 从尾部开始找bacChar,这里用j>=lastEnd是为了减少匹配次数,如果用j>=begin也可以,但效率会低一些
// begin到lastEnd之间的字符,都是在上次for循环里匹配过的
for (int j = end; j >= lastEnd; j--) {
if (!goodChars.contains(s.charAt(j))) {
badChar = j;
break;
}
}
lastEnd = end + 1;
// 如果找到了bacChar,直接跳到下一个位置,开始下次比较
if (badChar != -1) {
i = badChar + 1;
continue;
}
// 如果没有找到badChar,就判断[begin,end]这段substring,是否符合某个pattern,这时就用到索引了
if (validateSubstr(dummy, s, begin, end, lenPerWord, wordCount)) {
result.add(begin);
}
// 只有找到bacChar时i能跳的很快,否则只能老老实实i++
i++;
}
return result;
}
// 校验字符串s从begin到end这段substring,是否符合某个pattern
// 包括begin和end
private boolean validateSubstr(CharNode root, String s, int begin, int end, int lenPerWord, int wordCount) {
// 在校验过程中,我们要做一些count--的操作,事后必须把这些count再加回来
List<CharNode> minusOne = new ArrayList<CharNode>();
boolean result = true;
// 还是要理解索引的结构,不然下面这段代码不太好理解
// 比如输入是["abc","def"],我会从begin开始找第一个word,就是前3个字符
// 如果是"abd",虽然a/b/d都是goodChar,但b后面不可能是d,匹配失败
// 如果是"abc",那么我就认为"abc"已经出现过了,对应的count--,变成0,继续找第二个word,也就是第4到6个字符
// 如果第二个word还是"abc",虽然符合索引,能找到对应的count,但count已经是0了,说明"abc"已经被“用光”了,匹配失败
for (int i = 0; i < wordCount; i++) {
CharNode current = root; // 开始匹配一个word的过程
for (int j = 0; j < lenPerWord; j++) {
char thisChar = s.charAt(begin + i * lenPerWord + j);
// 不符合索引,直接失败
if (!current.next.containsKey(thisChar)) {
result = false;
break;
}
current = current.next.get(thisChar);
}
// 一个经典问题:如何跳出两层for循环
if (!result)
break;
// 如果当前word已经没有“富余”了,匹配失败
if (current.count <= 0) {
result = false;
break;
}
// 某个word被使用一次
current.count--;
minusOne.add(current);
}
// 最后别忘了加回来
for (CharNode node : minusOne) {
node.count++;
}
return result;
}
}
// 这是一个辅助类,充当索引
class CharNode {
char c; // 当前字符
int count = 0; // 其实只有每个word结尾那个字符的count有用
Map<Character, CharNode> next = new HashMap<Character, CharNode>(); // 当前字符的下一个字符可能是什么?
// 以下两个方法是用于debug的
public String toString() {
StringBuilder sb = new StringBuilder();
for (String s : innerToString()) {
sb.append(s);
sb.append("\n");
}
return sb.toString();
}
private List<String> innerToString() {
List<String> res = new ArrayList<String>();
if (next.size() != 0) {
for (CharNode tmp : next.values()) {
for (String s : tmp.innerToString()) {
res.add(this.c + "->" + s);
}
}
} else {
res.add(this.c + ":" + count);
}
return res;
}
}
我发现要解释清楚我的思路还是有点难的。。。其实我写代码的时候就很心虚,感觉我的思路有点偏,难道是脑洞太大。。。而且代码也有点长,纯算法题写太长的代码,总觉得有问题。提交的时候已经做好出错的心理准备了,结果一次就AC,还能beat 41.6% java submissions,心满意足了。。。
另外我发现我的代码工程化风格很明显啊。。。各种辅助类、辅助方法,还有变量名,方法名之类的,讲究可读性。还有“建索引”的这种想法,也是工作中常用到的。最关键的是,长。。。看了一些其他人的解法,确实简短高效,但我就是想吐槽l/r/m/k之类的变量名。。。我始终觉得,代码清晰是最重要的,不能盲目追求简短或者追求效率。