题目描述

image.png

题目链接

https://leetcode.cn/problems/substring-with-concatenation-of-all-words/

思路

1. 暴力匹配

首先,最直接的思路,就是以字符串【30】串联所有单词的子串 - 图2的每个下标为子串的起始位置来暴力判断是否匹配【30】串联所有单词的子串 - 图3数组中的所有单词,符合的就把下标保存起来,最后返回即可。
image.png
如上图,利用循环变量【30】串联所有单词的子串 - 图5,依次后移,判断每个子串是否完全匹配【30】串联所有单词的子串 - 图6中的所有单词即可。

那怎么判断子串是否匹配呢?由于不需要考虑【30】串联所有单词的子串 - 图7中单词串联的顺序,可以考虑利用哈希表来解决。首先,我们把【30】串联所有单词的子串 - 图8中的所有的单词添加到哈希表【30】串联所有单词的子串 - 图9里,其中,键为单词,值为单词出现的个数(因为【30】串联所有单词的子串 - 图10中的单词可能会有重复)。然后遍历子串,判断子串中的单词及单词出现的次数是否完全匹配【30】串联所有单词的子串 - 图11。如果遍历子串时发现有【30】串联所有单词的子串 - 图12中不存在的单词,或者单词数量超过了【30】串联所有单词的子串 - 图13中记录的数量,则可以提前结束遍历。

具体代码实现如下:

  1. public static List<Integer> findSubstring(String s, String[] words) {
  2. List<Integer> res = new ArrayList<>();
  3. if (s == null || s.length() == 0 || words == null || words.length == 0) {
  4. return res;
  5. }
  6. int wordLen = words[0].length();
  7. int winSize = wordLen * words.length;
  8. // 将单词数组构建成哈希表
  9. Map<String, Integer> map = new HashMap<>();
  10. for (String word : words) {
  11. map.put(word, map.getOrDefault(word, 0) + 1);
  12. }
  13. // 遍历s
  14. for (int i = 0; i <= s.length() - winSize; i++) {
  15. Map<String, Integer> tmp = new HashMap<>();
  16. int j = i, hit = 0;
  17. // 判断子串是否匹配
  18. while (j < i + winSize) {
  19. String word = s.substring(j, j + wordLen);
  20. j += wordLen;
  21. if (map.containsKey(word)) {
  22. int wordNum = tmp.getOrDefault(word, 0) + 1;
  23. if (map.get(word) < wordNum) {
  24. break;
  25. }
  26. tmp.put(word, wordNum);
  27. hit++;
  28. } else {
  29. break;
  30. }
  31. }
  32. // 判断是否完全匹配
  33. if (hit == words.length) {
  34. res.add(i);
  35. }
  36. }
  37. return res;
  38. }
  • 时间复杂度:【30】串联所有单词的子串 - 图14,其中【30】串联所有单词的子串 - 图15为字符串【30】串联所有单词的子串 - 图16的长度,【30】串联所有单词的子串 - 图17【30】串联所有单词的子串 - 图18中的单词数。
  • 空间复杂度:【30】串联所有单词的子串 - 图19,我们需要申请两个哈希表用来存储子串和【30】串联所有单词的子串 - 图20中的单词。

    2. 滑动窗口

    【30】串联所有单词的子串 - 图21中的单词数为【30】串联所有单词的子串 - 图22【30】串联所有单词的子串 - 图23中每个单词的长度为【30】串联所有单词的子串 - 图24,字符串【30】串联所有单词的子串 - 图25的长度为【30】串联所有单词的子串 - 图26。我们首先将【30】串联所有单词的子串 - 图27划分为单词组,每个单词的大小均为【30】串联所有单词的子串 - 图28(首尾除外)。这样的划分方法有【30】串联所有单词的子串 - 图29种,即先删去前【30】串联所有单词的子串 - 图30个字母后,将剩下的字母进行划分,如果末尾有不到【30】串联所有单词的子串 - 图31个字母的单词也删去。之后,我们就可以对这【30】串联所有单词的子串 - 图32种划分得到的单词数组分别使用滑动窗口对【30】串联所有单词的子串 - 图33进行搜索。

以下图为例,我们每次移动一个单词的长度,也就是【30】串联所有单词的子串 - 图34个字符,这样所有的移动被分成了三类。

  • 从 0 开始遍历

image.png

  • 从 1 开始遍历

image.png

  • 从 2 开始遍历

image.png

