Leetcode 160 最小覆盖子串 | 串联所有单词的子串

1 题目

76. 最小覆盖子串

给定两个字符串 st,长度分别是 mn,返回 s 中的 最短窗口 子串 ,使得该子串包含 t 中的每一个字符(包括重复字符 )。如果没有这样的子串,返回空字符串""

测试用例保证答案唯一。

示例 1:

复制代码
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

示例 2:

复制代码
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。

示例 3:

复制代码
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

提示:

  • m == s.length
  • n == t.length
  • 1 <= m, n <= 105
  • st 由英文字母组成

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

2 代码实现

c++

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
           unordered_map<char,int> need ;
           unordered_map<char,int> window;

           for (char c :t ){
                need[c]++;
           }

           int left = 0 , right = 0 ;
           int valid = 0 ;
           int start = 0 ;
           int minLen = INT_MAX ;

           while (right < s.size()){
            char c = s[right];
            right ++;
            if (need.count(c)){
                window[c]++;
                if (window[c] == need[c]){
                    valid ++ ;
                }
            }

            while(valid == need.size()){
                if (right - left < minLen){
                    start = left ;
                    minLen = right - left ;
                }

                char d = s[left];
                left ++ ;

                if (need.count(d)){
                    if (window[d] == need[d]){
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        return minLen == INT_MAX ? "" : s.substr(start , minLen);
    }
};

js

javascript 复制代码
/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
var minWindow = function(s, t) {
    const need = new Map ();
    const window = new Map();

    for (const char of t ){
        need.set(char , (need.get(char) || 0 ) + 1 );
    }

    let left = 0 ,right = 0 ;
    let valid = 0 ;
    let start = 0 ;
    let minLen = Infinity ;

    while (right < s.length){
        const c = s[right];
        right ++;

        if (need.has(c)){
            window.set(c , (window.get(c) || 0 ) + 1 );
            if (window.get(c) === need.get(c)){
                valid ++ ;
            }
        }

        while(valid === need.size){
            if (right - left < minLen ){
                start = left ;
                minLen = right - left ;
            }

            const d = s[left];
            left ++;

            if (need.has(d)){
                if (window.get(d) === need.get(d)){
                    valid -- ;
                }
                window.set(d , window.get(d) - 1);
            }
        }
    }
    return minLen === Infinity ? "" : s.substring (start , start + minLen);
};

思考

啥啊,感觉好难,乱序的,hash map吗?

我现在看过了题解自己梳理一下啊。

看一下题目要求,s,t。t里面可能会有重复字符,先把t里面的有哈希表存储一遍,记一下需求,就比如a : 2 字母a需要2个,然后开始滑窗,如果没有达到t的需求,右指针移动。达到了,左指针收紧,维护一个minLen也就是最终我要返回字符串的位置。

题解

这道题标准解法是滑动窗口(双指针)+ 哈希表计数 ,时间复杂度 O(m + n),完美适配题目数据范围(1e5)。

核心思路(一定要看懂)

  1. 哈希表 统计字符串 t 中每个字符需要的数量(比如 t=ABC,需要 A:1, B:1, C:1)。
  2. 左右指针 维护一个滑动窗口 [left, right),右指针扩大窗口,左指针缩小窗口。
  3. 窗口内满足 t 所有字符需求时,尽量缩小左指针,更新最小窗口。
  4. 最终返回最短的有效窗口,没有则返回空串。

完整 C++ 代码

cpp 复制代码
#include <iostream>
#include <string>
#include <unordered_map>
#include <climits>  // 用于 INT_MAX
using namespace std;

class Solution {
public:
    string minWindow(string s, string t) {
        // need:记录 t 中每个字符【需要的数量】
        unordered_map<char, int> need;
        // window:记录当前窗口内每个字符【已有的数量】
        unordered_map<char, int> window;

        // 第一步:统计 t 中所有字符的需求
        for (char c : t) {
            need[c]++;
        }

        int left = 0, right = 0;  // 滑动窗口双指针 [left, right)
        int valid = 0;            // 窗口内【满足需求的字符种类数】
        int start = 0;            // 最小窗口起始下标
        int minLen = INT_MAX;     // 最小窗口长度(初始为无穷大)

        // 右指针遍历整个 s,扩大窗口
        while (right < s.size()) {
            char c = s[right];  // 即将加入窗口的字符
            right++;            // 右指针右移

            // ② 如果这个字符是 t 里需要的,更新窗口计数
            if (need.count(c)) {
                window[c]++;
                // 当窗口内该字符数量 = 需求数量,valid+1
                if (window[c] == need[c]) {
                    valid++;
                }
            }

            // ③ 当窗口满足所有字符需求时,开始【收缩左指针】,找更小窗口
            while (valid == need.size()) {
                // ✅ 更新最小窗口
                if (right - left < minLen) {
                    start = left;
                    minLen = right - left;
                }

                char d = s[left];  // 即将移出窗口的字符
                left++;            // 左指针右移

                // ④ 如果移出的是需要的字符,判断是否破坏 valid
                if (need.count(d)) {
                    if (window[d] == need[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }

        // ⑤ 没有找到有效窗口返回空串,否则返回截取的子串
        return minLen == INT_MAX ? "" : s.substr(start, minLen);
    }
};

// 测试代码
int main() {
    Solution sol;
    // 示例 1
    string s1 = "ADOBECODEBANC", t1 = "ABC";
    cout << sol.minWindow(s1, t1) << endl;  // 输出 BANC

    // 示例 2
    string s2 = "a", t2 = "a";
    cout << sol.minWindow(s2, t2) << endl;  // 输出 a

    // 示例 3
    string s3 = "a", t3 = "aa";
    cout << sol.minWindow(s3, t3) << endl;  // 输出空串
    return 0;
}

逐行关键逻辑讲解

1. 两个哈希表的作用

  • need:存 t每个字符需要多少个 例:t = "ABC"need: A→1, B→1, C→1
  • window:存当前窗口里每个字符有多少个

2. 滑动指针规则

  • 右指针 right :只管扩大窗口,把字符加进来
  • 左指针 left :只有窗口满足条件 时,才缩小窗口,尝试找更短的子串

3. 核心判断:valid

  • valid 表示窗口内已经满足数量要求的字符种类数
  • valid == need.size()窗口完全包含 t,可以开始缩左指针

4. 最小窗口更新

cpp 复制代码
if (right - left < minLen) {
    start = left;
    minLen = right - left;
}

每次缩窗口时,都检查当前窗口是不是更短,如果是就记录新的起点和长度。

5. 结果返回

  • minLen == INT_MAX → 没找到有效窗口,返回 ""
  • 否则返回 s.substr(start, minLen)(C++ 截取子串函数)

复杂度分析

  • 时间复杂度O(m + n)左右指针各遍历字符串一次,哈希表操作是 O(1)
  • 空间复杂度O(k)k 是字符集大小(最多 52 个大小写字母)

3 题目

30. 串联所有单词的子串

困难

相关标签

相关企业

给定一个字符串 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 的任何顺序排列的连接。
所以我们返回一个空数组。

示例 3:

复制代码
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。
子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。
子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。
子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。

提示:

  • 1 <= s.length <= 104
  • 1 <= words.length <= 5000
  • 1 <= words[i].length <= 30
  • words[i]s 由小写英文字母组成

4 代码实现

c++

cpp 复制代码
class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) {
        vector <int> res ; 
        if (s.empty() || words.empty()) return res ;

        int wordLen = words[0].size();
        int wordCnt = words.size();
        int totalLen = wordLen * wordCnt ;
        int sLen = s.size();

        unordered_map<string , int > need ;
        for (string& word : words){
            need[word] ++;
        }

        for(int i = 0 ; i< wordLen ; i++){
            int left = i ;
            int valid = 0 ;
            unordered_map<string , int > window ;

            for (int right = i  ; right + wordLen <= sLen ; right += wordLen){
                string curWord = s.substr(right , wordLen);

                if(need.count(curWord)){
                    window[curWord] ++;
                    if (window[curWord] == need[curWord]){
                        valid ++ ;
                    }
                }
                while (right - left + wordLen > totalLen){
                    string leftWord = s.substr(left , wordLen);
                    left += wordLen ;

                    if (need.count(leftWord)){
                        if(window[leftWord] == need[leftWord]){
                            valid -- ;
                        }
                        window[leftWord] -- ;
                    }
                }

                if(valid == need.size()){
                    res.push_back(left);
                }
            }
        }
        return res ;
    }
};

js

javascript 复制代码
function findSubstring(s, words) {
    const res = [];
    if (!s || words.length === 0) return res;

    const wordLen = words[0].length;     // 每个单词长度
    const wordCount = words.length;     // 单词总数
    const totalLen = wordLen * wordCount; // 目标子串必须这么长
    const sLen = s.length;

    // 统计 words 里每个单词需要出现几次
    const need = {};
    for (const w of words) {
        need[w] = (need[w] || 0) + 1;
    }

    // 关键:按单词长度分组,每组起始点 i = 0,1,...,wordLen-1
    for (let i = 0; i < wordLen; i++) {

        let left = i;
        let valid = 0;
        const window = {}; // 当前窗口内单词计数

        // 右指针每次跳一个单词长度
        for (let right = i; right + wordLen <= sLen; right += wordLen) {

            // 取出当前单词
            const cur = s.slice(right, right + wordLen);

            // 如果这个单词是需要的
            if (need[cur] !== undefined) {
                window[cur] = (window[cur] || 0) + 1;
                if (window[cur] === need[cur]) {
                    valid++; // 匹配成功一种单词
                }
            }

            // 窗口太长了,收缩左边
            while (right - left + wordLen > totalLen) {
                const leftWord = s.slice(left, left + wordLen);
                left += wordLen;

                if (need[leftWord] !== undefined) {
                    if (window[leftWord] === need[leftWord]) {
                        valid--;
                    }
                    window[leftWord]--;
                }
            }

            // 所有单词都匹配上了,记录起点
            if (valid === Object.keys(need).length) {
                res.push(left);
            }
        }
    }

    return res;
}

思考

这啥意思,不懂啊,之前都是可以直接统计出来的,但是这里需要啊,需要内部是有顺序的,因为原先的字符不能打乱,这咋办啊??

我看了题解,写一写我自己的理解啊,这个题目比较重要的特点是把单词作为移动依据,也就是boy 是可以的,oyb是不ok的。那么还有一个要点就是,每一个单词的长度都是一样的,man , boy , eat ,长度都是一样的。

做法是,取string,长度已知,要移动一端另一端就也了解了,写不下去了,bbbboyman这样有杂乱字符的呢,其实也是i = 0 , i = 1 ,一个一个字符走。s 里找一段长度 = totalLen 的子串,恰好包含 words 里所有单词(数量、种类都一样)要是监测到byo会怎么样?

但是整体找子串的时候,滑窗是按照单词长度走的。

先回答一下以上加粗的疑问:

  1. 字符串 s 是连续的,不能打乱! 比如 bbbboyman 里,必须按顺序切分bbo yba... 不能乱拼
  2. 为什么不能一个字符一个字符滑动? 可以滑,但会超时!而且切分单词会乱掉
  3. **如果滑到 byo、oym 这种不是单词的东西怎么办?**直接判定无效,跳过!
  4. 为什么要分 i=0、i=1、i=2 三组? 因为单词是固定长度 ,必须按固定间隔切分才是合法单词!

这道题 = 按固定长度切块 + 滑动窗口

单词长度 = 3那么字符串 s 只能切成下面 3 种切法没有第 4 种!

cpp 复制代码
切法1(i=0):0-2, 3-5, 6-8...
切法2(i=1):1-3, 4-6, 7-9...
切法3(i=2):2-4, 5-7, 8-10...

任何其他切法,切出来的都不是单词!直接无效!


我拿你说的 bbbarfoo 现场演示(一看就懂)

s = b b b a r f o o words = ["bar","foo"]单词长度 = 3

只能切成 3 种方式:

① 第 1 组:i=0(从第 0 个字符开始切)

切出来的块是:[bbb] [arr] [foo]bbb 不是单词 → 无效→ arr 不是单词 → 无效→ 整段都废了 ❌

② 第 2 组:i=1(从第 1 个字符开始切)

块:bba rfo......全都不是单词 ❌

③ 第 3 组:i=2(从第 2 个字符开始切)

块:bbarfoo切法:[b b a] → 无效[r f o] → 无效❌


真正正确的那个位置在哪里?

s = b b b a r f o o 正确开始位置是 3

它属于 i=0 组 切出来是:[bar] [foo]✅ 正好是两个单词!匹配成功!

如果切到 byo、oym、asd 这种不是单词的块,怎么办?

直接判定这个窗口无效,跳过,继续往后滑!

代码里就是这句:

cpp 复制代码
if (need.count(curWord)) {
   // 是单词才加入统计
}

不是单词?直接不理它!

你自己的理解已经90% 正确了,我帮你理顺:

  1. 所有单词长度一样 → 必须按这个长度切块
  2. s 不能打乱 → 只能按固定间隔切分成单词
  3. 一共只有 单词长度种切法
  4. 对每一种切法,滑动窗口检查是否正好包含所有单词
  5. 遇到不是单词的块 → 直接跳过

固定长度切单词,只分几组不混乱。 是单词就进窗口, 不是单词直接扔。 凑齐所有单词时, 记录起点就满分!

题解

核心思路(超级好懂)

  1. words 里所有单词长度相同 → 设为 wordLen
  2. 串联子串总长度固定totalLen = 单词个数 × 每个单词长度
  3. 本质 :在 s 里找一段长度 = totalLen 的子串,恰好包含 words 里所有单词(数量、种类都一样)
  4. 滑动窗口 :按单词块移动窗口,用哈希表计数匹配

完整 C++ 代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <unordered_map>
using namespace std;

class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) {
        vector<int> res;  // 存答案索引
        if (s.empty() || words.empty()) return res;

        int wordLen = words[0].size();    // 每个单词的长度(固定)
        int wordCnt = words.size();       // 单词总个数
        int totalLen = wordLen * wordCnt; // 串联子串的总长度
        int sLen = s.size();

        // 哈希表1:记录 words 里每个单词需要的数量
        unordered_map<string, int> need;
        for (string& word : words) {
            need[word]++;
        }

        // 技巧:窗口起点只能是 0、1、2 ... wordLen-1,否则会重复遍历
        for (int i = 0; i < wordLen; i++) {
            int left = i;  // 窗口左边界
            int valid = 0; // 匹配成功的单词数量
            unordered_map<string, int> window; // 哈希表2:当前窗口内的单词计数

            // 右边界按【单词长度】移动
            for (int right = i; right + wordLen <= sLen; right += wordLen) {
                // 取出当前单词
                string curWord = s.substr(right, wordLen);

                // 情况1:这个单词在需求里 → 加入窗口
                if (need.count(curWord)) {
                    window[curWord]++;
                    // 数量匹配 → valid+1
                    if (window[curWord] == need[curWord]) {
                        valid++;
                    }
                }

                // 当窗口长度超过总长度 → 收缩左边界
                while (right - left + wordLen > totalLen) {
                    // 左边要移出的单词
                    string leftWord = s.substr(left, wordLen);
                    left += wordLen; // 左指针移动一个单词

                    // 如果移出的是需要的单词
                    if (need.count(leftWord)) {
                        if (window[leftWord] == need[leftWord]) {
                            valid--;
                        }
                        window[leftWord]--;
                    }
                }

                // 所有单词都匹配成功 → 记录起点 left
                if (valid == need.size()) {
                    res.push_back(left);
                }
            }
        }

        return res;
    }
};

// 测试代码
int main() {
    Solution sol;
    string s1 = "barfoothefoobarman";
    vector<string> w1 = {"foo","bar"};
    vector<int> ans1 = sol.findSubstring(s1, w1);
    for(int x : ans1) cout << x << " ";  // 0 9
    return 0;
}

逐行精讲

1. 关键变量

  • wordLen:每个单词长度(固定)
  • wordCnt:words 里有多少个单词
  • totalLen答案子串必须是这个长度
  • need:记录 words 里每个单词需要多少个
  • window:记录当前窗口里有多少个单词
  • valid已经匹配成功的单词种类数

2. 最关键的外层循环

cpp 复制代码
for (int i = 0; i < wordLen; i++)
  • 因为单词是固定长度 ,窗口起点只能是 0、1、2 ... wordLen-1
  • 这样不会重复遍历,保证效率最高

3. 滑动窗口逻辑

  1. 右指针 :每次移动一个单词长度
  2. 加入当前单词到哈希表
  3. 窗口超长 时,左指针移动一个单词长度
  4. 匹配成功valid == need.size() → 记录左指针位置

4. 匹配成功条件

  • 窗口里每个单词的数量和 words 完全一样
  • 此时 valid 等于需要的单词种类数
  • 把窗口左起点加入答案

复杂度分析(最优解)

  • 时间复杂度O(s.length × wordLen)
  • 空间复杂度:O (words 总字符数)
  • 完全能过题目所有数据范围(1e4)

测试示例

cpp 复制代码
s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]

s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]

