LeetCode 👉 HOT 100 👉 最小覆盖子串 - 中等题

330 阅读4分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战」。

题目

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例1

输入:s = "ADOBECODEBANC", t = "ABC"

输出:"BANC"

示例2

输入:s = "a", t = "a"

输出:"a"

示例3

输入: s = "a", t = "aa"

输出: ""

解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

思路

首先,按照题目的要求,如果 len(t) > len(s) 那么结果就是 ""

对于 s = "ADOBECODEBANC", t = "ABC",由于现在思路不是很清晰,可以尝试寻找一些规律:

字符串 s 每个位置的字符对应的索引坐标如下:

    A D O B E C O D E B A  N  C

    0 1 2 3 4 5 6 7 8 9 10 11 12

那么对于 t = "ABC",每个字符在 t 中的位置可能如下:

    A: [0, 10]

    B: [3, 9]

    C: [5, 12]

那么涵盖 t 字符串的组合如下:

    A    B   C
    0    3   5
    0    3   12
    0    9   5
    0    9   12
    10   3   5
    10   3   12
    10   9   5
    10   9   12

突然就发现,这不就变成了一个排列组合的问题么,找出所有组合中,最大值与最小值差距最小的组合,即为题目所求,于是便有了下面的解法:

    /**
     * @param {string} s
     * @param {string} t
     * @return {string}
     */
    var minWindow = function (s, t) {

        let minAnsLength = Number.MAX_VALUE, minAns = '', sLen = s.length, tLen = t.length;

        if(tLen > sLen) return '';

        // 用于将 s 转换为 word: num 的形式
        let sMap = {};

        for(let i = 0; i < sLen; i++) {
            if(!sMap[s[i]]) {
                sMap[s[i]] = [];
            }
            sMap[s[i]].push(i);
        }

        // 取出 t 中需要的 key
        let vaildMap = {};
        for(let j = 0; j < tLen; j++) {

            // 如果 t 中有而 s 中没有,则 s 不可能涵盖 t
            if(!sMap[t[j]]) {
                return '';
            }

            vaildMap[t[j]] = sMap[t[j]];
        }

        // 存储所有的排列组合
        let vaildArr = []

        // 每次从map 取出 map[t.charAt(idx)], 是一个 Array
        // 大概意思就是,对于上面的 例子 `s = "ADOBECODEBANC", t = "ABC"`
        // 先选出 A,之后再选 B,再选 C,当 currentSet == tLen 说明选择完成,放入 vaildArr
        const getDfs = (currentSet, idx, map) => {
            // console.log('currentSet :', currentSet)
            if(currentSet.length == tLen) {
                vaildArr.push(currentSet);
                return;
            }

            // 取出 A 所有的可能
            let key = t.charAt(idx);
            let codeArr = [...map[key]];

            for(let i = 0; i < codeArr.length; i++) {
                
                let copyArr = [...map[key]]
                // 将当前已选择的 A 去除
                let newItem = copyArr.splice(i, 1)[0];
                staticMap[key] = copyArr;

                let newSet = [...currentSet, newItem];
                // console.log(`now is picker ${key}, choose ${newItem}`)

                // 将idx + 1,去选择下一个位置 B
                getDfs(newSet, idx + 1, staticMap)
            }

        }

        // 从 A 开始选择
        getDfs([], 0, vaildMap);

        // 在所有的排列组合里,寻找最小的那个窗口
        for(let i = 0; i < vaildArr.length; i++) {
            let currentAnsStart = Math.min(...vaildArr[i]);
            let currentAnsEnd = Math.max(...vaildArr[i]);
            if(currentAnsEnd - currentAnsStart < minAnsLength) {
                minAns = s.substring(currentAnsStart, currentAnsEnd + 1);
                minAnsLength = currentAnsEnd - currentAnsStart;
            }
        }


        return minAns;
    };

反思: 上述代码的时间复杂度很高,在提交 LeetCode 的时候,果不其然的超时了~;分析其原因,是在寻找所有排列组合的时候,假设 t 有五种元素,每种元素在 s 中都有两种可能,那么时间复杂度就是 O(2 ^ 5),每当元素的种类增加,或者元素可能的位置增加,时间复杂度呈指数级增加的关系,而且好像还不太好优化~

再思考: 其实可以用一个变化的窗口,来解决当前问题,步骤如下:

  • 1、初始化窗口的位置 left = 0, right = 0,定义 needs、windowMap 类型,用于分别存储 t、窗口 中每种元素的数量,定义 valid 为当前窗口已匹配 s 中种类的数量

  • 2、遍历 t ,初始化 needs

  • 3、当窗口的右边界 right < len(s)

    • 判断当前的 s[right] 是否在 needs

      • 存在则将窗口中 window 当前元素的数量 window[s[right]] + 1,同时接着判断当前种类的元素 s[right] 是否全部都在窗口中 window[s[right]] == needs[s[right]],若相等,则将 valid++
    • 如果这个时候 valid === Object.keys(needs),说明当前窗口涵盖了 t

      • 计算当前窗口的大小和之前窗口的大小,取较小的

      • 尝试将窗口的左边界右移,减小窗口的长度,过程中需要判断右移之后,当前窗口是否还仍然涵盖 t,不满足后,需要将 valid--,用于退出 left 右移的循环

移动窗口解法

    
    /**
     * @param {string} s
     * @param {string} t
     * @return {string}
     */
    var minWindow = function(s, t) {
        let needs = {};
        let window = {};
        let left = 0, right = 0, len = Number.MAX_VALUE, start = 0;
        let valid = 0;

        // 初始化 needs
        for(let i of t) {
            needs[i] = (needs[i] || 0) + 1;
        }

        let keyLen = Object.keys(needs).length;

        // 循环
        while(right < s.length) {
            let c = s[right];
            // 将窗口右移
            right++;

            // 判断当前元素是否在needs中
            if(needs[c]) {
                // 将窗口中的元素标识也 + 1
                window[c] = (window[c] || 0) + 1;

                // 判断当前元素种类是否匹配完成
                if(window[c] == needs[c]) {
                    valid++;
                }
            }

            // 当前窗口涵盖了 t
            while(valid === keyLen) {

                // 保留最小窗口
                if(right - left < len) {
                    len = right - left;
                    start = left;
                }
                let d = s[left];
                // 尝试减小窗口
                left++;
                if(needs[d]) {

                    // 窗口不能完全涵盖 s
                    if(window[d] == needs[d]) {
                        valid--;
                    }
                    window[d]--
                }
            }

        }
        return len == Number.MAX_VALUE ? "" : s.substr(start, len);
    };

小结

递归还是需要慎用,一不小心便会 out of memory

移动可变窗口,对于涵盖问题,可能是一个不错的思路~

# LeetCode 👉 HOT 100 👉 最小覆盖子串 - 中等题

合集:LeetCode 👉 HOT 100,有空就会更新,大家多多支持,点个赞👍

如果大家有好的解法,或者发现本文理解不对的地方,欢迎留言评论 😄