滑动窗口:双指针的高效应用

理解

滑动窗口本质是双指针的一种应用,但比普通双指针更有明确的 “区间约束”。(双指针的一个子集+具体化)

滑动窗口是处理数组 / 字符串子问题的高效技巧。核心结论是:滑动窗口通过维护一个动态变化的 “窗口”(子区间),将暴力解法的 O (n²) 时间复杂度优化到 O (n),适用于解决子数组 / 子串的求和、计数、匹配等问题。

滑动窗口是在数组或字符串上维护的一个连续子区间,用两个指针(左指针 left、右指针 right)界定范围。窗口的 “滑动” 体现在指针的移动:右指针扩大窗口范围,左指针缩小窗口范围,通过动态调整窗口来满足题目条件,避免重复计算。

实战例题

leetcode209例题

算法原理讲解:

解法一:暴力枚举

枚举每一个数组,然后计算它的和,是否满足>=target

枚举每一个数组就是固定left指针,然后移动right指针,时间复杂度O(N^2)

然后枚举完之后计算sum,时间复杂度一共就会变成O(N^3)

优化:枚举的时候顺便计算sum,这样就会使时间复杂度O(N^2)

再优化的过程当中我们发现解法二

比如数组{2,3,1,2,4,3}

注意题目都是正整数,所以我们每次+一个数的时候,sum都会变大,所以sum是单调递增的

解法一暴力枚举的时候,当我们发现从左+到右,当sum>7的时候,right就没必要往右边+了。因为是正整数,你在往右边+,sum必然>7,len也在变大,所以此时固定left,right++的数组已经找到len最小的情况了

而且当我们right++到合适位置了之和,left++寻找下一组数组,left++的时候right不用回退到left的位置,因为前面一段区间的sum我们已经计算过了,当你left++的时候,

我们仅仅需要sum-nums[left]就能够算出left++,right合适的这段区间的新的sum了,新的sum只会<=target,不会大于,所以right是不会--的

这就是滑动窗口(类似于一个窗口再移动),利用了单调性,规避了很多没必要的枚举,也就是left和right都是从左往右再移动(同向指针),不会回退

举个例子:

固定left指针(出窗口)指向2,一开始移动right(进窗口)指针,从2开始移动

right指向2,2进窗口,sum=2,len=1,判断sum<target

right++,指向3,3进窗口,sum=5,len=2,判断sum<target

right++,指向1,1进窗口,sum=6,len=3,判断sum<target

right++,指向2,2进窗口,sum=8,len=4,判断sum>target

此时符合条件,那就算一个结果

然后left++,指向3,2出窗口,sum=8-2=6,len=3,判断sum<target

right++,指向4,4进窗口,sum=10,len=4,判断sum>target

所以也是符合一组

注意这里的更新结果是可能再进窗口的时候,也可能判断结果,具体的更新结果是根据题意来判断,这里把更新结果放在判断结果后面,判断完之后我们要更新完长度和sum才能出窗口

时间复杂度O(2N),即O(N)

class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int n = nums.size();
        int left = 0;
        int right = 0;
        int sum = 0;
        int len = INT_MAX;
        while (right < n) {
            sum += nums[right];
            while (1) {
                if (sum >= target) {
                    len = min(len, right - left + 1);
                    sum -= nums[left++];
                } else {
                    right++;
                    break;
                }
            }
        }
        if (len == INT_MAX)
            return 0;
        return len;
    }
};

leetcode3例题

算法原理讲解:

解法一:暴力枚举+hash判定是否重复

例如”abcabcbb“

left从a开始,right从a开始,right++,一直到abca发现重复,所以就是abc

left从b开始,right从b开始,right++,一直到bcab发现重复,所以就是bca

以每个字符开头,枚举发现是否重复,然后统计出最大的len,返回即可

当我们发现枚举的时候,例如“deabcabca”

left指针指向d,right指针指向d,然后right++,一直到a发现重复,所以此时len=deabc

