专栏文章
P2216 [HAOI2007] 理想的正方形 题解
P2216题解参与者 1已保存评论 1
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 1 条
- 当前快照
- 1 份
- 快照标识符
- @miqw9ttu
- 此快照首次捕获于
- 2025/12/04 11:46 3 个月前
- 此快照最后确认于
- 2025/12/04 11:46 3 个月前
前言
这道题是一道很好的深入理解 ST 算法的例题,通过优化,ST 算法成功地取得了这道题的总
222ms,最大 32ms,8.04MB 的最优解,与次优解拉开断层差距。本题解讲解如何从基础的 ST 算法开始优化,在这之前你需要掌握基本的 ST 算法。
题目分析
题目描述很直接,考虑用 ST 算法实现,初始化 ST 表后,暴力枚举正方形区域(共 个),然后用 ST 查出最值并更新答案即可。主要需要考虑空间大小,初始化时间和单次查询时间。
基础的 ST 做法
二维 ST 表的定义
用 表示原数组,首先我们来看一维 ST 算法定义的数组 ,最小的 是多少?基础二维 ST 表空间较大,有必要分析需要分配的长度。
ST 的原理是用两个可重叠的区间合并为要查询的区间,只要两个最长区间长度的和不小于最大查询长度即可,不妨设最大查询长度为 ,那么 满足 ,注意 从 开始,数组长度需设为 。
ST 的原理是用两个可重叠的区间合并为要查询的区间,只要两个最长区间长度的和不小于最大查询长度即可,不妨设最大查询长度为 ,那么 满足 ,注意 从 开始,数组长度需设为 。
接下来考虑二维 ST 表,参考一维下 表示从 开始的长度为 的线段的最值,我们可以定义 表示以 为左上角,大小为 的矩形的最值。
因为查询长度 ,块长需达到 ,则 维的长度定义为 。
求解 ST 表
求解这个 ST 表比较简单,设现在是在求最大值 ST 表,参考一维 ST 表的倍增转移方程,得到下面的初始化方法:
注意,并不需要同时通过两个方程转移,实现时可以先从小到大枚举 ,然后从小到大枚举 ,若 则通过第一个方程转移,否则通过第二个方程转移。
处理查询
接下来考虑查询操作,设要查询的矩形为 ,先在 上划分出两个长度为二的幂次的块,例如 划分为 和 ,然后在 上继续划分即可,总计需要查询 个块。下面的图展示了如何查询 :

