公司动态
滑动窗口与单调队列优化:从暴力到O(n²)解决二维区间最值问题
1. 项目概述从“理想的正方形”看信奥刷题的核心价值最近在带学生刷信奥信息学奥林匹克题目又遇到了P2216 [HAOI2007] 理想的正方形这道经典题。这道题在洛谷、牛客等刷题平台上热度一直不低它不像一些纯数学推导题那么抽象也不像某些复杂模拟题那样冗长但它完美地卡在了一个“承上启下”的关键位置——你需要扎实的C基础语法需要理解二维数组更需要初步掌握“滑动窗口”和“单调队列”这两种极其重要的优化思想。很多孩子第一次接触时会本能地想到暴力四重循环然后眼睁睁看着程序超时TLE这正是从“语法学习”迈向“算法思维”的一道典型门槛。这道题描述的场景很直观给你一个a*b的整数矩阵和一个整数n。你要从这个大矩阵中找出所有n*n大小的正方形区域。对于每个这样的正方形计算区域内的最大值和最小值的差。最后你的程序需要输出所有差值中最小的那个。简单说就是在一个数字矩阵里找一个最“平整”的n*n方块。题目链接通常指向洛谷P2216属于HAOI2007省选题目有一定难度但非常适合用来训练优化技巧。为什么我特别推荐这道题因为它几乎是一个“微型项目”。你不仅要写出正确的逻辑更要在数据规模a, b 1000, n 100下思考效率。直接暴力求解时间复杂度是O(a*b*n*n)在极限数据下必然超时。这就逼着你去学习更高效的方法而“单调队列优化”正是解决这类“固定区间最值”问题的利器。通过这道题你能把“滑动窗口”、“单调队列”这两个知识点从概念变成肌肉记忆以后遇到类似的“子矩阵极值”问题思路会非常清晰。下面我就结合自己多年的辅导经验拆解这道题的完整解决思路、C实现细节以及那些调试时容易踩的坑。2. 核心思路拆解从暴力到优雅的优化之路2.1 问题重述与暴力解法分析首先我们明确一下输入输出。输入三个整数a, b, n接着是一个a行b列的矩阵mat。我们需要找出所有n*n的子矩阵计算每个子矩阵的最大值 - 最小值最后输出这些差值中的最小值。最直接的想法也是很多初学者的第一版代码是四重循环遍历所有可能的子矩阵左上角坐标(i, j)其中0 i a-n,0 j b-n。对于每个左上角(i, j)再用两重循环遍历这个n*n的子矩阵找出最大值max_val和最小值min_val。计算差值max_val - min_val并更新全局最小值答案ans。// 伪代码示意暴力做法 int ans INF; for (int i 0; i a - n; i) { for (int j 0; j b - n; j) { int maxv -INF, minv INF; for (int x i; x i n; x) { for (int y j; y j n; y) { maxv max(maxv, mat[x][y]); minv min(minv, mat[x][y]); } } ans min(ans, maxv - minv); } }我们来估算一下复杂度。子矩阵左上角有(a-n1)*(b-n1)个每个子矩阵需要n*n次比较。在极限数据ab1000, n100时计算量级约为(901*901)*(100*100) ≈ 8.1e9次操作。这在1秒的时间限制内是绝对无法完成的。因此暴力法行不通我们必须优化。2.2 优化思路降维打击与单调队列优化的核心在于避免重复计算。当我们从左到右滑动子矩阵时相邻的两个子矩阵在垂直方向上是完全一样的行只是水平方向滑动了一列。如果我们能快速得到每一行在某个连续区间长度为n内的最大值和最小值那么问题就简化了。这就是“降维”思想先将二维问题分解为多个一维问题。第一步行内滑动窗口最值。对于矩阵的每一行我们预处理出所有长度为n的连续子区间的最大值和最小值。我们可以用两个数组row_max[i][j]和row_min[i][j]来记录其中i表示行号j表示以第j列作为结尾的长度为n的区间即区间[j-n1, j]的最大值和最小值。如何高效求一个一维数组所有固定长度区间的最大值这就是经典的“滑动窗口最大值”问题最优解是使用单调队列时间复杂度为O(长度)。第二步列上滑动窗口最值。经过第一步我们得到了一个“压缩”后的矩阵row_max和row_min它们的高度仍是a但宽度变成了b-n1。row_max[i][j]表示原矩阵第i行从第j-n1列到第j列这个区间的最大值。现在对于同一个列区间j如果我们再在垂直方向行方向上取一个高度为n的窗口那么这个窗口内所有row_max[i][j]的最大值不就是原矩阵中一个n*n子矩阵的最大值吗同理row_min也是如此。因此我们只需要对row_max和row_min的每一列再做一次竖直方向的“滑动窗口最值”计算就能得到每个n*n子矩阵的真正最大值和最小值。这个过程相当于做了两次“降维”先横向压缩再纵向压缩。最终我们通过O(a*b)的预处理就能直接得到每个子矩阵的极值将总复杂度从O(a*b*n*n)优化到O(a*b)这是一个质的飞跃。注意这里容易混淆下标。row_max[i][j]中的j对应的是原矩阵的列下标但它的含义是一个区间的右端点。在实现时我们通常会让j从n-1开始因为第一个完整的长度为n的区间右端点是n-1这样更直观。3. 关键技术实现手把手编写单调队列3.1 单调队列的原理与C实现单调队列是本题的灵魂。它能在O(n)时间内求出数组所有固定长度滑动窗口的最大值/最小值。其核心思想是维护一个具有单调性的双端队列deque队列里存放的是数组元素的下标存放下标可以方便判断窗口是否过期。以求滑动窗口最大值为例遍历数组中的每个元素。当队列不为空且队尾下标对应的元素值 当前元素值时从队尾弹出。因为当前元素更大且更新它更有可能成为后面窗口的最大值所以前面比它小的元素不可能再成为最大值了。将当前元素的下标加入队尾。检查队头元素的下标是否已经滑出窗口范围即队头下标 当前下标 - 窗口长度如果是则从队头弹出。当窗口形成后即当前下标 窗口长度-1队头下标对应的元素就是当前窗口的最大值。求最小值只需将第2步的比较条件反过来即可。下面是一个通用的函数模板用于计算数组arr中所有长度为k的滑动窗口的最值并将结果存入result数组result[i]对应以i为右端点的窗口的最值。#include deque #include vector using namespace std; // 计算滑动窗口最大值结果存入 res void slidingWindowMax(const vectorint arr, int k, vectorint res) { dequeint dq; // 存储下标 int n arr.size(); res.clear(); res.reserve(n); for (int i 0; i n; i) { // 维护队列单调递减性队头最大 while (!dq.empty() arr[dq.back()] arr[i]) { dq.pop_back(); } dq.push_back(i); // 移除滑出窗口的元素 if (dq.front() i - k) { dq.pop_front(); } // 当窗口形成时记录结果 if (i k - 1) { res.push_back(arr[dq.front()]); } } } // 计算滑动窗口最小值结果存入 res void slidingWindowMin(const vectorint arr, int k, vectorint res) { dequeint dq; // 存储下标 int n arr.size(); res.clear(); res.reserve(n); for (int i 0; i n; i) { // 维护队列单调递增性队头最小 while (!dq.empty() arr[dq.back()] arr[i]) { dq.pop_back(); } dq.push_back(i); // 移除滑出窗口的元素 if (dq.front() i - k) { dq.pop_front(); } // 当窗口形成时记录结果 if (i k - 1) { res.push_back(arr[dq.front()]); } } }3.2 针对本题的二维扩展实现理解了单调队列后我们将其应用到二维矩阵上。我们需要四个二维数组row_max[a][b]: 预处理每行滑动窗口最大值。row_min[a][b]: 预处理每行滑动窗口最小值。col_max[a][b]: 在row_max基础上对每列做滑动窗口最大值得到最终子矩阵最大值。col_min[a][b]: 在row_min基础上对每列做滑动窗口最小值得到最终子矩阵最小值。具体步骤初始化与输入。读取a, b, n和矩阵mat。横向预处理。遍历每一行i对mat[i]这一行数组应用slidingWindowMax和slidingWindowMin窗口长度为n。将结果分别存到row_max[i]和row_min[i]。注意结果数组的长度是b - n 1。为了后续处理方便我们可以让row_max和row_min的列维度也是b但只有下标j n-1的位置才有意义存储以j为右端点的窗口极值。纵向预处理。经过上一步我们得到了“行压缩”后的矩阵。现在对于每一列jj从n-1开始我们都有一个长度为a的数组其元素是row_max[0..a-1][j]。对这个竖直数组应用滑动窗口最大值窗口长度n得到的结果col_max[i][j]就表示原矩阵中以(i, j)为右下角的n*n子矩阵的最大值。col_min同理。计算答案。遍历所有有效的(i, j)i从n-1到a-1j从n-1到b-1计算col_max[i][j] - col_min[i][j]并更新全局最小值ans。这里有一个极其关键的细节数组下标的对应关系。col_max[i][j]对应原矩阵中左上角为(i-n1, j-n1)右下角为(i, j)的子矩阵。所以我们在最后遍历时i和j的起始点都是n-1。3.3 完整C代码实现与逐行解析结合以上思路下面是完整的AC代码。我加入了详细的注释并特别标注了易错点。#include iostream #include vector #include deque #include climits // 用于INT_MAX using namespace std; int main() { int a, b, n; cin a b n; // 原矩阵 vectorvectorint mat(a, vectorint(b)); for (int i 0; i a; i) { for (int j 0; j b; j) { cin mat[i][j]; } } // 第一步预处理每行的滑动窗口最值 // row_max[i][j] 表示第i行以第j列为右端点的长度为n的区间的最大值 // 同理 row_min[i][j] 表示最小值 vectorvectorint row_max(a, vectorint(b, 0)); vectorvectorint row_min(a, vectorint(b, 0)); for (int i 0; i a; i) { dequeint dq_max, dq_min; // 遍历当前行的每一列 for (int j 0; j b; j) { int val mat[i][j]; // 维护最大值单调队列递减 while (!dq_max.empty() mat[i][dq_max.back()] val) { dq_max.pop_back(); } dq_max.push_back(j); // 维护最小值单调队列递增 while (!dq_min.empty() mat[i][dq_min.back()] val) { dq_min.pop_back(); } dq_min.push_back(j); // 移除滑出窗口的队头元素 // 窗口区间为 [j-n1, j]所以当队头下标 j-n1 时过期 if (!dq_max.empty() dq_max.front() j - n 1) { dq_max.pop_front(); } if (!dq_min.empty() dq_min.front() j - n 1) { dq_min.pop_front(); } // 当窗口长度达到n时记录结果 // 注意j是从0开始的所以当 j n-1 时第一个完整的窗口才形成 if (j n - 1) { row_max[i][j] mat[i][dq_max.front()]; row_min[i][j] mat[i][dq_min.front()]; } // 注意对于 j n-1 的位置row_max/min[i][j] 是未初始化的默认为0 // 但我们后续只会使用 j n-1 的部分所以没关系。 } } // 第二步在行预处理的结果上对列做滑动窗口最值 // col_max[i][j] 表示以(i, j)为右下角的n*n子矩阵的最大值 // 同理 col_min[i][j] 表示最小值 vectorvectorint col_max(a, vectorint(b, 0)); vectorvectorint col_min(a, vectorint(b, 0)); // 注意列处理时我们只关心那些在行处理中有效的列即 j n-1 for (int j n - 1; j b; j) { dequeint dq_max, dq_min; for (int i 0; i a; i) { int val_max row_max[i][j]; int val_min row_min[i][j]; // 维护最大值单调队列 while (!dq_max.empty() row_max[dq_max.back()][j] val_max) { dq_max.pop_back(); } dq_max.push_back(i); // 维护最小值单调队列 while (!dq_min.empty() row_min[dq_min.back()][j] val_min) { dq_min.pop_back(); } dq_min.push_back(i); // 移除滑出窗口的队头元素竖直方向窗口 if (!dq_max.empty() dq_max.front() i - n 1) { dq_max.pop_front(); } if (!dq_min.empty() dq_min.front() i - n 1) { dq_min.pop_front(); } // 记录结果同样需要窗口形成 if (i n - 1) { col_max[i][j] row_max[dq_max.front()][j]; col_min[i][j] row_min[dq_min.front()][j]; } } } // 第三步计算答案 int ans INT_MAX; // 遍历所有可能的n*n子矩阵的右下角坐标 for (int i n - 1; i a; i) { for (int j n - 1; j b; j) { ans min(ans, col_max[i][j] - col_min[i][j]); } } cout ans endl; return 0; }代码关键点解析下标处理这是最容易出错的地方。row_max[i][j]的有效范围是j n-1。col_max[i][j]的有效范围是i n-1且j n-1。最终遍历答案时i和j都从n-1开始。队列存储下标单调队列里存储的是行号或列号下标而不是具体的值。这样便于判断元素是否已滑出窗口。窗口过期判断条件队头下标 当前下标 - 窗口长度 1是关键。例如当前下标j5窗口长度n3窗口区间是[3,5]。如果队头下标是2那么它已经不在窗口内需要弹出。初始化row_max和row_min在j n-1时未被赋值保持为0。但这不影响结果因为我们后续只使用有效部分。如果担心干扰可以初始化为一个不可能的值如-INF和INF但本题中矩阵元素值为非负整数0作为默认值在逻辑上不会影响最终求最小差值的正确性因为无效位置不会被用到。4. 调试技巧与常见问题实录即便思路清晰实现时也难免遇到各种问题。下面是我在辅导学生和自测时总结的几个典型“坑点”和调试技巧。4.1 常见错误类型与排查答案错误WA下标越界这是最常见的错误。检查所有数组访问特别是row_max[i][j]和col_max[i][j]在j或i很小时的访问。确保只在j n-1和i n-1时才读写这些值。建议在读写这些数组前加一个条件判断if (j n-1)或if (i n-1)虽然代码稍显冗余但能有效避免越界。初始化问题ans的初始值应设为一个很大的数如INT_MAX。如果设为0且所有差值都大于0答案就会错误地输出0。窗口长度处理当n1时需要特殊处理吗按照我们的算法n1时row_max[i][j]就等于mat[i][j]本身后续列处理也是它本身逻辑是通用的。但要注意此时j从0(n-1)开始循环是正常的。不过有些同学在写窗口过期判断时条件写成 i - n当n1时i - n可能等于i-1而队头下标可能就是i-1未过期却被错误地弹出了。所以务必使用 j - n 1这种形式。输入数据范围题目没说矩阵元素的正负所以可能包含负数。我们的代码中row_min的默认值0如果被用到在无效区域且矩阵中有负数可能导致计算出错。更稳健的做法是只给有效区域赋值或者用vector的resize只分配有效大小的空间。时间超限TLE使用了低效数据结构确保使用的是deque而不是list或自己用数组模拟的低效队列。deque支持O(1)的头部和尾部插入删除。嵌套循环过多确认你的算法是O(a*b)的。如果出现了O(a*b*n)的循环肯定是哪里写错了比如在每次列处理时又重新计算行最值。输入输出效率对于a, b最大1000数据量是10^6级别。使用cin/cout可能会比较慢。可以在主函数开头加上ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);来关闭同步加速流输入输出。或者使用scanf/printf。运行错误RE除零或非法内存访问几乎都是下标越界。仔细检查所有循环的起止条件特别是当n a或n b时。题目保证n min(a, b)所以不会出现这种情况。但如果是其他类似题目需要考虑这种边界。4.2 调试与测试策略构造小数据测试不要一上来就用最大数据测试。先构造一个3x3矩阵n2这样的小例子手动计算所有子矩阵的差值与程序输出对比。这是定位逻辑错误最快的方法。打印中间结果在怀疑出错的地方打印row_max,row_min,col_max,col_min等中间数组的值。与手动计算的结果对比可以迅速定位是行处理错了还是列处理错了。单元测试函数将slidingWindowMax和slidingWindowMin函数单独拿出来测试。给定一个一维数组和窗口长度看输出是否正确。确保这个基础组件没问题。边界条件测试n 1此时子矩阵就是单个元素最大值最小值相同差值应为0。n a b整个矩阵就是一个子矩阵答案就是全局最大值减最小值。a1或b1矩阵退化为一行或一列。我们的二维算法应该也能处理列处理时窗口高度为n如果a1且n1则只有一行一列有效。4.3 性能优化与代码精简上面的代码为了清晰使用了多个vector空间复杂度是O(a*b)。实际上我们可以进行空间优化因为我们在计算col_max和col_min时只需要按列处理不需要同时存储所有的row_max和row_min。我们可以一边生成row_max/row_min一边进行列方向的单调队列处理将空间复杂度降到O(a)或O(b)。但这会牺牲一些代码的清晰度。对于信奥比赛在空间限制不紧张的情况下本题256MB足够优先保证正确性和可读性更为重要。另一个常见的优化是我们可以只使用两个deque通过复用它们来处理每一行和每一列避免在循环内反复创建和销毁deque对象。虽然对性能提升不大但代码更整洁。5. 举一反三单调队列的应用场景与变式搞定这道题绝不仅仅是为了AC一道题。单调队列作为一种思想应用场景非常广泛。它的核心是维护一个候选集合并且保证集合的头部元素就是当前窗口的最优解最大或最小。典型应用场景滑动窗口最值问题这是最直接的应用如本题、LeetCode上的“滑动窗口最大值”。优化动态规划在某些DP问题中状态转移方程形如dp[i] max/min{ dp[j] f(i, j) } (j 在某个区间)如果f(i, j)可以分解为只与i和只与j有关的部分并且j的区间是滑动的就可以用单调队列将转移复杂度从O(n)降到O(1)。经典问题有“最大子序和”加强版、“修剪草坪”、“绿色通道”等。计算限定区间内的最值比如求每个数左边/右边第一个比它大/小的数“柱状图中最大的矩形”问题的基础。相关变式题目一维基础LeetCode 239. 滑动窗口最大值。二维扩展本题的姊妹题或类似题如求所有子矩阵的最大值/最小值之和或者求差值最大的子矩阵等。思路都是“降维单调队列”。带权值的情况窗口内的值不是直接求最值而是求满足某种条件的加权和有时可以通过维护前缀和配合单调队列来解决。回到信奥刷题本身我强烈建议在VSCode等配置好C调试环境的编辑器里练习。单步调试、观察变量是理解算法执行过程最有效的方式。刷题平台如洛谷、牛客网都有在线评测但本地有一个强大的调试环境能极大提升学习效率。对于这道“理想的正方形”吃透它你就掌握了解决一大类二维区间最值问题的钥匙。下次再遇到你就能自信地说“哦这个啊两次单调队列搞定。”