题干
给定一个字符串 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 的任何顺序排列的连接。
所以我们返回一个空数组。
题解
一个比较简单的想法是,我们遍历所有可能的子串起点,对每一个子串的起点,向后遍历,检查每一个子串是不是满足题目的要求。满足题目要求的子串被定义为words中单词的重新排列组合,言下之意是不关心顺序,但是每个单词在子串中的出现次数和words中的出现次数必须是一样的,因此我们可以定义两个hashmap,分别存储子串单词到单词出现次数的映射以及words中单词到出现次数的映射。如果这两个hashmap是不是一致的,就判定子串满足条件。实践中可以设置滑动窗口的左右指针left和right,遍历所有可能的left,将right向右移动,每移动一次判断当前left到right范围内的子串是否满足条件,不满足条件的情况可能有以下两种:
- 当
s[right: right+wordLen]不在words中 - 当
s[right: right+wordLen]在words中,但是出现次数超过了words中出现的次数 根据上面的思路,可以写出下面的代码:
func findSubstring(s string, words []string) []int {
// wordLen为单一单词的长度,wordNum为单词数量
wordLen, wordNum := len(words[0]), len(words)
// wordCntMap为单词到出现次数的映射
wordCntMap := make(map[string]int)
for _, word := range words {
if _, ok := wordCntMap[word]; !ok {
wordCntMap[word] = 0
}
wordCntMap[word]++
}
ans := []int{}
sLen := len(s)
for left := 0; left < sLen && left+wordLen*wordNum <= sLen; {
tmpWordCntMap := make(map[string]int)
// 对于每一个滑动窗口的左端点,右移滑动窗口右短剑,每移动一次,检查当前子串是否满足条件
isAns := true
for right := left; right < left+wordNum*wordLen; right += wordLen {
curWord := s[right : right+wordLen]
// 如果当前单词不在words中,则当前子串不满足条件,直接退出
wordCnt, ok := wordCntMap[curWord]
if !ok {
isAns = false
break
}
// 如果当前子串在words中,但是出现的次数超过了在words中出现的次数,则当前子串木满足条件,直接退出
if _, ok := tmpWordCntMap[curWord]; !ok {
tmpWordCntMap[curWord] = 0
}
tmpWordCntMap[curWord]++
if tmpWordCntMap[curWord] > wordCnt {
isAns = false
break
}
}
// 如果经过检查,发现当前子串满足条件,则将滑动窗口左端点加入结果数组中
if isAns {
ans = append(ans, left)
}
// 滑动窗口的左端点向右侧移动
left++
}
return ans
}
算法的时间复杂度为O(m*n),其中m为words的长度,n为s的长度。
在上面的算法中,我们移动滑动窗口,每次只移动一格,考虑到每一个单词的长度都是固定的,所以能不能一次移动一格单词的长度呢?答案是可以的。因为子串是words的组合,且每一个单词的长度都一样,设wordLen为每个单词的长度,wordNum为words的长度,满足条件的子串长度一定为wordNum*wordLen,所以我们的滑动窗口就是定长的,每一次移动滑动窗口就是以wordLen为步长去移动,每一次移动都要检查滑动窗口内的子串是不是满足条件,在上面的算法中,我们设置了两个hashmap,然后比较两个hashmap是不是相等的,实际上我们可以只设置一个hashmapdiffer用来表示这两个hashmap的差异,当differ为空的时候就认为子串满足条件。所以可以得出下面的算法,设置一个长度为wordNum*wordLen的滑动窗口,以wordLen为步长不断向右移动,每一次移动,将最左侧的单词移出窗口,将最右侧的单词移入窗口,并计算子串和words的differ,如果differ为空,那么就认为子串满足条件。到目前为止一切都非常合理,但是只是这样,我们并不能找到所有的解,原因是滑动窗口是从s的第一个下标开始向右移动的,而每次移动的步长都是wordLen,这样并不能遍历完整个s,所以我们需要在最外层加一个循环,用来遍历所有可能的循环起点,这个循环起点的范围是0~wordLen-1。由此,我们可以写出下面的代码:
func findSubstring(s string, words []string) (ans []int) {
sLen, wordNum, wordLen := len(s), len(words), len(words[0])
// 遍历所有可能的子串起点
for i := 0; i < wordLen && i+wordLen*wordNum <= sLen; i++ {
// 设置differ数组,代表子串和原串单词出现的次数差
// 子串出现某个单词就+1,原串出现某个单词就-1,当differ[word] = 0,就代表两边出现次数一致,将word从differ中删除
differ := map[string]int{}
for j := 0; j < wordNum; j++ {
differ[s[i+j*wordLen:i+(j+1)*wordLen]]++
}
for _, word := range words {
differ[word]--
if differ[word] == 0 {
delete(differ, word)
}
}
// 维护一个定长的滑动窗口,滑动窗口中的子串代表每一个可能的解
for start := i; start < sLen-wordLen*wordNum+1; start += wordLen {
if start != i {
// 由于滑动窗口是定长的,所以每一次移动一定会导致左侧的一个单词移出窗口,右侧的一个单词移入窗口
word := s[start+(wordNum-1)*wordLen : start+wordNum*wordLen]
differ[word]++
if differ[word] == 0 {
delete(differ, word)
}
word = s[start-wordLen : start]
differ[word]--
if differ[word] == 0 {
delete(differ, word)
}
}
// 当子串中各个单词出现的次数和原串一致的时候就找到了一个解,把子串的起始下标加入结果数组
if len(differ) == 0 {
ans = append(ans, start)
}
}
}
return ans
}
算法的时间复杂度为O(n),因为我们实际上只遍历了一遍s;空间复杂度为O(m),因为我们使用了differ数组来表示两个hashmap的差异。