滑动窗口/双指针系列(2)

滑动窗口 模板写法 详见 第一篇

leetcode1438. 绝对差不超过限制的最长连续子数组

给你一个整数数组 nums ,和一个表示限制的整数 limit,请你返回最长连续子数组的长度,该子数组中的任意两个元素之间的绝对差必须小于或者等于 limit 。
如果不存在满足条件的子数组,则返回 0 。

思路

  • 题意:求一个最长的子数组,该子数组内的最大值和最小值的差不超过 limit

本题是求最大连续子区间,可以使用滑动窗口方法。滑动窗口的限制条件是:窗口内最大值和最小值的差不超过 limit。
本题最大的难点在于快速地求滑动窗口内的最大值和最小值,类似题目如 480. 滑动窗口中位数。
降低时间复杂度的一个绝招就是增加空间复杂度:利用更好的数据结构。是的,我们的目的是快速让一组数据有序,那就寻找一个内部是有序的数据结构呗!下面我分语言讲解一下常见的内部有序的数据结构。

  • 在 C++ 中 set/multiset/map 内部元素是有序的,它们都基于红黑树实现。其中 set 会对元素去重,而 multiset 可以有重复元素,map 是 key 有序的哈希表。
  • 在 Java 中 TreeSet 是有序的去重集合,TreeMap 是 key 有序的哈希表,它们也是基于红黑树实现的。
  • 在 Python 中 sortedcontainers 实现了有序的容器。

下面是代码思路:

  1. 使用 left 和 right 两个指针,分别指向滑动窗口的左右边界;定义 multiset 保存滑动窗口的所有元素;
  2. right 主动右移:right 指针每次移动一步,把 A[right] 放入滑动窗口;
  3. left被动右移:判断此时窗口内最大值和最小值的差,如果大于 limitlimit,则 left 指针被迫右移,直至窗口内最大值和最小值的差小于等于 limit 为止;left 每次右移之前,需要把 A[left] 从 multiset
    中减去一次。
  4. 滑动窗口长度的最大值就是所求。
class Solution {
public:
    int longestSubarray(vector<int>& nums, int limit) {
        multiset<int> st;//有序集合自动升序存储窗口内的值
        int left = 0, right = 0, len = INT_MIN;
        
        while(right < nums.size()){
            st.insert(nums[right]);//将值加入到st中
            while(*st.rbegin() - *st.begin() >  limit){
                st.erase(st.find(nums[left]));//若最大-最小>限制,则删除左边界的数并右移left
                left++;
            }
            len = max(len, right - left + 1);//一直更新记录符合条件的子区间长度
            right++;//右边界
        }

        return len;
    }
};

leetcode995. K 连续位的最小翻转次数

在仅包含 0 和 1 的数组 A 中,一次 K 位翻转包括选择一个长度为 K 的(连续)子数组,同时将子数组中的每个 0 更改为 1,而每个 1 更改为 0。
返回所需的 K 位翻转的最小次数,以便数组没有值为 0 的元素。如果不可能,返回 -1。

法1.朴素贪心算法(超时)

题目大意:每次翻转长度为 K 的子数组,求最少的翻转次数使数组中所有的 0 都更改为 1。如果不能实现,则返回 -1.

  结论 1:后面区间的翻转,不会影响前面的元素。因此可以使用贪心策略,从左到右遍历,遇到每个 0 都把 它以及后面的 K-1个元素进行翻转。 
  结论 2:A[i] 翻转偶数次的结果是 A[i];翻转奇数次的结果是 A[i] ^ 1。

一个直观的思路是,从左到右遍历一遍,遇到数字为 0,那么翻转以该数字为起始的 K 个数字

class Solution(object):
    def minKBitFlips(self, A, K):
        """
        :type A: List[int]
        :type K: int
        :rtype: int
        """
        N = len(A)
        res = 0
        for i in range(N - K + 1):
            if A[i] == 1:
                continue
            for j in range(K):
                A[i + j] ^= 1
            res += 1
        for i in range(N):
            if A[i] == 0:
                return -1
        return res
  • 时间复杂度:O(N * K + N),超时。
  • 空间复杂度:O(1)。