下一轮移动的时候,left指向的是第一个a的下一个位置,right指向的是第二个a

思考为什么可以这样?(right不用回退?left和right都是从左往右移动(滑动窗口))

如果你left指向e,而不是第一个a的下一个位置,那它还是重复的,right不用回退是因为前面已经能够保证这一段不是重复的了,所以从第二个a开始就行

做滑动窗口的题目不是知道使用滑动窗口的前提下编写代码,代码很简单,主要理解为什么使用滑动窗口,为什么right不回退,为什么left和right都是从左往右移动

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int left = 0;
        int right = 0;
        int len = 0;
        int n = s.size();
        int hash[256] = {0};
        for (; right < n; right++) {
            int ret = s[right];
            hash[ret]++;
            while (hash[ret] > 1) {
                // 出现重复
                hash[s[left++]]--;
            }
            len = max(len, right - left+1);
        }
        return len;
    }
};

leetcode1004例题

算法原理讲解:
题目说最多可以翻转k个0,不是说只能恰好,所以可以翻转<=k个0

其次如果按翻转来做,代码书写和思维都比较难,需要翻转之后再记录1的个数

我们可以把题意理解为寻找一个子数组,子数组中0的个数<=k个

解法一:暴力枚举+zero计数器

也就是固定left,然后right往右移动,依次枚举每个子数组中0的个数

解法二:滑动窗口

在暴力枚举中我们发现可以优化

比如数组{1,1,1,0,0,0,1,1,1,0}

当固定left指针的时候,枚举子数组时,我们发现当枚举到第二个0的时候,刚好符合题意k=2,此时接着往下枚举,发现下一个是0,那就超过了,没必要往下枚举了,此时记录len为right-left

正常的暴力枚举是移动left,然后right回来和left同时指向一个位置,然后right++,一直往后枚举,但是此时我们发现如果left指向第二个1,right从第二个1开始往后的时候和第一种情况一样,也是枚举到3个0那里结束,所以优化就是不需要left++,而是直接跳过这一堆1,跳过1个0,让0的个数<=k,此时往后枚举的子数组才有意义,而且发现right不用回来,回来了也一样,因为我们知道了zero的个数,直接--就行,不用回是因为这一段区域由于left跳过了一定个数的0,所以导致left-right这段区间是合法的,right直接往后面找就行

因为left和right都是从左往右,并且right在left前面不回退

所以使用滑动窗口的解法

class Solution {
public:
    int longestOnes(vector<int>& nums, int k) {
        // 问题转换:让子数组中0的个数不超过k个
        int left = 0;
        int right = 0;
        int n = nums.size();
        int len = 0;
        int zero = 0;
        while (right < n) {
            // 进窗口
            if (!nums[right]) {
                zero++;
            }
            // 出窗口
            while (zero > k) {
                if (nums[left] == 0) {
                    zero--; // 左移移除0,减少计数
                }
                left++; // 无论移除的是0还是1,都要移动左指针
            }

            // 更新结果
            len = max(len, right - left + 1);
            right++;
        }

        return len;
    }
};

leetcode1658例题

class Solution {
public:
    int minOperations(vector<int>& nums, int x) {
        // 正难则反:找一个区间,让其和为sum-x
        int left = 0;
        int right = 0;
        int n = nums.size();
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += nums[i];
        }
        int target = sum - x; // 找区间让其和=target;
        if(target<0)
        return -1;
        int len = -1;
        int ret = 0; // 用来记录区间和
        while (right < n) {
            // 进窗口
            ret += nums[right];
            // 判断
            while (ret >target&&left<=right) {
                //出窗口
                ret -= nums[left++];
            }
            if(ret==target)
            {
                len=max(len,right-left+1);
            }
            right++;
        }
        if(len==-1)
        return len;
        else return n-len;
    }
};

leetcode904例题

