LeetCode_30 串联所有单词的子串
题目要求
给定一个字符串
s
** 和一个字符串数组words
。words
中所有字符串 长度相同。
s
中的 串联子串 是指一个包含words
中所有字符串以任意顺序排列连接起来的子串。
- 例如,如果
words = ["ab","cd","ef"]
, 那么"abcdef"
,"abefcd"
,"cdabef"
,"cdefab"
,"efabcd"
, 和"efcdab"
都是串联子串。
"acdbef"
不是串联子串,因为他不是任何words
排列的连接。返回所有串联子串在
s
中的开始索引。你可以以 任意顺序 返回答案。示例 1:
输入:s = “barfoothefoobarman”, words = [“foo”,“bar”]
输出:[0,9]
解释:因为
words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。 子串 “barfoo”
开始位置是 0。它是 words 中以 [“bar”,“foo”] 顺序排列的连接。 子串 “foobar” 开始位置是 9。它是
words 中以 [“foo”,“bar”] 顺序排列的连接。 输出顺序无关紧要。返回 [9,0] 也是可以的。示例 2:
输入:s = “wordgoodgoodgoodbestword”, words =
[“word”,“good”,“best”,“word”]输出:[]
解释:因为 words.length == 4 并且
words[i].length == 4,所以串联子串的长度必须为 16。 s 中没有子串长度为 16 并且等于 words
的任何顺序排列的连接。 所以我们返回一个空数组。示例 3:
输入:s = “barfoofoobarthefoobarman”, words =
[“bar”,“foo”,“the”] 输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length ==
3,所以串联子串的长度必须为 9。 子串 “foobarthe” 开始位置是 6。它是 words 中以
[“foo”,“bar”,“the”] 顺序排列的连接。 子串 “barthefoo” 开始位置是 9。它是 words 中以
[“bar”,“the”,“foo”] 顺序排列的连接。 子串 “thefoobar” 开始位置是 12。它是 words 中以
[“the”,“foo”,“bar”] 顺序排列的连接。提示:
1 <= s.length <= 10<sup>4</sup>
1 <= words.length <= 5000
1 <= words[i].length <= 30
words[i]
和s
由小写英文字母组成
难度:Hard
思路
s: 待查找的字符串
words:需要包含的单词数组
needMatchWordsCount:需要包含的单词数组长度
step: 单个单词的长度
needs: 每个单词的计数器
matchWordsCount:已经匹配的所有单词计数
这个问题最开始的想法是暴力破解,遍历字符串中的所有字符,以每个字符为起点,截取固定长度的字符串,然后对每个字符串判断是否符合要求,其最坏情况下的时间复杂度是O(m * n),m是字符串的长度,n是数组的元素数量。
但是“维护固定长度的字符串”这一要求很容易让人联想到滑动窗口方法
作为一个“Hard”题,滑动窗口也相对比较复杂,需要注意的点如下:
- 因为我们是做字符串匹配,而且待匹配的字符串长度固定,所以很容易想到用step做步长来遍历s,但是这样有一个明显的问题:会遗漏匹配从(step - 1,step - 2, … ,1)开始的所有可能性,所以,我们应该在外层套另一层循环,遍历的对象是0到step - 1,用来覆盖所有的情况。
- 匹配状态如何记录,因为我们要匹配的是字符串,而且可能存在重复的字符串待匹配,所以我们应该给每个待匹配的字符串设置一个计数器,标记它被匹配的次数,在我的解决方案中,我使用unordered_map来记录。
- 滑动窗口的右指针何时前进,左指针何时收缩,在循环中,右指针始终前进,左指针何时收缩却要分情况讨论:
- 如果右指针往窗口中添加了一个“不认识”的单词,那么,当前窗口将会崩溃,左指针收缩到右指针的位置,窗口长度清零,计数器清零。
- 如果右指针往窗口中添加了一个“认识”的单词,也要分情况讨论:
- 新的单词被加入后,当前单词的计数器提示 “我还没有超过标准”,(比如words中存在两个“good”,当前匹配了1个或者2个),那么matchWordsCount计数器加1,此时也应该比较 matchWordsCount和needMatchWordsCount是否相等,相等即意味着”当前窗口内的字符串包含指定的单词列表中的所有单词“,那么就需要记录一次结果
- 新的单词被加入后,当前单词的计数器提示 “我已经超过标准”,(比如words中存在两个“good”,当前匹配了3个以上),那么,左指针需要移动到离它最近的一个”good“的后面,同时在这个过程中,每次移除的单词,其对应的单词计数器减1,总的单词匹配数量减1
代码和解析
关键步骤
- 初始化:首先计算子串的长度
subStrLength
,并初始化返回结果ret
为空向量。如果s
的长度小于子串长度,直接返回空向量。 - 构建需求字典:使用
map
数据结构needs
来存储每个单词及其出现次数。 - 滑动窗口:通过两个指针
left
和right
来实现滑动窗口,right
指针向右移动,left
指针在必要时向右移动。 - 匹配单词:当
right
指针移动到一个新的单词时,检查这个单词是否在需求字典中。如果不在,则重置窗口,left
和right
指针都移动到right
指针的位置。如果在,则更新临时需求字典tmp_needs
和匹配单词计数matchWordCount
。 - 调整窗口:当
tmp_needs
中的某个单词的计数超过了needs
中的计数时,移动left
指针,直到tmp_needs
中的单词计数不再超过。 - 记录结果:当匹配单词计数等于需要匹配的单词数量时,记录
left
指针的位置到结果向量ret
中。
代码
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
// 计算子串的长度,即单词列表中所有单词的长度之和
int subStrLength = words.size() * words[0].length();
vector<int> ret; // 用于存储结果的向量
if (s.length() < subStrLength) // 如果字符串长度小于子串长度,直接返回空向量
{
return ret;
}
int step = words[0].length(); // 单词的长度,用于滑动窗口的步长
unordered_map<string,int> needs; // 存储每个单词及其出现次数的字典
for(string word : words) // 初始化字典
{
needs[word] ++;
}
unordered_map<string, int> tmp_needs; // 临时字典,用于滑动窗口中的单词计数
int matchWordCount = 0; // 匹配的单词数量
int needMatchWords = words.size(); // 需要匹配的单词数量
int left, right = 0; // 滑动窗口的左右指针
for (int i = 0; i < step; i ++) // 对于每个可能的起始位置
{
left = i;
right = left;
tmp_needs.clear(); // 清空临时字典
matchWordCount = 0; // 重置匹配单词数量
while(right + step <= s.length()) // 滑动窗口
{
right += step; // 移动右指针
string rightStr = s.substr(right - step, step); // 获取当前单词
if (needs.count(rightStr) < 1) // 如果单词不在需求字典中
{
left = right; // 移动左指针到右指针位置
matchWordCount = 0; // 重置匹配单词数量
tmp_needs.clear(); // 清空临时字典
}
else // 如果单词在需求字典中
{
tmp_needs[rightStr] ++; // 更新临时字典中的单词计数
matchWordCount ++; // 增加匹配单词数量
while(tmp_needs[rightStr] > needs[rightStr] && left <= (right - step)) // 如果当前单词的计数超过了需求,并且左指针还在窗口内
{
left += step; // 移动左指针
string leftStr = s.substr(left - step, step); // 获取左指针位置的单词
tmp_needs[leftStr] --; // 更新临时字典中的单词计数
matchWordCount --; // 减少匹配单词数量
}
if (matchWordCount == needMatchWords) // 如果匹配的单词数量等于需要匹配的单词数量
{
ret.push_back(left); // 记录结果
}
}
}
}
return ret;
}
};
思想和方法:
- 滑动窗口:通过滑动窗口来检查所有可能的子串,这是一种常见的解决子串问题的方法。
- 字典匹配:使用字典来存储和检查单词的出现次数,这是一种高效的方法,因为字典的查找操作的时间复杂度为O(1)。
时间复杂度和空间复杂度:
- 时间复杂度:由于需要遍历字符串
s
中的每个位置,并且在每个位置上可能需要调整窗口,所以时间复杂度为O(n*m),其中n为字符串s
的长度,m为单词列表words
的长度。 - 空间复杂度:主要的空间消耗来自于存储单词的字典和结果向量,所以空间复杂度为O(n),其中n为单词列表
words
的长度。
暴力匹配字符串方法
代码和解析
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
if (s.length() < (words.size() * words[0].length()))
{
return ret;
}
map<string, int> needs;
int subStrLength = words.size() * words[0].length();
for(string word : words)
{
needs[word] ++;
}
//printf("map count %d good count %d", needs.size(), needs["good"]);
for(int i = 0; i < (s.length() - subStrLength + 1); i ++)
{
string substr = s.substr(i, subStrLength);
//printf(" %s", substr.c_str());
int step = words[0].length();
if (isMatch(substr, step, needs))
{
ret.push_back(i);
}
}
return ret;
}
bool isMatch(string s, int step, map<string, int> needs)
{
int nCount = needs.size();
for(int i = 0; i < s.length(); i += step)
{
string substr = s.substr(i, step);
if (needs.count(substr) < 1)
{
return false;
}
else
{
needs[substr] --;
if (needs[substr] == 0)
{
nCount --;
if (nCount == 0)
{
return true;
}
}
if (needs[substr] < 0)
{
return false;
}
}
}
return true;
}
};
这段代码定义了一个名为Solution
的类,其中包含两个成员函数:findSubstring
和isMatch
。findSubstring
函数的目的是找到字符串s
中所有包含给定单词列表words
的子串的起始位置。isMatch
函数则是用来检查一个给定的子串是否包含所有需要的单词。
findSubstring
函数分析:
- 初始化:首先检查字符串
s
的长度是否小于子串的长度(即单词列表words
中所有单词的长度之和)。如果是,直接返回空向量。 - 构建需求字典:使用
map
数据结构needs
来存储每个单词及其出现次数。 - 遍历字符串:遍历字符串
s
中的每个可能的子串,长度为子串的长度。对于每个子串,调用isMatch
函数检查它是否包含所有需要的单词。 - 记录结果:如果
isMatch
函数返回true
,则将子串的起始位置添加到结果向量ret
中。
思想和方法:
- 子串匹配:通过遍历字符串
s
中的每个可能的子串,并使用isMatch
函数检查它是否包含所有需要的单词,来找到所有匹配的子串。 - 字典匹配:使用字典来存储和检查单词的出现次数,这是一种高效的方法,因为字典的查找操作的时间复杂度为O(1)。
时间复杂度和空间复杂度:
- 时间复杂度:由于需要遍历字符串
s
中的每个可能的子串,并且在每个子串上调用isMatch
函数,所以时间复杂度为O(n*m),其中n为字符串s
的长度,m为单词列表words
的长度。 - 空间复杂度:主要的空间消耗来自于存储单词的字典和结果向量,所以空间复杂度为O(n),其中n为单词列表
words
的长度。
isMatch
函数分析:
- 检查子串:遍历给定的子串,检查每个单词是否在需求字典中,并且其出现次数是否正确。如果找到一个不匹配的单词或者单词的出现次数不正确,则返回
false
。
为何使用滑动窗口方法
可以看到,上面两种情况,在最坏的情况下,时间复杂度都是O(m*n),那为何要选用更不直观的滑动窗口方法呢?
滑动窗口方法在以下情况下会对遍历字符串方法产生运行速度优势:
- 字符串长度较长:当字符串
s
的长度较长时,滑动窗口方法的优势更为明显。因为滑动窗口方法只需要遍历字符串一次,而遍历字符串方法需要遍历所有可能的子串,这在字符串长度较长时会导致大量的重复计算。 - 单词列表较短:当单词列表
words
的长度较短时,滑动窗口方法的优势更为明显。因为滑动窗口方法的时间复杂度与单词列表的长度成正比,而遍历字符串方法的时间复杂度与单词列表的长度无关。 - 单词长度较短:当单词的长度较短时,滑动窗口方法的优势更为明显。因为滑动窗口方法的窗口大小是固定的,与单词的长度成正比,而遍历字符串方法需要为每个可能的子串分别检查,这在单词长度较短时会导致大量的重复计算。
- 需要找到所有匹配的子串:当需要找到所有匹配的子串时,滑动窗口方法的优势更为明显。因为滑动窗口方法可以在遍历字符串的过程中就找到所有匹配的子串,而遍历字符串方法需要为每个可能的子串分别检查,这在需要找到所有匹配的子串时会导致大量的重复计算。
总之,滑动窗口方法在以下情况下会产生运行速度优势:字符串长度较长、单词列表较短、单词长度较短,以及需要找到所有匹配的子串。这些情况下,滑动窗口方法的时间复杂度为O(n),其中n为字符串的长度,而遍历字符串方法的时间复杂度为O(n*m),其中m为单词列表的长度。