滑动窗口算法精解:从最小覆盖子串到高效字符串匹配
1. 项目概述滑动窗口的“最小覆盖”实战如果你刷LeetCode时看到“子串”、“子数组”这类关键词脑子里第一个蹦出来的不是“滑动窗口”那你的算法武器库可能得更新一下了。今天要啃的这块硬骨头——LeetCode第76题“最小覆盖子串”可以说是滑动窗口技巧的“毕业考”。它不像简单的两数之和那样友好也不像动态规划那样有明确的套路公式它考验的是你如何优雅地驾驭两个指针在目标字符串这片“大海”里精准捞出包含特定字符集的那段“最短缆绳”。简单说题目给你两个字符串一个长的s源字符串一个短的t目标字符集。你的任务是在s里找到一个长度最小的子串使得这个子串包含了t中的所有字符包括重复次数。注意是“包含”意味着子串里对应字符的数量必须大于等于t中的数量。如果找不到返回空字符串。举个例子s ADOBECODEBANC,t ABC。肉眼扫一下包含A、B、C的最短子串是BANC长度4。ADOBEC虽然也包含但长度是6不是最短。CODEBA也包含长度是6。所以答案是BANC。这题为什么经典因为它完美诠释了滑动窗口的“伸缩”艺术。你不能用暴力法把所有子串都列出来检查那复杂度是O(n²)字符串一长就直接超时。滑动窗口可以将复杂度降到O(n)核心思想就是维护一个可动态调整的窗口[left, right)通过移动right来扩展窗口寻找可行解通过移动left来收缩窗口优化可行解在这个过程中不断更新和记录最优解。接下来我会带你从最朴素的思路开始一步步推导出最优的滑动窗口解法并附上逐行注释、可直接运行的C代码。我们不止要写出能AC的代码更要理解每一个变量、每一个判断背后的“为什么”。2. 核心思路拆解从暴力枚举到优雅滑动2.1 为什么暴力法行不通最直接的想法是枚举s中所有可能的子串起点i和终点ji j然后检查子串s[i..j]是否包含了t的所有字符。检查的方法可以是用哈希表统计t的字符频次然后遍历子串对应减少频次看最后是否所有频次都小于等于0。假设s长度为nt长度为m。枚举所有子串O(n²) 量级。检查每个子串最坏需要遍历子串长度也是O(n)量级。总时间复杂度O(n³)。当n为10⁴或10⁵时这个复杂度是完全无法接受的。我们需要一个更聪明的方法避免重复检查。2.2 滑动窗口的直觉建立想象一下你有一把可以伸缩的尺子窗口初始时左端left和右端right都指向s的开头窗口内没有字符。扩张右指针移动我们不断将right指针向右移动把新的字符纳入窗口。我们的目标是让窗口内的字符尽快满足条件包含所有t的字符。这就像一个渔夫撒网网窗口要足够大才能兜住所有想要的鱼目标字符。收缩左指针移动一旦当前窗口满足了条件我们就有了一个“候选解”。但当前窗口可能不是最短的因为窗口左边可能有一些多余的字符即使去掉它们窗口依然满足条件。这时我们就尝试将left指针向右移动缩小窗口试图得到一个更短的、依然满足条件的子串。这就像收网把网拉紧去掉多余的海草和海水只留下最核心的鱼。记录与更新在收缩窗口的过程中每当窗口依然满足条件时我们就记录下当前窗口的起始位置和长度如果比之前记录的最优解更短就更新最优解。循环当窗口因收缩而不再满足条件时我们再次回到步骤1向右移动right指针寻找下一个满足条件的窗口如此循环直到right到达s的末尾。这个过程保证了我们以O(n)的时间复杂度遍历了所有有可能成为最优解的窗口而不是所有子串。2.3 关键问题如何高效判断窗口是否“满足条件”这是本题的核心难点。我们不能每次移动指针后都重新遍历窗口来统计字符那又退化成O(n²)了。我们需要一种能在O(1)时间内更新和判断状态的方法。答案哈希表字符频次数组 一个计数器。需求表need用一个哈希表或长度128/256的数组因为ASCII字符有限记录t中每个字符还需要多少个。例如t AABC那么need[A] 2,need[B] 1,need[C] 1其他字符需求为0。窗口表window用另一个哈希表记录当前窗口内各个字符已经拥有了多少个。有效匹配计数器valid这个变量是灵魂。它记录当前窗口内已经满足t需求的字符种类数。注意是“种类”不是字符总数。什么叫“一个字符已满足需求”当window[c]的数量等于need[c]的数量时我们就说字符c的需求被满足了。初始时valid 0。当right指针右移字符c进入窗口时window[c]加1。如果加1后window[c]等于need[c]那么valid就加1。注意window[c]大于need[c]时valid不再增加因为需求早已满足多出来的部分是“冗余”。当left指针右移字符d移出窗口时window[d]减1。如果减1前window[d]等于need[d]那么valid就减1因为移出一个后该字符不再满足需求。判断条件当valid的值等于need中不同字符的种类数即need表中值大于0的键的数量时说明当前窗口已经包含了t的所有字符且每个字符的数量都至少达标此时窗口满足条件。这个机制的精妙之处在于我们通过valid这个单一变量在O(1)时间内就知道了窗口的整体匹配状态无需遍历两个哈希表进行比较。3. 算法步骤与代码实现详解理解了核心思路我们来看具体的算法步骤和C实现。我会先给出完整的代码框架然后逐一拆解。#include string #include unordered_map using namespace std; class Solution { public: string minWindow(string s, string t) { // 1. 初始化需求表和窗口表 unordered_mapchar, int need, window; for (char c : t) need[c]; // 2. 初始化双指针和关键变量 int left 0, right 0; int valid 0; // 满足需求的字符种类数 // 记录最小覆盖子串的起始索引和长度 int start 0, len INT_MAX; // 初始长度设为最大整数 // 3. 开始滑动窗口 while (right s.size()) { // c 是将移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 if (need.count(c)) { window[c]; if (window[c] need[c]) { valid; } } // 4. 判断左侧窗口是否要收缩 while (valid need.size()) { // 注意need.size()是不同字符的种类数 // 在这里更新最小覆盖子串 if (right - left len) { start left; len right - left; } // d 是将移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 if (need.count(d)) { // 注意必须先判断再减window[d] if (window[d] need[d]) { valid--; } window[d]--; } } } // 5. 返回结果 return len INT_MAX ? : s.substr(start, len); } };3.1 变量与数据结构初始化unordered_mapchar, int need, window; for (char c : t) need[c];needunordered_map键是字符值是t中该字符出现的次数也就是我们的“需求清单”。window同样是unordered_map用来实时统计当前窗口[left, right)内各个字符的出现次数。初始化need表遍历字符串t即可。int left 0, right 0; int valid 0; int start 0, len INT_MAX;left,right定义我们的滑动窗口为左闭右开区间[left, right)。这个定义非常方便因为right - left就是当前窗口的长度。valid有效匹配计数器初始为0。start,len用来记录我们找到的最优解最小覆盖子串的起始位置和长度。len初始化为INT_MAX需要#include climits这是一个常见的技巧方便后续用min函数更新。3.2 窗口扩张与数据更新右指针移动while (right s.size()) { char c s[right]; right; if (need.count(c)) { window[c]; if (window[c] need[c]) { valid; } } // ... 后续是收缩判断 }right指针不断右移直到字符串末尾。c s[right]是即将进入窗口的字符。因为区间是左闭右开right指向的是下一个待加入的位置所以先取值再right。关键更新逻辑只有当前字符c是t中需要的字符即need.count(c) 0我们才需要更新window表和valid计数器。window[c]该字符在窗口内的计数加1。如果加1后window[c]恰好等于need[c]说明这个字符的需求刚刚被满足因此valid加1。如果window[c]早就大于need[c]了这次增加只是让冗余更多valid不变。3.3 窗口收缩与最优解更新左指针移动while (valid need.size()) { // 更新最小覆盖子串 if (right - left len) { start left; len right - left; } // 左指针移动并更新数据 char d s[left]; left; if (need.count(d)) { if (window[d] need[d]) { valid--; } window[d]--; } }收缩条件while (valid need.size())。当有效匹配数等于需求表中不同字符的种类数时说明当前窗口是一个“可行解”我们可以尝试收缩它来寻找更优解。更新最优解在收缩之前当前窗口是满足条件的。我们比较当前窗口长度right - left与历史最小长度len如果更小则更新start和len。左指针移动与数据更新d s[left]是即将移出窗口的字符。同样先取值再left。只有移出的字符是目标字符时才需要更新。至关重要的顺序在减少window[d]之前先判断window[d]是否等于need[d]。如果是说明移出这个字符前该字符的需求是刚好满足的移出后就不再满足了因此valid需要减1。然后再执行window[d]--。这个顺序绝对不能颠倒否则逻辑就错了。3.4 循环结束与结果返回外层的while (right s.size())循环结束后意味着我们已经用滑动窗口扫描完了整个字符串s。return len INT_MAX ? : s.substr(start, len);如果len还是初始值INT_MAX说明我们从未找到过满足条件的窗口返回空字符串。否则利用string的substr方法从记录的start位置开始截取长度为len的子串返回。4. 复杂度分析与边界条件4.1 时间复杂度O(n)虽然代码里有嵌套的while循环但每个字符对应right指针和left指针都最多被访问两次一次进入窗口一次离开窗口。所有对哈希表的操作都是O(1)的。因此总时间复杂度是线性的O(n)其中n是字符串s的长度。4.2 空间复杂度O(C)这里C是字符集的大小。我们使用了两个哈希表need和window。在最坏情况下如果t包含所有种类的字符那么哈希表的大小就是字符集的大小。对于ASCII字符可以认为是O(1)或O(128)对于Unicode空间消耗会更大但题目通常保证输入在可控范围内。4.3 重要边界条件与测试用例s长度小于t长度直接返回。可以在函数开头加入判断if (s.size() t.size()) return ;虽然算法本身也能处理但提前判断更高效。t为空字符串根据题目定义空字符串是任何字符串的子串且是最短的。应该返回吗题目通常不会这样出但我们的算法中need.size()为0valid初始为0第一次进入内层while循环就会更新len为0最终返回空串逻辑是合理的。存在多个最短子串题目要求返回一个最小覆盖子串。我们的算法在right - left len时更新是严格小于所以当遇到另一个相同长度的子串时不会更新。最终返回的是最先找到的那个最短子串。如果需要返回起始索引最小的条件可以改为right - left len。字符大小写敏感题目明确是区分大小写的‘A‘和‘a‘是不同的字符。我们的哈希表能正确处理。包含非字母字符如数字、空格等。我们的算法使用unordered_mapchar, int可以存储任何char类型完全通用。实操心得在面试或竞赛中写完代码后主动用这几个边界案例测试一下能极大提升代码的健壮性和面试官的好感度。s “a“, t “aa“应该返回““因为s中只有一个‘a‘不够。s “a“, t “a“应该返回“a“。s “aa“, t “aa“应该返回“aa“。s “ADOBECODEBANC“, t “ABC“经典案例返回“BANC“。5. 滑动窗口的变体与相关题目掌握了“最小覆盖子串”的模板你可以轻松解决一大类滑动窗口问题。它们的主要区别在于窗口收缩的条件和更新答案的时机。5.1 固定窗口大小典型题目LeetCode 567. 字符串的排列、LeetCode 438. 找到字符串中所有字母异位词。特点寻找的窗口长度是固定的等于字符串t的长度。解法调整外层循环移动right扩张窗口当窗口长度达到固定大小时判断是否满足条件并记录答案然后同步移动left和right相当于窗口整体右滑保持窗口大小不变。判断条件依然是valid need.size()。5.2 最大无重复字符子串典型题目LeetCode 3. 无重复字符的最长子串。特点寻找一个不含重复字符的子串要求长度最大。解法调整need表不再需要因为目标不是匹配特定字符集。window表用来记录窗口内每个字符的出现次数。收缩条件当window[c] 1时说明当前加入的字符c导致了重复需要移动left指针直到window[c] 1。更新答案时机在每次右指针移动后即扩张后此时窗口保证无重复可以更新最大长度max_len max(max_len, right - left)。5.3 最多包含K个不同字符的最长子串典型题目LeetCode 340. 至多包含 K 个不同字符的最长子串。特点寻找一个最多包含K种不同字符的子串要求长度最大。解法调整需要一个变量diff_count来记录窗口内不同字符的种类数。当right指针移动加入新字符时如果该字符在窗口内原本计数为0则diff_count。收缩条件当diff_count K时需要移动left指针直到diff_count K。更新答案时机在收缩循环结束后即窗口重新满足条件后更新最大长度。5.4 通用滑动窗口框架从以上题目可以总结出一个滑动窗口的通用框架int left 0, right 0; while (right s.size()) { // 1. 将s[right]加入窗口 // 2. 更新窗口状态数据 (window, valid/count 等) right; // 3. 判断窗口是否需要收缩 while (window needs shrink) { // 4. 更新答案可能在收缩前也可能在收缩后取决于问题 // 5. 将s[left]移出窗口 // 6. 更新窗口状态数据 left; } // 或者在这里更新答案 }核心在于明确四点窗口内数据用什么结构维护哈希表、数组、计数器什么时候应该扩大窗口右指针移动什么时候应该收缩窗口左指针移动的条件应该在什么时候更新最终答案在收缩循环内、收缩循环外、还是两者皆可6. 常见陷阱与调试技巧即使理解了算法亲手实现时也可能掉进一些坑里。下面是我在刷题和教学中总结的几个高频问题。6.1 指针移动与数据更新的顺序这是最容易出错的地方务必牢记右指针移动时先获取s[right]再执行right。更新数据时判断的是移动前right指向的字符。左指针移动时先获取s[left]再执行left。更新数据时判断的是移动前left指向的字符。valid计数器的更新逻辑右移window[c]之后如果window[c] need[c]则valid。左移在window[d]--之前如果window[d] need[d]则valid--。顺序反了整个状态同步就会错乱。6.2 哈希表need的size()方法unordered_map::size()返回的是键值对的数量即不同键的个数。在我们的逻辑里它正好等于t中不同字符的种类数这正是valid需要达到的目标值。这一点很直观但需要理解清楚。6.3 初始化len为INT_MAX的替代方案有些人不喜欢用INT_MAX也可以初始化为s.size() 1因为最短覆盖子串的长度不可能超过s本身。最后判断时就用if (len s.size() 1)。两种方式都可以。6.4 使用数组替代哈希表进行优化对于明确字符范围如只有字母的题目使用定长数组比unordered_map更快因为避免了哈希函数的开销。例如如果字符串只包含大小写字母我们可以int need[128] {0}; // ASCII码范围 int window[128] {0}; for (char c : t) need[c]; int valid 0; int kind 0; // 需要单独计算need中大于0的元素个数 for (int i 0; i 128; i) if (need[i] 0) kind; // ... 后续逻辑中判断条件改为 while (valid kind)数组访问是O(1)常数时间更小在追求极致性能时可以考虑。6.5 调试输出技巧如果不确定算法哪里出了问题可以在循环中插入打印语句实时观察窗口和状态的变化。while (right s.size()) { // ... 右指针更新 cout “右移后: [“ left “, “ right “), window状态: “; // 打印window map cout “, valid“ valid endl; while (valid need.size()) { // ... 更新答案 cout “找到可行解: “ s.substr(left, right-left) endl; // ... 左指针更新 cout “左移后: [“ left “, “ right “), window状态: “; // 打印window map cout “, valid“ valid endl; } }通过观察valid的变化、窗口字符串的内容可以快速定位是扩张逻辑错误还是收缩逻辑错误。滑动窗口是一个“想通了就很简单没想通就死活调不对”的算法。它的核心在于维护一个合法的窗口状态并通过双指针的移动以线性时间扫描所有可能性。LeetCode 76题作为滑动窗口的经典难题吃透它你就掌握了解决一大类子串、子数组问题的利器。多练习几个变体题目你会发现很多看似复杂的题目都是在套用这个强大的框架。