s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]

总结

这道题 = 最小覆盖子串的升级版

  • 上一题按字符滑动
  • 这一题按单词块滑动
  • 哈希表计数逻辑完全一样

5 小结

这两题我觉得我什么都不会。我一直在抄答案看题解。

最小覆盖子串字符 滑动

串联所有单词的子串单词块 滑动


模板

复制代码
1. 统计需求 need
2. 初始化 left, right, valid
3. 右指针一直往右走
4. 符合需求就更新 window 和 valid
5. 当 valid 满足 → 收缩左指针
6. 记录答案
相关推荐
得想办法娶到那个女人1 小时前
项目中 TypeScript 类型推导 极简实战总结
前端·javascript·typescript
Rabitebla1 小时前
【数据结构】动态顺序表实现详解:从原理到接口设计(面试视角)
c语言·开发语言·数据结构·c++·面试·职场和发展
狐璃同学1 小时前
数据结构(1)三要素
数据结构·算法
Beginner x_u2 小时前
前端八股整理(Vue 02)|组件通信、生命周期、v-if 与 v-show
前端·javascript·vue.js
列星随旋2 小时前
拓扑排序(Kahn算法)
算法
郝学胜-神的一滴2 小时前
Linux 高并发基石:epoll 核心原理 + LT/ET 触发模式深度剖析
linux·运维·服务器·开发语言·c++·网络协议
Hello!!!!!!2 小时前
C++基础(六)——数组与字符串
c++·算法
A_aspectJ2 小时前
Java开发的学习优势:稳定基石与多元可能并存的技术赛道
java·开发语言
qq_283720052 小时前
Python 模块精讲:collections —— 高级数据结构深度解析(defaultdict、Counter、deque)
java·开发语言