算法原理讲解:读完题后,发现就是在一个数组当中寻找一个最长的子数组,子数组当中只有两种数字,做数组类的问题,一定要从暴力枚举当中寻找优化,先从暴力枚举解题

解法一:暴力枚举+hash

比如数组{1,2,3,2,2}

暴力枚举每一个数组,left和right同时指向1,然后right++,一直枚举子数组,发现right++到3的时候出现三种数字就没必要往下枚举了,在往下也是不符合的,枚举到12,长度为2

所以枚举下一组,left从2开始,right也从2开始,那就是枚举到2322,长度为4

枚举下一组left和right同时指向3,right++,那就是枚举到322,长度为3

依次这样枚举每一个子数组,找到其中最长的,这期间需要借助hash来判断是否有第三种元素

解法二:滑动窗口+hash

暴力枚举的时候发现,left枚举下一组的时候,right不用回退,left++的时候要么造成数字种类-1,要么数字种类不变,不会再增加,你right回退不回推都不影响,反而会增加时间复杂度,所以这符合滑动窗口的定义:left和right都是从左往右,然后right一直再left前面,窗口内一直保持一个特性

举一个例子在此理解,比如数组{1,2,1,2,3,2,3,3}

left和right同时指向1,进窗口就是hash[f[r]]++,也就是hash存的是<int,int>,然后判断一下,hash里面的数量是不是超过2,没有那就依次right++,然后加到3,发现hash中的元素种类超过了3,所以right暂停,left移动的时候要依次给hash里面元素种类的个数--,直到某个元素减到没,所以此时left是移动的第二个2的,也就是下图当中的left和right指向的样子

class Solution {
public:
    int totalFruit(vector<int>& fruits) {
        int left = 0;
        int right = 0;
        int n = fruits.size();
        int len = 0;
        unordered_map<int, int> hash;
        while (right < n) {
            hash[fruits[right]]++;
            while (hash.size() > 2) {
                // 此时有三个元素了
                hash[fruits[left]]--;
                if (hash[fruits[left]] == 0) {
                    hash.erase(fruits[left]);
                }
                left++;
            }
            len = max(len, right - left + 1);
            right++;
        }
        return len;
    }
};

leetcode438例题

算法原理讲解:异位词就是比如p为abc,那异位词就可以是abc,acd,bac,bca,cab,cba

解法一:暴力解法+hash

枚举每一个数组,但是此时我们发现子数组的长度肯定要和p的长度一样,才能称为异位词,所以枚举数组长度不能超过psize,所以这就有点像滑动窗口(固定版本)

解法二:滑动窗口+hash

滑动窗口大小就是psize大小,固定这个窗口大小,从左往右边扫,一样就记录下来

此时还需要配合hash,hash1记录窗口里面的状态,hash2是p字符串的状态,我们只需要比较hash中的元素是否相等,相等了之后元素个数是否一样,就能判断这两个是不是异位词

class Solution {
public:
    template <typename Key, typename Value>
    bool compareUnorderedMaps(const std::unordered_map<Key, Value>& map1,
                              const std::unordered_map<Key, Value>& map2) {
        if (map1.size() != map2.size()) {
            return false;
        }
        for (const auto& pair : map1) {
            auto it = map2.find(pair.first);
            if (it == map2.end() || it->second != pair.second) {
                return false;
            }
        }
        return true;
    }