可以看出查询被划分为 个 的矩形 ,在 个子最值中取出最值即为查询结果。
复杂度分析
ST 表的大小即空间复杂度为 ,初始化复杂度 ,查询复杂度 (忽略求子块长的 复杂度,这可以通过初始化规避),总时间复杂度 。
然而我们计算程序需要的空间大小,最大最小 ST 表总计需要内存 ,MLE,必须优化。
正方形优化
ST 表压维
注意到查询的矩形始终是正方形的,所以查询时的子块也是正方形的,这意味着我们没有必要定义两个 分别表示两个方向上的长度,只需要一个 表示正方形的大小就可以了。
定义新的压维 ST 表 表示以 为左上角,边长为 的正方形的最值。
定义新的压维 ST 表 表示以 为左上角,边长为 的正方形的最值。
压维 ST 表的求解
一个大正方形可以由 个小正方形组合,倍增转移方程如下:
处理查询
同基础 ST 表的查询,由于查询的是正方形,一定能找到 个子正方形,注意如果不保证查询为正方形,这一步无法进行,例如上面举例的 就无法划分为 个子正方形。
复杂度分析
容易知道时空复杂度均为 ,现在的最大最小 ST 表总计需要内存 ,已经可以通过本题。
自我滚动优化
ST 算法的本质是倍增 DP,既然是 DP,那么就可以使用滚动数组优化空间复杂度,倍增长度这一维是可以滚掉的,不过为什么普通的 ST 算法不使用滚动优化呢?
原因显然:对于不同的查询长度,需要不同长度的块来合并为查询区间,例如查询 必须用长度为 的子块合并,查询 必须用长度为 的子块合并,这两种不同长度的子块在这种情况下无法相互替代,因此必须存储倍增过程中的中间计算结果用于查询。
然而本题询问的正方形边长 为定值!例如 时,只需要用到 的子正方形的最值,即查询时只用到了 ,这是我们使用滚动数组优化的依据。
原因显然:对于不同的查询长度,需要不同长度的块来合并为查询区间,例如查询 必须用长度为 的子块合并,查询 必须用长度为 的子块合并,这两种不同长度的子块在这种情况下无法相互替代,因此必须存储倍增过程中的中间计算结果用于查询。
然而本题询问的正方形边长 为定值!例如 时,只需要用到 的子正方形的最值,即查询时只用到了 ,这是我们使用滚动数组优化的依据。
滚动 ST 表
我们把 也压维,定义滚动 ST 表 表示外层循环 意义下,以 为左上角,边长为 的正方形的最值。
滚动 ST 表的求解
同压维 ST 表的求解,需要注意的是外层 的枚举次数需要随着 变动,如果锁定为一个大值,那么无法处理 较小的查询。
同时滚动时注意 的取值顺序。
同时滚动时注意 的取值顺序。
处理查询
查询正方形的大小是固定的,对于给定的正方形左上角坐标,要查询的 个子正方形的左上角坐标是可以预处理出 的,详见代码。
复杂度分析
容易知道这一步优化后空间复杂度变为 ,最好可以只定义两个 的 ST 表,总内存仅为存储输入数据所需内存的两倍!
递推优化
设想如果要求查询的矩形不是正方形,那么无法使用正方形优化,但是依然可以使用自我滚动优化:先滚动 ,再滚动 ,同样可以求出滚动 ST 表,观察两种方法的转移方程:
将 1. 中的 拆成二元 ,会拆出 个 ,相比之下 2. 只有 个,后者优于前者。
实际上并不一定真的先滚动 ,再滚动 ,可以轮流滚动,由于查询是正方形,滚动会同时结束,可以在一个循环内同时完成 的滚动。
实际上并不一定真的先滚动 ,再滚动 ,可以轮流滚动,由于查询是正方形,滚动会同时结束,可以在一个循环内同时完成 的滚动。
当然,查询过程中也可以使用这样的优化。
图形解释
观察下面一张格点有向图:

注意,这张图省略了主对角线外的斜有向边,例如 就被省略,事实上可以通过这些隐藏的边转移。
其中,每个节点都可视为 ST 表的状态(仅含 维),点上的 表示对应状态下的矩形的长为 宽为 ()。
查询时,需要根据查询长度在 上找到合适长度,例如 需要查找图中的节点 , 需要查找图中的节点 。
回顾基础 ST 表 ,我们发现这个表包括了整张图的全部节点,根据上面给出的思路(优先通过第二个方程转移)可以得到它的转移图:

这张图共 个节点, 条边,据此可知时空复杂度为 。
对于正方形优化中的压维 ST 表 ,我们发现它只包括了对角线上的节点,通过向右下的有向边转移,其转移图为:

对角线上的节点数共 个,据此可知时空复杂度为 。
滚动 ST 表利用了询问只用到了右下角的节点这一特性,只存储一个节点的信息,然后通过自我滚动转移到新的节点上。
边上的数值表示了转移所需的时间,前面已经提到了,先向下再向右由于直接向右下,递推优化的转移图如下:

上面演示的是先滚动 再滚动 的转移图,我们发现路径的长度减少了。
由于询问是正方形的,到达最终节点向下和向右的边数相等,所以可以同步滚动,转移图如下:

