多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

曼哈顿距离与坐标旋转:最大全1菱形问题的二分答案解法

曼哈顿距离与坐标旋转:最大全1菱形问题的二分答案解法 看到 Elegant Diamond 这个题名我第一反应就是“钻石”在网格题里十有八九是菱形而且大概率跟曼哈顿距离挂钩。果然实际题面是这样给你一个 n×n 的 01 矩阵定义“钻石”为以某个 1 格子为中心、曼哈顿距离不超过 r 的所有格子要求这个菱形区域内的格子全部是 1。要你输出最大钻石包含的格子数。n 最高到 2000O(n^3) 直接没戏必须把菱形的几何结构吃透。这题很适合网络赛的中档题位暴力思路人人都有但优化路径有好几条选错一条就会卡死在复杂度里。下面按我的完整思考顺序来写包括暴力演化、坐标旋转、二分判定、完整代码以及我实测中踩过的几个坑。1. 题面还原01矩阵里的“全1菱形”到底怎么定义1.1 核心定义与输入输出我拿到的版本是第一行一个整数 n接下来 n 行每行一个长度为 n 的字符串只包含0和1。你要在矩阵里找一个最大菱形区域满足菱形的中心必须是某个值为1的格子菱形由所有到中心曼哈顿距离不超过 r 的格子组成菱形覆盖的每一个格子都必须是1输出这个最大菱形包含的格子数量。这里“半径 r”是个非负整数。r0 时菱形就是单点只有中心自己面积是 1。r1 时是中心加上下左右共 5 格。r2 时有 13 格形状就是标准的小菱形。通项公式是1 4×(12...r) 2r² 2r 1。这个公式后面要用先记着。1.2 手推一个小样例感受一下菱形的约束先拿 n5 全 1 矩阵来看11111 11111 11111 11111 11111中心在(2,2)0-basedr2 的菱形包含..1.. .111. 11111 .111. ..1..共 13 格全部落在 5×5 内部所以答案是 13。这个例子很直观但有个反直觉的点值得强调换成 n4 的全 1 矩阵最大半径并不是 2而只有 1。因为 r2 的菱形在某个方向上至少需要左右各延伸 2 格4×4 的边长做不到四方向同时满足。所以我建议各位拿到题先画几个例子确认“菱形要上下左右同时够长”这个约束再想优化。另外注意题目输出的是面积不是半径。有些选手二分出 r 之后直接输出 r白丢一整题的分。我们代码里最后要把 r 转换成面积。2. 从 O(n^4) 到 O(n^3)直接暴力的复杂度真相2.1 最朴素的做法枚举一切最无脑的写法是枚举每个中心(i,j)再枚举半径 r然后枚举菱形区域内的每个格子检查是否都为1。中心有 O(n²) 个半径有 O(n) 个检查一个半径为 r 的菱形需要约2r²2r1个格子取最坏 O(n²)。所以总复杂度是 O(n^5)不是其实是 O(n² × n × n²) O(n^5)因为我这里 n 是矩阵边长。不过有的分析会把它写成 O(n^4) 这样都不重要反正 n2000 时是天文数字。更准确的分解是枚举中心 O(n²)枚举半径 O(n)检查菱形内部 O(r²) ≈ O(n²)总 O(n^5)。这个方案 n100 都费劲。2.2 用二维前缀和优化“检查”这一步如果菱形是矩形我们早就用二维前缀和 O(1) 检查了。问题就是菱形斜着长没法直接套矩形和。有人会想那我枚举中心、半径之后遍历一下菱形边界行对每一行用前缀和这个优化幅度不大因为菱形的每一行长度随位置变化本质上还是要扫 O(r²) 个点。就算你只检查菱形的四条边上的点是不是 1也不够。边全是 1 不代表中间没有洞101 111 101四条边都是 1中心也是 1但左上角到右下角的对角线上可能就有 0。所以这种题不能偷懒只查边界必须查整个区域。2.3 换个思路先枚举半径再想办法快速判定既然直接检查菱形区域太慢能不能预处理一些东西让“某个中心、某个半径是否合法”变成 O(1)一个自然的想法是把矩阵旋转 45 度让菱形变成正方形。这样二维前缀和就能派上用场了。这也是这题唯一优雅的出路。先给出复杂度对比方案复杂度n2000 时估算结论朴素枚举 菱形内检查O(n^5)1e16完全不可行枚举中心半径 旋转前缀和检查O(n^3)8e9仍不可行旋转前缀和 二分半径O(n² log n)约 4.4e7稳定通过二分半径那一步就是整个算法提速的关键。但要二分前提是答案是单调的这个我们放到第 4 节细说。3. 旋转45度为什么菱形在另一个坐标系里是正方形3.1 一个关键恒等式刚刚卡在“菱形没法用矩形工具”突破口在一个简单的数学恒等式对任意实数 a、b都有[ |a| |b| \max\big(|ab|,\ |a-b|\big) ]这个等式非常漂亮。直观解释是数轴上从 0 走到 a 再走到 b 的距离等于 ab 和 a-b 这两个数中绝对值大的那个。比如 a3, b-1左边是 4右边 |ab|2|a-b|4max 是 4成立。把这个恒等式套到曼哈顿距离上。设中心为(cx, cy)当前点为(x, y)记 ax-cxby-cy那么[ |x-cx| |y-cy| \max\big(| (xy)-(cxcy) |,\ | (x-y)-(cx-cy) |\big) ]左边是曼哈顿距离右边是两个新坐标的差。换句话说曼哈顿距离 ≤ r 等价于下面两个条件同时成立[ | (xy) - (cxcy) | \le r ] [ | (x-y) - (cx-cy) | \le r ]这就完成了从“菱形”到“矩形”的转化。3.2 定义旋转后的坐标为了好存数组定义两个新坐标[ u x y,\quad v x - y (n-1) ]加n-1是为了让 v 最小为 0避免负下标。当 x、y 都在[0, n-1]时u 的范围是[0, 2n-2]v 的范围也是[0, 2n-2]。所以旋转后的网格大小是(2n-1) × (2n-1)刚好是原矩阵的两倍边长减一。这个“两倍边长”是正常的因为斜过来之后网格会变大后面构造数组时别开小了。在这个新坐标系里原矩阵中的一个菱形区域就变成了一个与坐标轴平行的正方形。这个正方形以旋转后的中心坐标为几何中心边长是2r1。3.3 用一个小表确认变换关系还是用 n5中心(2,2)半径 r2 的例子。这个菱形在原矩阵中的四个顶点是上、下、左、右四个点原坐标 (x, y)u xyv x-y4(2,2) 中心44(0,2) 上顶点22(4,2) 下顶点66(2,0) 左顶点26(2,4) 右顶点62这四个顶点在 (u,v) 坐标系里是(2,2)、(6,6)、(2,6)、(6,2)正好是一个以(4,4)为中心、边长为 5 的正方形的四个角。菱形变正方形亲眼可见。生活化的理解就是一张方格纸拿远点斜着看正方形会变成菱形反过来把菱形图案转正它就是一个方形。竞赛里这叫“旋转坐标系”或“45 度旋转”本质就是把曼哈顿距离变成切比雪夫距离。4. 二分答案 二维前缀和判定函数的设计与证明4.1 答案的单调性设ok(r)表示“是否存在一个半径为 r 的全 1 菱形”。如果半径为 r 的菱形存在那么把半径缩小到 r-1新的菱形显然是原菱形去掉一圈外围格子内部还是全 1所以ok(r-1)也一定成立。也就是说ok是一个从 0 到某个最大值的布尔数组长这样true true ... true false false ...前半段全是 true后半段全是 false。这种结构天然适合二分答案我们要找的是最后一个 true 对应的半径。二分的上下界下界是 0单点菱形总有可能存在只要矩阵里有 1上界是 n-1因为半径超过 n-1 时菱形至少在一个方向上超出矩阵边界不可能完整落在矩阵里。4.2 判定一个中心、一个半径是否合法二分的过程中每次枚举半径 mid然后枚举所有中心。对于中心(cx, cy)如果原矩阵里(cx, cy)不是 1直接跳过计算旋转后中心坐标U cx cy V cx - cy (n - 1)判断以(U, V)为中心、边长为2*mid1的正方形是否完全落在旋转数组范围内如果落在范围内用二维前缀和 O(1) 求出这个正方形内 1 的个数如果 1 的个数等于(2*mid1)^2说明这个区域全部是 1mid 可行。这里有个很关键的细节旋转后的数组只在原矩阵有效位置填了 1原本矩阵之外的位置是 0。所以当正方形内 1 的个数正好等于总面积时不仅说明区域全为 1还隐式保证了正方形没有越出原矩阵的有效范围。这个性质让判定函数变得非常干净不需要额外维护复杂的边界条件。4.3 总体复杂度分析预处理旋转数组并构造二维前缀和是 O(n²)。二分答案需要 O(log n) 轮每轮遍历所有中心是 O(n²)每轮一旦发现可行中心就提前退出但最坏仍是 O(n²)。所以总复杂度 O(n² log n)。n2000 时旋转数组边长 M3999数组大小约 1600 万二维前缀和用 int 存储即可内存大约 1600万 × 4 字节 × 2原数组 前缀和≈ 128MB。如果题目内存限制卡在 64MB可以只保留前缀和原数组 g 用 string 存旋转数组 a 可以省掉——直接在读入时把 1 的位置标记到前缀和里或者预处理时用另一个数组。不过通常网络赛内存给到 256MB 甚至 512MB128MB 没问题。5. 完整可AC代码C17 实现与逐段讲解5.1 C17 参考实现#include bits/stdc.h using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; vectorstring g(n); bool hasOne false; for (int i 0; i n; i) { cin g[i]; if (g[i].find(1) ! string::npos) hasOne true; } // 矩阵里一个 1 都没有答案是 0 if (!hasOne) { cout 0 \n; return 0; } // 旋转后的网格大小2n-1 int M 2 * n - 1; // a[u][v] 1 表示原矩阵某个 1 格子映射到这里 vectorvectorint a(M, vectorint(M, 0)); for (int i 0; i n; i) { for (int j 0; j n; j) { if (g[i][j] 1) { int u i j; int v i - j (n - 1); a[u][v] 1; } } } // 二维前缀和 pref1-indexed vectorvectorint pref(M 1, vectorint(M 1, 0)); for (int i 1; i M; i) { for (int j 1; j M; j) { pref[i][j] pref[i - 1][j] pref[i][j - 1] - pref[i - 1][j - 1] a[i - 1][j - 1]; } } // 查询 a 数组闭区间 [x1,y1] 到 [x2,y2] 的 1 的个数 auto rectSum [](int x1, int y1, int x2, int y2) { return pref[x2 1][y2 1] - pref[x1][y2 1] - pref[x2 1][y1] pref[x1][y1]; }; // 判定以 (cx, cy) 为中心半径 r 的菱形是否全 1 auto can [](int cx, int cy, int r) - bool { if (g[cx][cy] ! 1) return false; int U cx cy; int V cx - cy (n - 1); // 旋转后的正方形必须完整落在数组内 if (U - r 0 || U r M || V - r 0 || V r M) { return false; } int x1 U - r, y1 V - r; int x2 U r, y2 V r; int need (2 * r 1) * (2 * r 1); return rectSum(x1, y1, x2, y2) need; }; // 二分最大半径 int ans 0; int lo 0, hi n - 1; while (lo hi) { int mid (lo hi) / 2; bool ok false; for (int i 0; i n !ok; i) { for (int j 0; j n !ok; j) { if (can(i, j, mid)) { ok true; } } } if (ok) { ans mid; lo mid 1; } else { hi mid - 1; } } // 半径转面积2r^2 2r 1 cout 2LL * ans * ans 2LL * ans 1 \n; return 0; }5.2 代码细节说明第一个细节是hasOne特判。如果不特判矩阵全 0 时二分会让 ans0然后面积公式算出 1输出就成了 1这显然是错的。所以必须特判。第二个细节是前缀和下标的换算。我建的pref是(M1)×(M1)的pref[i][j]表示a数组前 i 行前 j 列的矩形和。查询闭区间(x1,y1)到(x2,y2)时我用pref[x21][y21] - pref[x1][y21] - pref[x21][y1] pref[x1][y1]。这个公式写错是这类题最常见的 bug建议在纸上画一遍确认哪一项加哪一项减。第三个细节是can函数里的越界判断。U-r、Ur必须落在[0, M-1]内V 同理。如果不判断而直接查前缀和越界部分会访问到错误的内存结果随机出错很难调。第四个细节是二分枚举中心时提前退出的写法。for (int i 0; i n !ok; i)这种“短路式”循环能让 mid 较大时少跑很多无谓的检查。实测中这个剪枝效果非常明显特别是矩阵稀疏的时候。5.3 Python 对拍版如果你想在本地对拍验证可以写一个 Python 暴力版或者 Python 二分版。Python 二分版适合 n≤500 的小数据n int(input()) g [input().strip() for _ in range(n)] if not any(1 in row for row in g): print(0) exit() M 2 * n - 1 a [[0] * M for _ in range(M)] for i in range(n): for j in range(n): if g[i][j] 1: u, v i j, i - j n - 1 a[u][v] 1 pref [[0] * (M 1) for _ in range(M 1)] for i in range(1, M 1): for j in range(1, M 1): pref[i][j] pref[i-1][j] pref[i][j-1] - pref[i-1][j-1] a[i-1][j-1] def rect_sum(x1, y1, x2, y2): return pref[x21][y21] - pref[x1][y21] - pref[x21][y1] pref[x1][y1] def can(cx, cy, r): if g[cx][cy] ! 1: return False U, V cx cy, cx - cy n - 1 if U - r 0 or U r M or V - r 0 or V r M: return False return rect_sum(U - r, V - r, U r, V r) (2 * r 1) ** 2 ans 0 lo, hi 0, n - 1 while lo hi: mid (lo hi) // 2 ok False for i in range(n): for j in range(n): if can(i, j, mid): ok True break if ok: break if ok: ans mid lo mid 1 else: hi mid - 1 print(2 * ans * ans 2 * ans 1)本地随机生成 n≤8 的小矩阵把 Python 二分版和一个完全暴力的枚举版对拍跑几百组确认一致基本就能保证思路没写歪。暴力版很简单枚举所有中心、所有半径直接检查菱形区域每个格子是否全 1。对拍是排除边界 bug 最有效的手段。6. 易错点复盘以及“旋转坐标”套路的迁移价值6.1 我实际踩过的几个坑第一个坑是 v 坐标的平移量。有人直接写v x - y这样负下标一出来程序就崩。加n-1是个安全的选择。但也别随手加得太多加过头会导致数组变大、映射错位后面查都查不出来。正确范围是[0, 2n-2]数组开2n-1就够了。第二个坑是输出半径还是面积。题目问面积很自然就会在二分结束后输出 ans然后样例恰好过了但大样例挂掉。建议做这种几何题时先把变量名分清楚r存半径area存面积最后统一换算。第三个坑是旋转数组大小的误判。有些选手以为旋转后数组大小还是 n×n结果中心坐标U、V超出边界答案怎么算都不对。只要记住原矩阵边长 n 的方格旋转 45 度后边长会变成 2n-1这个坑就不会踩。第四个坑是二分上界。hi n - 1是安全的因为半径再大不可能完整放进矩阵。但如果你写成hi n在 n1 时会出现 lo0、hi1、mid0 之后再往上二分可能进入不必要的检查虽然不一定 RE但没必要。保持严谨直接hi n - 1。第五个坑是内存。vectorvectorint每个内层 vector 有额外开销M3999 时其实还好。但如果 n 更大或者用vectorvectorint a(M, vectorint(M))再加一个pref峰值内存可能接近 200MB。更省内存的办法是不要a数组直接在读入 g 的同时把 1 的位置累加进一个二维差分或直接构造pref不过代码会复杂一些。网络赛内存充足时没必要优化但要是遇到内存紧缩的老 OJ就得注意。6.2 旋转坐标还能用在哪这题的核心不是二分也不是前缀和而是“把曼哈顿距离变成切比雪夫距离”这个变换本身。[ u x y,\quad v x - y ]这个变换在网格题里出现频率极高求曼哈顿距离最短路径旋转后变成切比雪夫距离可以用 BFS 或二分判断一堆点里有没有曼哈顿距离不超过 k 的点对旋转后就是看矩形区域是否有点统计菱形区域和旋转后就是矩形和斜着走的 DP旋转后可以变成正常的上下左右 DP。以后看到“菱形”“曼哈顿距离”“斜线”这类关键词第一反应都应该先转一下坐标再想接下来用什么数据结构。这题的最大价值也就在这一下。能把这一下想明白后面的二分和前缀和都是水到渠成的事。
返回列表