《挑战程序设计竞赛》这本书中把滑动窗口叫做「虫取法」,我觉得非常生动形象。因为滑动窗口的两个指针移动的过程和虫子爬动的过程非常像:前脚不动,把后脚移动过来;后脚不动,把前脚向前移动。
我分享一个滑动窗口的模板,能解决大多数的滑动窗口问题:
def findSubArray(nums):
N = len(nums) # 数组/字符串长度
left, right = 0, 0 # 双指针,表示当前遍历的区间[left, right],闭区间
sums = 0 # 用于统计 子数组/子区间 是否有效,根据题目要求可能会改成求和/计数等
res = 0 # 保存最大的满足题目要求的 子数组/子串 长度
while right < N: # 当右边的指针没有搜索到 数组/字符串 的结尾
sums += nums[right] # 增加当前右边指针的数字/字符的求和/计数
while 区间[left, right]不符合题意:# 此时需要**一直移动**左指针,**直至找到**一个符合题意的区间,**注意是while不是if**
sums -= nums[left] # 移动左指针前需要从counter中减少left位置字符的求和/计数
left += 1 # 真正的移动左指针,注意不能跟上面一行代码写反
# 到 while 结束时,我们找到了一个符合题意要求的 子数组/子串
res = max(res, right - left + 1) # 需要更新结果
right += 1 # 移动右指针,去探索新的区间
return res
滑动窗口中用到了左右两个指针,它们移动的思路是:
以右指针作为驱动,拖着左指针向前走。右指针每次只移动一步,而左指针在内部 while循环中每次可能移动多步。右指针是主动前移,探索未知的新区域;左指针是被迫移动,负责寻找满足题意的区间。
模板的整体思想是:
- 定义两个指针 left 和 right 分别指向区间的开头和结尾,注意是闭区间;定义 sums 用来统计该区间内的各个字符出现次数;
- 第一重while 循环是为了判断 right 指针的位置是否超出了数组边界;当 right 每次到了新位置,需要增加 right指针的求和/计数;
- 第二重 while 循环是让 left 指针向右移动到 [left, right] 区间符合题意的位置;当 left每次移动到了新位置,需要减少 left 指针的求和/计数;
- 在第二重 while 循环之后,成功找到了一个符合题意的 [left, right] 区间,题目要求最大的区间长度,因此更新 res 为 max(res, 当前区间的长度) 。
- right指针每次向右移动一步,开始探索新的区间。
模板中的 sums 需要根据题目意思具体去修改,若是求和题目就把sums 定义成整数用于求和;如果是计数题目,就需要改成字典用于计数。当左右指针发生变化的时候,都需要更新 sums 。
另外一个需要根据题目去修改的是内层 while 循环的判断条件,即: 区间[left, right]不符合题意 。对于下面1208题而言,就是该区内的和 sums 超过了 maxCost 。
给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。
注意:字符串长度 和 k 不会超过 104。
如果一个问题暂时没有思路,可以先考虑暴力解法(不一定要实现)。当前问题的暴力解法是:枚举输入字符串的 所有 子串,对于每一个子串:
- 如果子串里所有的字符都一样,就考虑长度更长的子串;
- 如果当前子串里出现了至少两种字符,要想使得替换以后所有的字符都一样,并且重复的、连续的部分更长,应该替换掉出现次数最多字符 以外 的字符。
暴力解法的缺点:
- 做了重复的工作,子串和子串有很多重合的部分,重复扫描它们是不划算的;
- 做了很多没有必要的工作:
- 如果找到了一个长度为 L 且替换 k个字符以后全部相等的子串,就没有必要考虑长度小于等于 L 的子串,因为题目只让我们找到符合题意的最长的长度;
- 如果找到了一个长度为 L且替换 k 个字符以后不能全部相等的子串,左边界相同、长度更长的子串一定不符合要求(原因我们放在最后说)。
优化暴力解法,我们须要研究一些典型的例子并结合题意找到思路。
如: s = “AABABBA”, k = 1
max 记录窗口内相同字符最多的次数
遍历字符串, 窗口往右扩张 一旦 窗口大小 大于 max + k, 则窗口左边收缩 (窗口内可替换 k个其他字符 为 出现最多的字符)
窗口扩张: left: 0, right: 0, 窗口: [ A ]ABABBA 窗口扩张: left: 0, right: 1, 窗口: [ AA ]BABBA 窗口扩张: left: 0, right: 2, 窗口: [ AAB ]ABBA 窗口扩张: left: 0, right: 3, 窗口: [ AABA ]BBA 移动左边: left: 1, right: 4, 窗口: A[ ABAB ]BA 移动左边: left: 2, right: 5, 窗口: AA[ BABB ]A 移动左边: left: 3, right: 6, 窗口: AAB[ ABBA ]
遍历完后, 只要看窗口大小即可
整个过程,我们使用了两个表示边界的变量,一前一后,交替在字符串上前进:右边界先向右和移动,直到它不能移动了为止,左边界再继续向右移动,整个过程像极了一个滑动的窗口在一条线段上移动。
我们还一直关心的是:考虑的子串中最多出现的字符是次数,因此须要一个频数数组,记录每个字符出现的次数。
方法:双指针(滑动窗口) 右边界先移动找到一个满足题意的可以替换 k个字符以后,所有字符都变成一样的当前看来最长的子串,直到右边界纳入一个字符以后,不能满足的时候停下;
然后考虑左边界向右移动,左边界只须要向右移动一格以后,右边界就又可以开始向右移动了,继续尝试找到更长的目标子串;
替换后的最长重复子串就产生在右边界、左边界交替向右移动的过程中。
class Solution {
public:
int characterReplacement(string s, int k) {
vector<int> counts(26, 0);//记录当前窗口中每个字母出现的次数
int left = 0, right = 0, maxCount = 0;// maxCount记录字符出现次数最多那个字符的次数
while(right < s.length()){
counts[s[right] - 'A']++;
maxCount = max(maxCount, counts[s[right] - 'A']);
// 若当前窗口大小 减去 窗口中最多相同字符的个数 大于 k 时,窗口右移一位,相应的左边界的字母个数-1
if(right - left + 1 - maxCount > k){
counts[s[left] - 'A']--;// 将窗口最左边的字符 在计数数组中减1
left++;// 整个窗口右移一位
}
right++;
}
return right - left;//窗口大小即为最长长度,此处不用+1,因为上面while循环结束后right会多+1
}
};
「力扣」第 1004 题:最大连续 1 的个数 III;
「力扣」第 1208 题:尽可能使字符串相等;
「力扣」第 1493 题:删掉一个元素以后全为 1 的最长子数组。
「滑动窗口」是一类使用「双指针」技巧,通过两个变量在数组上同向交替移动解决问题的算法。
这一类问题的思考路径通常是:先思考暴力解法,分析暴力解法的缺点(一般而言暴力解法的缺点是重复计算),然后结合问题的特点,使用「双指针」技巧对暴力解法进行剪枝。因此,思考算法设计的合理性是更关键的,这一点适用于所有算法问题。
「力扣」第 3 题:无重复字符的最长子串;
「力扣」第 209 题:长度最小的子数组;
「力扣」第 76 题:最小覆盖子串;
「力扣」第 438 题:找到字符串中所有字母异位词;
「力扣」第 567 题:字符串的排列。
中位数是有序序列最中间的那个数。如果序列的长度是偶数,则没有最中间的数;此时中位数是最中间的两个数的平均数。
例如:
[2,3,4],中位数是 3 [2,3],中位数是 (2 + 3) / 2 = 2.5 给你一个数组 nums,有一个长度为 k
的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1
位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。
该题描述很直白,要解决不难,可以直接暴力取每个窗口值排序取中位数,此处略去代码。但遇到大量输入数据会超时。
难就难在如何利用数据结构+算法去优化,此处提供两种思路,法2更优。
怎么快速求中位数呢?为了降低时间复杂度的一个绝招就是空间换时间:利用更好的数据结构。是的,我们的目的是快速让一组数据有序,那就寻找一个内部是有序的数据结构呗!下面我分语言讲解一下常见的内部有序的数据结构。
- 在 C++ 中 set/multiset 是有序的集合,它们是基于红黑树实现的。其中 set 会对元素去重,而 multiset 可以有重复元素。
- 在 Java 中 TreeSet 是有序的集合,它也是基于红黑树实现的。 TreeSet 会对元素去重。
- 在 Python 中 heapq 实现了堆的算法,它不会对元素去重。 当频繁的插入和删除元素时,这些有序的数据结构能够在在 O(log(k))
的时间复杂度内维护结构的有序性。但是对于红黑树实现的数据结构来说,不能做随机读取,因此获取中位数的时候,也只能通过 O(k)
时间复杂度的查找。
- 首先定义了结果数组 res 和 multiset;
- 遍历输入中的每个元素;
- 如果 multiset 中的元素超过了 k 个,需要把滑动窗口最左边 i - k 位置元素从 multiset 中删除(multiset 自动维护有序性);
- 把遍历到的当前元素插入到 multiset 中(multiset 自动维护有序性);
- 如果当前的位置达到了下标 k - 1,说明滑动窗口中有 k 个元素,开始求滑动窗口的中位数。
我们知道,如果数组 A 长度 k 是奇数时,令 mid = k / 2 ,那么中位数元素是 A[mid] ;如果数组长度 k为偶数时,那么中位数是 (A[mid] + A[mid - 1]) / 2 。为了适应奇数和偶数长度,可以用通用的表达式 (A[mid] +A[mid - (1 - k % 2)]) / 2来求中位数。
求 multiset 里的中位数:我们先求 multiset 中所有元素的起始位置(最小元素),然后在此基础上让指针走 k / 2 步得到mid ,最终 (A[mid] + A[mid - (1 - k % 2)]) / 2 就是我们要求的中位数。
class Solution {
public:
vector<double> medianSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
multiset<double> mst;//multiset维护窗口内数字有序性O(log(k)),但无法快速查找,必须迭代器偏移,O(k)
vector<double> ans;
//模拟滑动窗口,满k个后,左边出,右边进
for(int i = 0; i < n; i++){
//i为右边界
if(mst.size() >= k) mst.erase(mst.find(nums[i - k]));//删除左边的数
mst.insert(nums[i]);//加入右边的数
if(i >= k -1){
multiset<double>::iterator mid = mst.begin();
std::advance(mid, k / 2);//迭代器偏移到中间位置
ans.push_back((*mid + *prev(mid, (1 - k % 2))) / 2);//根据k%2 - 1来统一奇偶两种情况的中位数计算
}
}
return ans;
}
};
本题考查动态维护数组的中位数。
我们思考中位数的性质:如果一个数是中位数,那么在这个数组中,大于中位数的数目和小于中位数的数目,要么相等,要么就相差一。
因此,我们采用对顶堆的做法,控制所有小于等于的数字放到一个堆中,控制所有比中位数大的数字放到另一个堆中,并且保证两个堆的数目相差小于等于1。这样就可以保证每一次查询中位数的时候,答案一定出于两个堆的堆顶元素之一。
因此选定数据结构:优先队列。因为优先队列采用的是堆结构,正好符合我们的需求。我们将所有小于等于中位数的元素放到small堆中(是一个大顶堆),将所有大于中位数的元素放到big堆中(是一个小顶堆)。
初始化方法如下:
将前K个元素全部插入到small堆中。从small堆中弹出K/2个元素到big堆中。
这样,当K为奇数,则small堆元素比big堆元素多1;当K为偶数,两个堆元素相等。取中位数的操作:
我们的插入操作可以保证每次优先插入到small堆中,因此small堆中的元素个数大于等于big堆的元素个数。
- 当K为奇数时候,中位数是元素数量较多的small堆 堆顶元素。
- 当K为偶数时候,中位数是small堆和big堆的堆顶元素平均值。
窗口滑动过程中的操作:
假定在上一次滑动之后,已经有small堆和big堆元素数目相差小于等于1. 设置当前的滑动时,假设balance =0。balance表示small堆元素数目减去big堆元素个数。 删除窗口左侧的元素。
由于堆无法直接删除掉某个指定元素,先欠下这个账,等某次元素出现在堆顶的时候,再删除他。mp记录这个元素欠账的个数。mp[left]++;
虽然没有真的在堆数据结构中删除窗口最左侧的元素,但是在我们的心中已经删掉他了。堆两侧的平衡性发生了变化。
- 如果left<=small.top(),就说明删掉的元素在small堆中,我们让balance–;
- 否则,就说明删掉的元素在big堆中,让balance++;
添加进来窗口右侧的元素:
- 如果right<=small.top(),就应该让这个元素放到samll堆里面,balance++;
- 否则放到big堆里,balance–。
经过上面的操作,balance要么为0,要么为2,要么为-2。我们需要经过调整使得balance为0。
- 如果balance为0,在这次窗口滑动之前已经是平衡的,这次调整也没有让两堆的数目变化,所以不用调整两边的堆。
- 如果balance为2,就说明small堆的元素比big堆的元素多了两个。从small堆减少一个,big堆里增加一个,就可以让两边相等。big.push(small.top());small.pop();
- 如果balance为-2,就说明big堆的元素比small堆的元素多了两个。从big堆减少一个,small堆里增加一个,就可以让两边相等。small.push(big.top());big.pop();
调整完了,现在该欠债还钱了。不能让那些早该删除的元素涉及到中位数的运算。
分别检查两边的堆顶元素,如果堆顶元素欠着债,则弹出堆顶元素,直到堆顶元素没有欠债为止。
有朋友问了:堆顶下面也有欠债的怎么办呢?我们之前说过,取中位数的时候只与堆顶元素有关,至于那些堆顶下面欠着债的,欠着就欠着吧,等他们到堆顶的时候再弹出去就好了。最后,添加中位数即可。
class Solution {
public:
//两个堆各维护一半窗口内的数,那么中位数则在两个堆顶元素中得到
priority_queue<int> small;//默认大顶堆
priority_queue<int, vector<int>, greater<int>> big;//小顶堆
unordered_map<int, int> mp;//记录延迟删除的数
//返回中位数
double get(int& k){
if(k % 2) return small.top();//奇数个
else return ((long)small.top() + big.top()) * 0.5;//偶数个
}
//主要来初始化并按规则维护两个堆
vector<double> medianSlidingWindow(vector<int>& nums, int k) {
//1.初始化:窗口中一半数在small中,一半在big中,且small个数>=big的个数
for(int i = 0; i < k; i++){
small.push(nums[i]);}
for(int i = 0; i < k / 2; i++){
big.push(small.top()); small.pop();}
vector<double> ans{
get(k)};//答案数组,且存入第一个中位数
//2.滑动窗口,并维护两个堆
for(int i = k; i < nums.size(); i++){
int balance = 0;//表示small堆元素数目减去big堆元素个数,假设当前为0
int l = nums[i - k];//当前要移除窗口的数
mp[l]++;//因为priority_queue不好直接删除,此处标记一下后面到堆顶再删除
//判断移除的数在哪,更新balance
if(!small.empty() && l <= small.top()){
balance--;}//移除的数在small中
else{
balance++;}
//判断加入的数在哪,更新balance,加该数入堆
if(!small.empty() && nums[i] <= small.top()){
small.push(nums[i]);
balance++;
}else{
big.push(nums[i]);
balance--;
}
//根据balance的值(0,-2,2三种)调整两个堆的元素个数
if(balance > 0){
//small元素个数过多
big.push(small.top());
small.pop();
}
if(balance < 0){
//big中多
small.push(big.top());
big.pop();
}
//延迟删除:若被标记要删除的数到了堆顶,就删除掉,记得是while不是if
while(!small.empty() && mp[small.top()] > 0){
mp[small.top()]--;
small.pop();
}
while(!big.empty() && mp[big.top()] > 0){
mp[big.top()]--;
big.pop();
}
//窗口滑一次维护完两个堆,就找一次中位数
ans.push_back(get(k));
}
return ans;
}
};
给你两个长度相同的字符串,s 和 t。
将 s 中的第 i 个字符变到 t 中的第 i 个字符需要 |s[i] - t[i]| 的开销(开销可能为 0),也就是两个字符的ASCII 码值的差的绝对值。
用于变更字符串的最大预算是 maxCost。在转化字符串时,总开销应当小于等于该预算,这也意味着字符串的转化可能是不完全的。
如果你可以将 s 的子字符串转化为它在 t 中对应的子字符串,则返回可以转化的最大长度。
如果 s 中没有子字符串可以转化成 t 中对应的子字符串,则返回 0。
两个长度相等字符串的 s 和 t ,把 i 位置的 s[i] 转成 t[i] 的开销是两者 ASCII
码之差的绝对值。题目给出了允许的最大预算 maxCost ,求不超过预算的情况下能够转换的最长子串。比如,对于 s = “abcd”, t = “bcdf”, cost = 3 而言,我们使用 costs[i] 表示从 s[i] 转成
t[i] 的开销,那么 costs = [1, 1, 1, 2] 。由于 maxCost = 3, 所以最多允许其前面三个字符进行转换。
于是题目变成了:已知一个数组 costs ,求:和不超过 maxCost 时最长的子数组的长度。
上面的表达方式跟题目是等价的。对题目抽象之后,是不是跟昨天的每日一题「643. 子数组最大平均数 I」非常像了,也和「424.
替换后的最长重复字符」非常像。这就是坚持刷每日一题的作用,继续坚持啊!抽象之后,我们知道这是一个区间题,求子区间经常使用的方法就是滑动窗口。套模板即可
class Solution {
public:
int equalSubstring(string s, string t, int maxCost) {
int n = s.length();
int left = 0, right = 0;//滑动窗口双指针
int sum = 0, maxLen = INT_MIN;//sum存储窗口内的和
//题中字符串关系转换成差值数组,问题转化为找差值数组满足maxCost的最长子串的和
vector<int> dif(n);
for(int i = 0; i < n; i++){
dif[i] = abs(s[i] - t[i]);
}
//滑动窗口固定写法
while(right < n){
//循环边界条件
sum += dif[right];//窗口内的计算
while(sum > maxCost){
//窗口移动的条件
sum -= dif[left];
left++;
}
maxLen = max(maxLen, right - left + 1);//题目要求解的
right++;
}
return maxLen;
}
};
几张卡牌 排成一行,每张卡牌都有一个对应的点数。点数由整数数组 cardPoints 给出。
每次行动,你可以从行的开头或者末尾拿一张卡牌,最终你必须正好拿 k 张卡牌。
你的点数就是你拿到手中的所有卡牌的点数之和。
给你一个整数数组 cardPoints 和整数 k,请你返回可以获得的最大点数。
当数据规模到达了 10 ^ 5 ,已经在提醒我们这个题应该使用 O(N) 的解法。
把今天的这个问题思路整理一下,题目等价于:
求从 cardPoints 最左边抽 i 个数字,从 cardPoints 最右边抽取 k - i 个数字,能抽取获得的最大点数是多少。
一旦这么想,立马柳暗花明:抽走的卡牌点数之和 = cardPoints 所有元素之和 - 剩余的中间部分元素之和。
现在问题是怎么快速求 剩余的中间部分元素之和?没错,preSum!下面是 preSum 的介绍:
**求区间的和可以用 preSum** 。 preSum 方法还能快速计算指定区间段 i ~ j 的元素之和。它的计算方法是从左向右遍历数组,当遍历到数组的 i 位置时, preSum 表示 i 位置左边的元素之和。
假设数组长度为 N ,我们定义一个长度为 N+1 的 preSum 数组, preSum[i]表示该元素左边所有元素之和(不包含当前元素)。然后遍历一次数组,累加区间 [0, i) 范围内的元素,可以得到 preSum数组。
N = len(nums)
preSum = range(N + 1)
for i in range(N):
preSum[i + 1] = preSum[i] + nums[i]
print(preSum)
利用 preSum 数组,可以在 O(1) 的时间内快速求出 nums 任意区间 [i, j] (两端都包含) 的各元素之和。
sum(i, j) = preSum[i + 1] - preSum[j]
综合以上的思路,我们的想法可以先求 preSum ,然后使用一个 0 ~ k 的遍历表示从左边拿走的元素数,然后根据窗口大小 windowSize = N - k ,利用 preSum 快速求窗口内元素之和。
class Solution(object):
def maxScore(self, cardPoints, k):
"""
:type cardPoints: List[int]
:type k: int
:rtype: int
"""
N = len(cardPoints)
preSum = [0] * (N + 1)
for i in range(N):
preSum[i + 1] = preSum[i] + cardPoints[i]
res = float("inf")
windowSize = N - k
for i in range(k + 1):
res = min(res, preSum[windowSize + i] - preSum[i])
return preSum[N] - res
记数组 cardPoints 的长度为 n,由于只能从开头和末尾拿 k 张卡牌,所以最后剩下的必然是连续的 n−k 张卡牌。
我们可以通过求出剩余卡牌点数之和的最小值,来求出拿走卡牌点数之和的最大值。
由于剩余卡牌是连续的,使用一个固定长度为 n−k 的滑动窗口对数组 cardPoints
进行遍历,求出滑动窗口最小值,然后用所有卡牌的点数之和减去该最小值,即得到了拿走卡牌点数之和的最大值。
class Solution {
public:
//该题正向思考有难度,可逆向转化为滑动窗口问题,找到长为n-k的连续子区间的和最小,那么两端取的k个值即为最大
int maxScore(vector<int>& cardPoints, int k) {
int n = cardPoints.size();
int minSum = INT_MAX, allSum = 0, tmpSum = 0, left = 0, right = 0;
//求总和
for(const auto &p : cardPoints){
allSum += p;
}
if(n == k){
return allSum;
}else{
while(right < n){
tmpSum += cardPoints[right];
if(right >= n - k){
//窗口有n-k个数后开始右移
tmpSum -= cardPoints[left];
left++;
}
if(right - left + 1 == n - k){
//更新窗口最小的和
minSum = min(minSum, tmpSum);
}
right++;
}
}
return allSum - minSum;
}
};