滑动窗口不必真的去反转数字(耗时),只需记录 滑动窗口内数字 的 反转次数 即可知道翻转结果!!!

法2.滑动窗口

上面方法超时的主要原因是我们真实地进行了翻转。根据结论二,位置 i 现在的状态,和它被前面 K−1 个元素翻转的次数(奇偶性)有关

我们使用队列模拟滑动窗口,该滑动窗口的含义是前面 K−1 个元素中,以哪些位置起始的 子区间
进行了翻转
。该滑动窗口从左向右滑动,如果当前位置 i 需要翻转,则把该位置存储到队列中。遍历到新位置 j (j < i + K)
时,队列中元素的个数代表了 i 被前面 K - 1 个元素翻转的次数。

  • 当 A[i]A[i] 为 0,如果 ii 位置被翻转了偶数次,那么翻转后仍是 0,当前元素需要翻转;
  • 当 A[i]A[i] 为 1,如果 ii 位置被翻转了奇数次,那么翻转后变成 0,当前元素需要翻转。

综合上面两点,我们得到一个结论,如果 len(que) % 2 == A[i] 时,当前元素需要翻转

当 i + K > N 时,说明需要翻转大小为 K 的子区间,但是后面剩余的元素不到 K 个了,所以返回 -1。

class Solution {
public:
    int minKBitFlips(vector<int>& A, int K) {
        int N = A.size();
        queue<int> que;
        int res = 0;
        for (int i = 0; i < N; ++i) {
            if (!que.empty() && i >= que.front() + K) {
                que.pop();
            }
            if (que.size() % 2 == A[i]) {
                if (i + K > N) {
                    return -1;
                }
                que.push(i);
                res ++;
            }
        }
        return res;
    }
};
简化代码空间复杂度O(K)到O(1)

上面代码是用队列queue来记录需要反转的子区间起始点下标,其实也只用到了队列的长度这一信息(就是反转次数)。
若我们直接用一个int值flips来统计,辅以 在处理过的元素中 +2 ,来标记反转窗口起始点(原地修改数组中处理过的值,代表已翻转的窗口起点,不必开辟新的内存空间来记录此信息),这样仍然满足 curFlips % 2 == A[i]代表当前第i个元素需要反转 的条件。

class Solution {
public:
    int minKBitFlips(vector<int>& A, int K) {
        int curFlips = 0;//统计当前第i个数被翻转的次数(只和窗口内前K-1个数被翻转次数有关)
        int cnt = 0;//统计总的反转的次数

        for(int i = 0; i < A.size(); i++){
            //滑出窗口左边界的数(下标为i-k)若是 被翻转过的左起点,则curFlips--,当前数被翻转次数就少1
            if(i - K >= 0 && A[i - K] > 1){
                curFlips--;
                A[i - K] -= 2;//还原
            }
            //若当前数需要翻转
            if(curFlips % 2 == A[i]){
                if(i + K > A.size()) return -1;//剩余可翻转数不足自区间长度则不满足
                A[i] += 2;//标记代表当前数为需要反转的窗口左起点
                curFlips++;
                cnt++;
            }
        }
        return cnt;
    }
};

法3.差分数组

暂未记录,详见该篇讲解

leetcode1052. 爱生气的书店老板

今天,书店老板有一家店打算试营业 customers.length 分钟。每分钟都有一些顾客(customers[i])会进入书店,所有这些顾客都会在那一分钟结束后离开。
在某些时候,书店老板会生气。 如果书店老板在第 i 分钟生气,那么 grumpy[i] = 1,否则 grumpy[i] = 0。 当书店老板生气时,那一分钟的顾客就会不满意,不生气则他们是满意的。
书店老板知道一个秘密技巧,能抑制自己的情绪,可以让自己连续 X 分钟不生气,但却只能使用一次。
请你返回这一天营业下来,最多有多少客户能够感到满意的数量。