这仅仅是为了将转移写在一个循环内,两者没有性能上的明显差距。
请读者借助以以上图片思考以下问题:
- 如果需要找出的是 的矩形,如何实现?
- 如果正方形的边长可以是给定的 个 的值(假设题目为此缩小了数据范围或者延长了时间限制),能否用 的空间复杂度实现?
- 如果找出的矩形的大小可以是给定的 个 ,并且满足 ,能否用 的空间复杂度实现?
- 跳出本题,考虑二维数组 RMQ,每次查询一个 的矩阵,其中 是一个在所有询问前给出的定值, 与查询有关,那么最好的时空复杂度分别为多少?如果强制在线呢?
代码
关于取值细节,代码中的注释有详细解释,其它优化如快读,预处理等请参见代码。
CPP#include <cstdio>
#include <algorithm>
using namespace std;
// 快读
#ifdef ONLINE_JUDGE
#define getchar() getchar_unlocked()
#endif
inline int read(){
int c = getchar();
while(c<48||c>57) c = getchar();
int x = 0;
while(48<=c&&c<=57){
x = (x<<3)+(x<<1)+(c^48);
c = getchar();
}
return x;
}
int a, b, n;
// dp1 维护最大值 dp2 维护最小值
int dp1[1000][1000], dp2[1000][1000];
int main(){
a = read(); b = read(); n = read();
// 即使去掉这个特判也是正确的
if(n==1){
putchar('0');
return 0;
}
for(int i=0; i<a; ++i){
for(int j=0; j<b; ++j){
// 不需要存原矩阵,直接作为 dp 初值
dp2[i][j] = dp1[i][j] = read();
}
}
int maxT = 0;
while((2<<maxT)<n) ++maxT;
int offset, maxi, maxj;
for(int t=0; t<maxT; ++t){
offset = 1<<t;
// 在 i 方向上倍增
// i 方向上区间长是 2*offset,区间为 [i, i+2*offset-1]
// i+2*offset-1<a => i<a-2*offset+1 => i<=a-2*offset
maxi = a-(offset<<1);
// j 方向上区间长是 offset,区间为 [j, j+offset-1]
// j+offset-1<b => j<b-offset+1 => j<=b-offset
maxj = b-offset;
for(int i=0; i<=maxi; ++i){
for(int j=0; j<=maxj; ++j){
// std::max, std::min 快得不止一点
//if(dp1[i+offset][j]>dp1[i][j]) dp1[i][j] = dp1[i+offset][j];
//if(dp2[i+offset][j]<dp2[i][j]) dp2[i][j] = dp2[i+offset][j];
dp1[i][j] = max(dp1[i][j], dp1[i+offset][j]);
dp2[i][j] = min(dp2[i][j], dp2[i+offset][j]);
}
}
// 在 j 方向上倍增
// maxi = a-(offset<<1);
maxj = b-(offset<<1);
for(int i=0; i<=maxi; ++i){
for(int j=0; j<=maxj; ++j){
dp1[i][j] = max(dp1[i][j], dp1[i][j+offset]);
dp2[i][j] = min(dp2[i][j], dp2[i][j+offset]);
}
}
}
offset = n-(1<<maxT);
// 在 i 方向上倍增
// 区间长是 n,区间为 [i, i+n-1]
// i+n-1<a => i<a-n+1 => i<=a-n
maxi = a-n;
// 区间长是 2^maxT,区间为 [j, j+2^maxT-1]
// j+2^maxT-1<b => j<b-2^maxT+1 => j<=b-2^maxT
maxj = b-(1<<maxT);
for(int i=0; i<=maxi; ++i){
for(int j=0; j<=maxj; ++j){
dp1[i][j] = max(dp1[i][j], dp1[i+offset][j]);
dp2[i][j] = min(dp2[i][j], dp2[i+offset][j]);
}
}
// 在 j 方向上倍增
// maxi = a-n;
maxj = b-n;
int ans = 1000000000;
for(int i=0; i<=maxi; ++i){
for(int j=0; j<=maxj; ++j){
// 直接更新 ans,不用更新 dp
ans = min(ans, max(dp1[i][j], dp1[i][j+offset])-min(dp2[i][j], dp2[i][j+offset]));
}
}
printf("%d", ans);
return 0;
}
相关推荐
评论
共 1 条评论,欢迎与作者交流。
正在加载评论...