题目链接:https://leetcode.cn/problems/substring-with-concatenation-of-all-words/
解题思路:因为要找到s的一段连续子串是由words中的所有字符串组成,顺序不重要,又因为wors中所有字符串的长度相等,那么s的连续子串长度也就确定了sumLength=words.length*words[0].length,所以,一种比较简单的解题思路,遍历s的每一个字符,判断从当前字符开始的sumLength个字符是否由words中的单词组成,因为顺序不重要,words中的单词可能会出现重复。
判断s中连续的sunLength个字符是否由words中的单词组成:
使用一个map<String,Integer>来保存words中的每一个单词以及在words中出现的次数
对于s的每一次遍历:
对从当前字符开始的sumLength个字符以words[0].length为长度从左到右进行划分出每一个单词并记录单词出现的次数,保存在temMap中
然后判断temMap和map是否相等,如果相等,找到一个结果,保存当前遍历的字符的索引
AC代码
class Solution {
public static List<Integer> findSubstring(String s, String[] words) {
List<Integer> result = new LinkedList<>();
Map<String, Integer> map = new HashMap<>();
//保存words中每个单词出现的次数
for (String word : words) {
map.merge(word, 1, Integer::sum);
}
int wordLength = words[0].length();
int sumLength = words.length * wordLength;
//s的长度比sumLength还小,显然没有符合的子串,直接返回result
if (s.length() < sumLength) {
return result;
}
for (int i = 0; i <= s.length() - sumLength; i++) {
HashMap<String, Integer> temMap = new HashMap<>();
//标记一下是否退出了内层循环,如果提前退出了,说明不符合,不需要判断两个map是否相等
boolean preExit = false;
for (int j = i; j < i + sumLength; j += wordLength) {
String tem = s.substring(j, j + wordLength);
if (!map.containsKey(tem)) {
preExit = true;
break;
}
temMap.merge(tem, 1, Integer::sum);
if (temMap.get(tem) > map.get(tem)) {
preExit = true;
break;
}
}
if (!preExit && temMap.equals(map)) {
//找到了一种结果
result.add(i);
}
}
return result;
}
}
上述解题方法中,每次遍历子串的时候,都需要重新对从当前字符开始的sumLength个字符组成的子串进行划分出每一个单词,然后保存在temMap中,之后在对两个map进行比较是否相等,效率比较低,而且不能够利用已经匹配过字符的信息,可以进行优化
使用滑动窗口进行优化
在对s进行遍历的时候,可以使用一个滑动窗口记录s中sumLength个字符,如果当前窗口里的子串并不是由words中的单词组成,就让窗口右移,那么窗口每次可以右移一个字符或者一个单词,然后在比较窗口里的子串是否符合条件
比如对于s = "barfoothefoobarman", words = ["foo","bar"],那么滑动窗口里的字符长度为sumLength=2*3=6
初始时,滑动窗口里的字符为barfoo
然后判断滑动窗口里的字符是否由words里的单词组成,判断方式还是使用两个map统计每个单词出现的次数,然后判断两个map是否相等
判断完以后,之后就只需要让滑动窗口右移,右移将新的单词加入窗口的最右边,窗口原来最左边的单词被删除,然后接着判断窗口里的字符是否符合条件即可。
每次让滑动窗口右移多少比较合适?
可以有两种方式,每次右移(或者左移)一个字符或者单词
如果每次右移一个字符,则需要重新划分窗口中的所有单词,然后统计每个单词出现的次数,这种方式不能够利用已经匹配的信息,效率比较低,但是一次遍历,可以找到所有的情况
如果每次右移一个单词,这种情况可以利用已经匹配的信息,不需要重新划分窗口里的所有单词。滑动窗口每次移动一个单词的时候,最左边的单词从窗口的移除,最右边加入了一个新的单词,只需要对两个单词进行修改其在temMap中出现的次数就可以了,窗口中间的单词不需要修改,这样可以充分利用已经匹配的信息,提高效率
因为每次滑动窗口右移的是一个单词,那么一次遍历可能找不到s所有的子串
比如s = "barfoofoobarthefoobarman", words = ["bar","foo"],滑动窗口的长度为6,每次移动一个单词
初始时,窗口里的字符是barfoofoobarthefoobarman
第一次移动,窗口里的字符是barfoofoobarthefoobarman
显然这种每次右移一个单词的情况,没有考虑到barfoofoobarthefoobarman的子串arfoof,以及rfoofo,因为第一次移动滑动窗口中的内容是直接从foofoo开始的
因此,滑动窗口每次移动一个单词会有三种情况(也就是words中每个单词的长度,这个例子中每个单词的长度为3),只有这三种情况都满足,就可以遍历出s的所有子串
第一种情况,从第一个字符开始移动滑动窗口(蓝色字体表示窗口里的字符)
遍历的子串为
初始时,barfoofoobarthefoobarman
第一次移动:barfoofoobarthefoobarman
第一次移动没有遍历到的子串为:
barfoofoobarthefoobarman
barfoofoobarthefoobarman
第二种情况,从第二个字符开始移动滑动窗口
遍历的子串为:
初始时:barfoofoobarthefoobarman(这种情况下,遍历到了情况一种没有遍历到的子串arfoof)
第一次移动:barfoofoobarthefoobarman
第一次移动窗口没有遍历到的子串为
barfoofoobarthefoobarman
barfoofoobarthefoobarman(foofoo会在情况一中第一次移动滑动窗口时遍历到)
第三种情况,从第三个字符开始移动滑动窗口
遍历到的子串
初始时:barfoofoobarthefoobarman(这种情况下,遍历到了情况一和情况二中没有遍历到的子串rfoofo)
此时情况一和情况二中第一次没有遍历到的子串都已经遍历到了!
第一次移动窗口:barfoofoobarthefoobarman
第一次移动窗口没有遍历到的子串
barfoofoobarthefoobarman(foofoo会在情况一中第一次移动滑动窗口时遍历到)
barfoofoobarthefoobarman(oofoo会在情况二中第一次情况滑动窗口时遍历到)情况三种第一次移动滑动窗口没有遍历到的子串,已经在第前两种情况下都遍历到了!
上述只分析了滑动窗口移动一次没有遍历到的子串,可以通过三种遍历方式全部被相互遍历到,滑动窗口移动第二次时没有遍历到的子串,也会在其他情况种被遍历到
回到问题每次让滑动窗口右移多少比较合适?通过上述分析,可以发现,每次右移一个单词可以让滑动窗口充分利用已经匹配的信息,不需要重新划分窗口里的字符,并统计每个单词出现的次数,但是每次滑动一个单词长度,可能会出现子串遍历不到的情况,可以通过遍历三种情况进行遍历所有其他情况没有遍历到的子串,即滑动窗口从第一个字符开始滑动,从第二个字符开始滑动,从第三个字符开始滑动(有几种情况由words种的单词长度决定),因此最外层循环中可以遍历所有的情况,循环结束条件为i<words[0].length,内层循环进行滑动窗口移动(动态增加窗口的大小,增加或者删除窗口里的单词数量)。
可以使用一个变量num来记录滑动窗口中的单词数,滑动窗口中只包含words中的单词,每次只从窗口中增加会删除一个单词,当num==words.length时,找到了一个符合条件的子串,将滑动窗口中最左边单词的第一个字符的索引记录到最终结果中,然后右移窗口
如果num<words.length,说明还没有找到符合条件的子串为了保证滑动窗口中只包含words中的单词,需要增大或者减小窗口大小:有以下几种情况
对于待加入窗口的单词(从窗口最右边加入),如果在map(map记录的是words中所有单词出现的次数)中包含这个键,就将这个键加入temMap中,并将其value+1,num+1
如果temMap.get(word) > map.get(word),说明滑动窗口中word单词比words单词里的个数多了,比如s = "barfoofoobarthefoobarman", words = ["bar","foo"],当前滑动窗口中的字符为barfoofoobarthefoobarman,显然多了一个foo,需要右移滑动窗口,将多出的那个foo从窗口里移除,这样窗口里的子串才可能满足条件,因为要求s的子串连续,所以只能从窗口中最左边的单词开始删除,直到删除一个foo为止,删去的过程中及时更新num
如果待加入的单词不在words中,说明窗口里的单词不可能和后面的所有单词组成符合条件的子串了,s = "barfoofoobarthefoobarman", words = ["bar","foo"],当前滑动窗口中的字符为barfoofoobarthefoobarman,the为待加入窗口的单词,这种情况下,需要清除窗口里的所有单词,从barfoofoobarthefoobarman,the后面的那个单词重新开始增大或减少窗口的大小
如果新加入的单词正好和窗口里的单词组合成了一个符合条件的子串,就记录窗口第一个单词的索引,然后右移滑动窗口,找下一个符合条件的子串
AC代码
class Solution {
public static List<Integer> findSubstring(String s, String[] words) {
List<Integer> result = new LinkedList<>();
Map<String, Integer> map = new HashMap<>();
for (String word : words) {
map.merge(word, 1, Integer::sum);
}
int wordLength = words[0].length();
int sumLength = words.length * wordLength;
if (s.length() < sumLength) {
return result;
}
for (int i = 0; i < wordLength; i++) {//遍历所有情况,使得其他情况下没有遍历到的子串都被遍历到
//记录窗口中单词及其出现的次数
HashMap<String, Integer> temMap = new HashMap<>();
int num = 0;//滑动窗口里的单词数量
//每次移动一个单词数量
for (int j = i; j <= s.length() - words.length * wordLength; j += wordLength) {
//只要滑动窗口里单词数量小于words中的单词数量,就继续增大或减少窗口的大小
while (num < words.length) {
String word = s.substring(j + num * wordLength, j + (num + 1) * wordLength);
//words中包含这个单词,将这个单词加入到temMap中
if (map.containsKey(word)) {
temMap.merge(word, 1, Integer::sum);
num++;
//窗口里的word个数多了,找到并删除它
if (temMap.get(word) > map.get(word)) {
//删除窗口里单词的个数
int deleteNum = 0;
//窗口中word单词出现的次数多了,删除它,
while (temMap.get(word) > map.get(word)) {
String leftWord = s.substring(j + deleteNum * wordLength, j + (deleteNum + 1) * wordLength);
//从窗口中删除这个单词,对应的value-1
temMap.put(leftWord, temMap.get(leftWord) - 1);
deleteNum++;
num--;
}
//deleteNum-1是因为外层循环会j += wordLength
j = j + (deleteNum - 1) * wordLength;
break;
}
} else {
//word中不包含这个单词
j = j + num * wordLength;
num = 0;
temMap.clear();
break;
}
}
//窗口中单词数量等一words长度,找到了符合条件的子串
if (num == words.length) {
result.add(j);
String leftWord = s.substring(j, j + wordLength);
num--;
temMap.put(leftWord, temMap.get(leftWord) - 1);
}
}
}
return result;
}
}
这种方式的时间复杂度为O(m*n),m为words中每个单词的长度,n为s的长度