1. 滑动窗口算法从暴力搜索到优雅秒杀的思维跃迁如果你已经刷过一些LeetCode对双指针的左右夹逼和快慢指针技巧有了感觉甚至见识过单调队列如何高效维护滑动窗口最值那么现在是时候把这几块拼图组合起来了。滑动窗口算法本质上是一种在特定约束下将暴力搜索的O(N²)复杂度优化到O(N)的“魔法”。它不是什么全新的数据结构而是一种基于双指针的、高度模式化的解题思想。尤其在处理子串、子数组问题时一旦你掌握了它的核心框架很多Hard题目都能迎刃而解解题速度会有质的飞跃。我最初接触滑动窗口时总觉得它有点“玄学”——指针移来移去什么时候该动左指针什么时候该动右指针条件判断怎么写经常搞得一头雾水。后来在实战中反复调试、总结才真正理解了其背后的统一逻辑。本文将带你绕过我走过的弯路直接深入核心。我们会通过三道经典题目最小覆盖子串、找到字符串中所有字母异位词、无重复字符的最长子串手把手拆解滑动窗口的每一步并最终抽象出一个可以应对大多数场景的通用框架。无论你是正在准备面试还是想提升算法内功这篇文章都将提供可直接“抄作业”的清晰路径。2. 核心思想与算法框架拆解2.1 滑动窗口究竟解决了什么问题在深入代码之前我们必须先搞清楚滑动窗口算法的适用场景和核心目标。它主要用来解决一类连续的区间问题通常是在一个数组或字符串上寻找一个满足特定条件的、连续的子区间。最典型的例子就是“请找出字符串S中包含字符串T所有字符的最短子串”。最直观的方法是暴力枚举列举所有可能的子串起点i和终点j然后检查子串S[i:j]是否满足条件。这需要两层循环时间复杂度是O(N²)如果检查子串还需要O(M)时间总复杂度会高达O(N² * M)在数据量稍大时完全不可接受。滑动窗口的精妙之处在于它通过维护一个窗口由左指针left和右指针right界定并让两个指针交替单向向右移动从而避免了大量的重复计算。其核心思想可以概括为扩大窗口右移right寻找一个可能的解可行解。收缩窗口右移left在找到可行解的基础上优化这个解寻找更优解比如更短的子串直到条件不再满足。循环往复重复1和2直到right到达序列末端。这个过程就像用一个可伸缩的窗口在序列上扫描窗口内的元素集合动态变化但指针永不回头确保了每个元素最多被left和right各访问一次从而将时间复杂度降到了线性的O(N)。2.2 通用算法框架与关键变量理解了思想我们可以先给出一个高度抽象的伪代码框架。这个框架是理解所有滑动窗口问题的基石int left 0, right 0; // 初始化双指针窗口为[left, right)左闭右开区间 // 通常使用一些数据结构来记录窗口状态和目标状态例如哈希表 unordered_mapchar, int window, need; while (right s.size()) { // c 是将移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 // ... (更新window计数器等) // 判断左侧窗口是否要收缩 while (window needs shrink) { // 这个条件是关键因题而异 // d 是将移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 // ... (更新window计数器等) } }几个至关重要的理解点窗口区间表示我强烈建议且后续代码将采用左闭右开区间[left, right)。这意味着left指向窗口的第一个元素。right指向窗口最后一个元素的下一个位置。初始时窗口[0, 0)为空没有任何元素。这种表示法的好处是窗口长度直接就是right - left初始长度为0逻辑非常清晰。核心数据结构need和window两个哈希表或数组是灵魂。need记录目标子串或目标条件中各个字符需要的数量。例如在“最小覆盖子串”问题中need记录t中每个字符的出现次数。window记录当前窗口中各个字符的实际数量。valid一个辅助变量用于记录当前窗口中满足need要求的字符种类数。这是高效判断“窗口是否满足条件”的关键避免了每次循环都遍历比较两个哈希表。收缩条件window needs shrink这是不同滑动窗口问题的主要区别所在。可能是“窗口已包含所有目标字符”也可能是“窗口内出现了重复字符”。这个条件的判断效率直接决定了算法的效率。注意网上很多教程的代码框架略有不同有的用左闭右闭区间有的用while循环包裹整个逻辑。我经过大量实践发现左闭右开配合上述框架在处理边界条件和长度计算时最为简洁不易出错。请务必先接受这个设定在后续例题中你会体会到它的便利。3. 实战精讲一最小覆盖子串LeetCode 76这是滑动窗口最经典、最复杂的题目堪称“母题”。彻底弄懂它其他问题都是变体。题目要求给你一个字符串s、一个字符串t请在s中找到涵盖t所有字符的最短子串。如果不存在返回空字符串。示例输入s “ADOBECODEBANC”, t “ABC” 输出“BANC” 解释最短的包含”A”、”B”、”C”的子串是”BANC”。3.1 思路逐步推导与变量定义我们按照框架来一步步思考定义need和window首先遍历目标字符串t用need哈希表统计每个字符出现的次数。对于t “ABC”need {‘A’:1, ‘B’:1, ‘C’:1}。window哈希表初始为空用于记录当前窗口中的字符计数。定义valid变量valid表示当前窗口中满足need数量要求的字符种类数。注意是“种类数”不是字符总数。如何算“满足”例如need[‘A’] 1只有当window[‘A’]的值大于等于1时字符’A’才算是达标了。如果need[‘A’] 2那么window[‘A’]必须2才算达标。算法过程模拟初始化left 0, right 0窗口为空valid 0。开始移动right扩大窗口。每次将一个字符c加入窗口就执行window[c]。加入后立即检查如果window[c]的值等于need[c]的值注意是等于不是大于等于说明字符c的数量刚刚好达到要求那么valid就可以加1。当valid的值等于need.size()即need中不同字符的种类数时说明当前窗口已经包含了t的所有字符且每个字符的数量都至少达到了要求。此时我们找到了一个“可行解”。找到可行解后开始尝试移动left收缩窗口目的是寻找更短的、同样满足条件的子串。在移动left前如果当前窗口长度比之前记录的最短长度更短就更新结果。收缩窗口时将字符d s[left]移出窗口执行window[d]–。移出后需要检查如果移出前window[d]的值等于need[d]的值说明移出这个字符后该字符的数量将不再满足要求因此valid需要减1。然后left右移。valid减1后条件valid need.size()不再成立于是退出内层while循环继续外层循环移动right寻找新的可行解。3.2 完整代码实现与逐行解析以下是基于上述思路的C实现。代码包含了详细的注释请结合上面的推导过程阅读class Solution { public: string minWindow(string s, string t) { // 哈希表记录目标字符及其所需数量 unordered_mapchar, int need, window; for (char c : t) need[c]; int left 0, right 0; // 窗口左闭右开 [left, right) int valid 0; // 窗口中满足need条件的字符种类数 // 记录最小覆盖子串的起始索引和长度 int start 0, len INT_MAX; while (right s.size()) { // c 是将移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 if (need.count(c)) { // 只关心出现在need中的字符 window[c]; // 当前字符的数量刚好达到要求则valid增加 if (window[c] need[c]) { valid; } } // 判断左侧窗口是否要收缩当前窗口已包含所有所需字符 while (valid need.size()) { // 更新最小覆盖子串。注意窗口是[left, right)长度为right-left if (right - left len) { start left; len right - left; } // d 是将移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 if (need.count(d)) { // 如果移出前该字符的数量刚好满足要求移出后就不满足了valid减少 if (window[d] need[d]) { valid--; } window[d]--; } } } // 返回结果 return len INT_MAX ? : s.substr(start, len); } };关键操作解析与避坑指南need.count(c)的作用我们只统计和更新出现在t即need中的字符。对于s中的其他字符我们完全忽略不放入window。这减少了不必要的存储和判断是常见的优化。valid更新的时机重中之重增加valid必须在window[c] need[c]时。如果是说明之前已经达标了valid不能重复增加。这个判断保证了valid准确反映了“刚好达标”的字符种类数。减少valid必须在window[d] need[d]时进行。因为窗口收缩时window[d]先减1再判断是否小于need[d]的逻辑是错的。必须先判断移出前的状态再执行减操作。结果更新在valid need.size()的循环内更新结果。此时窗口满足条件我们比较right - left当前窗口长度与历史最短长度len。注意我们获取子串用的是s.substr(start, len)。时间复杂度每个字符最多被right和left各访问一次所有哈希表操作都是O(1)因此总时间复杂度为O(N)其中N为字符串s的长度。实操心得很多朋友在这里容易混淆valid的判断条件写成window[c] need[c]。你可以这样记忆valid表示“刚好达到及格线的科目数”。一个科目分数从59变成60valid从60变成61valid不变从61变成60valid不变从60变成59valid–。这个“临界点”的判断是算法正确的核心。4. 实战精讲二找到字符串中所有字母异位词LeetCode 438掌握了最小覆盖子串这道题就几乎成了“送分题”。题目要求给定两个字符串s和p找到s中所有p的字母异位词即由相同字母重排列形成的子串的起始索引。示例输入s “cbaebabacd”, p “abc” 输出[0, 6] 解释 起始索引 0 的子串是 “cba”是 “abc” 的异位词。 起始索引 6 的子串是 “bac”是 “abc” 的异位词。4.1 问题转化与框架适配字母异位词的本质是什么就是子串的长度等于p的长度且子串中每个字符的出现次数与p中完全相同。这和我们上一题的条件非常像但有两个关键区别窗口长度固定目标子串的长度必须严格等于p.size()。匹配要求更严格不仅要求窗口包含p的所有字符还要求每个字符的数量恰好相等不能多也不能少上一题是可以多的。如何融入我们的框架need依然统计p中字符频率。window记录当前窗口字符频率。valid逻辑不变记录达标字符种类数。收缩条件的变化因为窗口长度固定我们不能再无限制地收缩窗口来找更短子串。那么什么时候收缩答案是当窗口长度大于p.size()时必须收缩以维持固定窗口大小。而判断一个固定窗口是否满足异位词条件就看valid是否等于need.size()。思路调整我们依然用right去扩大窗口。当窗口长度(right - left)等于p.size()时检查valid need.size()如果相等则当前窗口是一个异位词记录left。然后无论是否找到异位词我们都需要移动left来收缩窗口因为下一步right要右移窗口长度会超。这样就能保证窗口以固定大小向右滑动。4.2 代码实现与差异点剖析class Solution { public: vectorint findAnagrams(string s, string p) { unordered_mapchar, int need, window; for (char c : p) need[c]; int left 0, right 0; int valid 0; vectorint res; // 存储结果索引 while (right s.size()) { char c s[right]; right; // 更新窗口数据 if (need.count(c)) { window[c]; if (window[c] need[c]) { valid; } } // 判断左侧窗口是否要收缩当窗口大小大于p的长度时 // 注意这里是if不是while因为每次只需要收缩一次以维持固定窗口大小。 if (right - left p.size()) { // 当窗口大小等于p的长度且valid达标时记录答案 if (valid need.size()) { res.push_back(left); } // 移出窗口左边的字符为下一次右移right做准备 char d s[left]; left; if (need.count(d)) { if (window[d] need[d]) { valid--; } window[d]--; } } } return res; } };与上一题的核心差异收缩条件从while变为if这是最大的不同。因为我们要维持一个固定长度的窗口所以当窗口长度达到或超过p.size()时我们只需要收缩一次移动一次left使窗口长度恢复到p.size()以便下一次right移动。而最小覆盖子串是为了找最短的所以需要不断收缩直到条件不满足用while。结果记录的时机在收缩判断的内部先检查当前窗口是否满足条件valid need.size()如果满足则当前窗口的起始索引left就是一个答案。然后再执行收缩操作移动left并更新window和valid。窗口长度的判断使用if (right - left p.size())。当等于时检查并记录当大于时实际上只会大1先检查记录此时窗口是满足长度条件的再收缩。注意事项这里有一个非常细微但重要的点。我们是在收缩之前检查窗口条件。因为此时窗口[left, right)的长度正好是p.size()。收缩操作是为了让窗口为下一次right右移腾出空间。如果把检查放在收缩之后就错了。5. 实战精讲三无重复字符的最长子串LeetCode 3这是滑动窗口的另一类典型应用寻找满足某种内部约束的最长子区间。题目要求给定一个字符串s请你找出其中不含有重复字符的最长子串的长度。示例输入s “abcabcbb” 输出3 解释因为无重复字符的最长子串是 “abc”所以其长度为 3。5.1 问题转化与条件判断这次我们没有外部的目标字符串t了。约束来自于窗口内部窗口内的所有字符必须都是唯一的。那么need哈希表不需要了因为我们没有外部目标。window哈希表依然需要用来记录当前窗口内每个字符的出现次数。valid的概念需要转变。我们关心的是窗口内是否有重复字符这等价于是否存在某个字符其window值大于1。收缩条件当window中任何字符的计数大于1时说明窗口中出现了重复字符此时需要收缩窗口移动left直到这个重复字符的计数降回1。结果更新时机我们需要的是最长子串。那么在什么时候更新结果答案是在窗口内没有重复字符时即收缩条件不满足时。更具体地说在每次right右移、更新window后如果此时窗口内没有重复即所有window值均1我们就可以尝试更新最大长度。注意因为我们要找最长的所以应该在可能变长的时候更新即right移动后。5.2 代码实现与另一种视角class Solution { public: int lengthOfLongestSubstring(string s) { unordered_mapchar, int window; // 只记录窗口内字符频次 int left 0, right 0; int res 0; // 记录最长长度 while (right s.size()) { char c s[right]; right; // 更新窗口数据 window[c]; // 判断左侧窗口是否要收缩如果当前加入的字符c导致其重复 while (window[c] 1) { char d s[left]; left; // 进行窗口内数据更新 window[d]--; } // 在收缩完成后此时窗口内一定没有重复字符更新答案 // 窗口是[left, right)长度为 right - left res max(res, right - left); } return res; } };代码精讲简化了的window这里window只服务于当前窗口用来检测重复。window[c]后如果window[c]变为2说明字符c重复了。收缩条件while (window[c] 1)注意这里重复的特定字符就是刚刚加入的c。我们不断移动left将字符移出窗口直到window[c]的值降回1。这意味着我们移出了之前出现的那个c从而保证了窗口内c唯一。结果更新位置在内层while循环结束之后更新res。为什么因为内层循环保证了当退出时窗口[left, right)内没有任何重复字符。此时窗口的长度right - left就是一个候选的“无重复字符子串长度”。我们取历史最大值。时间复杂度依然是O(N)。虽然有一个嵌套的while循环但每个字符最多被left和right各访问一次left和right都在单调向右移动。一个更高效的技巧使用字符索引映射对于这道题还有一种更巧妙的解法可以进一步优化。我们不仅记录字符是否出现还记录它最后一次出现的索引位置。int lengthOfLongestSubstring(string s) { vectorint lastIndex(128, -1); // ASCII字符集初始值为-1 int left 0, res 0; for (int right 0; right s.size(); right) { char c s[right]; // 如果当前字符上次出现的位置在left右侧即在当前窗口内则需要快速移动left if (lastIndex[c] left) { left lastIndex[c] 1; // 直接跳到重复字符的下一个位置 } lastIndex[c] right; // 更新当前字符的最新索引 res max(res, right - left 1); } return res; }这种方法将内层的while循环优化成了if判断通过直接跳转left指针常数时间更优。它体现了滑动窗口思想的另一种变体根据历史信息直接计算出需要收缩到的位置。理解基础解法后可以尝试掌握这种优化。6. 滑动窗口问题通用框架与心法总结通过以上三道题我们可以提炼出滑动窗口算法的通用框架和解题心法。6.1 标准化代码框架/* 滑动窗口算法通用框架 */ void slidingWindow(string s) { // 根据需要初始化需要的数据结构need, window等 unordered_mapchar, int need, window; // 初始化 need (如果问题有目标字符串的话) int left 0, right 0; // 窗口左闭右开 [left, right) int valid 0; // 或其他用于判断窗口状态的变量 while (right s.size()) { // c 是将要移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 // ... (更新window更新valid等状态) // *** 判断左侧窗口是否要收缩 *** while (window needs shrink) { // 收缩条件因题而异 // 更新答案可能在收缩前也可能在收缩后因题而异 // ... (例如更新最短长度/记录起始索引等) // d 是将要移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 // ... (更新window更新valid等状态) } // *** 更新答案也可能在这里当窗口满足条件时*** // ... (例如更新最长长度) } // 返回最终结果 }6.2 解题四步心法面对一道新的滑动窗口问题可以按以下步骤思考定义状态明确need和window分别记录什么。need通常记录“目标”状态如目标字符计数window记录“当前窗口”状态。如果没有外部目标如第三题window就是用来维护窗口内部约束的。确定valid或等价判断想清楚如何高效判断“当前窗口是否满足题目要求”。通常需要一个valid变量或像第三题那样直接检查window中某个特定值。这是算法的核心逻辑。明确收缩条件回答“什么时候应该开始收缩窗口移动left” 常见条件有窗口已满足要求需要优化如找最短用while。窗口违反了约束条件如出现重复用while。窗口大小超过了限定值如固定长度用if。确定答案更新时机回答“在哪里更新最终答案res”找最短通常在收缩窗口之前即刚满足条件时因为收缩是为了找更短的。找最长通常在收缩窗口之后即重新满足条件时或者在外层循环更新如第三题因为我们要记录的是稳定满足条件的最大窗口。找固定长度在窗口长度达到要求且满足条件时如第二题在收缩前判断。6.3 常见陷阱与调试技巧指针移动与更新顺序务必注意指针移动和哈希表更新的顺序。通常是“右指针移动 - 更新数据 - 判断收缩 - (更新答案) - 左指针移动 - 更新数据”。顺序错乱会导致状态不一致。区间表示与长度计算坚持使用左闭右开[left, right)。窗口长度永远是right - left。子串提取用s.substr(start, length)。valid的更新逻辑这是最容易出错的地方。牢记“等于时加等于时减”的原则。可以画一个状态转换图来帮助理解window[c]从need[c]-1增加到need[c]时valid从need[c]减少到need[c]-1时valid–。收缩条件用while还是if这取决于收缩的目的。如果是为了尽可能的优化如找最短用while循环收缩到不满足条件为止。如果是为了维持某个窗口属性如固定长度用if只收缩一次。调试方法对于复杂问题不要只看代码。在纸上或使用调试器用小规模示例如s“ADOBECODEBANC”, t“ABC”一步步模拟left、right、window、valid和res的变化。这是理解算法最有效的方式。7. 举一反三滑动窗口的扩展应用滑动窗口的思想绝不局限于字符串。任何涉及连续子数组且满足某种单调性扩大窗口使某一量增加缩小窗口使其减少的问题都可以尝试套用。例如最大子数组和LeetCode 53这题通常用动态规划或贪心但也可以用滑动窗口思想理解当窗口和为负时它不可能成为最大和前缀直接重置窗口。长度最小的子数组LeetCode 209给定一个正整数数组和一个正整数target找出该数组中满足其和 ≥target的长度最小的连续子数组。这几乎是模板题need变成了目标和window记录当前和。水果成篮LeetCode 904本质是求最多包含两种不同字符的最长子串。window记录水果类型计数收缩条件是window中键的数量大于2。替换后的最长重复字符LeetCode 424窗口内出现次数最多的字符加上可替换次数k需要大于等于窗口长度时窗口可以扩张否则收缩。这里需要实时维护窗口内最大字符频次。掌握框架后这些问题的难点就转化为如何定义window和valid以及如何设置收缩条件。多练习就能培养出快速识别和建模滑动窗口问题的能力。滑动窗口算法之所以强大在于它将看似复杂的O(N²)搜索问题转化为了指针单向扫描的O(N)问题。其核心在于维护窗口内的状态并利用状态的单调变化避免重复计算。理解并熟练运用need、window、valid这三个核心变量以及扩大-判断-收缩-更新的循环节奏你就能解决LeetCode上一大批中高难度的子串、子数组问题。
滑动窗口算法详解:从暴力搜索到O(N)优化的核心框架与实战
发布时间:2026/5/20 4:06:31
1. 滑动窗口算法从暴力搜索到优雅秒杀的思维跃迁如果你已经刷过一些LeetCode对双指针的左右夹逼和快慢指针技巧有了感觉甚至见识过单调队列如何高效维护滑动窗口最值那么现在是时候把这几块拼图组合起来了。滑动窗口算法本质上是一种在特定约束下将暴力搜索的O(N²)复杂度优化到O(N)的“魔法”。它不是什么全新的数据结构而是一种基于双指针的、高度模式化的解题思想。尤其在处理子串、子数组问题时一旦你掌握了它的核心框架很多Hard题目都能迎刃而解解题速度会有质的飞跃。我最初接触滑动窗口时总觉得它有点“玄学”——指针移来移去什么时候该动左指针什么时候该动右指针条件判断怎么写经常搞得一头雾水。后来在实战中反复调试、总结才真正理解了其背后的统一逻辑。本文将带你绕过我走过的弯路直接深入核心。我们会通过三道经典题目最小覆盖子串、找到字符串中所有字母异位词、无重复字符的最长子串手把手拆解滑动窗口的每一步并最终抽象出一个可以应对大多数场景的通用框架。无论你是正在准备面试还是想提升算法内功这篇文章都将提供可直接“抄作业”的清晰路径。2. 核心思想与算法框架拆解2.1 滑动窗口究竟解决了什么问题在深入代码之前我们必须先搞清楚滑动窗口算法的适用场景和核心目标。它主要用来解决一类连续的区间问题通常是在一个数组或字符串上寻找一个满足特定条件的、连续的子区间。最典型的例子就是“请找出字符串S中包含字符串T所有字符的最短子串”。最直观的方法是暴力枚举列举所有可能的子串起点i和终点j然后检查子串S[i:j]是否满足条件。这需要两层循环时间复杂度是O(N²)如果检查子串还需要O(M)时间总复杂度会高达O(N² * M)在数据量稍大时完全不可接受。滑动窗口的精妙之处在于它通过维护一个窗口由左指针left和右指针right界定并让两个指针交替单向向右移动从而避免了大量的重复计算。其核心思想可以概括为扩大窗口右移right寻找一个可能的解可行解。收缩窗口右移left在找到可行解的基础上优化这个解寻找更优解比如更短的子串直到条件不再满足。循环往复重复1和2直到right到达序列末端。这个过程就像用一个可伸缩的窗口在序列上扫描窗口内的元素集合动态变化但指针永不回头确保了每个元素最多被left和right各访问一次从而将时间复杂度降到了线性的O(N)。2.2 通用算法框架与关键变量理解了思想我们可以先给出一个高度抽象的伪代码框架。这个框架是理解所有滑动窗口问题的基石int left 0, right 0; // 初始化双指针窗口为[left, right)左闭右开区间 // 通常使用一些数据结构来记录窗口状态和目标状态例如哈希表 unordered_mapchar, int window, need; while (right s.size()) { // c 是将移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 // ... (更新window计数器等) // 判断左侧窗口是否要收缩 while (window needs shrink) { // 这个条件是关键因题而异 // d 是将移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 // ... (更新window计数器等) } }几个至关重要的理解点窗口区间表示我强烈建议且后续代码将采用左闭右开区间[left, right)。这意味着left指向窗口的第一个元素。right指向窗口最后一个元素的下一个位置。初始时窗口[0, 0)为空没有任何元素。这种表示法的好处是窗口长度直接就是right - left初始长度为0逻辑非常清晰。核心数据结构need和window两个哈希表或数组是灵魂。need记录目标子串或目标条件中各个字符需要的数量。例如在“最小覆盖子串”问题中need记录t中每个字符的出现次数。window记录当前窗口中各个字符的实际数量。valid一个辅助变量用于记录当前窗口中满足need要求的字符种类数。这是高效判断“窗口是否满足条件”的关键避免了每次循环都遍历比较两个哈希表。收缩条件window needs shrink这是不同滑动窗口问题的主要区别所在。可能是“窗口已包含所有目标字符”也可能是“窗口内出现了重复字符”。这个条件的判断效率直接决定了算法的效率。注意网上很多教程的代码框架略有不同有的用左闭右闭区间有的用while循环包裹整个逻辑。我经过大量实践发现左闭右开配合上述框架在处理边界条件和长度计算时最为简洁不易出错。请务必先接受这个设定在后续例题中你会体会到它的便利。3. 实战精讲一最小覆盖子串LeetCode 76这是滑动窗口最经典、最复杂的题目堪称“母题”。彻底弄懂它其他问题都是变体。题目要求给你一个字符串s、一个字符串t请在s中找到涵盖t所有字符的最短子串。如果不存在返回空字符串。示例输入s “ADOBECODEBANC”, t “ABC” 输出“BANC” 解释最短的包含”A”、”B”、”C”的子串是”BANC”。3.1 思路逐步推导与变量定义我们按照框架来一步步思考定义need和window首先遍历目标字符串t用need哈希表统计每个字符出现的次数。对于t “ABC”need {‘A’:1, ‘B’:1, ‘C’:1}。window哈希表初始为空用于记录当前窗口中的字符计数。定义valid变量valid表示当前窗口中满足need数量要求的字符种类数。注意是“种类数”不是字符总数。如何算“满足”例如need[‘A’] 1只有当window[‘A’]的值大于等于1时字符’A’才算是达标了。如果need[‘A’] 2那么window[‘A’]必须2才算达标。算法过程模拟初始化left 0, right 0窗口为空valid 0。开始移动right扩大窗口。每次将一个字符c加入窗口就执行window[c]。加入后立即检查如果window[c]的值等于need[c]的值注意是等于不是大于等于说明字符c的数量刚刚好达到要求那么valid就可以加1。当valid的值等于need.size()即need中不同字符的种类数时说明当前窗口已经包含了t的所有字符且每个字符的数量都至少达到了要求。此时我们找到了一个“可行解”。找到可行解后开始尝试移动left收缩窗口目的是寻找更短的、同样满足条件的子串。在移动left前如果当前窗口长度比之前记录的最短长度更短就更新结果。收缩窗口时将字符d s[left]移出窗口执行window[d]–。移出后需要检查如果移出前window[d]的值等于need[d]的值说明移出这个字符后该字符的数量将不再满足要求因此valid需要减1。然后left右移。valid减1后条件valid need.size()不再成立于是退出内层while循环继续外层循环移动right寻找新的可行解。3.2 完整代码实现与逐行解析以下是基于上述思路的C实现。代码包含了详细的注释请结合上面的推导过程阅读class Solution { public: string minWindow(string s, string t) { // 哈希表记录目标字符及其所需数量 unordered_mapchar, int need, window; for (char c : t) need[c]; int left 0, right 0; // 窗口左闭右开 [left, right) int valid 0; // 窗口中满足need条件的字符种类数 // 记录最小覆盖子串的起始索引和长度 int start 0, len INT_MAX; while (right s.size()) { // c 是将移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 if (need.count(c)) { // 只关心出现在need中的字符 window[c]; // 当前字符的数量刚好达到要求则valid增加 if (window[c] need[c]) { valid; } } // 判断左侧窗口是否要收缩当前窗口已包含所有所需字符 while (valid need.size()) { // 更新最小覆盖子串。注意窗口是[left, right)长度为right-left if (right - left len) { start left; len right - left; } // d 是将移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 if (need.count(d)) { // 如果移出前该字符的数量刚好满足要求移出后就不满足了valid减少 if (window[d] need[d]) { valid--; } window[d]--; } } } // 返回结果 return len INT_MAX ? : s.substr(start, len); } };关键操作解析与避坑指南need.count(c)的作用我们只统计和更新出现在t即need中的字符。对于s中的其他字符我们完全忽略不放入window。这减少了不必要的存储和判断是常见的优化。valid更新的时机重中之重增加valid必须在window[c] need[c]时。如果是说明之前已经达标了valid不能重复增加。这个判断保证了valid准确反映了“刚好达标”的字符种类数。减少valid必须在window[d] need[d]时进行。因为窗口收缩时window[d]先减1再判断是否小于need[d]的逻辑是错的。必须先判断移出前的状态再执行减操作。结果更新在valid need.size()的循环内更新结果。此时窗口满足条件我们比较right - left当前窗口长度与历史最短长度len。注意我们获取子串用的是s.substr(start, len)。时间复杂度每个字符最多被right和left各访问一次所有哈希表操作都是O(1)因此总时间复杂度为O(N)其中N为字符串s的长度。实操心得很多朋友在这里容易混淆valid的判断条件写成window[c] need[c]。你可以这样记忆valid表示“刚好达到及格线的科目数”。一个科目分数从59变成60valid从60变成61valid不变从61变成60valid不变从60变成59valid–。这个“临界点”的判断是算法正确的核心。4. 实战精讲二找到字符串中所有字母异位词LeetCode 438掌握了最小覆盖子串这道题就几乎成了“送分题”。题目要求给定两个字符串s和p找到s中所有p的字母异位词即由相同字母重排列形成的子串的起始索引。示例输入s “cbaebabacd”, p “abc” 输出[0, 6] 解释 起始索引 0 的子串是 “cba”是 “abc” 的异位词。 起始索引 6 的子串是 “bac”是 “abc” 的异位词。4.1 问题转化与框架适配字母异位词的本质是什么就是子串的长度等于p的长度且子串中每个字符的出现次数与p中完全相同。这和我们上一题的条件非常像但有两个关键区别窗口长度固定目标子串的长度必须严格等于p.size()。匹配要求更严格不仅要求窗口包含p的所有字符还要求每个字符的数量恰好相等不能多也不能少上一题是可以多的。如何融入我们的框架need依然统计p中字符频率。window记录当前窗口字符频率。valid逻辑不变记录达标字符种类数。收缩条件的变化因为窗口长度固定我们不能再无限制地收缩窗口来找更短子串。那么什么时候收缩答案是当窗口长度大于p.size()时必须收缩以维持固定窗口大小。而判断一个固定窗口是否满足异位词条件就看valid是否等于need.size()。思路调整我们依然用right去扩大窗口。当窗口长度(right - left)等于p.size()时检查valid need.size()如果相等则当前窗口是一个异位词记录left。然后无论是否找到异位词我们都需要移动left来收缩窗口因为下一步right要右移窗口长度会超。这样就能保证窗口以固定大小向右滑动。4.2 代码实现与差异点剖析class Solution { public: vectorint findAnagrams(string s, string p) { unordered_mapchar, int need, window; for (char c : p) need[c]; int left 0, right 0; int valid 0; vectorint res; // 存储结果索引 while (right s.size()) { char c s[right]; right; // 更新窗口数据 if (need.count(c)) { window[c]; if (window[c] need[c]) { valid; } } // 判断左侧窗口是否要收缩当窗口大小大于p的长度时 // 注意这里是if不是while因为每次只需要收缩一次以维持固定窗口大小。 if (right - left p.size()) { // 当窗口大小等于p的长度且valid达标时记录答案 if (valid need.size()) { res.push_back(left); } // 移出窗口左边的字符为下一次右移right做准备 char d s[left]; left; if (need.count(d)) { if (window[d] need[d]) { valid--; } window[d]--; } } } return res; } };与上一题的核心差异收缩条件从while变为if这是最大的不同。因为我们要维持一个固定长度的窗口所以当窗口长度达到或超过p.size()时我们只需要收缩一次移动一次left使窗口长度恢复到p.size()以便下一次right移动。而最小覆盖子串是为了找最短的所以需要不断收缩直到条件不满足用while。结果记录的时机在收缩判断的内部先检查当前窗口是否满足条件valid need.size()如果满足则当前窗口的起始索引left就是一个答案。然后再执行收缩操作移动left并更新window和valid。窗口长度的判断使用if (right - left p.size())。当等于时检查并记录当大于时实际上只会大1先检查记录此时窗口是满足长度条件的再收缩。注意事项这里有一个非常细微但重要的点。我们是在收缩之前检查窗口条件。因为此时窗口[left, right)的长度正好是p.size()。收缩操作是为了让窗口为下一次right右移腾出空间。如果把检查放在收缩之后就错了。5. 实战精讲三无重复字符的最长子串LeetCode 3这是滑动窗口的另一类典型应用寻找满足某种内部约束的最长子区间。题目要求给定一个字符串s请你找出其中不含有重复字符的最长子串的长度。示例输入s “abcabcbb” 输出3 解释因为无重复字符的最长子串是 “abc”所以其长度为 3。5.1 问题转化与条件判断这次我们没有外部的目标字符串t了。约束来自于窗口内部窗口内的所有字符必须都是唯一的。那么need哈希表不需要了因为我们没有外部目标。window哈希表依然需要用来记录当前窗口内每个字符的出现次数。valid的概念需要转变。我们关心的是窗口内是否有重复字符这等价于是否存在某个字符其window值大于1。收缩条件当window中任何字符的计数大于1时说明窗口中出现了重复字符此时需要收缩窗口移动left直到这个重复字符的计数降回1。结果更新时机我们需要的是最长子串。那么在什么时候更新结果答案是在窗口内没有重复字符时即收缩条件不满足时。更具体地说在每次right右移、更新window后如果此时窗口内没有重复即所有window值均1我们就可以尝试更新最大长度。注意因为我们要找最长的所以应该在可能变长的时候更新即right移动后。5.2 代码实现与另一种视角class Solution { public: int lengthOfLongestSubstring(string s) { unordered_mapchar, int window; // 只记录窗口内字符频次 int left 0, right 0; int res 0; // 记录最长长度 while (right s.size()) { char c s[right]; right; // 更新窗口数据 window[c]; // 判断左侧窗口是否要收缩如果当前加入的字符c导致其重复 while (window[c] 1) { char d s[left]; left; // 进行窗口内数据更新 window[d]--; } // 在收缩完成后此时窗口内一定没有重复字符更新答案 // 窗口是[left, right)长度为 right - left res max(res, right - left); } return res; } };代码精讲简化了的window这里window只服务于当前窗口用来检测重复。window[c]后如果window[c]变为2说明字符c重复了。收缩条件while (window[c] 1)注意这里重复的特定字符就是刚刚加入的c。我们不断移动left将字符移出窗口直到window[c]的值降回1。这意味着我们移出了之前出现的那个c从而保证了窗口内c唯一。结果更新位置在内层while循环结束之后更新res。为什么因为内层循环保证了当退出时窗口[left, right)内没有任何重复字符。此时窗口的长度right - left就是一个候选的“无重复字符子串长度”。我们取历史最大值。时间复杂度依然是O(N)。虽然有一个嵌套的while循环但每个字符最多被left和right各访问一次left和right都在单调向右移动。一个更高效的技巧使用字符索引映射对于这道题还有一种更巧妙的解法可以进一步优化。我们不仅记录字符是否出现还记录它最后一次出现的索引位置。int lengthOfLongestSubstring(string s) { vectorint lastIndex(128, -1); // ASCII字符集初始值为-1 int left 0, res 0; for (int right 0; right s.size(); right) { char c s[right]; // 如果当前字符上次出现的位置在left右侧即在当前窗口内则需要快速移动left if (lastIndex[c] left) { left lastIndex[c] 1; // 直接跳到重复字符的下一个位置 } lastIndex[c] right; // 更新当前字符的最新索引 res max(res, right - left 1); } return res; }这种方法将内层的while循环优化成了if判断通过直接跳转left指针常数时间更优。它体现了滑动窗口思想的另一种变体根据历史信息直接计算出需要收缩到的位置。理解基础解法后可以尝试掌握这种优化。6. 滑动窗口问题通用框架与心法总结通过以上三道题我们可以提炼出滑动窗口算法的通用框架和解题心法。6.1 标准化代码框架/* 滑动窗口算法通用框架 */ void slidingWindow(string s) { // 根据需要初始化需要的数据结构need, window等 unordered_mapchar, int need, window; // 初始化 need (如果问题有目标字符串的话) int left 0, right 0; // 窗口左闭右开 [left, right) int valid 0; // 或其他用于判断窗口状态的变量 while (right s.size()) { // c 是将要移入窗口的字符 char c s[right]; // 右移窗口 right; // 进行窗口内数据的一系列更新 // ... (更新window更新valid等状态) // *** 判断左侧窗口是否要收缩 *** while (window needs shrink) { // 收缩条件因题而异 // 更新答案可能在收缩前也可能在收缩后因题而异 // ... (例如更新最短长度/记录起始索引等) // d 是将要移出窗口的字符 char d s[left]; // 左移窗口 left; // 进行窗口内数据的一系列更新 // ... (更新window更新valid等状态) } // *** 更新答案也可能在这里当窗口满足条件时*** // ... (例如更新最长长度) } // 返回最终结果 }6.2 解题四步心法面对一道新的滑动窗口问题可以按以下步骤思考定义状态明确need和window分别记录什么。need通常记录“目标”状态如目标字符计数window记录“当前窗口”状态。如果没有外部目标如第三题window就是用来维护窗口内部约束的。确定valid或等价判断想清楚如何高效判断“当前窗口是否满足题目要求”。通常需要一个valid变量或像第三题那样直接检查window中某个特定值。这是算法的核心逻辑。明确收缩条件回答“什么时候应该开始收缩窗口移动left” 常见条件有窗口已满足要求需要优化如找最短用while。窗口违反了约束条件如出现重复用while。窗口大小超过了限定值如固定长度用if。确定答案更新时机回答“在哪里更新最终答案res”找最短通常在收缩窗口之前即刚满足条件时因为收缩是为了找更短的。找最长通常在收缩窗口之后即重新满足条件时或者在外层循环更新如第三题因为我们要记录的是稳定满足条件的最大窗口。找固定长度在窗口长度达到要求且满足条件时如第二题在收缩前判断。6.3 常见陷阱与调试技巧指针移动与更新顺序务必注意指针移动和哈希表更新的顺序。通常是“右指针移动 - 更新数据 - 判断收缩 - (更新答案) - 左指针移动 - 更新数据”。顺序错乱会导致状态不一致。区间表示与长度计算坚持使用左闭右开[left, right)。窗口长度永远是right - left。子串提取用s.substr(start, length)。valid的更新逻辑这是最容易出错的地方。牢记“等于时加等于时减”的原则。可以画一个状态转换图来帮助理解window[c]从need[c]-1增加到need[c]时valid从need[c]减少到need[c]-1时valid–。收缩条件用while还是if这取决于收缩的目的。如果是为了尽可能的优化如找最短用while循环收缩到不满足条件为止。如果是为了维持某个窗口属性如固定长度用if只收缩一次。调试方法对于复杂问题不要只看代码。在纸上或使用调试器用小规模示例如s“ADOBECODEBANC”, t“ABC”一步步模拟left、right、window、valid和res的变化。这是理解算法最有效的方式。7. 举一反三滑动窗口的扩展应用滑动窗口的思想绝不局限于字符串。任何涉及连续子数组且满足某种单调性扩大窗口使某一量增加缩小窗口使其减少的问题都可以尝试套用。例如最大子数组和LeetCode 53这题通常用动态规划或贪心但也可以用滑动窗口思想理解当窗口和为负时它不可能成为最大和前缀直接重置窗口。长度最小的子数组LeetCode 209给定一个正整数数组和一个正整数target找出该数组中满足其和 ≥target的长度最小的连续子数组。这几乎是模板题need变成了目标和window记录当前和。水果成篮LeetCode 904本质是求最多包含两种不同字符的最长子串。window记录水果类型计数收缩条件是window中键的数量大于2。替换后的最长重复字符LeetCode 424窗口内出现次数最多的字符加上可替换次数k需要大于等于窗口长度时窗口可以扩张否则收缩。这里需要实时维护窗口内最大字符频次。掌握框架后这些问题的难点就转化为如何定义window和valid以及如何设置收缩条件。多练习就能培养出快速识别和建模滑动窗口问题的能力。滑动窗口算法之所以强大在于它将看似复杂的O(N²)搜索问题转化为了指针单向扫描的O(N)问题。其核心在于维护窗口内的状态并利用状态的单调变化避免重复计算。理解并熟练运用need、window、valid这三个核心变量以及扩大-判断-收缩-更新的循环节奏你就能解决LeetCode上一大批中高难度的子串、子数组问题。