算法总结之滑动窗口
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法总结之滑动窗口相关的知识,希望对你有一定的参考价值。
参考技术A 滑动窗口类问题是面试当中的高频题,问题本身其实并不复杂,但是实现起来细节思考非常的多,想着想着可能因为变量变化,指针移动等等问题,导致程序反复删来改去,有思路,但是程序写不出是这类问题最大的障碍。
本文会将 LeetCode 里面的大部分滑动窗口问题分析、总结、分类,并提供一个可以参考的模版
滑动窗口这类问题一般需要用到 双指针 来进行求解,另外一类比较特殊则是需要用到特定的数据结构,如 Map,队列等。
题目问法大致有这几种
1 . 给两个字符串,一长一短,问其中短的是否在长的中满足一定的条件存在
2 . 给一个字符串或者数组,问这个字符串的子串或者子数组是否满足一定的条件
除此之外,还有一些其他的问法,但是不变的是,这类题目脱离不开主串(主数组)和子串(子数组)的关系,要求的时间复杂度往往是 O ( N ) O(N) O(N) ,空间复杂度往往是 O ( 1 ) O(1) O(1) 的。
根据前面的描述,滑动窗口就是这类题目的重点,换句话说, 窗口的移动 就是重点!我们要控制前后指针的移动来控制窗口,这样的移动是有条件的,也就是要想清楚在什么情况下移动,在什么情况下保持不变。
思路是保证右指针每次往前移动一格,每次移动都会有新的一个元素进入窗口,这时条件可能就会发生改变,然后根据当前条件来决定左指针是否移动,以及移动多少格。
无重复字符的最长子串
给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。
示例
解题思路
输入只有一个字符串,要求子串里面不能够有重复的元素,这里 count 都不需要定义,直接判断哈希散列里面的元素是不是在窗口内即可,是的话得移动左指针去重。
建立一个 128 位大小的整型数组,用来建立字符和其出现位置之间的映射。维护一个滑动窗口,窗口内的都是没有重复的字符,去尽可能的扩大窗口的大小,窗口不停的向右滑动。
替换后的最长重复字符
给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。
示例
解题思路
最简单的方法就是把哈希散列遍历一边找到最大的字符数量,但是仔细想想如果我们每次新进元素都更新这个最大数量,且只更新一次,我们保存的是当前遍历过的全局的最大值,它肯定是比实际的最大值大的,我们左指针移动的条件是 right - left + 1 - count > k,保存的结果是 result = Math.max(result, right - left + 1); 这里 count 比实际偏大的话,虽然导致左指针不能移动,但是不会记录当前的结果,所以最后的答案并不会受影响。
长度为 K 的无重复字符子串
给你一个字符串 S,找出所有长度为 K 且不含重复字符的子串,请你返回全部满足要求的子串的数目。
示例
解题思路
根据题意我们发现相当于窗口大小固定为K,同时在窗口内必须没有重复的字符。我们用左右指针可以计算出当前窗口的大小right - left + 1,同时再利用一个count对字符种类进行计数(也可以直接用一个boolean值即可),那么很容易可以得出当right - left + 1 > K 或者 count > 0时需要移动左指针了。剩下的部分就是愉快地套用模板啦。
至多包含两个不同字符的最长子串
给定一个字符串 s ,找出至多包含两个不同字符的最长子串 t 。
示例
解题思路
类似于上一题,不过我们用count来记录当前窗口内字符的种类数量,当出现新字符以及滑动左指针时,做相应的判断来改变count,窗口大小始终保持在满足条件至多两个不同字符的情况下。
至多包含 K 个不同字符的最长子串
给定一个字符串 s ,找出 至多 包含 k 个不同字符的最长子串 T。
示例
解题思路
和上一题完全一样的思路,只需要把判断窗口条件的地方改成 count > k ,又一题困难被我们直接秒杀。
下面来看看两个字符串的情况
最小覆盖子串
给你一个字符串 S、一个字符串 T,请在字符串 S 里面找出:包含 T 所有字母的最小子串。
示例
解题思路
同样是两个字符串之间的关系问题,因为题目求的最小子串,也就是窗口的最小长度,说明这里的窗口大小是可变的,这里移动左指针的条件变成,只要左指针指向不需要的字符,就进行移动。
字符串的排列
给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的排列。
示例
解题思路
首先窗口是固定的,窗口长度就是s1的长度,也就是说,右指针移动到某个位置后,左指针必须跟着一同移动,且每次移动都是一格,count 用来记录窗口内满足条件的元素,直到 count 和窗口长度相等即可。
找到字符串中所有字母异位词
给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。
示例
解题思路
和上一题完全一致的思路,窗口固定为p串的长度。
最后来看看数组类型的题吧
最大连续1的个数 III
给定一个由若干 0 和 1 组成的数组 A,我们最多可以将 K 个值从 0 变成 1 。返回仅包含 1 的最长(连续)子数组的长度。
示例
解题思路
这题有点像上面的 替换后的最长重复字符,只不过把字符串换成了数组,由于只有两种数字 0 和 1,并且只求连续 1 的长度,我们可以连 hash 映射都不需要了,直接计算遍历到的 0 的个数即可。
K 个不同整数的子数组
给定一个正整数数组 A,如果 A 的某个子数组中不同整数的个数恰好为 K,则称 A 的这个连续、不一定独立的子数组为好子数组。
示例
解题思路
这题比较 tricky 的一个地方在于,这里不是求最小值最大值,而是要你计数。
但是如果每次仅仅加 1 的话又不太对,例如 A = [1,2,1,2,3], K = 2 这个例子,假如右指针移到 index 为 3 的位置,如果按之前的思路左指针根据 count 来移动,当前窗口是 [1,2,1,2],但是怎么把 [2,1] 给考虑进去呢?
可以从数组和子数组的关系来思考!
假如 [1,2,1,2] 是符合条件的数组,如果要计数的话,[1,2,1,2] 要求的结果是否和 [1,2,1] 的结果存在联系?这两个数组的区别在于多了一个新进来的元素,之前子数组计数没考虑到这个元素,假如把这个元素放到之前符合条件的子数组中组成的新数组也是符合条件的,我们看看这个例子中所有满足条件的窗口以及对应的满足条件的子数组情况:
你可以看到对于一段连续的数组,新的元素进来,窗口增加 1,每次的增量都会在前一次增量的基础上加 1。当新的元素进来打破当前条件会使这个增量从新回到 1,这样我们左指针移动条件就是只要是移动不会改变条件,就移动,不然就停止。
至此,本文用同一个框架解决了多道滑动窗口的题目,这类问题思维复杂度并不高,但是出错点往往在细节。记忆常用的解题模版还是很有必要的,特别是对于这种变量名多,容易混淆的题型。有了这个框架,思考的点就转化为 “什么条件下移动左指针”,无关信息少了,思考加实现自然不是问题。
Redis之zset实现滑动窗口限流
本文已收录于专栏
上千人点赞收藏的,全套Redis学习资料,大厂必备技能!
目录
1、需求
限定用户的某个行为在指定时间T内,只允许发生N次。假设T为1秒钟,N为1000次。
2、常见的错误设计
程序员设计了一个在每分钟内只允许访问1000次的限流方案,如下图01:00s-02:00s之间只允许访问1000次,这种设计最大的问题在于,请求可能在01:59s-02:00s之间被请求1000次,02:00s-02:01s之间被请求了1000次,这种情况下01:59s-02:01s间隔0.02s之间被请求2000次,很显然这种设计是错误的。
3、滑动窗口算法
3.1 解决方案
指定时间T内,只允许发生N次。我们可以将这个指定时间T,看成一个滑动时间窗口(定宽)。我们采用Redis的zset基本数据类型的score来圈出这个滑动时间窗口。在实际操作zset的过程中,我们只需要保留在这个滑动时间窗口以内的数据,其他的数据不处理即可。
- 每个用户的行为采用一个zset存储,score为毫秒时间戳,value也使用毫秒时间戳(比UUID更加节省内存)
- 只保留滑动窗口时间内的行为记录,如果zset为空,则移除zset,不再占用内存(节省内存)
3.2 pipeline代码实现
代码的实现的逻辑是统计滑动窗口内zset中的行为数量,并且与阈值maxCount直接进行比较就可以判断当前行为是否被允许。这里涉及多个redis操作,因此使用pipeline可以大大提升效率
package com.lizba.redis.limit;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;
/**
* <p>
* 通过zset实现滑动窗口算法限流
* </p>
*
* @Author: Liziba
* @Date: 2021/9/6 18:11
*/
public class SimpleSlidingWindowByZSet {
private Jedis jedis;
public SimpleSlidingWindowByZSet(Jedis jedis) {
this.jedis = jedis;
}
/**
* 判断行为是否被允许
*
* @param userId 用户id
* @param actionKey 行为key
* @param period 限流周期
* @param maxCount 最大请求次数(滑动窗口大小)
* @return
*/
public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
String key = this.key(userId, actionKey);
long ts = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
pipe.multi();
pipe.zadd(key, ts, String.valueOf(ts));
// 移除滑动窗口之外的数据
pipe.zremrangeByScore(key, 0, ts - (period * 1000));
Response<Long> count = pipe.zcard(key);
// 设置行为的过期时间,如果数据为冷数据,zset将会删除以此节省内存空间
pipe.expire(key, period);
pipe.exec();
pipe.close();
return count.get() <= maxCount;
}
/**
* 限流key
*
* @param userId
* @param actionKey
* @return
*/
public String key(String userId, String actionKey) {
return String.format("limit:%s:%s", userId, actionKey);
}
}
测试代码:
package com.lizba.redis.limit;
import redis.clients.jedis.Jedis;
/**
*
* @Author: Liziba
* @Date: 2021/9/6 20:10
*/
public class TestSimpleSlidingWindowByZSet {
public static void main(String[] args) {
Jedis jedis = new Jedis("192.168.211.108", 6379);
SimpleSlidingWindowByZSet slidingWindow = new SimpleSlidingWindowByZSet(jedis);
for (int i = 1; i <= 15; i++) {
boolean actionAllowed = slidingWindow.isActionAllowed("liziba", "view", 60, 5);
System.out.println("第" + i +"次操作" + (actionAllowed ? "成功" : "失败"));
}
jedis.close();
}
}
测试效果:
从测试输出的数据可以看出,起到了限流的效果,从第11次以后的请求操作都是失败的,但是这个和我们允许的5次误差还是比较大的。这个问题的原因是我们测试System.currentTimeMillis()的毫秒可能相同,而且此时value也是System.currentTimeMillis()也相同,会导致zset中元素覆盖!
修改代码测试:
在循环中睡眠1毫秒即可,测试结果符合预期!
TimeUnit.MILLISECONDS.sleep(1);
3.3 lua代码实现
我们在项目中使用原子性的lua脚步来实现限流的使用会更多,因此这里也提供一个基于操作zset的lua版本
package com.lizba.redis.limit;
import com.google.common.collect.ImmutableList;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;
/**
* <p>
* 通过zset实现滑动窗口算法限流
* </p>
*
* @Author: Liziba
* @Date: 2021/9/6 18:11
*/
public class SimpleSlidingWindowByZSet {
private Jedis jedis;
public SimpleSlidingWindowByZSet(Jedis jedis) {
this.jedis = jedis;
}
/**
* lua脚本限流
*
* @param userId
* @param actionKey
* @param period
* @param maxCount
* @return
*/
public boolean isActionAllowedByLua(String userId, String actionKey, int period, int maxCount) {
String luaScript = this.buildLuaScript();
String key = key(userId, actionKey);
long ts = System.currentTimeMillis();
System.out.println(ts);
ImmutableList<String> keys = ImmutableList.of(key);
ImmutableList<String> args = ImmutableList.of(String.valueOf(ts),String.valueOf((ts - period * 1000)), String.valueOf(period));
Number count = (Number) jedis.eval(luaScript, keys, args);
return count != null && count.intValue() <= maxCount;
}
/**
* 限流key
*
* @param userId
* @param actionKey
* @return
*/
private String key(String userId, String actionKey) {
return String.format("limit:%s:%s", userId, actionKey);
}
/**
* 针对某个key使用lua脚本限流
*
* @return
*/
private String buildLuaScript() {
return "redis.call('ZADD', KEYS[1], tonumber(ARGV[1]), ARGV[1])" +
"\\nlocal c" +
"\\nc = redis.call('ZCARD', KEYS[1])" +
"\\nredis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[2]))" +
"\\nredis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))" +
"\\nreturn c;";
}
}
测试代码不变,大家可以自行测试,记得还是要考虑我们测试的时候System.currentTimeMillis()相等的问题,不信你输出System.currentTimeMillis()就知道了!多思考问题,技术其实都在心里!
以上是关于算法总结之滑动窗口的主要内容,如果未能解决你的问题,请参考以下文章