在数组操作类面试题中,“滑动窗口最大值”是一道极具代表性的题目,它不仅考察对数组遍历的基础能力,更侧重对时间复杂度优化和数据结构选型的理解。许多初学者会用暴力解法实现,但面对大规模数据时会因效率问题失败。本文将从题目解析、思路演进、多语言实现到优化总结,带你掌握兼顾效率与鲁棒性的解题方案。
一、题目解析:明确需求与约束
1. 题目要求
给定一个数组 nums 和一个滑动窗口的大小 k,让滑动窗口从数组的最左侧移动到最右侧,要求输出每一次滑动后窗口内元素的最大值。注意:滑动窗口每次只向右移动一位,且数组长度 n 满足 n >= k >= 1。
2. 输入输出示例
为更直观理解滑动窗口的移动与结果输出,以下是典型示例:
输入数组 | 窗口大小 | 滑动过程与输出结果 |
|---|---|---|
[2,3,4,2,6,2,5,1] | 3 | 窗口依次为 [2,3,4](max=4)、[3,4,2](max=4)、[4,2,6](max=6)、[2,6,2](max=6)、[6,2,5](max=6)、[2,5,1](max=5),最终输出 [4,4,6,6,6,5] |
[1,3,-1,-3,5,3,6,7] | 3 | 输出 [3,3,5,5,6,7] |
[1] | 1 | 输出 [1](单个元素,窗口无移动) |
二、核心难点:为什么暴力解法不可行?
这道题的核心挑战在于如何在滑动窗口移动时,快速找到窗口内的最大值,而非重复遍历窗口元素。我们先分析常见的暴力解法问题,再引出高效思路:
暴力解法思路:对于每个窗口(共
n - k + 1个),遍历窗口内的k个元素,找到最大值并记录。时间复杂度:
O(n*k)。当n达到10^5、k达到10^4时,计算量会达到10^9,远超程序运行的时间上限(通常为10^8次操作/秒),必然会超时。关键矛盾:每次窗口滑动仅新增右侧一个元素、移除左侧一个元素,暴力解法却重复遍历整个窗口,存在大量冗余计算。因此,高效解法的核心是用合适的数据结构记录窗口内的“候选最大值”,避免重复遍历。
三、实现思路:单调队列(双端队列)的核心应用
目前业界公认的最优解法是使用单调队列(Monotonic Queue)(通常用双端队列 Deque 实现),其核心思想是:让队列内始终保持“从队首到队尾元素递减”的顺序,队首元素即为当前窗口的最大值。具体操作分为以下 3 个步骤:
步骤 1:初始化双端队列,处理第一个窗口
遍历数组的前
k个元素(第一个窗口),对于每个元素nums[i]:
若队列不为空,且当前元素
nums[i]大于等于队列尾部元素,则将尾部元素弹出(因为这些元素在当前窗口内,不可能成为最大值,后续窗口也不会用到它们);将当前元素
nums[i]加入队列尾部;
此时队列队首元素即为第一个窗口的最大值,加入结果列表。
步骤 2:滑动窗口移动,维护队列单调性
从第
k个元素开始(窗口开始向右移动),对于每个元素nums[i](当前窗口为[i - k + 1, i]):子步骤 2.1:移除“窗口外”的元素
判断队列队首元素是否等于
nums[i - k](即上一个窗口的左侧元素,当前窗口已移出该元素):若是,则将队首元素弹出(该元素不再属于当前窗口,不能作为最大值);
若不是,则无需操作(队首元素仍在当前窗口内)。
子步骤 2.2:维护队列递减顺序
重复步骤 1 的逻辑:若队列不为空,且
nums[i]大于等于队列尾部元素,则弹出尾部元素,直至队列满足“递减”要求,再将nums[i]加入队列尾部。子步骤 2.3:记录当前窗口最大值
此时队列队首元素即为当前窗口的最大值,加入结果列表。
步骤 3:输出结果列表
遍历结束后,结果列表中存储的就是所有窗口的最大值,返回该列表。
为什么单调队列高效?
时间复杂度:
O(n)。每个元素最多被加入队列一次、弹出队列一次,整体操作次数为2n,无冗余计算;空间复杂度:
O(k)。队列内最多存储k个元素(极端情况:数组严格递减,队列存储整个窗口元素)。
四、多语言实现:代码与详细解读
不同语言的双端队列 API 略有差异,以下分别提供 Java(标准双端队列) 和 Python(collections.deque) 的实现,覆盖主流面试场景。
1. Java 实现(基于 LinkedList 双端队列)
Java 中可用
LinkedList实现双端队列功能(addLast()、removeLast()、peekFirst()等方法),代码逻辑严格遵循上述步骤:import java.util.ArrayList; import java.util.LinkedList; import java.util.List; public class Solution { public int[] maxSlidingWindow(int[] nums, int k) { // 边界判断:数组为空或窗口大小为0,返回空数组 if (nums == null || nums.length == 0 || k == 0) { return new int[0]; } int n = nums.length; // 结果数组长度 = 数组长度 - 窗口大小 + 1 int[] result = new int[n - k + 1]; int resIndex = 0; // 结果数组的索引 // 双端队列:存储数组元素的索引(而非元素值,便于判断是否在窗口内) LinkedList<Integer> deque = new LinkedList<>(); for (int i = 0; i < n; i++) { // 步骤1/2.2:维护队列递减顺序,弹出尾部小于当前元素的索引 while (!deque.isEmpty() && nums[i] >= nums[deque.peekLast()]) { deque.removeLast(); } // 将当前元素索引加入队列尾部 deque.addLast(i); // 步骤2.1:移除窗口外的元素(窗口左边界为 i - k) while (deque.peekFirst() <= i - k) { deque.removeFirst(); } // 步骤1/2.3:从第 k-1 个元素开始(第一个窗口遍历完成),记录最大值 if (i >= k - 1) { result[resIndex++] = nums[deque.peekFirst()]; } } return result; } // 测试示例 public static void main(String[] args) { Solution solution = new Solution(); int[] nums1 = {2,3,4,2,6,2,5,1}; int k1 = 3; int[] res1 = solution.maxSlidingWindow(nums1, k1); // 输出 [4,4,6,6,6,5] for (int num : res1) { System.out.print(num + " "); } System.out.println(); int[] nums2 = {1,3,-1,-3,5,3,6,7}; int k2 = 3; int[] res2 = solution.maxSlidingWindow(nums2, k2); // 输出 [3,3,5,5,6,7] for (int num : res2) { System.out.print(num + " "); } } }Java 代码关键说明:
存储索引而非元素值:队列中存储数组元素的索引,而非元素本身,这是判断“元素是否在当前窗口内”的关键(通过
deque.peekFirst() <= i - k判断);while 循环维护单调性:使用
while而非if,是因为可能存在多个尾部元素小于当前元素(如窗口内元素为 [4,3,2],当前元素为 5,需弹出 4、3、2 三个元素);结果记录时机:当
i >= k - 1时,第一个窗口遍历完成,之后每遍历一个元素就对应一个新窗口,需记录最大值。
2. Python 实现(基于 collections.deque)
Python 中
collections.deque提供了高效的双端队列操作(append()、pop()、popleft()等),代码逻辑与 Java 一致,但语法更简洁:from collections import deque from typing import List class Solution: def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]: # 边界判断:数组为空或窗口大小为0,返回空列表 if not nums or k == 0: return [] n = len(nums) result = [] # 双端队列:存储元素索引 dq = deque() for i in range(n): # 维护队列递减顺序,弹出尾部小于当前元素的索引 while dq and nums[i] >= nums[dq[-1]]: dq.pop() # 加入当前元素索引 dq.append(i) # 移除窗口外的元素(窗口左边界为 i - k) while dq[0] <= i - k: dq.popleft() # 从第一个窗口(i >= k-1)开始记录最大值 if i >= k - 1: result.append(nums[dq[0]]) return result # 测试示例 if __name__ == "__main__": solution = Solution() nums1 = [2,3,4,2,6,2,5,1] k1 = 3 res1 = solution.maxSlidingWindow(nums1, k1) print(res1) # 输出 [4,4,6,6,6,5] nums2 = [1,3,-1,-3,5,3,6,7] k2 = 3 res2 = solution.maxSlidingWindow(nums2, k2) print(res2) # 输出 [3,3,5,5,6,7]Python 代码关键说明:
deque操作简化:dq[-1]获取队尾元素,dq[0]获取队首元素,pop()弹出队尾,popleft()弹出队首,操作直观;边界判断简洁:
if not nums直接判断数组是否为空,无需额外判断长度;结果列表动态添加:用
result.append()动态记录每个窗口的最大值,无需提前指定列表长度。
五、常见误区与优化拓展
1. 容易踩坑的 3 个点
队列存储元素值而非索引:若直接存储元素值,无法判断该元素是否在当前窗口内(如数组 [1,2,1,0],窗口大小 3,队首元素 2 可能来自索引 1,当窗口移动到 [2,1,0] 时,2 已移出窗口,但无法通过值判断);
用 if 而非 while 维护单调性:若用
if仅弹出一次尾部元素,可能导致队列仍存在小于当前元素的元素(如窗口 [5,4,3],当前元素 6,用if仅弹出 3,队列变为 [5,4],仍不满足递减,需用while弹出所有小于 6 的元素);忽略边界场景:未处理“数组长度等于窗口大小”(如 [1,2,3],k=3)或“k=1”(每个元素都是窗口最大值)的场景,导致代码报错。
2. 其他解法对比(了解即可,不推荐)
除了单调队列,还有两种常见解法,但均存在明显缺陷,面试中不推荐作为首选:
优先队列(大顶堆):
思路:用大顶堆存储窗口内元素,堆顶即为最大值;窗口移动时,移除堆中不属于当前窗口的元素。
缺陷:移除堆中指定元素的时间复杂度为
O(k),整体时间复杂度变为O(nk),与暴力解法效率相当,且实现复杂。
分块预处理:
思路:将数组分为
n/k个块,预处理每个块的“前缀最大值”和“后缀最大值”,每个窗口的最大值为“右侧块的前缀最大值”与“左侧块的后缀最大值”的较大值。缺陷:仅适用于
k固定且数组长度能被k整除的场景,通用性差,且预处理逻辑复杂。
3. 拓展场景:滑动窗口最小值
若题目改为“滑动窗口最小值”,只需将单调队列的维护逻辑改为“队列从队首到队尾递增”,队首元素即为最小值,其他操作完全一致。例如:
# 滑动窗口最小值(仅修改单调性判断条件) def minSlidingWindow(nums: List[int], k: int) -> List[int]: if not nums or k == 0: return [] dq = deque() result = [] for i in range(len(nums)): # 维护队列递增顺序,弹出尾部大于当前元素的索引 while dq and nums[i] <= nums[dq[-1]]: dq.pop() dq.append(i) # 移除窗口外元素 while dq[0] <= i - k: dq.popleft() # 记录最小值 if i >= k - 1: result.append(nums[dq[0]]) return result六、总结
“滑动窗口最大值”的核心是用单调队列解决“重复计算”问题,其解题逻辑可总结为:
队列单调性:始终保持队列从队首到队尾递减,确保队首是当前窗口最大值;
窗口边界控制:通过存储元素索引,判断队首元素是否在当前窗口内,及时移除无效元素;
高效操作:每个元素仅入队、出队一次,时间复杂度优化至
O(n),是大规模数据场景的最优解。
掌握该题不仅能应对面试,更能理解“用合适数据结构优化算法效率”的核心思想,为后续解决“滑动窗口求和”“最长无重复子串”等问题打下基础。
这个Markdown格式的文档可直接用于博客发布、笔记整理或面试复习。如果需要调整标题层级、补充示例代码注释,或增加特定语言(如C++)的实现版本,你可以随时告诉我,我会进一步优化内容。
3598

被折叠的 条评论
为什么被折叠?



