算法 - 1. 滑动窗口(固定大小)

algorithmssliding-windowarraysoptimizationtwo-pointers
By sko9/27/20257 min read

何时使用固定大小滑动窗口

当你需要找到特定大小的最优连续子数组时,使用固定大小滑动窗口。虽然大小约束看起来有限,但这种模式经常出现:

  • 最大/最小和 k个连续元素的(经典用例)
  • 平均值 k个连续元素的
  • 模式匹配 使用固定长度模式(如查找字母异位词)
  • 滚动哈希 用于子串匹配
  • 时间序列分析 使用固定时间窗口(例如:7天移动平均)
  • 任何需要分析所有大小为k的子数组的问题

示例问题

K个元素的最大和:给定每日销售收入 [100, 200, 300, 400, 100, 200, 50],找到总收入最高的3连续天期间。

  • 天[0-2]:100 + 200 + 300 = 600
  • 天[1-3]:200 + 300 + 400 = 900 ✓ 最佳期间
  • 天[2-4]:300 + 400 + 100 = 800
  • 天[3-5]:400 + 100 + 200 = 700
  • 天[4-6]:100 + 200 + 50 = 350

答案:第1-3天拥有最高总收入$900。

最重要的洞察

重用重叠计算 - 大小为k的相邻窗口共享k-1个公共元素,仅在一个退出的元素和一个进入的元素上有所不同,因此我们只需更新这两个变化而不是重新计算一切,从而实现O(n)而非O(n*k)的复杂度。

备注:这个算法的天才之处在于认识到相邻窗口显著重叠。对于大小为3的窗口,向前移动意味着2个元素保持相同 - 只有最左边的退出,新的最右边的进入。我们维护运行状态并递增更新,而不是从头重新计算。一次减法,一次加法 - 这就是"滑动"到下一个窗口所需的全部。

决策框架

在窗口的每次滑动中,你执行两个操作:

  • 移除退出窗口的元素的贡献(左侧)
  • 添加进入窗口的元素的贡献(右侧)

代码

// 注意:我们使用for循环(不是while)因为:
// - 在固定大小滑动窗口中,我们恰好处理每个元素一次
// - 右指针总是递增1
// - for循环更清洁且防止忘记right++

// 恰好大小为k的固定窗口
// 我们需要检查每个可能的k大小窗口
function maxSumOfK(nums: number[], k: number): number {
  if (nums.length < k) return -1;

  let windowSum = 0;
  let maxSum = -Infinity;
  let left = 0;

  for (let right = 0; right < nums.length; right++) {
    // 将当前元素添加到窗口
    const elementEnteringWindow = nums[right];
    windowSum += elementEnteringWindow;

    // 决策框架:维护恰好大小为k的窗口
    const windowSize = right - left + 1;
    // 当窗口恰好为k时处理,然后滑动它
    if (windowSize === k) {
      maxSum = Math.max(maxSum, windowSum);
      // 为下次迭代滑动窗口
      const elementLeavingWindow = nums[left];
      windowSum -= elementLeavingWindow;
      left++;
    }
  }

  return maxSum;
}

// 最多大小为k的窗口
// "k距离内的重复"意味着我们需要一个永不超过k的窗口
function hasDuplicateWithinK(nums: number[], k: number): boolean {
  const window = new Set<number>();
  let left = 0;

  for (let right = 0; right < nums.length; right++) {
    // 决策框架:维护窗口大小 <= k
    // 为什么与上面不同?"k距离内"意味着最多相距k个元素
    // 所以我们维护一个增长到k的固定窗口,然后在大小k时滑动
    const windowSize = right - left + 1;
    const windowExceedsK = windowSize > k;
    if (windowExceedsK) {
      const elementLeavingWindow = nums[left];
      window.delete(elementLeavingWindow);
      left++;
    }

    // 检查当前窗口中的重复
    const currentElement = nums[right];
    const isDuplicate = window.has(currentElement);

    if (isDuplicate) {
      return true;
    }

    // 将当前元素添加到窗口
    window.add(currentElement);
  }

  return false;
}

疑问解答

常见疑问:"为什么要追踪窗口和?我不能使用嵌套循环分别对每个k元素窗口求和吗?"

为什么你可能认为嵌套循环就够了

考虑在 [100, 200, 300, 400, 100, 200, 50] 中用k=3找最大和:

  • "我可以用外层循环表示起始位置,内层循环对k个元素求和"
  • "对于每个起始位置i,从i到i+k-1求和"
  • "只有3个元素,所以内层循环很小"
  • "代码更直观 - 我可以准确看到什么被求和了"

为什么这种想法是低效的

关键认识:嵌套循环重复重新计算相同的元素!

让我们用k=3追踪 [100, 200, 300, 400, 100, 200, 50]

嵌套循环方法(O(n*k)):
窗口[0-2]:100 + 200 + 300 = 600    (3次加法)
窗口[1-3]:200 + 300 + 400 = 900    (3次加法)
窗口[2-4]:300 + 400 + 100 = 800    (3次加法)
窗口[3-5]:400 + 100 + 200 = 700    (3次加法)
窗口[4-6]:100 + 200 + 50 = 350     (3次加法)
总计:15次加法

滑动窗口(O(n)):
初始[0-2]:100 + 200 + 300 = 600   (3次加法)
滑动到[1-3]:600 - 100 + 400 = 900  (1次减法,1次加法)
滑动到[2-4]:900 - 200 + 100 = 800  (1次减法,1次加法)
滑动到[3-5]:800 - 300 + 200 = 700  (1次减法,1次加法)
滑动到[4-6]:700 - 400 + 50 = 350   (1次减法,1次加法)
总计:3次加法 + 4次减法 + 4次加法 = 11次操作

注意像300这样的元素在嵌套循环中被加了3次,但在滑动窗口中只加了一次!

具体例子:扩展问题

对于在10,000个元素的数组中用k=1000找最大和:

嵌套循环

  • 每个窗口:对1000个元素求和
  • 总窗口数:9001
  • 总加法次数:9,001,000

滑动窗口

  • 初始窗口:1000次加法
  • 每次滑动:1次减法 + 1次加法
  • 总操作数:1000 + (9000 × 2) = 19,000
  • 快474倍!

为什么优化很重要

滑动窗口技术:

  • 重用重叠计算(k-1个元素保持相同)
  • 每个元素恰好被加一次,恰好被减一次
  • 没有相同元素的冗余计算
  • 将O(n×k)嵌套循环解法转换为O(n)滑动窗口

关键洞察:当移动窗口一个位置时,只有2个元素改变(一个离开,一个进入),那为什么要重新计算一切呢?