思路

具体思路及代码 见我写的 题解,此处不再次记录

leetcode395. 至少有K个重复字符的最长子串

给你一个字符串 s 和一个整数 k ,请你找出 s 中的最长子串, 要求该子串中的每一字符出现次数都不少于 k 。返回这一子串的长度。

法1.分治+递归

思路

本题要求的一个最长的子字符串的长度,该子字符串中每个字符出现的次数都最少为 k。

求最长子字符串/区间的这类题一般可以用滑动窗口来做,但是本题滑动窗口的代码不好写,我改用递归。也借本题来帮助大家理解递归。

重点

   我们在调用递归函数的时候,把递归函数当做普通函数(黑箱)来调用,即明白该函数的输入输出是什么,而不用管此函数内部在做什么。

下面是详细讲解:

  • 递归最基本的是记住递归函数的含义(务必牢记函数定义):本题的 longestSubstring(s, k)函数表示的就是题意,即求一个最长的子字符串的长度,该子字符串中每个字符出现的次数都最少为 k。函数入参 s 是表示源字符串;k是限制条件,即子字符串中每个字符最少出现的次数;函数返回结果是满足题意的最长子字符串长度。

  • 递归的终止条件(能直接写出的最简单 case):如果字符串 s 的长度少于 k,那么一定不存在满足题意的子字符串,返回 0;

  • 调用递归(重点):如果一个字符 c 在 s 中出现的次数少于 k 次,那么 s 中所有的包含 c的子字符串都不能满足题意。所以,应该在 s 的所有不包含 c 的子字符串中继续寻找结果:把 ss 按照 c分割(分割后每个子串都不包含 c),得到很多子字符串 t;下一步要求 t作为源字符串的时候,它的最长的满足题意的子字符串长度(到现在为止,我们把大问题分割为了小问题(s->t))。
    此时我们发现,恰好已经定义了函数 longestSubstring(s, k) 就是来解决这个问题的!所以直接把longestSubstring(s, k) 函数拿来用,于是形成了递归。

  • 未进入递归时的返回结果:如果 s 中的每个字符出现的次数都大于 k 次,那么 s 就是我们要求的字符串,直接返回该字符串的长度。

总之,通过上面的分析,我们看出了:我们不是为了递归而递归。而是因为我们把大问题拆解成了小问题恰好有函数可以解决小问题,所以直接用这个函数。由于这个函数正好是本身,所以我们把此现象叫做递归。小问题是原因递归是结果。而递归函数到底怎么一层层展开与终止的,不要用大脑去想,这是计算机干的事。我们只用把递归函数当做一个能解决问题的黑箱就够了,把更多的注意力放在拆解子问题、递归终止条件、递归函数的正确性上来

class Solution {
public:
    //分治+递归:每个不足k个的字母把原字符串分成各个小段,每个小段同样如此,递归调用到结束时返回记录的最大长度
    int longestSubstring(string s, int k) {
        if(s.size() < k) return 0;//递归结束条件:分出的子串长度不足k
        
        unordered_set<char> chars(s.begin(), s.end());//去重s中的字母,无需排序
        unordered_map<char, int> counter;

        for(char c : s) counter[c]++;//统计s中字符和对应个数
        for(char c : chars){//遍历s中每个字符(无重复)
            vector<string> t;//存储分割后的子串
            if(counter[c] < k){//找到个数少于k的字符就要以此来分割
                split(s, t, c);//c++中未实现字符串的split功能,自己写一下
                int res = 0;
                for(string tn : t){
                    res = max(res, longestSubstring(tn, k));//递归调用,算子串的符合要求的长度
                }
                return res;
            }            
        }
        return s.size();//若是上面没找到个数<k的字符,说明当前串都满足,直接返回完整长度。
    }
    //自定义分割函数:s为要分割的字符串,sv为存储分割好的字符串数组,flage为分割标志
    void split(const string& s, vector<string>& sv, const char flag = ' '){
        sv.clear();
        istringstream iss(s);//初始化字符串输入流
        string tmp;

        while(getline(iss, tmp, flag)){//getline函数会一直读取iss存到tmp中,直到遇到flag停止
            sv.push_back(tmp);//只有getline函数动作完毕时,while才会执行内部的循环
        }
    }
};

