【leetcode】滑动窗口类问题

news/2024/5/20 8:59:16 标签: 算法, leetcode, 滑动窗口, TreeSet, 双指针

滑动窗口算法中一种重要的解题思想,通过分析力扣中的几道题目来学习滑动窗口的思想。

文章目录

一、更贴合笔试的滑动窗口综合题

1.1 题目分析

题目链接:220. 存在重复元素 III

题目描述

给你一个整数数组 nums 和两个整数 k 和 t 。请你判断是否存在 两个不同下标 i 和 j,使得 abs(nums[i] - nums[j]) <= t ,同时又满足 abs(i - j) <= k 。

如果存在则返回 true,不存在返回 false。

示例一

输入:nums = [1,2,3,1], k = 3, t = 0
输出:true

示例二

输入:nums = [1,5,9,1,5,9], k = 2, t = 3
输出:false

题目分析

根据题意,对于任意一个位置 i(假设其值为 u),我们其实是希望在下标范围为 [max(0, i-k), i] 内找到值范围在 [u-t, u+t] 的数。

一个朴素的想法是每次遍历到任意位置 i 的时候,往后检查 k 个元素,但这样做的复杂度是O(nk) 的,会超时。

显然我们需要优化「检查后面 k 个元素」这一过程。

我们希望使用一个「有序集合」去维护长度为 k 的滑动窗口内的数,该数据结构最好支持高效「查询」与「插入/删除」操作:

  • 查询:能够在「有序集合」中应用「二分查找」,快速找到「小于等于的最大值」和「大于等于 u 的最小值」(即「有序集合」中的最接近 u 的数)。
  • 插入/删除:在往「有序集合」添加或删除元素时,能够在低于线性的复杂度内完成(维持有序特性)。

或许你会想到近似 操作的 HashMap,但注意这里我们需要找的是符合abs(nums[i] - nums[j]) <= t 的两个值,nums[i] 与 nums[j] 并不一定相等,而 HashMap 无法很好的支持「范围查询」操作。

我们还会想到「」结构。

例如 AVL,能够让我们在最坏为 O(logk)的复杂度内取得到最接近 u 的值是多少,但本题除了「查询」以外,还涉及频繁的「插入/删除」操作(随着我们遍历 nums 的元素,滑动窗口不断右移,我们需要不断的往「有序集合」中删除和添加元素)。

简单采用 AVL 树,会导致每次的插入删除操作都触发 AVL 的平衡调整,一次平衡调整会伴随着若干次的旋转。

红黑树则很好解决了上述问题:将平衡调整引发的旋转的次数从「若干次」限制到「最多三次」

因此,当「查询」动作和「插入/删除」动作频率相当时,更好的选择是使用「红黑树」。

也就是对应到 Java 中的 TreeSet 数据结构基于红黑树,查找和插入都具有折半的效率)。

1.2 滑动窗口+有序集合

实现方面,我们在有序集合 TreeSet 中查找大于等于 x−t 的最小的元素 y,如果 y 存在,且 y ≤ x+t,我们就找到了一对符合条件的元素。完成检查后,我们将 x 插入到有序集合中,如果有序集合中元素数量超过了 k,我们将有序集合中最早被插入的元素删除即可。

其他细节:由于 nums 中的数较大,会存在 int 溢出问题,我们需要使用 long 来存储。

class Solution {
    public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
        // TreeSet,基于红黑树,查找和插入都具有折半的效率
        int n = nums.length;
        TreeSet<Long> set = new TreeSet<Long>();
        for (int i = 0; i < n; i++) {
            Long ceiling = set.ceiling((long) nums[i] - (long) t);  // 返回集合中大于或等于nums[i] - t的最小元素
            if (ceiling != null && ceiling <= (long) nums[i] + (long) t) {
                return true;
            }
            set.add((long) nums[i]);  // 滑动窗口移动
            if (i >= k) {
                set.remove((long) nums[i - k]);
            }
        }
        return false;
    }
}

TreeSet函数

ceiling(E e) 方法返回在这个集合中大于或者等于给定元素的最小元素,如果不存在这样的元素,返回 null.
floor(E e) 方法返回在这个集合中小于或者等于给定元素的最大元素,如果不存在这样的元素,返回null.
remove(Object O) 删除指定元素
add(Object O) 添加元素

1.3 桶排序

上述解法无法做到线性的原因是:我们需要在大小为 k 的滑动窗口所在的「有序集合」中找到与 u 接近的数

如果我们能够将 k 个数字分到 个桶的话,那么我们就能 O(1) 的复杂度确定是否有 [u - t, u + t] 的数字(检查目标桶是否有元素)。

