滑动窗口

41 阅读8分钟

滑动窗口算法详解与应用

引言

滑动窗口是一种在处理数组或字符串等线性数据结构时常用的算法技巧。它通过维护一个"窗口"(即子数组或子字符串),并在数据结构上"滑动"这个窗口来解决问题,从而将许多需要嵌套循环的问题优化为单循环问题,时间复杂度通常从O(n²)降至O(n)。本文将详细介绍滑动窗口的基本概念、适用场景、实现方法以及常见的应用案例,帮助读者掌握这一重要的算法技巧。

第1部分:滑动窗口的基本概念与适用场景

什么是滑动窗口?

滑动窗口是一种在线性数据结构(如数组、字符串)上操作的算法技巧,它通过维护一个窗口(即连续的子序列),并在数据结构上滑动这个窗口来解决问题。这种方法可以将许多需要嵌套循环的问题优化为单循环问题,从而显著提高算法效率。

滑动窗口的类型

滑动窗口主要分为两种类型:

  1. 固定大小的滑动窗口:窗口大小保持不变,窗口在数组上平移
  2. 可变大小的滑动窗口:窗口大小根据特定条件动态调整

适用场景

滑动窗口算法适用于以下场景:

  • 查找满足特定条件的连续子数组/子字符串
  • 求解最长/最短的满足条件的子数组/子字符串
  • 计算满足条件的子数组/子字符串的数量
  • 需要维护区间内元素关系的问题

第2部分:固定大小滑动窗口的实现与应用

固定大小滑动窗口的实现步骤

  1. 初始化

    • 确定窗口大小k
    • 初始化左右指针left = 0, right = 0
    • 初始化所需的辅助变量(如和、计数器等)
  2. 形成初始窗口

    • 移动右指针,直到窗口大小达到k
    • 同时更新辅助变量
  3. 滑动窗口

    • 循环直到右指针到达数组末尾
    • 计算当前窗口的结果
    • 移除左指针元素的影响(更新辅助变量)
    • 左指针右移一位
    • 右指针右移一位
    • 添加新的右指针元素的影响(更新辅助变量)

固定大小滑动窗口的代码实现

// 固定大小滑动窗口
func fixedSlidingWindow(arr []int, k int) []int {
    n := len(arr)
    if n < k {
        return []int{}
    }
    
    // 初始化结果数组
    result := make([]int, n-k+1)
    
    // 计算第一个窗口的和
    windowSum := 0
    for i := 0; i < k; i++ {
        windowSum += arr[i]
    }
    result[0] = windowSum
    
    // 滑动窗口并计算后续窗口的和
    for i := k; i < n; i++ {
        // 加入新元素,移除旧元素
        windowSum = windowSum + arr[i] - arr[i-k]
        result[i-k+1] = windowSum
    }
    
    return result
}

应用案例:找到字符串中所有的字母异位词

问题描述:给定两个字符串s和p,找到s中所有是p的异位词的子串,返回这些子串的起始索引。

示例

  • 输入: s = "cbaebabacd", p = "abc"
  • 输出: [0,6]
  • 解释: 起始索引等于0的子串是"cba", 它是"abc"的异位词;起始索引等于6的子串是"bac", 它是"abc"的异位词。

解决方案

func findAnagrams(s string, p string) []int {
    sCount := [26]int{}
    pCount := [26]int{}
    ans := []int{}
    
    slen := len(s)
    plen := len(p)
    
    if slen < plen {
        return nil
    }
    
    // 初始化目标字符计数
    for _, char := range p {
        pCount[char - 'a']++
    }
    
    // 初始化第一个窗口的字符计数
    for i := 0; i < plen; i++ {
        sCount[s[i] - 'a']++
    }
    
    // 检查第一个窗口
    if sCount == pCount {
        ans = append(ans, 0)
    }
    
    // 滑动窗口
    for i := plen; i < slen; i++ {
        sCount[s[i] - 'a']++
        sCount[s[i-plen] - 'a']--
        
        if sCount == pCount {
            ans = append(ans, i-plen+1)
        }
    }
    
    return ans
}

第3部分:可变大小滑动窗口的实现与应用

可变大小滑动窗口的实现步骤

  1. 初始化

    • 初始化左右指针left = 0, right = 0
    • 初始化所需的辅助变量(如和、哈希表等)
  2. 扩大窗口

    • 移动右指针,扩大窗口
    • 更新辅助变量
    • 判断当前窗口是否满足条件
  3. 缩小窗口

    • 当窗口满足条件时,记录结果
    • 移动左指针,缩小窗口
    • 更新辅助变量
    • 重复直到窗口不再满足条件
  4. 重复步骤2和3

    • 直到右指针到达数组末尾

可变大小滑动窗口的代码实现

// 查找最长的子数组,其和小于等于目标值
func maxSubArrayLengthWithSumLessThanTarget(arr []int, target int) int {
    n := len(arr)
    if n == 0 {
        return 0
    }
    
    left, right := 0, 0
    currentSum := 0
    maxLength := 0
    
    for right < n {
        // 扩大窗口
        currentSum += arr[right]
        
        // 缩小窗口,直到满足条件
        for left <= right && currentSum > target {
            currentSum -= arr[left]
            left++
        }
    
        // 更新最大长度
        if currentSum <= target {
            maxLength = max(maxLength, right-left+1)
        }
        
        // 继续扩大窗口
        right++
    }
    
    return maxLength
}

