LeetCode 30.串联所有单词的子串【困难】

125 阅读6分钟

题干

给定一个字符串 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是不是一致的,就判定子串满足条件。实践中可以设置滑动窗口的左右指针leftright,遍历所有可能的left,将right向右移动,每移动一次判断当前leftright范围内的子串是否满足条件,不满足条件的情况可能有以下两种:

  1. s[right: right+wordLen]不在words
  2. 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),其中mwords的长度,ns的长度。
在上面的算法中,我们移动滑动窗口,每次只移动一格,考虑到每一个单词的长度都是固定的,所以能不能一次移动一格单词的长度呢?答案是可以的。因为子串是words的组合,且每一个单词的长度都一样,设wordLen为每个单词的长度,wordNumwords的长度,满足条件的子串长度一定为wordNum*wordLen,所以我们的滑动窗口就是定长的,每一次移动滑动窗口就是以wordLen为步长去移动,每一次移动都要检查滑动窗口内的子串是不是满足条件,在上面的算法中,我们设置了两个hashmap,然后比较两个hashmap是不是相等的,实际上我们可以只设置一个hashmapdiffer用来表示这两个hashmap的差异,当differ为空的时候就认为子串满足条件。所以可以得出下面的算法,设置一个长度为wordNum*wordLen的滑动窗口,以wordLen为步长不断向右移动,每一次移动,将最左侧的单词移出窗口,将最右侧的单词移入窗口,并计算子串和wordsdiffer,如果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的差异。