本质为同向双指针。大致来说就是当要统计符合某一性质的区间数量时,可以考虑左右指针选取一个窗口,不停地扩展窗口,边扩展边统计,直至窗口大小达到区间长度上限或窗口不再满足该性质为止。
说的再玄没用,看题。
1. 双周赛120 T3 LC 2972 统计移除递增子数组地数目Ⅱ
其实场上我想到一个O(n²)的滑窗。大致就是枚举窗口长度,然后将窗口从一开始移动到右指针等于区间末为止。移动过程中检查递增性质:
-
假设[lp,rp]为指定长度的待删除的子数组,则下一次滑动该区间将变为[lp+1,rp+1]
左侧 : [0,lp]
右侧 : [rp+2,n-1]
-
检查 nums[lp] < nums[rp+2] ,保证左右两侧交界处的单调性
-
而左右两侧的单调性,通过维护最长递增前缀和最长递增后缀实现。比如最长递增前缀的最右端为i,最长递增后缀的最左端为j。那么滑窗至少要覆盖[i+1,j-1],不然中间肯定不严格递增。
但可以看到,这里面充斥着lp+1,rp+2,i+1,j-1这样会导致下标越界的东西,所以最后我交了一发,里面充斥着大量特判,还是吃RE了,码力还是不够。
最后看了灵神题解,发现其实可以有O(n)的滑窗做法。记录于此。
滑窗思路还是一样的,删掉中间的一段,保证前面单调,后面单调,前面和后面交接处单调,这个交接处就是我说的nums[lp] 和 nums[rp+2]。保证了前后两段的单调递增。
既然前面要单调,那势必要把不单调的全删了,也就是我们要维护最长递增前缀[0,i],假设后缀的首个数字的索引是j,那么[i+1,j-1]的部分是一定要删掉的,这个东西没有选择的余地,不然不单增了。
而前方[0,i]的部分,是可选项。你当然可以选择只删掉[i+1,j-1],但无疑也可以删[i-k,j-1],其中k≤i。这是因为删完了之后,你将得到[0,i-k-1]和[j,n-1],而[0,i]是最长递增前缀,单增,所以[0,i-k-1]也是单增的。因此总计有
[0,j-1]
[1,j-1]
...
[i+1,j-1]
这i+2种删除前缀的方案。
对于后缀,我们可以直接从右向左枚举删除后的起始点j。只要保证nums[j]<nums[j+1]即可,这样后缀也能保证单调了。
细节在代码中
class Solution {
public long incremovableSubarrayCount(int[] nums) {
int n = nums.length;
// 最长递增前缀
int i = 0;
while(i<n-1){
if(nums[i]<nums[i+1]){
i++;
}else{
break;
}
}
// 特判整个数组严格递增
// 长度为1的子数组 n个
// 长度为2的子数组 n-1个
// 长度为3的子数组 n-2个
// ...
// 长度为n的子数组 1个
if(i == n-1){
return (long) (1 + n) *n/2;
}
// 枚举删除后的后缀起始点j
// 也就是最多删到j-1
int j = n-1;
// 那么删掉j的情况就要额外计算
// 因为[0,i]是严格增的(最长递增前缀算过)
// 可删的就是[0,j-1] [1,j-1] ... [i+1,j-1]
// 删[i+1,j-1],前缀就是[0,i],递增,否则不严格递增(这是一定的,否则i不会停在这个位置)
// 因此在前缀的递增性质得到保障之后,我们只要保障后缀也有递增性质即可
long ans = i+2;
while(j==n-1||nums[j]<nums[j+1]){
// j为n-1时后续没有数字,要特判的
while(i>=0 && nums[i]>=nums[j]){ // 注意nums[0]也是可以删的
i--; // 保障前缀和后缀的交接处的严格递增性质
}
ans += i+2;
j--;
}
return ans;
}
}
别看while里套while,内层while由于i一定≤n-1,那么无论外层怎么套,最多总共也就执行O(n)次。因此时间复杂度是O(n)的。
2. LC 2831 找出最长等值子数组
这题一开始我的思路其实差不多对了。就是把每个本来连续的块的位置记录下来,存到哈希表,然后每个列表进行滑窗求最大值。
简单举个例子,nums = [1,1,2,2,1,1],以1为基值的块有两个,[0:1]和[4:5],那么当滑窗滑到[4:5]时,就会消耗掉4-1-1 = 2个删除元素的机会。当这个删除元素的机会不够,就要把滑窗左指针右移。比如k=1的时候。
from typing import List
from collections import defaultdict
class Solution:
def longestEqualSubarray(self, nums: List[int], k: int) -> int:
m = defaultdict(list)
i = 0
while i<len(nums):
j = i
while j<len(nums) and nums[i]==nums[j]:
j += 1
m[nums[i]].append((i,j-1))
i = j
ans = 0
for v in m.values():
tmp = v[0][1]-v[0][0]+1
ans = max(ans,tmp)
l = 0
r = 1
rest = k
while r<len(v):
useage = v[r][0]-v[r-1][1]-1
if useage <= rest:
tmp += v[r][1]-v[r][0]+1
r += 1
ans = max(ans,tmp)
rest -= useage
else:
l += 1
back = v[l][0]-v[l-1][1]-1
tmp -= (v[l-1][1]-v[l-1][0]+1)
rest += back
ans = max(ans,tmp)
return ans
随后这份代码跑的非常慢,压线过了差点T。后来我看了其他人的提交,发现不用维护块的信息,单个维护就可以了。计算消耗的方式是两个等值元素之间的元素个数(含这两个等值元素)减去除了等值元素以外的元素个数,这下哈希表维护每个基值的所有元素的索引就行了。
from typing import List
from collections import defaultdict
class Solution:
def longestEqualSubarray(self, nums: List[int], k: int) -> int:
m = defaultdict(list)
for i in range(len(nums)):
m[nums[i]].append(i)
ans = 0
for v in m.values():
if len(v)<=ans:
continue
j = 0
for i in range(len(v)):
if v[i]-v[j]+1 - (i-j+1) > k: # 实际距离差 减去 基值个数 例如 区间[3,10],里面有5个基值,那么总共消耗10-3+1-5=3个删除机会
j+=1
ans = max(ans,i-j+1)
return ans
这个复杂度看似O(n^2),实则O(n)
3. LC 1004/2024 最大连续1的个数Ⅲ/考试的最大困扰度
后者就是前者nums的数全部反转再做一次。
滑窗维护最大窗口长度。如果次数用完那么需要右移左指针。
class Solution:
def longestOnes(self, nums: List[int], k: int) -> int:
left,right,ans = 0,0,0
cnt = 0
while right<len(nums):
cnt += 1-nums[right]
while cnt>k:
cnt -= 1-nums[left]
left += 1
ans = max(ans,right-left+1)
right += 1
return ans
class Solution:
def maxConsecutiveAnswers(self, answerKey: str, k: int) -> int:
aid1 = [1 if answerKey[i]=='T' else 0 for i in range(len(answerKey))]
aid2 = [1 if answerKey[i]=='F' else 0 for i in range(len(answerKey))]
return max(self.longestOnes(aid1,k),self.longestOnes(aid2,k))
def longestOnes(self, nums: List[int], k: int) -> int:
left,right,ans = 0,0,0
cnt = 0
while right<len(nums):
cnt += 1-nums[right]
while cnt>k:
cnt -= 1-nums[left]
left += 1
ans = max(ans,right-left+1)
right += 1
return ans
第二题还有另外一个做法。如果一个区间里T和F的数量存在一个不大于K,那么就可以把这个不大于K的字符全部改掉。否则必须右移左指针。
class Solution:
def maxConsecutiveAnswers(self, answerKey: str, k: int) -> int:
left,T,F = 0,0,0
for ch in answerKey:
if ch=='T':
T+=1
else:
F+=1
if T>k and F>k:
if answerKey[left]=='T':
T-=1
else:
F-=1
left+=1
return len(answerKey)-left
4. LC 2555 两个线段获得的最多奖品
这题如果是一条线段的话那么直接滑窗就可以了。但现在有两条线段。
不难发现,对于任意一种两条线段相交的情况,必存在至少一种其他不相交的方案,使得后者获得的 奖品数不少于前者。
那么我们可以枚举后一条线段的位置,然后计算前一条线段能获得的最大奖品数。
后一条线段获得的最大奖品数可以用滑窗。对于前一条,我们可以定义dp(i+1)为第一条线段右端点≤prizePositions[i]情况下的可以获得的最大奖品数。
则:
其中,r,l为当前滑窗的左右指针。
则答案为:
class Solution:
def maximizeWin(self, prizePositions: List[int], k: int) -> int:
n = len(prizePositions)
if 2*k+1 >= prizePositions[-1]-prizePositions[0]:
return n
mx = [0 for _ in range(n+1)]
l,ans = 0,0
for r,pos in enumerate(prizePositions):
while l<r and prizePositions[r]-prizePositions[l]>k:
l += 1
mx[r+1] = max(mx[r],r-l+1)
ans = max(ans,mx[l]+r-l+1)
return ans