23. 数组的滑动窗口:固定窗口与可变窗口的最大值、最小值问题

滑动窗口,这个名字听起来挺唬人。说白了,就是在一个数组上,用一个「窗口」框住一段连续的元素,然后让这个窗口滑起来。你想想看,是不是很像我们看手机上的新闻列表,手指一划,新内容进来,旧内容出去。

我刚开始学这个的时候,总觉得这玩意儿不就是两层循环嘛,有啥好讲的?后来在项目中处理实时传感器数据流,才明白它的威力。嗯,今天我们就把它彻底讲透。

23.1 什么是滑动窗口?

滑动窗口分两种:固定窗口可变窗口

  • 固定窗口:窗口大小不变,比如每次看3个元素,从左往右滑。
  • 可变窗口:窗口大小可以伸缩,比如找「和大于某个值的最短子数组」。

我个人习惯把固定窗口想象成「火车车厢」,每节车厢长度一样,一节一节往前开。可变窗口则像「伸缩门」,根据需要拉长或缩短。

核心思想:利用窗口的滑动,避免重复计算。每次移动窗口时,只处理新进入的元素和离开的元素,而不是重新计算整个窗口。

23.2 固定窗口的最大值问题

先来个经典题:给定一个数组和一个窗口大小 k,求每个窗口的最大值。

举个例子:arr = [1, 3, -1, -3, 5, 3, 6, 7],k = 3。

窗口依次为:
[1, 3, -1] → 最大值 3
[3, -1, -3] → 最大值 3
[-1, -3, 5] → 最大值 5
... 以此类推

最笨的方法是什么?每个窗口都遍历一遍找最大值。时间复杂度 O(n*k),数据量一大就崩了。

我在项目中遇到过类似场景:处理每秒上千条的股票行情数据,每个窗口都要算最高价。用暴力法?CPU直接报警。

那怎么办?用双端队列(deque)。

双端队列解法

思路很简单:队列里只保留「有可能成为最大值的元素」的下标。

  • 新元素进来时,把队列尾部比它小的元素全部干掉(因为它们永远没机会了)
  • 窗口滑动时,检查队首元素是否已经滑出窗口,如果是,弹出
  • 每次窗口满时,队首就是当前窗口的最大值
#include <stdio.h>
#include <stdlib.h>

#define MAX_N 100000

// 双端队列结构
typedef struct {
    int data[MAX_N];
    int front, rear;
} Deque;

void init(Deque *q) {
    q->front = 0;
    q->rear = 0;
}

int isEmpty(Deque *q) {
    return q->front == q->rear;
}

void push_back(Deque *q, int val) {
    q->data[q->rear++] = val;
}

int pop_front(Deque *q) {
    return q->data[q->front++];
}

int pop_back(Deque *q) {
    return q->data[--q->rear];
}

int get_front(Deque *q) {
    return q->data[q->front];
}

int get_back(Deque *q) {
    return q->data[q->rear - 1];
}

// 滑动窗口最大值
void slidingWindowMax(int *arr, int n, int k) {
    Deque dq;
    init(&dq);
    
    for (int i = 0; i < n; i++) {
        // 移除队尾比当前元素小的元素
        while (!isEmpty(&dq) && arr[get_back(&dq)] <= arr[i]) {
            pop_back(&dq);
        }
        push_back(&dq, i);
        
        // 移除滑出窗口的队首元素
        if (get_front(&dq) <= i - k) {
            pop_front(&dq);
        }
        
        // 窗口满时输出结果
        if (i >= k - 1) {
            printf("%d ", arr[get_front(&dq)]);
        }
    }
    printf("\n");
}

int main() {
    int arr[] = {1, 3, -1, -3, 5, 3, 6, 7};
    int n = sizeof(arr) / sizeof(arr[0]);
    int k = 3;
    
    slidingWindowMax(arr, n, k);
    // 输出:3 3 5 5 6 7
    
    return 0;
}

小技巧:队列里存的是下标,不是值。为什么?因为我们需要通过下标判断元素是否还在窗口内。存值的话,你没法知道它什么时候该走了。

23.3 固定窗口的最小值问题

最大值会了,最小值就是反过来。把比较符号换一下就行:

// 滑动窗口最小值
void slidingWindowMin(int *arr, int n, int k) {
    Deque dq;
    init(&dq);
    
    for (int i = 0; i < n; i++) {
        // 移除队尾比当前元素大的元素(注意这里是大于)
        while (!isEmpty(&dq) && arr[get_back(&dq)] >= arr[i]) {
            pop_back(&dq);
        }
        push_back(&dq, i);
        
        if (get_front(&dq) <= i - k) {
            pop_front(&dq);
        }
        
        if (i >= k - 1) {
            printf("%d ", arr[get_front(&dq)]);
        }
    }
    printf("\n");
}

你看,就改了一个符号。最大值是干掉比当前小的,最小值是干掉比当前大的。逻辑完全对称。

23.4 可变窗口:最长/最短子数组问题

可变窗口就更有意思了。它没有固定大小,而是根据条件动态调整。

经典问题:找出和大于等于 target 的最短子数组

思路:用两个指针 left 和 right 维护窗口。right 不断右移扩大窗口,当窗口内的和满足条件时,尝试移动 left 缩小窗口,记录最小长度。

int minSubArrayLen(int target, int *arr, int n) {
    int left = 0, sum = 0;
    int minLen = n + 1;  // 初始化为一个不可能的大值
    
    for (int right = 0; right < n; right++) {
        sum += arr[right];
        
        // 满足条件时,尝试缩小窗口
        while (sum >= target) {
            int curLen = right - left + 1;
            if (curLen < minLen) {
                minLen = curLen;
            }
            sum -= arr[left];
            left++;
        }
    }
    
    return (minLen == n + 1) ? 0 : minLen;
}

我曾经在做一个网络流量监控系统时,需要找出「连续时间窗口内流量超过阈值的最短时间段」。用的就是这套逻辑。当时数据量很大,O(n) 的复杂度才能扛得住。

注意:可变窗口的 while 循环条件一定要想清楚。是「满足条件时缩小」还是「不满足条件时扩大」?搞反了就是死循环或者结果全错。

23.5 滑动窗口的通用模板

我总结了一个通用模板,基本上所有滑动窗口问题都能套:

int slidingWindow(int *arr, int n, int k) {
    int left = 0;
    // 初始化窗口状态,比如 sum、count 等
    
    for (int right = 0; right < n; right++) {
        // 1. 将 arr[right] 加入窗口,更新状态
        
        // 2. 根据条件调整 left(固定窗口不需要这步)
        while (/* 窗口需要缩小的条件 */) {
            // 移除 arr[left],更新状态
            left++;
        }
        
        // 3. 如果窗口满足条件,更新答案
        // 固定窗口:right - left + 1 == k 时
        // 可变窗口:条件满足时
    }
    
    return 答案;
}

你想想看,不管是最大值、最小值、和、乘积、字符种类,本质上都是这个框架。区别只在于「状态」是什么,以及「条件」怎么判断。

23.6 知识体系图

下面这张图帮你理清滑动窗口的整个知识脉络:

滑动窗口知识体系 滑动窗口 固定窗口 可变窗口 最大值(双端队列) 最小值(双端队列) 最短子数组 最长子数组 核心:O(n) 时间复杂度,避免重复计算

23.7 避坑指南

我踩过的坑,你最好别踩:

  • 边界条件:窗口大小 k 大于数组长度怎么办?返回空还是返回整个数组的最大值?提前想好。
  • 下标越界:双端队列里存的是下标,pop 的时候一定要检查队列是否为空。我曾经因为这个在线上环境出了 bug,排查了半天。
  • 可变窗口的 while 循环:一定要确保循环能终止。比如 sum 减去 arr[left] 后,left 要递增,不然就死循环了。
  • 数据类型:如果数组元素很大,sum 可能会溢出。用 long long 或者更大的类型。

我的习惯:写滑动窗口代码时,先画个图,把 left 和 right 的移动过程走一遍。纸上跑通了,代码基本不会错。

滑动窗口就这么点东西。固定窗口用双端队列,可变窗口用双指针。掌握了这两个套路,面试中遇到滑动窗口的题,基本就是秒杀。


公众号:蓝海资料掘金营,微信 deep3321