应用案例:无重复字符的最长子串

问题描述:给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。

示例

  • 输入: s = "abcabcbb"
  • 输出: 3
  • 解释: 因为无重复字符的最长子串是"abc",所以其长度为3。

解决方案

func lengthOfLongestSubstring(s string) int {
    n := len(s)
    if n == 0 {
        return 0
    }
    
    charIndex := make(map[byte]int) // 字符 -> 最后出现的索引
    maxLength := 0
    left := 0
    
    for right := 0; right < n; right++ {
        // 如果字符已经在当前窗口中出现过,更新左边界
        if idx, exists := charIndex[s[right]]; exists && idx >= left {
            left = idx + 1
        }
        
        // 更新字符的最后位置
        charIndex[s[right]] = right
        
        // 更新最大长度
        maxLength = max(maxLength, right-left+1)
    }
    
    return maxLength
}

第4部分:前缀和技巧与滑动窗口的结合

前缀和的基本概念

前缀和是一种预处理技术,用于快速计算数组的子数组之和。对于数组nums,其前缀和数组prefixSum定义为:

  • prefixSum[0] = 0(通常设置为0,便于计算)
  • prefixSum[i] = prefixSum[i-1] + nums[i-1],其中i >= 1

使用前缀和,可以在O(1)时间内计算任意子数组的和:

  • sum(nums[left...right]) = prefixSum[right+1] - prefixSum[left]

前缀和与滑动窗口的结合应用

有些问题可以通过前缀和与滑动窗口的结合来高效解决,特别是寻找和为特定值的子数组问题。

应用案例:最大连续子数组和为k的子数组长度

func maxSubArrayLengthWithSumK(nums []int, k int) int {
    prefixSum := make(map[int]int)
    prefixSum[0] = -1 // 初始前缀和为0,对应索引-1
    
    currentSum := 0
    maxLength := 0
    
    for i, num := range nums {
        currentSum += num
        
        // 如果存在前缀和等于currentSum-k,则找到了和为k的子数组
        if prevIndex, exists := prefixSum[currentSum-k]; exists {
            maxLength = max(maxLength, i-prevIndex)
        }
        
        // 只记录第一次出现的前缀和,以获得最长子数组
        if _, exists := prefixSum[currentSum]; !exists {
            prefixSum[currentSum] = i
        }
    }
    
    return maxLength
}

第5部分:复杂滑动窗口问题解析

最小覆盖子串

问题描述:给你一个字符串s、一个字符串t。返回s中涵盖t所有字符的最小子串。如果s中不存在涵盖t所有字符的子串,则返回空字符串""。

示例

  • 输入:s = "ADOBECODEBANC", t = "ABC"
  • 输出:"BANC"
  • 解释:最小覆盖子串"BANC"包含来自字符串t的'A'、'B'和'C'。

解决方案

func minWindow(s string, t string) string {
    if len(s) == 0 || len(t) == 0 {
        return ""
    }
    
    // 目标字符计数
    tCount := make(map[byte]int)
    for i := 0; i < len(t); i++ {
        tCount[t[i]]++
    }
    
    // 当前窗口中的字符计数
    windowCount := make(map[byte]int)
    
    // 记录结果
    minLen := len(s) + 1
    minStart := 0
    
    // 记录已经匹配的字符数量
    matched := 0
    required := len(tCount)
    
    left, right := 0, 0
    
    for right < len(s) {
        // 扩大窗口
        char := s[right]
        windowCount[char]++
        
        // 检查是否匹配目标字符
        if count, exists := tCount[char]; exists && windowCount[char] == count {
            matched++
        }
        
        // 尝试缩小窗口
        for left <= right && matched == required {
            // 更新结果
            if right-left+1 < minLen {
                minLen = right - left + 1
                minStart = left
            }
            
            // 移除左侧字符
            leftChar := s[left]
            windowCount[leftChar]--
            
            // 检查是否影响匹配
            if count, exists := tCount[leftChar]; exists && windowCount[leftChar] < count {
                matched--
            }
            
            left++
        }
        
        right++
    }
    
    if minLen > len(s) {
        return ""
    }
    return s[minStart : minStart+minLen]
}

解题关键点分析

在解决最小覆盖子串这样的复杂滑动窗口问题时,关键点在于:

  1. 窗口扩展条件:右指针移动,扩大窗口,直到包含所有目标字符
  2. 窗口收缩条件:当窗口满足条件时,尝试移动左指针,缩小窗口,同时保持条件满足
  3. 匹配状态维护:使用辅助数据结构(如哈希表)维护当前窗口的状态,快速判断是否满足条件
  4. 结果更新时机:在窗口满足条件的同时,尝试更新最优结果

结论

滑动窗口是解决数组和字符串问题的强大技巧,通过维护一个动态变化的窗口,可以将许多需要嵌套循环的问题优化为线性时间复杂度。本文详细介绍了固定大小和可变大小两种滑动窗口的实现方法,并结合前缀和等技巧,通过实际案例展示了滑动窗口算法的应用。

在实际应用中,滑动窗口算法的关键在于:

  1. 明确窗口的定义和维护的状态变量
  2. 确定窗口扩展和收缩的条件
  3. 正确处理边界情况
  4. 高效更新窗口状态

掌握滑动窗口技巧,对于提高算法解题能力和编程效率有着显著的帮助。通过不断练习和应用,读者可以更加熟练地运用这一技巧解决各种复杂问题。