实际上,这种滑动窗口的思想还有可以优化的点,具体情况可分为以下三种:

情况一:当子串完全匹配,移动到下一个子串的时候。

image.png
对于这种情况,当【30】串联所有单词的子串 - 图39【30】串联所有单词的子串 - 图40移动到【30】串联所有单词的子串 - 图41时,由于前两个 foo 我们在第一次匹配的时候已经判断过了,所以我们只需要判断加上最后一个 foo 后是否还匹配。因此,我们就不必将哈希表【30】串联所有单词的子串 - 图42清空从新开始填充,而是可以只移除之前【30】串联所有单词的子串 - 图43子串的第一个单词 bar 即可,然后直接从箭头所指的 foo 开始匹配就可以了。

情况二:当判断过程中,出现不符合的单词。

image.png
在判断【30】串联所有单词的子串 - 图45的子串时,出现了一个并不在【30】串联所有单词的子串 - 图46中的单词 the,只要包含这个单词的子串肯定也是不能匹配的,所以此时【30】串联所有单词的子串 - 图47的子串,我们就不需要判断了,可以直接从【30】串联所有单词的子串 - 图48继续匹配。

情况三:判断过程中,出现的是符合的单词,但是次数超了。

image.png
对于【30】串联所有单词的子串 - 图50的子串,在判断第二个单词 bar 时,由于之前已经出现了一次 bar,所以【30】串联所有单词的子串 - 图51的子串是不符合要求的。继续往后移动窗口,当【30】串联所有单词的子串 - 图52【30】串联所有单词的子串 - 图53移动到【30】串联所有单词的子串 - 图54时将 foo 移除了,此时子串还是有两个 bar,所以该子串也一定是不符合的。继续往后移动,当之前的 bar 被移除后,此时【30】串联所有单词的子串 - 图55的子串,就可以接着按正常的方法判断了。

具体代码实现如下:

  1. public static List<Integer> findSubstring(String s, String[] words) {
  2. List<Integer> res = new ArrayList<>();
  3. if (s == null || s.length() == 0 || words == null || words.length == 0) {
  4. return res;
  5. }
  6. int wordNum = words.length;
  7. int wordLen = words[0].length();
  8. // 将单词数组构建成哈希表
  9. Map<String, Integer> map = new HashMap<>();
  10. for (String word : words) {
  11. map.put(word, map.getOrDefault(word, 0) + 1);
  12. }
  13. // 这里只需遍历0~wordLen即可,因为滑动窗口都是按照wordLen的倍数进行滑动的
  14. for (int i = 0; i < wordLen; i++) {
  15. Map<String, Integer> tmp = new HashMap<>();
  16. // 滑动窗口
  17. int left = i, right = i, hit = 0;
  18. while (right + wordLen <= s.length()) {
  19. String word = s.substring(right, right + wordLen);
  20. right += wordLen;
  21. if (map.containsKey(word)) {
  22. int num = tmp.getOrDefault(word, 0) + 1;
  23. tmp.put(word, num);
  24. hit++;
  25. // 出现情况三,遇到了符合的单词,但是次数超了
  26. if (map.get(word) < num) {
  27. // 从left开始移除单词,直到次数符合
  28. while (map.get(word) < tmp.get(word)) {
  29. String deleteWord = s.substring(left, left + wordLen);
  30. tmp.put(deleteWord, tmp.get(deleteWord) - 1);
  31. left += wordLen;
  32. hit--;
  33. }
  34. }
  35. } else {
  36. // 出现情况二,遇到了不匹配的单词,直接将 left 移动到该单词的后边
  37. tmp.clear();
  38. hit = 0;
  39. left = right;
  40. }
  41. if (hit == wordNum) {
  42. res.add(left);
  43. // 出现情况一,子串完全匹配,我们将上一个子串的第一个单词从tmp中移除,窗口后移wordLen
  44. String firstWord = s.substring(left, left + wordLen);
  45. tmp.put(firstWord, tmp.get(firstWord) - 1);
  46. hit--;
  47. left = left + wordLen;
  48. }
  49. }
  50. }
  51. return res;
  52. }
  • 时间复杂度:【30】串联所有单词的子串 - 图56,其中【30】串联所有单词的子串 - 图57为字符串【30】串联所有单词的子串 - 图58的长度。
  • 空间复杂度:【30】串联所有单词的子串 - 图59,我们需要申请两个哈希表用来存储子串和【30】串联所有单词的子串 - 图60中的单词。