最小覆盖子串

题目介绍

力扣76题:https://leetcode-cn.com/problems/minimum-window-substring/
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。

注意:如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 1:

输入:s = “ADOBECODEBANC”, t = “ABC”
输出:“BANC”

示列2:

输入:s = “a”, t = “a”
输出:“a”

进阶:你能设计一个在 o(n) 时间内解决此问题的算法吗?

分析

所谓“子串”,指的是字符串中连续的一部分字符集合。这就要求我们考察的所有字符,应该在同一个“窗口”内,这样的问题非常适合用滑动窗口的思路来解决。

而所谓的“最小子串”,当然就是符合要求的、长度最小的子串了。
另外还有一个小细节:需要找出“包含T所有字符的最小子串”,那T中的字符会不会有重复呢?给出的示例“ABC”没有重复,但提交代码之后会发现,测试用例是包含有重复字符的T的,比如“ABBC”在这种情况下,重复出现的字符“B”在子串中也要重复出现才可以。

方法一:暴力法

最简单直接的方法,就是直接枚举出当前字符串所有的子串,然后一一进行比对,选出覆盖T中所有字符的最小的那个。进一步我们发现,其实只需要枚举长度大于等于T的子串就可以了。

这里的核心问题是,怎样判断一个子串中包含了T中的所有字符?

如果T中没有重复,那这个非常简单,只要再遍历一遍T,依次检查每个字符是否包含就可以了;但现在T中字符可能重复,如果一个字符“A”重复出现3次,那我们寻找的子串中也必须有3个“A”。

所以我们发现,只要统计出T每个字符出现的次数,然后在子串中进行比对就可以。这可以用一个HashMap来进行存储,当然也可以更简单的只用一个数组来存。
在这里插入图片描述
子串S符合要求的条件是:统计T中每个字符出现的次数,全部小于等于在S中出现次数。
代码演示如下:

// 方法一:暴力法,枚举s中所有子串
public String minWindow1(String s, String t) {
    // 定义最小子串,保存结果,初始为空字符串
    String minSubString = "";

    // 定义一个HashMap,保存t中字符出现的频次
    HashMap<Character, Integer> tCharFrequency = new HashMap<>();

    // 统计t中字符频次
    for (int i = 0; i < t.length(); i++) {
        char c = t.charAt(i);
        int count = tCharFrequency.getOrDefault(c, 0);
        tCharFrequency.put(c, count + 1);
    }

    // 接下来在s中搜索覆盖子串
    // 遍历所有字符,作为当前子串的起始位置
    for (int i = 0; i < s.length(); i++) {
        // 遍历i之后不小于t长度的位置,作为子串的结束位置
        for (int j = i + t.length(); j <= s.length(); j++) {
            // 统计s子串中每个字符出现的频次
            // 定义一个HashMap,保存s子串中字符出现的频次
            HashMap<Character, Integer> subStrCharFrequency = new HashMap<>();

            // 统计子串中字符频次
            for (int k = i; k < j; k++) {
                char c = s.charAt(k);
                int count = subStrCharFrequency.getOrDefault(c, 0);
                subStrCharFrequency.put(c, count + 1);
            }

            // 如果当前子串符合覆盖子串的要求,并且比之前的最小子串要短,就替换
            if (check(tCharFrequency, subStrCharFrequency) && (minSubString.equals("") || j - i < minSubString.length())) {
                minSubString = s.substring(i, j);
            }

        }
    }
    return minSubString;
}
// 提炼一个方法,用于检查当前子串是否是一个覆盖t的子串
public boolean check(HashMap<Character, Integer> tFreq, HashMap<Character, Integer> subStrFreq) {
     // 遍历t中每个字符的频次,与subStr进行比较
     for (char c : tFreq.keySet()) {
         if (subStrFreq.getOrDefault(c, 0) < tFreq.get(c)) {
             return false;
         }
     }
     return true;
 }

复杂度分析

  • 时间复杂度:O(|s|^3), 事实上,应该写作O(|s|^3+|t|), 这里|s|表示字符串s的长度,|t|表示t的长度。我们枚举s所有的子串,之后又要对每一个子串统计字符频次,所以是三重循环,耗费O(|s|^3)。另外还需要遍历t统计字符频次,耗费O(|t|)。t的长度本身要小于s,而且本题的应用场景一般情况是关键字的全文搜索,t相当于关键字,长度应该远小于s,所以可以忽略不计。
  • 空间复杂度:O©,这里C表示字符集的大小。我们用到了HashMap来存储S和T的字符频次,而每张哈希表中存储的键值对不会超过字符集的大小。

方法二:滑动窗口

暴力法的缺点是显而易见的:时间复杂度过大,超出了运行时间限制。在哪些方面可以优化呢?

仔细观察可以发现,我们在暴力求解的时候,做了很多无用的比对:对于字符串“ADOBECODEBANC”,当找到一个符合条件的子串“ADOBEC”后,我们会继续仍以“A”作为起点扩展这个子串,得到一个符合条件的“ADOBECO”——它肯定符合条件,也肯定比之前的子串长,这其实是完全不必要的。