法2.枚举 + 双指针解法

思路

其实看到这道题,我第一反应是「二分」,直接「二分」答案。

但是往下分析就能发现「二分」不可行,因为不具有二段性质

也就是假设有长度 t 的一段区间满足要求的话, t + 1 长度的区间是否「一定满足」或者「一定不满足」呢?

显然并不一定,是否满足取决于 t + 1 个位置出现的字符在不在原有区间内。

举个🌰吧,假设我们已经画出来一段长度为 t 的区间满足要求(且此时 k > 1),那么当我们将长度扩成 t + 1
的时候(无论是往左扩还是往右扩):

  • 如果新位置的字符在原有区间出现过,那必然还是满足出现次数大于 k,这时候 t + 1 的长度满足要求
  • 如果新位置的字符在原有区间没出现过,那新字符的出现次数只有一次,这时候 t + 1 的长度不满足要求

因此我们无法是使用「二分」,相应的也无法直接使用「滑动窗口」思路的双指针。

  因为双指针其实也是利用了二段性质,当一个指针确定在某个位置,另外一个指针能够落在某个明确的分割点,使得左半部分满足,右半部分不满足。

那么还有什么性质可以利用呢?这时候要留意数据范围「数值小」的内容。

题目说明了只包含小写字母(26 个,为有限数据),我们可以枚举最大长度所包含的字符类型数量,答案必然是 [1, 26],即最少包含 1个字母,最多包含 26 个字母。

你会发现,当确定了长度所包含的字符种类数量时,区间重新具有了二段性质

当我们使用双指针的时候:

  右端点往右移动必然会导致字符类型数量增加(或不变) 左端点往右移动必然会导致字符类型数量减少(或不变)
  当然,我们还需要记录有多少字符符合要求(出现次数不少于 k),当区间内所有字符都符合时更新答案。
class Solution {
    public int longestSubstring(String s, int k) {
        int ans = 0;
        int n = s.length();
        char[] cs = s.toCharArray();
        int[] cnt = new int[26];
        for (int p = 1; p <= 26; p++) {
            Arrays.fill(cnt, 0);
            // tot 代表 [j, i] 区间所有的字符种类数量;sum 代表满足「出现次数不少于 k」的字符种类数量
            for (int i = 0, j = 0, tot = 0, sum = 0; i < n; i++) {
                int u = cs[i] - 'a';
                cnt[u]++;
                // 如果添加到 cnt 之后为 1,说明字符总数 +1
                if (cnt[u] == 1) tot++;
                // 如果添加到 cnt 之后等于 k,说明该字符从不达标变为达标,达标数量 + 1
                if (cnt[u] == k) sum++;
                // 当区间所包含的字符种类数量 tot 超过了当前限定的数量 p,那么我们要删除掉一些字母,即「左指针」右移
                while (tot > p) {
                    int t = cs[j++] - 'a';
                    cnt[t]--;
                    // 如果添加到 cnt 之后为 0,说明字符总数-1
                    if (cnt[t] == 0) tot--;
                    // 如果添加到 cnt 之后等于 k - 1,说明该字符从达标变为不达标,达标数量 - 1
                    if (cnt[t] == k - 1) sum--;
                }
                // 当所有字符都符合要求,更新答案
                if (tot == sum) ans = Math.max(ans, i - j + 1);
            }
        }
        return ans;
    }
}

该方法参考自:https://leetcode-cn.com/problems/longest-substring-with-at-least-k-repeating-characters/solution/xiang-jie-mei-ju-shuang-zhi-zhen-jie-fa-50ri1/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值