具体的做法为:令桶的大小为 size = t + 1,根据 u 计算所在桶编号:

  • 如果已经存在该桶,说明前面已有 [u - t, u + t] 范围的数字,返回 true
  • 如果不存在该桶,则检查相邻两个桶的元素是有[u - t, u + t]范围的数字,如有 返回 true
  • 建立目标桶,并删除下标范围不在 [max(0, i-k), i] 内的桶
class Solution {
    public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
        // 桶排序
        long size;
        int n = nums.length;
        Map<Long, Long> map = new HashMap<>();
        size = t + 1L;
        for (int i = 0; i < n; i++) {
            long u = nums[i] * 1L;
            long idx = getIdx(u, size);
            // 目标桶已存在(桶不为空),说明前面已有 [u - t, u + t] 范围的数字
            if (map.containsKey(idx)) return true;
            // 检查相邻的桶
            long l = idx - 1, r = idx + 1;
            if (map.containsKey(l) && u - map.get(l) <= t) return true;
            if (map.containsKey(r) && map.get(r) - u <= t) return true;
            // 建立目标桶
            map.put(idx, u);
            // 移除下标范围不在 [max(0, i - k), i) 内的桶
            if (i >= k) map.remove(getIdx(nums[i - k] * 1L, size));
        }
        return false;
    }
    long getIdx(long u, long size) {
        return u >= 0 ? u / size : ((u + 1) / size) - 1;
    }
}

理解 getIdx() 逻辑

  1. 为什么 size 需要对 t 进行 +1 操作?

    目的是为了确保差值小于等于 t 的数能够落到一个桶中

    举个 🌰,假设 [0,1,2,3],t = 3,显然四个数都应该落在同一个桶。

    如果不对 t 进行 +1 操作的话,那么 [0,1,2][3] 会被落到不同的桶中,那么为了解决这种错误,我们需要对 t 进行 +1 作为 size

    这样我们的数轴就能被分割成:

    0 1 2 3 | 4 5 6 7 | 8 9 10 11 | 12 13 14 15 |

    总结一下,令 size = t + 1 的本质是因为差值为 t 两个数在数轴上相隔距离为 t + 1,它们需要被落到同一个桶中。

    当明确了 size 的大小之后,对于正数部分我们则有 idx = nums[i] / size

  2. 如何理解负数部分的逻辑?

    由于我们处理正数的时候,处理了数值 0,因此我们负数部分是从 -1 开始的。

    还是我们上述 🌰,此时我们有 t = 3size = t + 1 = 4

    考虑 [-4,-3,-2,-1] 的情况,它们应该落在一个桶中。

    如果直接复用 idx = nums[i] / size 的话,[-4][-3,-2,-1] 会被分到不同的桶中。

    根本原因是我们处理整数的时候,已经分掉了数值 0

    这时候我们需要先对 nums[i] 进行 +1 操作(即将负数部分在数轴上进行整体右移),即得到 (nums[i] + 1) / size

    这样一来负数部分与正数部分一样,可以被正常分割了。

    但由于 0 号桶已经被使用了,我们还需要在此基础上进行 -1,相当于将负数部分的桶下标(idx)往左移,即得到 ((nums[i] + 1) / size) - 1

二、字符串滑动窗口

2.1 题目分析

题目链接:424. 替换后的最长重复字符

题目描述

给定一个字符串 s 和一个整数 k 。您可以选择字符串中的任意字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。

在执行上述操作后,返回 包含相同字母的最长子字符串的长度 。

示例一

输入:s = "ABAB", k = 2
输出:4
解释:用两个'A'替换为两个'B',反之亦然。

示例二

输入:s = "AABABBA", k = 1
输出:4
解释:
将中间的一个'A'替换为'B',字符串变为 "AABBBBA"。
子串 "BBBB" 有最长重复字母, 答案为 4

题目分析

枚举字符串中的每一个位置作为右端点,然后找到其最远的左端点的位置,满足该区间内除了出现次数最多的那一类字符之外,剩余的字符(即非最长重复字符)数量不超过 k 个。

这样我们可以想到使用双指针维护这些区间,每次右指针右移,如果区间仍然满足条件,那么左指针不移动,否则左指针至多右移一格,保证区间长度不减小

2.2 滑动窗口+双指针

class Solution {
    public int characterReplacement(String s, int k) {
        int[] num = new int[26];
        int n = s.length();
        int max = 0;  // 字母出现最大次数
        int left = 0, right = 0;
        while (right < n) {
            num[s.charAt(right) - 'A']++;
            max = Math.max(max, num[s.charAt(right) - 'A']);
            // len - 字母出现最大次数 > 替换次数K => len > 字母出现最大次数 + 替换次数k
            // 则字母出现最大次数越大,len越大
            if (right - left + 1 - max > k) {
                num[s.charAt(left) - 'A']--;
                left++;
            }
            right++;
        }
        return right - left;
    }
}

三、滑动窗口转化问题