代码实现上,我们可以定义两个指针:指向子串“起始点”的左指针,和指向子串“结束点”的右指针。它们一个固定、另一个移动,彼此交替向右移动,就好像开了一个大小可变的窗口、在不停向右滑动一样,所以这就是非常经典的滑动窗口解决问题的应用场景。所以有时候,滑动窗口也可以归类到双指针法。

代码演示如下:

// 方法二:滑动窗口
public String minWindow2(String s, String t) {
    // 定义最小子串,保存结果,初始为空字符串
    String minSubString = "";

    // 定义一个HashMap,保存t中字符出现的频次
    HashMap<Character, Integer> tCharFrequency = new HashMap<>();

    // 统计t中字符频次
    for (int i = 0; i < t.length(); i++) {
        char c = t.charAt(i);
        int count = tCharFrequency.getOrDefault(c, 0);
        tCharFrequency.put(c, count + 1);
    }

    // 定义左右指针,指向滑动窗口的起始和结束位置
    int start = 0, end = t.length();
    while (end <= s.length()) {
        // 定义一个HashMap,保存s子串中字符出现的频次
        HashMap<Character, Integer> subStrCharFrequency = new HashMap<>();

        // 统计子串中字符频次
        for (int k = start; k < end; k++) {
            char c = s.charAt(k);
            int count = subStrCharFrequency.getOrDefault(c, 0);
            subStrCharFrequency.put(c, count + 1);
        }

        // 如果当前子串符合覆盖子串的要求,并且比之前的最小子串要短,就替换
        if (check(tCharFrequency, subStrCharFrequency)) {
            if (minSubString.equals("") || end - start < minSubString.length()) {
                minSubString = s.substring(start, end);
            }
            // 只要是覆盖子串,就移动初始位置,缩小窗口,寻找当前的局部最优解
            start++;
        } else {
            // 如果不是覆盖子串,需要扩大窗口,继续寻找新的子串
            end++;
        }
    }
    return minSubString;
}

// 提炼一个方法,用于检查当前子串是否是一个覆盖t的子串
 public boolean check(HashMap<Character, Integer> tFreq, HashMap<Character, Integer> subStrFreq) {
      // 遍历t中每个字符的频次,与subStr进行比较
      for (char c : tFreq.keySet()) {
          if (subStrFreq.getOrDefault(c, 0) < tFreq.get(c)) {
              return false;
          }
      }
      return true;
  }

复杂度分析

  • 时间复杂度:O(|s|^2), 尽管运用双指针遍历字符串,可以做到线性时间O(|s|),但内部仍需要循环遍历子串,总共消耗O(|s|)*O(|s|)=O(|s|^2)。
  • 空间复杂度:O©,这里C表示字符集的大小。同样用到了HashMap来存储S和T的字符频次,而每张哈希表中存储的键值对不会超过字符集的大小。

方法三:滑动窗口优化

这里考虑进一步优化:
我们计算子串S的字符频次时,每次都要遍历当前子串,这做了很多重复工作。

其实,每次都只是左指针或右指针做了一次右移,只涉及到一个字符的增减。我们不需要重新遍历子串,只要找到移动指针之前的S中,这个字符的频次,然后再加一或者减一就可以了。
具体应该分左指针右移和右指针右移两种情况讨论。

  • 左指针i右移(i –> i+1)。这时子串长度减小,减少的一个字符就是s[i],对应频次应该减一。
  • 右指针j右移(j -> j+1)。这时子串长度增加,增加的一个字符就是s[j],对应频次加1。

代码如下:

// 方法三:滑动窗口优化
public String minWindow3(String s, String t) {
    // 定义最小子串,保存结果,初始为空字符串
    String minSubString = "";

    // 定义一个HashMap,保存t中字符出现的频次
    HashMap<Character, Integer> tCharFrequency = new HashMap<>();

    // 统计t中字符频次
    for (int i = 0; i < t.length(); i++) {
        char c = t.charAt(i);
        int count = tCharFrequency.getOrDefault(c, 0);
        tCharFrequency.put(c, count + 1);
    }

    // 定义左右指针,指向滑动窗口的起始和结束位置
    int start = 0, end = 1;

    // 定义一个HashMap,保存s子串中字符出现的频次
    HashMap<Character, Integer> subStrCharFrequency = new HashMap<>();

    while (end <= s.length()) {

        // end增加之后,新增的字符
        char newChar = s.charAt(end - 1);

        // 新增字符频次加1
        //只需要保存t字符串中出现的字符,其余的字符不需要统计
        if (tCharFrequency.containsKey(newChar)) {
            subStrCharFrequency.put(newChar, subStrCharFrequency.getOrDefault(newChar, 0) + 1);
        }

        // 如果当前子串符合覆盖子串的要求,并且比之前的最小子串要短,就替换
        while (check(tCharFrequency, subStrCharFrequency) && start < end) {
            if (minSubString.equals("") || end - start < minSubString.length()) {
                minSubString = s.substring(start, end);
            }

            // 对要删除的字符,频次减1
            char removedChar = s.charAt(start);

            if (tCharFrequency.containsKey(removedChar)) {
                subStrCharFrequency.put(removedChar, subStrCharFrequency.getOrDefault(removedChar, 0) - 1);
            }

            // 只要是覆盖子串,就移动初始位置,缩小窗口,寻找当前的局部最优解
            start ++;
        }
        // 如果不是覆盖子串,需要扩大窗口,继续寻找新的子串
        end++;
    }
    return minSubString;
}

