作为一名长期奋战在算法竞赛一线的开发者,我深刻体会到滑动窗口技术在解决子数组/子字符串问题时的独特价值。这种算法之所以高效,核心在于它通过维护一个动态变化的窗口区间,避免了暴力解法中大量的重复计算。今天我们就来深入剖析四道经典的滑动窗口题目,从原理到实现,从代码到优化,全面掌握这一算法的精髓。
滑动窗口本质上是一种特殊的双指针技术,主要用于解决数组/字符串中的连续子区间问题。与普通双指针不同,滑动窗口的两个指针通常保持同向移动,形成一个动态变化的"窗口"。这个窗口会根据特定条件进行扩展或收缩,从而高效地找到满足条件的子区间。
在实际应用中,滑动窗口算法通常能达到O(n)的时间复杂度,这得益于每个元素最多被访问两次(一次被右指针纳入窗口,一次被左指针移出窗口)。相比暴力解法的O(n²)复杂度,在处理大规模数据时优势尤为明显。下面我们通过四道LeetCode经典题目,逐步拆解滑动窗口的应用技巧。
给定一个含有n个正整数的数组和一个正整数target,找出该数组中满足其和≥target的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,则返回0。
这个问题的暴力解法是枚举所有可能的子数组,计算它们的和并比较长度,时间复杂度为O(n²)。而滑动窗口解法可以将其优化到O(n),关键在于如何高效地维护这个窗口。
核心思路:维护一个动态窗口,右指针负责扩展窗口以增加和,左指针负责收缩窗口以减少和。当窗口和首次≥target时,我们就找到了一个候选解,然后尝试收缩窗口左边界以寻找更优解。
java复制class Solution {
public int minSubArrayLen(int target, int[] nums) {
int left = 0; // 窗口左边界
int right = 0; // 窗口右边界
int sum = 0; // 当前窗口和
int minLength = Integer.MAX_VALUE; // 最小长度,初始设为最大值
while (right < nums.length) {
// 扩展窗口:右指针右移,将新元素加入窗口和
sum += nums[right];
// 当窗口和≥target时,尝试收缩窗口寻找更优解
while (sum >= target) {
// 更新最小长度
minLength = Math.min(minLength, right - left + 1);
// 收缩窗口:左指针右移,从窗口和中移除最左侧元素
sum -= nums[left];
left++;
}
// 继续扩展窗口
right++;
}
// 如果没找到符合条件的子数组,返回0
return minLength == Integer.MAX_VALUE ? 0 : minLength;
}
}
初始化技巧:minLength初始化为Integer.MAX_VALUE,这样在比较时可以确保任何有效的子数组长度都会更新它。
窗口收缩条件:只有当sum≥target时才收缩窗口,这保证了我们总是在寻找满足条件的最小窗口。
边界处理:最后需要检查minLength是否被更新过,如果没有则返回0。
优化空间:在某些情况下,如果数组中有非常大的数,可以提前检查是否存在单个元素≥target的情况,这样可以立即返回1。
注意事项:输入数组必须全部为正整数,如果有负数,滑动窗口将失效,需要使用前缀和+二分查找的方法。
给定一个字符串s,找出其中不含有重复字符的最长子串的长度。这个问题看似简单,但要想出O(n)的解法需要巧妙的思路。
核心洞察:当我们在字符串中遇到重复字符时,新的无重复子串应该从该重复字符上次出现位置的下一个位置开始。这提示我们需要记录每个字符最后出现的位置。
java复制class Solution {
public int lengthOfLongestSubstring(String s) {
// 使用数组模拟哈希表,记录字符最后出现的位置
int[] lastIndex = new int[128]; // ASCII字符集
Arrays.fill(lastIndex, -1); // 初始化为-1表示未出现过
int left = 0; // 窗口左边界
int maxLength = 0; // 最大长度
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 如果字符已经存在于当前窗口中,更新左边界
if (lastIndex[c] >= left) {
left = lastIndex[c] + 1;
}
// 更新字符的最后出现位置
lastIndex[c] = right;
// 计算当前窗口长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
变种思考:如果字符集很大(如Unicode),可以使用HashMap代替数组,但实际测试中数组的实现通常更快。
实战技巧:对于Java来说,使用int[128]比HashMap<Character, Integer>更高效,特别是在高频调用的场景下。
给定一个二进制数组nums和一个整数k,可以翻转最多k个0变为1,返回数组中连续1的最大个数。
关键转化:这个问题可以转化为"寻找包含最多k个0的最长子数组"。这样我们就可以使用滑动窗口来维护一个最多包含k个0的窗口。
java复制class Solution {
public int longestOnes(int[] nums, int k) {
int left = 0; // 窗口左边界
int zeroCount = 0; // 窗口中0的个数
int maxLength = 0; // 最大长度
for (int right = 0; right < nums.length; right++) {
// 统计0的个数
if (nums[right] == 0) {
zeroCount++;
}
// 当0的个数超过k时,收缩左边界
while (zeroCount > k) {
if (nums[left] == 0) {
zeroCount--;
}
left++;
}
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
常见错误:容易忽略k=0的情况,此时问题退化为寻找最长的连续1子数组,需要特殊处理吗?实际上上述代码已经包含了这种情况。
给定一个整数数组nums和一个整数x,每次操作可以从数组的最左或最右移除一个元素,并减去该元素的值。返回使x减到0的最小操作数,否则返回-1。
关键转化:这个问题可以转化为寻找数组中最长的子数组,其和等于sum(nums)-x。这样,最小操作数就是数组长度减去这个子数组的长度。
java复制class Solution {
public int minOperations(int[] nums, int x) {
int totalSum = 0;
for (int num : nums) {
totalSum += num;
}
int target = totalSum - x;
// 边界情况处理
if (target < 0) return -1;
if (target == 0) return nums.length;
int left = 0;
int currentSum = 0;
int maxSubarrayLength = -1;
for (int right = 0; right < nums.length; right++) {
currentSum += nums[right];
// 收缩窗口
while (currentSum > target && left <= right) {
currentSum -= nums[left];
left++;
}
// 找到目标子数组
if (currentSum == target) {
maxSubarrayLength = Math.max(maxSubarrayLength, right - left + 1);
}
}
return maxSubarrayLength == -1 ? -1 : nums.length - maxSubarrayLength;
}
}
调试建议:在实现这类问题时,先手动计算几个测试用例的总和和目标值,确保问题转化正确。
通过以上四个问题,我们可以总结出滑动窗口算法的通用模板:
java复制int slidingWindowTemplate(int[] nums, int target) {
int left = 0; // 窗口左边界
int result = 0; // 存储结果
int current = 0; // 当前窗口状态
for (int right = 0; right < nums.length; right++) {
// 更新窗口状态(扩展窗口)
current += nums[right]; // 或其他操作
// 检查是否需要收缩窗口
while (/* 窗口不满足条件 */) {
// 更新窗口状态(收缩窗口)
current -= nums[left]; // 或其他操作
left++;
}
// 更新结果
result = Math.max(result, right - left + 1);
}
return result;
}
在实际编码中,滑动窗口算法容易遇到以下问题:
窗口收缩条件错误:导致无法找到最优解或死循环
边界条件处理不当:如空数组、全0数组等特殊情况
初始值设置错误:如minLength初始值不够大
更新结果的时机错误:在收缩窗口前或后更新结果
对于大规模数据或性能敏感场景,可以考虑以下优化:
在实际面试中,滑动窗口问题常常作为中等难度题目出现。掌握这四道经典题目及其变种,就能应对大多数滑动窗口类的问题。记住,理解算法本质比死记硬背代码更重要,要能够根据具体问题灵活调整窗口的维护策略。