给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值。
示例 1:
示例 2:
输入:nums = [1], k = 1
输出:[1]
提示:
对于维护「最大值」和「之前的最大值」,我们可以想到一种非常合适的数据结构和一种算法思想,那就是优先队列+延迟删除。其中的最大值优先队列可以帮助我们实时维护一系列元素中的最大值。类似 Leetcode 的第218题天际线。
对于本题而言,初始时,我们将数组 nums \textit{nums} nums 的前 k k k 个元素放入优先队列中。每当我们向右移动窗口时,我们就可以把一个新的元素放入优先队列中,此时堆顶的元素就是堆中所有元素的最大值。然而这个最大值可能并不在滑动窗口中,在这种情况下,只需要判断这个最大值在数组 nums \textit{nums} nums 中的位置是否出现在滑动窗口左边界的左侧,是则是无效最大值,否则是有效最大值。因此,当我们后续继续向右移动窗口时,这个值就永远不可能出现在滑动窗口中了,我们可以将其永久地从优先队列中移除。
我们不断地移除堆顶的元素,直到其确实出现在滑动窗口中。此时,堆顶元素就是滑动窗口中的最大值。为了方便判断堆顶元素与滑动窗口的位置关系,我们可以在优先队列中存储二元组 ( num , index ) (\textit{num}, \textit{index}) (num,index),表示元素 num \textit{num} num 在数组中的下标为 index \textit{index} index。
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
priority_queue<pair<int, int>> max;
int head=0, tail=k-1, last=nums.size()-1;
for(int n=0; n<k; n++){
max.emplace(nums[n], n);
}
vector<int> ans{max.top().first}; // 存储答案
while(tail != last){
head++;
tail++;
max.emplace(nums[tail], tail);
// 找到有效的最大值
while(head > max.top().second){
max.pop();
}
ans.push_back(max.top().first);
}
return ans;
}
};
时间复杂度: O ( n log n ) O(n\log n) O(nlogn) ,其中 n n n 为数组的长度。该算法会遍历数组的每个数,为 O ( n ) O(n) O(n),而在最坏情况下,数组 nums \textit{nums} nums 中的元素单调递增,那么最终优先队列中包含了所有元素,没有元素被移除。由于将一个元素放入优先队列的时间复杂度为 O ( log n ) O(\log n) O(logn),因此总时间复杂度为 O ( n log n ) O(n \log n) O(nlogn)。
空间复杂度: O ( n ) O(n) O(n) ,其中 n n n 为数组的长度。即为优先队列需要使用的空间。这里所有的空间复杂度分析都不考虑返回的答案需要的 O ( n ) O(n) O(n) 空间,只计算额外的空间使用。
我们可以顺着方法一的思路继续进行优化某些操作——比如在方法一中每次窗口滑动时所需要的操作,即将一个元素放入优先队列操作的时间复杂度为 O ( log n ) O(\log n) O(logn)。
我们可以考虑使用单调队列,来使每次窗口滑动时所需要的操作的时间复杂度优化为 O ( 1 ) O(1) O(1)——单调队列入队和出队的时间复杂度为 O ( 1 ) O(1) O(1),进而实现算法的时间复杂度为 O(n)。
关键点:
算法实现:
举个例子: “543321” ,k=3
- 队列存值的情况下,如果不将两个3都加入,当第一个3被移出时,会导致321的最大值变为2,因为3已经被移出了,因此存值的话,需要新的元素大于队列尾部元素再去移除队列尾部的元素。
- 队列存下标的情况下,就可以只存一个3(存第二个),因为通过下标就能判断出移出的是第一个3还是第二个3。
例子:
输入:nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出:[3,3,5,5,6,7]解释过程中队列中都是具体的值,方便理解。
初始状态:L=R=0,队列:{}
i=0,nums[0]=1。队列为空,直接加入。队列:{1}
i=1,nums[1]=3。队尾值为1,3>1,弹出队尾值,加入3。队列:{3}
i=2,nums[2]=-1。队尾值为3,-1<3,直接加入。队列:{3,-1}。此时窗口已经形成,L=0,R=2,result=[3]
i=3,nums[3]=-3。队尾值为-1,-3<-1,直接加入。队列:{3,-1,-3}。队首3对应的下标为1,L=1,R=3,有效。result=[3,3]
i=4,nums[4]=5。队尾值为-3,5>-3,依次弹出后加入。队列:{5}。此时L=2,R=4,有效。result=[3,3,5]
i=5,nums[5]=3。队尾值为5,3<5,直接加入。队列:{5,3}。此时L=3,R=5,有效。result=[3,3,5,5]
i=6,nums[6]=6。队尾值为3,6>3,依次弹出后加入。队列:{6}。此时L=4,R=6,有效。result=[3,3,5,5,6]
i=7,nums[7]=7。队尾值为6,7>6,弹出队尾值后加入。队列:{7}。此时L=5,R=7,有效。result=[3,3,5,5,6,7]
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> monotone_queue; // 单调队列。存储下标,既方便取值,又方便判断是不是有效最大值
// 准备阶段
for(int n=0; n<k; n++){
while(!monotone_queue.empty() && nums[monotone_queue.back()] <= nums[n])
monotone_queue.pop_back();
monotone_queue.push_back(n);
}
vector<int> ans{nums[monotone_queue.front()]};
int length = nums.size(), n=k;
while(n != length){
// 插入新值
while(!monotone_queue.empty() && nums[monotone_queue.back()] <= nums[n])
monotone_queue.pop_back();
monotone_queue.push_back(n);
// 清理无效最大值
while(!monotone_queue.empty() && monotone_queue.front() <= n-k)
monotone_queue.pop_front();
// 经历两个步骤(插入新值,清理无效最大值)后
// 此时单调队列 monotone_queue.front() 中的就是滑动窗口右移后的新最大值的下标
ans.push_back(nums[monotone_queue.front()]);
n++;
}
return ans;
}
};
时间复杂度: O ( n ) O(n) O(n),其中 n n n 为数组 nums \textit{nums} nums 的长度。和方法一一样,每一个下标恰好被放入队列一次,并且最多被弹出队列一次,但是这次一个下标放入队列的时间复杂度为 O ( 1 ) O(1) O(1),所以时间复杂度为 O ( n ) O(n) O(n)。
空间复杂度: O ( k ) O(k) O(k),其中 k k k 是滑动窗口大小。与方法一不同的是,在方法二中我们使用的数据结构是双向的,因此「不断从队首弹出元素」保证了队列中最多不会有超过 k + 1 k+1 k+1 个元素,因此队列使用的空间为 O ( k ) O(k) O(k)。这里所有的空间复杂度分析都不考虑返回的答案需要的 O ( n ) O(n) O(n) 空间,只计算额外的空间使用。
除了基于「随着窗口的移动实时维护最大值」的方法一以及方法二之外,我们还可以考虑其他有趣的做法。
我们可以将数组 nums \textit{nums} nums 从左到右按照 k k k 个一组进行分组,最后一组中元素的数量可能会不足 k k k 个。如果我们希望求出 nums [ i ] \textit{nums}[i] nums[i] 到 nums [ i + k − 1 ] \textit{nums}[i+k-1] nums[i+k−1] 的最大值,就会有两种情况:
算法实现:
因此我们用 prefixMax [ i ] \textit{prefixMax}[i] prefixMax[i] 表示下标 i i i 对应的分组中,以 i i i 结尾的前缀最大值; suffixMax [ i ] \textit{suffixMax}[i] suffixMax[i] 表示下标 i i i 对应的分组中,以 i i i 开始的后缀最大值。它们分别满足如下的递推式
prefixMax [ i ] = { nums [ i ] , i 是 k 的 倍 数 max { prefixMax [ i − 1 ] , nums [ i ] } , i 不 是 k 的 倍 数 \textit{prefixMax}[i]=\begin{cases} \textit{nums}[i], & \quad i ~是~ k ~的倍数 \\ \max\{ \textit{prefixMax}[i-1], \textit{nums}[i] \}, & \quad i ~不是~ k ~的倍数 \end{cases} prefixMax[i]={nums[i],max{prefixMax[i−1],nums[i]},i 是 k 的倍数i 不是 k 的倍数
以及
suffixMax [ i ] = { nums [ i ] , i + 1 是 k 的 倍 数 max { suffixMax [ i + 1 ] , nums [ i ] } , i + 1 不 是 k 的 倍 数 \textit{suffixMax}[i]=\begin{cases} \textit{nums}[i], & \quad i+1 ~是~ k ~的倍数 \\ \max\{ \textit{suffixMax}[i+1], \textit{nums}[i] \}, & \quad i+1 ~不是~ k ~的倍数 \end{cases} suffixMax[i]={nums[i],max{suffixMax[i+1],nums[i]},i+1 是 k 的倍数i+1 不是 k 的倍数
需要注意在递推 suffixMax [ i ] \textit{suffixMax}[i] suffixMax[i] 时需要考虑到边界条件 suffixMax [ n − 1 ] = nums [ n − 1 ] \textit{suffixMax}[n-1]=\textit{nums}[n-1] suffixMax[n−1]=nums[n−1],而在递推 prefixMax [ i ] \textit{prefixMax}[i] prefixMax[i] 时的边界条件 prefixMax [ 0 ] = nums [ 0 ] \textit{prefixMax}[0]=\textit{nums}[0] prefixMax[0]=nums[0] 恰好包含在递推式的第一种情况中,因此无需特殊考虑。
在预处理完成之后,对于 nums [ i ] \textit{nums}[i] nums[i] 到 nums [ i + k − 1 ] \textit{nums}[i+k-1] nums[i+k−1] 的所有元素,如果 i i i 不是 k k k 的倍数,那么窗口中的最大值为 suffixMax [ i ] \textit{suffixMax}[i] suffixMax[i] 与 prefixMax [ i + k − 1 ] \textit{prefixMax}[i+k-1] prefixMax[i+k−1] 中的较大值;如果 i i i 是 k k k 的倍数,那么此时窗口恰好对应一整个分组, suffixMax [ i ] \textit{suffixMax}[i] suffixMax[i] 和 prefixMax [ i + k − 1 ] \textit{prefixMax}[i+k-1] prefixMax[i+k−1] 都等于分组中的最大值,因此无论窗口属于哪一种情况,
max { suffixMax [ i ] , prefixMax [ i + k − 1 ] } \max\big\{ \textit{suffixMax}[i], \textit{prefixMax}[i+k-1] \big\} max{suffixMax[i],prefixMax[i+k−1]}
即为答案。
这种方法与稀疏表(Sparse Table)非常类似,感兴趣的读者可以自行查阅资料进行学习。
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> prefixMax(n), suffixMax(n);
for (int i = 0; i < n; ++i) {
if (i % k == 0) {
prefixMax[i] = nums[i];
}
else {
prefixMax[i] = max(prefixMax[i - 1], nums[i]);
}
}
for (int i = n - 1; i >= 0; --i) {
if (i == n - 1 || (i + 1) % k == 0) {
suffixMax[i] = nums[i];
}
else {
suffixMax[i] = max(suffixMax[i + 1], nums[i]);
}
}
vector<int> ans;
for (int i = 0; i <= n - k; ++i) {
ans.push_back(max(suffixMax[i], prefixMax[i + k - 1]));
}
return ans;
}
};
时间复杂度: O ( n ) O(n) O(n),其中 n n n 为数组 n u m s nums nums 的长度。我们需要 O ( n ) O(n) O(n) 的时间预处理出数组 prefixMax \textit{prefixMax} prefixMax, suffixMax \textit{suffixMax} suffixMax 和计算答案。
空间复杂度: O ( n ) O(n) O(n),其中 n n n 为存储 prefixMax \textit{prefixMax} prefixMax 和 suffixMax \textit{suffixMax} suffixMax 需要的空间。这里所有的空间复杂度分析都不考虑返回的答案需要的 O ( n ) O(n) O(n) 空间,只计算额外的空间使用。
时间复杂度和空间复杂度都比前三个方法高
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
// 整数数组长为l,窗口长度为k,滑动窗口中的最大值有l-k+1个
// 两个问题:1.更新滑动窗口的内容 2.维护滑动窗口的最大值
// 1.用队列(C++ 的双端队列)保存滑动窗口的内容————————优化,不需要队列,使用数组下标来保持
// 2.用 map 来维护滑动窗口的最大值———— pair<值,个数>
map<int, int> max; // pair<值,个数>
int front = 0,back = k-1; // 使用数组下标和输入数组来实现队列
int l = nums.size();
// 准备阶段
for(int n = 0; n<k; n++){
if(max.find(nums[n]) == max.end()){
max.insert({nums[n], 1}); // 不存在该值
}else max[nums[n]] += 1;
}
vector<int> ans{max.crbegin()->first};
int head,newone; // 用于保存队列的队首和下一个进队列的值
while(back != l-1){
head = nums[front];
newone = nums[++back];
if(max[head] == 1){
max.erase(head); // 该值只有一个,直接删除
}else max[head] -= 1; // 该值不止一个,递减
if(max.find(newone) == max.end()){
max.insert({newone, 1}); // 不存在该值
}else max[newone] += 1;
ans.push_back(max.crbegin()->first);
front++;
}
return ans;
}
};