    vector<int> findAnagrams(string s, string p) {
        unordered_map<int, int> hash1;
        unordered_map<int, int> hash2;
        vector<int> v;
        int psize = p.size();
        int ssize = s.size();
        int left = 0;
        int right = psize - 1;
        for (int i = 0; i < psize; i++) {
            hash2[p[i]]++;
        }
        // 初始化第一个窗口(left=0到right=psize-1)
        for (int i = 0; i < psize; ++i) {
            hash1[s[i]]++;
        }

        // 检查第一个窗口
        if (compareUnorderedMaps(hash1, hash2)) {
            v.push_back(0);
        }
        // 滑动窗口:每次右移一位,高效更新哈希表
        for (int i = psize; i < ssize; ++i) {
            // 移除窗口最左侧的字符(i - psize 是左边界索引)
            char leftChar = s[i - psize];
            hash1[leftChar]--;
            if (hash1[leftChar] == 0) {
                hash1.erase(leftChar); // 计数为0时删除键,避免干扰比较
            }

            // 添加新的右边界字符(i是当前右边界索引)
            char rightChar = s[i];
            hash1[rightChar]++;

            // 检查当前窗口(左边界索引为 i - psize + 1)
            if (compareUnorderedMaps(hash1, hash2)) {
                v.push_back(i - psize + 1);
            }
        }
        return v;
    }
};

注意:这里题目当中的都是小写字母,所以我们可以用一个数组hash[26]来充当hash表

第二种写法:

这是对于更新结果的优化(不需要再每次遍历一遍hash,而是维护一个计数器)

leetcode30例题

算法原理讲解:读完题会发现和之前的438题类似

转换:把word中的字符串看成一个个字符,比如foo可以整体看成a,bar整体看成b,然后s字符串中进行3个3个的划分bar就是b,然后foo就是a,其他第一次出现可以看成c、d

所以s字符串就变成了,bacabd,然后在这个字符串当中找ab的异位词,所以就是438题的变形

注意:这里我们进行滑动窗口的次数是len次,第一次是紫色线划分,第二次是绿色线,第三次是蓝色线

class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) {
        unordered_map<string,int> hash1;//hash1用来存储words中的
        vector<int> ret;
        for(auto&ws:words){
            hash1[ws]++;
        }
        //控制滑动窗口的次数
        int len=words[0].size();
        int m=words.size();
        for(int i=0;i<len;i++){
            //每次滑动窗口
            unordered_map<string,int> hash2;//hash2用来存储s中的
            int count=0;
            for(int left=i,right=i;right+len<=s.size();right+=len){
                //进窗口,维护count
                string in=s.substr(right,len);
                hash2[in]++;
                if(hash1.count(in)&&hash2[in]<=hash1[in]){
                    count++;
                }
                //判断
                if(right-left==len*m){
                    //出窗口
                    string out=s.substr(left,len);
                    if(hash1.count(out)&&hash2[out]<=hash1[out]){
                        count--;
                    }
                    hash2[out]--;
                    left+=len;
                }
                //更新结果
                if(count==m){
                    ret.push_back(left);
                }

            }
        }
        return ret;
    }
};

leetcode76例题

算法原理讲解:

解法一:暴力枚举+哈希表

做子数组一定要学会枚举,把所有的可能性枚举出来,在暴力当中寻找最优解

例如例1当中的字符串,我们以A开头枚举所有的,发现right走到C的时候结束,此时是最短的

以D开头枚举,枚举到下一个A的时候结束,此时是最短的

借助哈希表,如果hash2当中的每个元素的个数都>=hash1的才符合

把所有的情况枚举出来,然后返回符合条件最小的,时间复杂度O(N^2)

解法二:滑动窗口+哈希表

当我们枚举到一个[left,right]窗口的时候,left++,无非就两种情况,left出窗口时是一个合法的字符,但是hash2当中的每个元素的大小还是>=hash1当中的每个元素的大小,right这是是可以不用动的,如果left是一个非法的字符,那right也是可以不用左移回去的,直接右移寻找下一个合法字符就行,所以这里满足滑动窗口的性质,同向指针+right>left

这里count标记的是种类不是个数,比如你ABC都是要求1个就行,但是如果你找到的是两b1a,这样有3个导致判断合法,应该是abc至少各有1个,多的话也是符合

什么叫有效的能够导致count++的,比如你的A要求3个,但是此时你是2个,count就不能++,只有你有的个数>=要求的个数的时候,count才能++,当你的A是3个4个5个的时候,只用统计一次表明是种类就行        