// 提炼一个方法,用于检查当前子串是否是一个覆盖t的子串
public boolean check(HashMap<Character, Integer> tFreq, HashMap<Character, Integer> subStrFreq) {
     // 遍历t中每个字符的频次,与subStr进行比较
     for (char c : tFreq.keySet()) {
         if (subStrFreq.getOrDefault(c, 0) < tFreq.get(c)) {
             return false;
         }
     }
     return true;
 }

复杂度分析

  • 时间复杂度:O(|s|)。尽管有双重循环,但我们可以发现,内外两重循环其实做的只是分别移动左右指针。最坏情况下左右指针对 s的每个元素各遍历一遍,哈希表中对 s 中的每个元素各插入、删除一次,对 t中的元素各插入一次。另外,每次调用check方法检查是否可行,会遍历整个 t 的哈希表。 哈希表的大小与字符集的大小有关,设字符集大小为C,则渐进时间复杂度为 O(C⋅∣s∣+∣t∣)。如果认为t的长度远小于s,可以近似为O(|s|)。 这种解法实现了线性时间运行。
  • 空间复杂度:O©,这里C表示字符集的大小。同样用到了HashMap来存储S和T的字符频次,而每张哈希表中存储的键值对不会超过字符集的大小。

方法四:滑动窗口进一步优化

我们判断S是否满足包含T中所有字符的时候,调用的方法check其实又是一个暴力法:遍历T中所有字符频次,一一比对。上面的复杂度分析也可以看出,遍历s只用了线性时间,但每次都要遍历一遍T的频次哈希表,这就耗费了大量时间。

我们已经知道,每次指针的移动,只涉及到一个字符的增减。所以我们其实不需要知道完整的频次HashMap,只要获取改变的这个字符的频次,然后再和T中的频次比较,就可以知道新子串是否符合要求了。

代码如下:

// 方法四:进一步优化
public String minWindow(String s, String t) {
    // 定义最小子串,保存结果,初始为空字符串
    String minSubString = "";

    // 定义一个HashMap,保存t中字符出现的频次
    HashMap<Character, Integer> tCharFrequency = new HashMap<>();

    // 统计t中字符频次
    for (int i = 0; i < t.length(); i++) {
        char c = t.charAt(i);
        int count = tCharFrequency.getOrDefault(c, 0);
        tCharFrequency.put(c, count + 1);
    }

    // 定义左右指针,指向滑动窗口的起始和结束位置
    int start = 0, end = 1;

    // 定义一个HashMap,保存s子串中字符出现的频次
    HashMap<Character, Integer> subStrCharFrequency = new HashMap<>();

    // 定义一个“子串贡献值”变量,统计t中的字符在子串中出现了多少
    int charCount = 0;

    while (end <= s.length()) {

        // end增加之后,新增的字符
        char newChar = s.charAt(end - 1);

        // 新增字符频次加1
        if (tCharFrequency.containsKey(newChar)) {
            subStrCharFrequency.put(newChar, subStrCharFrequency.getOrDefault(newChar, 0) + 1);
            // 如果子串中频次小于t中频次,当前字符就有贡献
            if (subStrCharFrequency.get(newChar) <= tCharFrequency.get(newChar))
                charCount ++;
        }

        // 如果当前子串符合覆盖子串的要求,并且比之前的最小子串要短,就替换
        while ( charCount == t.length() && start < end) {
            if (minSubString.equals("") || end - start < minSubString.length()) {
                minSubString = s.substring(start, end);
            }

            // 对要删除的字符,频次减1
            char removedChar = s.charAt(start);

            if (tCharFrequency.containsKey(removedChar)) {
                subStrCharFrequency.put(removedChar, subStrCharFrequency.getOrDefault(removedChar, 0) - 1);
                // 如果子串中的频次如果不够t中的频次,贡献值减少
                if (subStrCharFrequency.get(removedChar) < tCharFrequency.get(removedChar))
                    charCount --;
            }

            // 只要是覆盖子串,就移动初始位置,缩小窗口,寻找当前的局部最优解
            start ++;
        }
        // 如果不是覆盖子串,需要扩大窗口,继续寻找新的子串
        end ++;
    }
    return minSubString;
}

复杂度分析

  • 时间复杂度:O(|s|)。同样,内外双重循环只是移动左右指针遍历了两遍s;而且由于引入了charCount,对子串是否符合条件的判断可以在常数时间内完成,所以整体时间复杂度为O(|s|+|t|),近似为O(|s|)。
  • 空间复杂度:O©,这里C表示字符集的大小。同样用到了HashMap来存储S和T的字符频次,而每张哈希表中存储的键值对不会超过字符集的大小。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值