滑动窗口算法详解:从原理到C++实战,解决子数组与子串问题

📅 发布时间:2026/7/15 13:18:22 👁️ 浏览次数:
滑动窗口算法详解:从原理到C++实战,解决子数组与子串问题
1. 项目概述为什么“滑动窗口”是算法中的“移动窗帘”如果你写过C处理过数组或字符串大概率遇到过这样的问题在一个很长的序列里找一段连续的子序列它满足某种条件比如和最大、长度最小、或者包含所有指定字符。最直接的想法是什么暴力枚举。从第一个元素开始尝试所有可能的起点和终点两层循环一嵌套时间复杂度轻松上O(n²)。数据量小还好一旦序列长度上万程序就开始“思考人生”了。这时候“滑动窗口”算法就该登场了。我更喜欢把它比喻成“移动窗帘”。想象一下你家窗户上的那副双轨窗帘左边一片右边一片中间露出的玻璃就是你的“窗口”。你可以同时拉动两片窗帘让这个“窗口”在整面墙上平滑地移动观察窗外不同区域的风景。滑动窗口算法的精髓就在于此它用两个指针或者索引来标记这个窗口的左右边界通过有规律地、协同地移动这两个边界来遍历所有有意义的连续子区间从而高效地解决问题。这个算法的魅力在于它常常能将O(n²)的暴力解法优化到O(n)。它不是某种特定的数据结构而是一种技巧一种思想。在C的语境下实现它只需要基础的循环、指针或索引和简单的数据结构如数组、哈希表但带来的性能提升是巨大的。无论是面试刷题LeetCode上“滑动窗口”是一个重要的专题标签还是实际开发中处理数据流、日志分析、网络包检测这都是一个必须掌握的高频技能。接下来我会带你彻底拆解这个“移动窗帘”的机关。我们不仅要知道怎么拉动它更要明白在什么场景下、为什么要这样拉以及拉动时可能会卡住哪些地方。我会用C代码示例结合我调试这类代码时积累的“肌肉记忆”把滑动窗口的几种典型形态和避坑要点讲清楚。2. 滑动窗口的核心思想与两种基本形态滑动窗口算法之所以高效是因为它避免了重复计算。在暴力枚举中窗口的每次移动都意味着重新计算窗口内的所有元素。而滑动窗口通过维护一个动态的窗口在移动时只更新因边界变化而受影响的部分从而将计算量降下来。根据窗口大小是否固定滑动窗口主要分为两种基本形态。理解这两种形态是掌握所有变种问题的基础。2.1 固定大小的窗口像用卡尺测量这是最简单的一种。窗口的长度k是预先给定的、固定不变的。你的任务就是让这个长度为k的“卡尺”从序列的起始位置滑动到末尾在每一个位置进行计算或判断。核心操作流程初始化窗口将右指针right移动到k-1的位置此时左指针left在0窗口[left, right]包含了前k个元素。我们通常先计算这个初始窗口的状态比如元素和、最大值等。滑动窗口将左右指针同时向右移动一位。此时新窗口失去了原左指针指向的元素加入了原右指针下一个位置的元素。更新状态根据元素的“出窗”和“入窗”高效地更新窗口的状态而不是重新计算整个窗口。这是性能优化的关键。重复步骤2-3直到右指针到达序列末尾。一个经典例子计算大小为k的子数组的最大和。假设数组nums [1, 3, -1, -3, 5, 3, 6, 7]k 3。 暴力法需要计算 (n-k1) * k 次加法而滑动窗口只需要做 (n-k) 次加法和 (n-k) 次减法。#include vector #include iostream #include climits using namespace std; int maxSumFixedWindow(vectorint nums, int k) { if (nums.size() k) return -1; // 处理边界 int window_sum 0; // 1. 计算初始窗口和 for (int i 0; i k; i) { window_sum nums[i]; } int max_sum window_sum; // 2. 开始滑动 for (int i k; i nums.size(); i) { window_sum window_sum - nums[i - k] nums[i]; // 核心减旧加新 max_sum max(max_sum, window_sum); } return max_sum; } int main() { vectorint nums {1, 3, -1, -3, 5, 3, 6, 7}; int k 3; cout 最大和为: maxSumFixedWindow(nums, k) endl; // 输出 16 (子数组 [5,3,6]) return 0; }注意这里用索引i同时充当了右指针的角色。i-k就是左指针的位置。这种写法在固定窗口问题中非常简洁。2.2 可变大小的窗口像调节望远镜的焦距这种形态更常见也更有挑战性。窗口的大小不是固定的而是根据窗口内的内容是否满足某个条件动态调整。通常用于寻找“最短”、“最长”、“包含所有某类元素”的子数组/子字符串。核心操作流程以寻找最小满足条件的窗口为例初始化左右指针left,right都置于起始位置0窗口[left, right)初始为空或包含第一个元素。我们通常用一个哈希表或计数器来记录窗口内关键元素的状态。扩大窗口右指针移动向右移动right指针扩大窗口直到窗口内的状态首次满足题目要求。收缩窗口左指针移动当窗口满足条件后开始向右移动left指针尝试收缩窗口以找到更小的满足条件的窗口。每次移动左指针都要更新窗口状态。更新答案在每次窗口满足条件时步骤2之后和步骤3的每次迭代中都比较并更新最终答案如最小长度。循环重复步骤2-4直到right指针到达序列末尾。一个经典例子最小覆盖子串LeetCode 76。给你一个字符串s、一个字符串t。返回s中涵盖t所有字符的最小子串。#include string #include unordered_map using namespace std; string minWindow(string s, string t) { unordered_mapchar, int need, window; for (char c : t) need[c]; // 统计t中所需字符及其数量 int left 0, right 0; int valid 0; // 记录窗口中满足need条件的字符种类数 int start 0, len INT_MAX; // 记录最小子串的起始位置和长度 while (right s.size()) { // 1. 扩大窗口 char c s[right]; right; if (need.count(c)) { // 如果是目标字符 window[c]; if (window[c] need[c]) { valid; // 该字符数量已满足要求 } } // 2. 判断是否需要收缩窗口 while (valid need.size()) { // 当前窗口已覆盖所有t中字符 // 3. 更新答案 if (right - left len) { start left; len right - left; } // 4. 收缩窗口 char d s[left]; left; if (need.count(d)) { if (window[d] need[d]) { valid--; // 该字符即将不满足要求 } window[d]--; } } } return len INT_MAX ? : s.substr(start, len); }实操心得可变窗口的代码模板性很强。核心是right指针负责探索扩大窗口left指针负责优化收缩窗口。valid变量是关键它标志着窗口何时满足了整体条件从而触发收缩。记住这个“扩大-判断-收缩-更新”的循环节奏。3. 滑动窗口的四大关键问题与实战解析理解了基本形态我们来看看在实现滑动窗口时必然会遇到的几个关键问题。解决它们你的滑动窗口算法就算真正入门了。3.1 窗口状态如何高效维护窗口在滑动窗口内的元素集合和它们的统计信息和、最大值、字符频次等在动态变化。我们必须设计一个数据结构能够支持快速增加元素右指针移动、快速移除元素左指针移动、快速查询当前状态这三个操作。对于和、积等可逆运算像前面固定窗口求和的例子状态就是window_sum。更新公式是新和 旧和 - 出窗元素 入窗元素。这是最高效的O(1)更新。对于最大值、最小值这是难点。窗口滑动时最大值可能因为出窗而失效。此时需要借助单调队列。维护一个双端队列队头始终是当前窗口的最大值。入窗时从队尾移除所有比新元素小的值因为它们不可能再成为最大值了出窗时检查出窗元素是否是队头是则弹出。这样查询最大值是O(1)。对于字符/元素频次统计使用哈希表unordered_map是最佳选择。键是元素值是频次。右指针移动时map[c]左指针移动时map[d]--如果减到0可以考虑删除该键以节省空间。查询某个字符是否在窗口内、其数量是否达标都是平均O(1)。// 使用单调队列维护滑动窗口最大值 vectorint maxSlidingWindow(vectorint nums, int k) { dequeint dq; // 存储索引而非值便于判断出窗 vectorint res; for (int i 0; i nums.size(); i) { // 1. 维护队列单调性保证队头到队尾递减 while (!dq.empty() nums[dq.back()] nums[i]) { dq.pop_back(); } dq.push_back(i); // 2. 移除出窗的元素索引 if (dq.front() i - k) { dq.pop_front(); } // 3. 当窗口形成时记录答案 if (i k - 1) { res.push_back(nums[dq.front()]); } } return res; }3.2 窗口的滑动时机与条件判断什么时候移动右指针什么时候移动左指针这是算法的逻辑核心直接对应题目的要求。右指针移动的时机通常是“当窗口不满足条件时”。右指针的任务是探索寻找能让窗口满足条件的潜在元素。在可变窗口模板中right指针的移动放在一个主while循环里。左指针移动的时机通常是“当窗口满足条件后”。左指针的任务是优化在满足条件的前提下尝试剔除不必要的元素看是否能得到一个更优的解如更短的子串。这通常用一个内层的while循环来实现条件是窗口满足条件。条件判断的粒度需要仔细设计。像“最小覆盖子串”问题条件判断是valid need.size()表示所有字符种类都达标了。而像“无重复字符的最长子串”问题条件判断是window[s[right]] 1表示出现了重复字符此时就需要移动左指针直到重复消除。易错点条件判断的循环是while而不是if。因为收缩窗口可能是一个连续的过程。例如在“最小覆盖子串”中可能左指针需要连续移动多位才能让窗口再次不满足条件。3.3 边界条件与初始状态处理这是Bug的高发区务必小心。空输入或无效输入如果输入字符串或数组为空或者k大于数组长度函数应该有一个合理的返回值如空字符串、0、或特定错误值。窗口初始状态在可变窗口问题中初始时窗口为空状态如哈希表也为空。循环通常从right0开始将s[0]纳入窗口。要确保你的状态更新逻辑能正确处理第一个元素。指针越界while循环的条件通常是right n。在循环内部访问s[right]是安全的因为right是即将访问的位置可以先访问再right这种“左闭右开”的区间定义很常用。移动左指针时要确保left right。答案初始化记录最小长度的变量应初始化为一个极大值如INT_MAX记录最大长度的变量应初始化为0或一个极小值。3.4 复杂度的分析与证明滑动窗口算法为什么是O(n)我们需要说服自己尤其是在面试中。时间复杂度左右指针left和right都只从0移动到n-1每个元素最多被右指针访问一次入窗被左指针访问一次出窗。所有操作哈希表增删、单调队列维护都是O(1)或均摊O(1)。因此总时间复杂度是 O(2n) O(n)。空间复杂度主要取决于用于维护窗口状态的数据结构。哈希表在最坏情况下可能需要存储所有不同字符空间复杂度O(|Σ|)其中Σ是字符集大小。单调队列最多存储k个元素空间复杂度O(k)。通常空间复杂度是 O(n) 或 O(k) 级别。理解并能在白板上画出指针移动的轨迹清晰地解释每个元素如何“一进一出”是掌握算法的重要标志。4. 从模板到实战高频题型深度剖析掌握了原理和关键问题我们来看几个LeetCode上的经典题目看看如何将模板灵活运用。4.1 题型一字符串匹配与覆盖问题例题找到字符串中所有字母异位词LeetCode 438给定两个字符串s和p找到s中所有p的字母异位词的子串返回这些子串的起始索引。分析这本质上是一个固定大小的滑动窗口问题因为我们要找的子串长度必须等于p的长度。但是判断条件不是简单的相等而是“字母异位词”即字符频次相同但顺序可以不同。因此我们需要维护一个固定长度为len(p)的窗口并用一个哈希表来比较窗口内字符频次是否与p的频次一致。vectorint findAnagrams(string s, string p) { vectorint res; if (s.size() p.size()) return res; vectorint need(26, 0), window(26, 0); // 因为只有小写字母用数组更高效 for (char c : p) need[c - a]; int left 0, right 0; // 先初始化第一个窗口 while (right p.size()) { window[s[right] - a]; right; } if (window need) res.push_back(left); // 开始滑动固定窗口 while (right s.size()) { // 右指针移动新字符入窗 window[s[right] - a]; // 左指针移动旧字符出窗 window[s[left] - a]--; left; right; if (window need) { res.push_back(left); } } return res; }注意事项这里比较两个vectorint是否相等是O(26)的操作是常数时间。我们也可以维护一个valid变量来记录匹配的字符数当valid 26时表示完全匹配这样判断是O(1)。但在这个特定约束小写字母下直接比较数组代码更简洁。在实际工程中如果字符集很大用valid变量是更好的选择。4.2 题型二最值问题涉及单调数据结构例题滑动窗口最大值LeetCode 239前面已经给出了单调队列的解法。这里再强调一下为什么不能用一个大顶堆优先队列。堆虽然能O(1)获取最大值但无法高效地删除一个非堆顶的任意元素。当窗口滑动左指针指向的元素出窗时如果它不是堆顶我们无法直接从堆中删除它只能标记为“已失效”等它爬到堆顶时再弹出。这会导致堆里积累大量无效元素最坏情况下空间复杂度是O(n)并且弹出无效元素的操作也增加了常数时间。而单调队列可以完美解决这个问题。4.3 题型三计数与子数组问题例题乘积小于K的子数组个数LeetCode 713给定一个正整数数组nums和一个整数k返回子数组内所有元素的乘积严格小于k的连续子数组的个数。分析这是一个可变窗口问题。窗口条件是“乘积小于k”。注意是求个数而不是最短或最长。对于每个以right为结尾的子数组满足条件的窗口[left, right]那么从这个窗口内以right结尾的子数组个数就是(right - left 1)。例如窗口为[2,3,4]以4结尾的子数组有[4],[3,4],[2,3,4]共3个。int numSubarrayProductLessThanK(vectorint nums, int k) { if (k 1) return 0; // 重要因为都是正整数k1时不可能有乘积小于k的子数组 int left 0, product 1, count 0; for (int right 0; right nums.size(); right) { product * nums[right]; // 当乘积过大时移动左指针缩小窗口 while (product k) { product / nums[left]; left; } // 关键以right结尾的合法子数组个数 count (right - left 1); } return count; }避坑技巧k 1的边界条件很容易漏掉。如果k0或k1由于数组元素是正整数乘积至少为1不可能小于k结果应为0。同时内层while循环的条件是product k确保跳出循环后窗口乘积一定小于k此时才能进行计数。4.4 题型四多指针协同的复杂窗口有些问题窗口内需要满足多个条件或者需要维护不止两个指针。例题替换后的最长重复字符LeetCode 424给你一个字符串s和一个整数k。你可以将任意位置上的字符替换成另外的字符最多替换k次。找到替换后包含重复字母的最长子串的长度。分析窗口的条件是“窗口长度 - 窗口内出现次数最多的字符的频次 k”。这意味着我们可以通过最多k次替换将窗口内所有字符都变成那个出现最多的字符。核心在于如何快速得到窗口内出现次数最多的字符的频次。我们可以在移动窗口时用一个数组count记录窗口内各字符频次并用一个变量maxCount来维护历史最大值。注意这个maxCount在左指针移动导致字符频次减少时不需要更新。因为即使它变小了也不会影响我们之前计算出的最大窗口长度我们只关心历史最大值是否被新的窗口打破。这是一个非常巧妙的优化。int characterReplacement(string s, int k) { vectorint count(26, 0); int left 0, maxCount 0, maxLength 0; for (int right 0; right s.size(); right) { // 右指针字符入窗 count[s[right] - A]; // 更新窗口内最大字符频次历史最大值 maxCount max(maxCount, count[s[right] - A]); // 判断当前窗口是否合法窗口长度 - maxCount k 则需要收缩 if (right - left 1 - maxCount k) { // 左指针字符出窗 count[s[left] - A]--; left; } // 更新最大长度 maxLength max(maxLength, right - left 1); } return maxLength; }核心理解为什么maxCount不随左指针移动而减小因为maxLength记录的是我们遇到过的最大值。只有当右指针移动带来一个新的、更大的maxCount时才有可能扩展出更长的窗口。旧的maxCount变小了只意味着当前窗口不满足条件需要收缩但不会影响我们已经记录下来的maxLength。这个“历史最大值”的维护是此题O(n)复杂度的关键。5. 滑动窗口算法的调试心法与常见“坑点”实录即使理解了算法亲手实现时还是会遇到各种问题。下面是我在大量练习和调试中总结出的“坑点”和应对策略。5.1 指针移动与区间表示的混乱这是最常见的错误来源。你的区间是[left, right]左右都闭还是[left, right)左闭右开必须从一开始就确定并在整个代码中保持一致。我推荐的范式左闭右开int left 0, right 0; while (right n) { // 此时窗口是 [left, right)还不包含 s[right] char c s[right]; // 获取即将进入窗口的元素 right; // 右指针右移元素c正式进入窗口 // ... 更新窗口状态基于新加入的c while (窗口需要收缩) { char d s[left]; // 获取即将离开窗口的元素 left; // 左指针右移元素d正式离开窗口 // ... 更新窗口状态基于离开的d } // 此时窗口是 [left, right) }好处right - left直接就是当前窗口的长度。初始时窗口为空[0,0)逻辑清晰。另一种范式左右都闭int left 0, right -1; // 初始窗口为空 while (right n - 1) { right; // 先移动右指针 // ... 更新状态 while (窗口需要收缩) { // ... 更新状态基于s[left] left; } }这种写法也可以但初始状态和长度计算 (right-left1) 需要稍加注意。选择一种并坚持下去。在团队协作或自己回顾代码时一致性至关重要。5.2 状态更新与条件判断的顺序错误在扩大窗口和收缩窗口时先更新指针还是先更新状态顺序错了逻辑就全乱了。扩大窗口时通常是获取元素-右指针-更新状态基于新元素。收缩窗口时通常是获取元素-更新状态基于即将移除的元素-左指针。在上面的“最小覆盖子串”代码中你可以清晰地看到这个顺序。收缩窗口时我们先检查window[d] need[d]再执行window[d]--最后left。因为我们需要用更新前的状态来做判断。5.3 哈希表访问与“need.count(c)”检查在更新窗口哈希表window时我们通常只关心那些在目标need哈希表中存在的字符。直接写window[c]是危险的因为这可能会向window中插入大量无关的字符键导致哈希表膨胀影响性能和后续valid的判断valid是基于need的键来比较的。所以一定要先判断if (need.count(c)) { window[c]; // ... 后续判断 }5.4 无限循环与指针停滞如果收缩窗口的条件while写成了if可能会导致窗口无法充分收缩右指针移动后窗口仍然满足条件但左指针只移动了一次就停了。下次循环右指针又无法移动因为窗口仍满足条件程序就卡住了。反之如果扩大窗口的循环条件写错也可能导致右指针无法前进。调试方法在IDE中用小规模数据比如字符串“ADOBECODEBANC”单步调试观察left、right、valid、window和need的变化看它们是否按照你设计的逻辑在推进。5.5 适用于滑动窗口的问题特征最后如何判断一个问题是否能用滑动窗口解决我总结了三个关键特征满足其中两个就值得尝试问题涉及数组/字符串的连续子区间这是使用滑动窗口的前提。问题的解可以由窗口的某种“状态”快速判定比如“和”、“最大值”、“字符频次”等这些状态可以在窗口滑动时增量更新。窗口的滑动具有单调性通常当右指针向右移动时窗口状态朝着一个方向变化例如和增大包含的字符种类可能增多。这使得左指针的移动收缩窗口有意义目的是在满足条件的前提下寻找更优解。滑动窗口是C算法工具箱里一把锋利而优雅的“手术刀”。它把看似复杂的O(n²)问题化简为一次优雅的线性扫描。理解其“移动窗帘”的本质掌握固定与可变两种模式熟记状态维护、指针移动、条件判断这三个核心操作再结合具体问题灵活调整你就能在字符串处理、子数组统计等大量场景下游刃有余。多写多调试把模板内化成自己的思维模式这才是从“看懂”到“写好”的关键一步。