当你进窗口的时候,如果个数相等才需要将count++,如果是>就++的话就会导致有很多种类

出窗口的时候,也是只有相等才需要将count--,如果>也--的话就会导致原本是由这种的,但是减掉了

当我每一遍都使用循环去检查两个hash的时候就会超时,所以我们可以使用优化的方法,也就是维护一个count计数器

class Solution {
public:
    string minWindow(string s, string t) {
        int hash1[128] = {0};
        int hash2[128] = {0};
        int kind = 0;
        for (auto e : t) {
            if (hash1[e] == 0)
                kind++;
            hash1[e]++;
        }
        int count = 0;
        int begin = -1; // 记录合法字符串的起始位置
        int min_len = INT_MAX;
        for (int left = 0, right = 0; right < s.size();) {
            // 进窗口+维护count计数器
            hash2[s[right]]++;
            if (hash2[s[right]] == hash1[s[right]]) {
                count++;
            }
            // 判断
            while (count == kind) {
                if ( right - left + 1<min_len) {
                    begin = left;
                    min_len = right - left + 1;
                }
                if (hash2[s[left]] == hash1[s[left]]) {
                    count--;
                }
                hash2[s[left]]--;
                left++;
            }
            right++;
        }
        if(begin==-1)
        return "";
        return s.substr(begin, min_len);
    }
};

总结:

对于滑动窗口,我们需要先想到暴力枚举,如果在暴力枚举中发现,left和right是同向指针,并且right一直在left前面,然后窗口之内能够维护一个动态的符合题意的规定,就能够记录下来

注意:有时候如果题目当中能够有数组代替hash的话就用,这样比较高效,而且有时候能够维护一个count计数器来降低时间复杂度(因为每次对比hash我们都要循环一次,所以维护count的话就不需要)

滑动窗口是一种常用的算法技巧,主要用于解决数组或字符串的子串、子数组相关问题,核心思想是通过维护一个动态的区间(窗口),在这个区间内进行操作,从而将原本需要嵌套循环(时间复杂度较高,如 O(n2))的问题优化到线性时间复杂度(O(n))。

核心概念

  1. 窗口的定义:通常是数组或字符串中一个左闭右开(或闭区间)的区间,用两个指针(如 left 和 right)来表示窗口的左右边界。
  2. 窗口的滑动:通过移动左右指针来 “滑动” 窗口,分为扩张收缩两个阶段:
    • 扩张:右指针向右移动,扩大窗口范围,将新元素纳入窗口,直到满足某个条件。
    • 收缩:左指针向右移动,缩小窗口范围,直到不满足条件,在此过程中寻找最优解(如最短、最长子串 / 子数组)。

适用场景

滑动窗口适用于需要连续子串 / 子数组,且满足 “当窗口包含某些特征时,其内部子窗口也可能满足特征” 的问题,典型场景包括:

  • 找包含目标字符串所有字符的最短子串(如 “最小覆盖子串” 问题)。
  • 找无重复字符的最长子串。
  • 找和为目标值的连续子数组。

算法步骤(通用模板)

  1. 初始化窗口:左指针 left 初始化为 0,右指针 right 从 0 开始移动。
  2. 扩张窗口:右指针向右移动,将元素加入窗口,同时更新窗口内的统计信息(如字符计数、和等)。
  3. 满足条件时收缩:当窗口满足题目要求的条件(如包含所有目标字符、和为目标值等),尝试移动左指针缩小窗口,在此过程中更新最优解(如最短长度、最长长度等)。
  4. 更新统计信息:左指针移动时,同步更新窗口内的统计信息,确保后续判断的正确性。

优势

  • 时间复杂度优化:将嵌套循环的 O(n2) 优化为 O(n),因为每个元素最多被左右指针各访问一次。
  • 空间复杂度低:通常只需额外的常数空间(如数组、哈希表统计窗口内的信息)。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值