3.1 题目分析

题目链接:1052. 爱生气的书店老板

题目描述:

有一个书店老板,他的书店开了 n 分钟。每分钟都有一些顾客进入这家商店。给定一个长度为 n 的整数数组 customers ,其中 customers[i] 是在第 i 分钟开始时进入商店的顾客的编号,所有这些顾客在第 i 分钟结束后离开。

在某些时候,书店老板会生气。 如果书店老板在第 i 分钟生气,那么 grumpy[i] = 1,否则 grumpy[i] = 0。

当书店老板生气时,那一分钟的顾客就会不满意,若老板不生气则顾客是满意的。

书店老板知道一个秘密技巧,能抑制自己的情绪,可以让自己连续 minutes 分钟不生气,但却只能使用一次。

请你返回 这一天营业下来,最多有多少客户能够感到满意 。

示例一:

输入:customers = [1,0,1,2,1,1,7,5], grumpy = [0,1,0,1,0,1,0,1], minutes = 3
输出:16
解释:书店老板在最后 3 分钟保持冷静。
感到满意的最大客户数量 = 1 + 1 + 1 + 1 + 7 + 5 = 16.

示例二:

输入:customers = [1], grumpy = [0], minutes = 1
输出:1

题目分析:

书店老板的秘密技巧只会将生气变为不生气不生气仍然是不生气

  1. 可以先将原来就满意的客户加入答案,同时将对应的customers[i]变为0。
  2. 之后问题转化为,在customers 中找到连续一段长度为minutes 的子数组,使得其总和最大。

3.2 滑动窗口 + 双指针

代码实现:

class Solution {
    public int maxSatisfied(int[] customers, int[] grumpy, int minutes) {
        int res = 0;
        for (int i = 0; i < customers.length; i++) {
            if (grumpy[i] == 0) {  // 将老板不生气的时间的顾客数加起来,且置为0
                res += customers[i];
                customers[i] = 0;
            }
        }
        // 滑动窗口找剩余的顾客中最大的连续minutes的数
        int cur = 0, max = 0;
        for (int i = 0; i < customers.length; i++) {
            cur += customers[i];
            if (i >= minutes) {  // 超过minutes,将最前面的删掉,窗口滑动
                cur -= customers[i - minutes];
            }
            max = Math.max(max, cur);  // max存储滑动窗口最大值
        }
        return res + max;
    }
}

参考

滑动窗口专题】更贴合笔试/面试的滑动窗口综合题

存在重复元素 III 官方题解

滑动窗口专题】字符串滑动窗口运用题

滑动窗口专题】众多滑动窗口变形题的原题


http://www.niftyadmin.cn/n/1727759.html

相关文章

js去掉html中的注释

//去掉html中的注释function filterNotes(str) {var tmp str.substring(str.indexOf("<!--"), str.lastIndexOf("-->") 3);return str.replace(tmp, "");}

设计模式面试突击

Java面试中设计模式的常见面试题总结。 其它面试知识点突击整理&#xff1a; 序号文章1Java基础面试突击2JVM面试突击3设计模式面试突击4并发编程面试突击5消息队列Kafka面试突击6Redis面试突击7计算机网络面试突击8Spring面试突击9Dubbo面试突击10MyBatis面试突击11操作系统…

Flink面试突击

大数据方面的面试总结汇总&#xff0c;本篇为Flink的面试总结。 Flink面试突击Spark面试突击 文章目录一、简单介绍一下 Flink二、Flink 相比传统的 Spark Streaming 区别?三、为什么说 Flink 统一了流和批处理&#xff1f;四、Flink是如何支持批流一体的&#xff1f;五、Fli…

iview-admin如何修改调试端口号

默认端口是8080&#xff0c;修改成别的需要在launch.json文件中修改&#xff0c;开发工具我用的是vscode

Java的HashMap原理总结(问答式学习)

HashMap原理详解&#xff0c;包括面试会问到的一些问题的总结。 Java重要知识点总结如下&#xff1a; 序号文章1Java并发的CAS原理详解2Java并发的ABA原理详解3Java的18种Queue4一篇文章整理Java的volatile5Java集合的线程不安全6Java中的21种锁7JVM进阶之思维导图8Java的Has…

jgrid指定序号列宽度

gridComplete: function () { $("#" this.id).setSelection(selectedRowIndex, false); $("#gridTable_rn").width(45); $("#gridTable tr td:eq(0)").width(45); }

uniapp Cannot find module ‘uni-read-pages‘解决方案

npm install uni-simple-router 突然uniapp无法运行启动了 报错 ERROR Error loading vue.config.js: ERROR Error: Cannot find module uni-read-pages vue.config.js const TransformPages require(uni-read-pages) 找不到 是因为缺少了组件&#xff0c;目的对症下药…