开头
LeetCode 883:Projection Area of 3D Shapes,翻译过来叫“三维形体投影面积”,属于那种题目名字听着很难、实际做起来却很友好的类型。它给你一个 n×n 的二维数组 grid,每个格子的数值表示这个位置上堆叠了几个 1×1×1 的立方体,让你求这个立体造型在 xy、yz、zx 三个方向上的投影面积之和。我第一次看到“三维形体”四个字,第一反应是“是不是要建模,是不是要搞立体几何那一套”,结果仔细读完题才发现,这题的核心压根不在三维,而在二维数组上怎么高效做三类统计。我做完提交之后在自己的刷题笔记里标了一句“耗时 100”,指的就是这类题哪怕数据拉满,实际运行时间也就几十毫秒量级,完全不需要焦虑。这篇博客就围绕“从 3D 到 2D 的转化”来拆,手把手把三种投影分别对应什么统计、代码怎么写、有哪些坑可以提前绕开,一次性讲透。适合刚开始刷数组模拟题的同学,也适合想从这题延伸出去、把一类“网格统计”题串起来复习的人。
1. 三个投影方向怎么理解:别被“3D”两个字吓住
1.1 用搭积木的方式想清楚三个视图
这题最劝退的地方是“三维形体投影面积”这个名字,听起来像要做空间几何,实际题目给你的是一个二维数组。我自己习惯把 grid[i][j] 想象成一堆积木:第 i 行第 j 列的格子上,叠了 grid[i][j] 个 1×1×1 的小方块。一个格子可能是空的(值为 0),也可能叠了 1 块、2 块、甚至 50 块。现在你站在三个不同的方向去看这堆积木,问你能看到的“影子”面积加起来是多少。
第一个方向是俯视,也就是从正上方往下看。这时候不管一个格子上叠了 3 块还是 5 块,你看到的都是同一个 1×1 的顶面。只要这个格子的值不是 0,就在俯视图上占一个格子。所以俯视图的面积,就是数组里非零元素的个数。注意这里不是把每个格子的数值加起来,数值加起来那是体积,不是投影面积。第一次做这题的人十个里有八个会在这里卡一下。
第二个方向是从侧面看,也就是沿 y 轴方向看过去。你看到的其实是每一行的轮廓线。对于同一行来说,后面柱子无论多高,只要前面有一根更高的柱子挡着,你就看不到后面的顶。换句话说,这一行在侧视图上的“高度”,取决于这一行所有元素的最大值。把所有行的最大值加起来,就是这一面投影的面积。
第三个方向是从正面(另一个侧面)看过去,沿 x 轴方向看。道理完全一样,只是这次换成按列来看:每一列的最高值决定了这一列在正视图上的高度。把每一列的最大值加起来,就是这一面投影的面积。
为了让你彻底记住这三个统计量,我把它整理成了下面这张对照表:
| 投影方向 | 你需要统计什么 | 为什么 |
|---|---|---|
| xy 平面(俯视) | 所有非零格子的数量 | 从上往下看,堆多高都只占一个格子 |
| yz 平面(侧视) | 每一行的最大值之和 | 沿行方向看去,最高柱子挡住后面所有矮柱子 |
| zx 平面(正视) | 每一列的最大值之和 | 沿列方向看去,最高柱子决定这列的轮廓高度 |
1.2 为什么不需要真的建三维模型
很多人看到这种题会想:那我是不是得开一个三维数组,把每个格子的小立方体都存进去,再模拟光线照射?完全不用,而且这么做只会把你的代码写复杂,还可能超时。所有投影面积都可以直接从原始二维数组里算出来,不需要任何中间结构。
用数学语言写一下就更清楚了。假设 grid 是一个 n×n 的矩阵,那么:
- xy 方向投影面积 = count(grid[i][j] > 0);
- yz 方向投影面积 = sum(max(grid[i][j] for j in range(n)) for i in range(n));
- zx 方向投影面积 = sum(max(grid[i][j] for i in range(n)) for j in range(n));
- 最终答案就是这三项相加。
整道题的本质,就是把一个“看起来很立体”的问题,翻译成三个“非常平面”的数组统计问题:非零计数、行最大值求和、列最大值求和。你现在应该能感觉到,这题真正考的是你能不能快速把一个几何概念映射到数组操作上,一旦映射完成,代码量可以非常小。
我们拿题目里最经典的示例手算一遍:grid = [[1,2],[3,4]]。俯视图:四个格子全非零,得到 4。侧视图:第一行 [1,2] 最大值是 2,第二行 [3,4] 最大值是 4,加起来是 6。正视图:第一列 [1,3] 最大值是 3,第二列 [2,4] 最大值是 4,加起来是 7。所以总答案 = 4 + 6 + 7 = 17。如果这里你算出来不是 17,说明某个方向的统计逻辑理解偏了,建议重新对着表格核一遍。
再来一个带零的示例:grid = [[1,0],[0,2]]。俯视图:非零格子是 (0,0) 和 (1,1),共 2 个。侧视图:第一行最大值 1,第二行最大值 2,和是 3。正视图:第一列最大值 1(因为 [1,0]),第二列最大值 2(因为 [0,2]),和是 3。总答案 = 2 + 3 + 3 = 8。这个示例特别适合检验你是否真的理解了“俯视图算个数,而不是算总和”:你把两个数加起来能得到 3,但那不是投影面积。
2. 关键细节:行最大和列最大怎么一次遍历拿全
2.1 同一个内层循环里同时处理行和列
思路清楚了之后,代码怎么写就成了下一个问题。最直观的办法是写三个循环:第一个循环数非零个数,第二个循环算行最大,第三个循环算列最大。这么做完全没问题,时间复杂度也不会变,只是代码会长一点。但如果你仔细观察题目条件会发现:grid 是一个方阵,行数和列数相等。这意味着你可以在同一个双重循环里,通过交换下标同时取到“当前行的元素”和“当前列的元素”。
具体来说,外层循环变量 i 遍历每一行,内层循环变量 j 遍历每一列。那么 grid[i][j] 就是第 i 行第 j 列的元素,grid[j][i] 就是第 j 行第 i 列的元素,也就是第 i 列上的第 j 个元素。于是你在一次内层循环里可以同时维护两个最大值:一个是行最大值 rowMax,一个是列最大值 colMax。核心伪代码的骨架长这样:
- for i in range(n):
- rowMax = 0
- colMax = 0
- for j in range(n):
- if grid[i][j] > 0: ans += 1
- rowMax = max(rowMax, grid[i][j])
- colMax = max(colMax, grid[j][i])
- ans += rowMax + colMax
这个写法把三次数组遍历压成了一次,代码量少,可读性也没有下降。我第一次提交的时候用的是“先数非零,再单独算两个最大值”的三个循环写法,后来发现一次遍历明显更清爽,也更符合这类题的惯用解法。
这里最值得注意的,是 colMax 那一行里面的下标顺序。很多人写到这里容易顺手写成 max(colMax, grid[i][j]),那实际上是在算“行的最大值”,只不过把它硬命名为 colMax。如果这么写,代码不会报错,样例也能过,但一旦遇到行和列最大值分布不均匀的用例,答案就会错。要理解这个下标交换,可以这样记:你是在以一个方阵的视角同时“横着看”和“竖着看”,横着看是 grid[i][j],i 不动,j 在动;竖着看就必须把 j 当作行、i 当作列,所以是 grid[j][i]。
2.2 边界条件、数据范围和防御性写法
聊完核心遍历,再来看看那些平时不起眼、但真出问题会很头疼的边界细节。
先说数据范围。题目给出的约束是 1 ≤ n ≤ 50,且 grid[i][j] 的取值范围在 1 到 50 之间,格子为 0 表示空。按照这个约束,答案的最大值是可以估算的:俯视图最多 n×n 也就是 2500 个非零格子,行最大之和最多 50 行 × 50 = 2500,列最大之和同样最多 2500,三项合计最大 7500。这个量级用 int 存储绰绰有余,完全不需要担心溢出。这里可以放心用 int 作为返回值类型。
再考虑空数组的极端情况。题目本身交代了 grid 是方阵且 n≥1,但是如果你把这段逻辑抽成一个工具函数,放到别的项目里复用,那 grid 为空的情况是可能发生的。安全起见,我习惯在函数开头加一行防御性判断:if (grid.empty()) return 0;。这个判断不会影响正常提交,但在面试场景里属于加分项,能够体现你对输入边界的敏感度。
还有一个细节是“非零”判断的写法。因为本题所有有效值都是正数,所以写 grid[i][j] > 0 和 grid[i][j] != 0 结果一样。但从通用性角度看,如果某天数据源里出现了负数,> 0 依然能正确表达“有方块”,而 != 0 就会误判。我个人的习惯是一律写 > 0,既符合语义,也不用担心未来数据格式变化。
3. 代码实现:C++、Python、Java 三版对照
3.1 C++ 版本与逐行解释
先给出我最常用的一版 C++ 实现,代码短到你几乎不需要记:
class Solution { public: int projectionArea(vector<vector<int>>& grid) { int n = grid.size(); int ans = 0; for (int i = 0; i < n; ++i) { int rowMax = 0, colMax = 0; for (int j = 0; j < n; ++j) { if (grid[i][j] > 0) ++ans; rowMax = max(rowMax, grid[i][j]); colMax = max(colMax, grid[j][i]); } ans += rowMax + colMax; } return ans; } };这版代码的逻辑非常直白。外层循环每处理完一行,就把这一行的行最大值和这一列的列最大值累加到答案里。之所以选择在每一行结束时统一加一次,而不是在内层循环里不断累加,是为了避免重复统计。你可以在内层循环里做 max 更新,但 ans 的累加放在行结尾更清晰,因为每个 rowMax 和 colMax 都是一行/列一轮循环的产物。
C++ 版本要注意的是,函数形参使用的是 vector<vector >& 引用传递,这样避免了整个二维数组的拷贝。如果你不小心写成了 vector<vector > grid,每次调用都会复制一份数组,虽然 n 很小看不出问题,但工程习惯上引用传递更专业。
3.2 Python 版本与逐行解释
Python 写起来更短,也更贴合伪代码:
class Solution: def projectionArea(self, grid: List[List[int]]) -> int: n = len(grid) ans = 0 for i in range(n): row_max = 0 col_max = 0 for j in range(n): if grid[i][j] > 0: ans += 1 row_max = max(row_max, grid[i][j]) col_max = max(col_max, grid[j][i]) ans += row_max + col_max return ans需要提醒一点,Python 内置的 max 函数在调用时会有一点额外开销,但在这个题的规模下完全不用在意。相比性能,我更看重代码的可读性,这里直接使用 max 比手写 if 判断要清晰得多。
如果你追求更“函数式”的写法,还可以用 zip 把列拆出来单独算:
xy = sum(1 for row in grid for v in row if v > 0) yz = sum(max(row) for row in grid) zx = sum(max(col) for col in zip(*grid)) return xy + yz + zx这个写法很短,适合讨论思路时使用,但是实际提交时我更推荐上面那种一次循环的版本,因为对初学者来说,理解和维护成本更低。
3.3 Java 版本与复杂度总结
Java 版本和 C++ 几乎一一对应:
class Solution { public int projectionArea(int[][] grid) { int n = grid.length; int ans = 0; for (int i = 0; i < n; i++) { int rowMax = 0, colMax = 0; for (int j = 0; j < n; j++) { if (grid[i][j] > 0) ans++; rowMax = Math.max(rowMax, grid[i][j]); colMax = Math.max(colMax, grid[j][i]); } ans += rowMax + colMax; } return ans; } }三种语言的思路完全一致,也是这类题的通用解法。复杂度方面,由于我们必须遍历整个 n×n 数组的每一个元素才能完成统计,时间复杂度最优就是 O(n^2),不可能更低。空间上只用了几个临时变量,所以是 O(1) 的额外空间。把关键信息整理成表:
| 语言 | 时间复杂度 | 空间复杂度 | 实现要点 |
|---|---|---|---|
| C++ | O(n^2) | O(1) | 引用传参,避免拷贝 |
| Python | O(n^2) | O(1) | 同一循环里算行、列最大 |
| Java | O(n^2) | O(1) | Math.max 与下标交换 |
4. 常见问题与排查技巧实录
4.1 把俯视图面积算成体积
这条算是我见过最多的错误,没有之一。很多刚接触这个题的人,看到 grid[i][j] 是非零值,就顺手 ans += grid[i][j],然后嘴里还念叨“这不是投影面积吗”。这样算出来的其实是整个三维形体的体积,而不是俯视图的投影面积。想象你站在一栋楼顶往下看,不管这栋楼是三层还是三十层,你在平面图上看到的地基轮廓都是一样的。俯视图只关心“有没有建筑”,不关心建筑有多高。正确的做法是只对非零格子计数,也就是 if (grid[i][j] > 0) ans++。
如果你担心自己混淆,我提供一个验证手段:拿一个含 0 的用例去跑,看结果是否符合预期。比如 grid = [[1,0],[0,2]],正确答案是 8。如果某个解法输出 3,那它大概率把俯视图做成了非零计数,但把体积误当投影,或者干脆统计错方向了。
4.2 列最大值的下标写错
第二个高频 bug 是 colMax 的更新写成 grid[i][j] 而不是 grid[j][i]。这个错误很隐蔽,因为小规模样例很容易误打误撞通过。比如 grid = [[1,2],[3,4]] 这种行列对称性较好的数据,行最大之和等于列最大之和,你甚至不会发现哪里算错了。但一旦遇到 grid = [[1,1],[3,4]],行最大之和是 1 + 4 = 5,列最大之和是 3 + 4 = 7,两者不相等,写错下标时答案就会偏小。
排查这种问题最快的方法是写一个 2×2 或 3×3 的随机数组,手动算出期望答案,再和代码输出对照。如果发现正视图方向偏小,优先检查下标是否交换了。我自己的经验是,凡是涉及“行列同时统计”的题目,写完之后先检查一次所有下标,这种检查成本极低,但能省下很多调试时间。
4.3 “耗时 100”背后的性能执念
标题里的“耗时 100”,在刷题圈通常指某次提交运行耗时在 100ms 附近,或者是自己给自己定的一个目标:希望把耗时压进 100ms 以内。对于这道题而言,这个目标相当保守。即便网格是 50×50 的满数据,需要处理的数据点也只有 2500 个,三个统计量在一次双重循环里就能全部算完,实际运行时间通常在个位数毫秒到几十毫秒之间波动,而平台耗时还受服务器负载、网络传输等外部因素影响。
如果你真的希望把耗时压到尽可能低,可以从三个方向优化:第一,避免在循环内部调用过于复杂的库函数,比如每次都重新创建容器;第二,传参使用引用而不是值传递;第三,把可以提前结束的统计合并到同一轮循环里。但说实话,这些优化在这个题里都属于锦上添花,最重要的还是保证算法复杂度正确。我刷题很少看每次提交的毫秒数,因为同一个代码在不同时段提交可能差出一倍,过度追求那个数字没有意义。
4.4 常见错误速查表
| 错误现象 | 根本原因 | 解决办法 |
|---|---|---|
| 最终答案比正确值大很多 | 把俯视图面积算成了所有格子数值之和 | 改成只对 >0 的格子计数 |
| 答案偏小,且只差列方向的统计 | 正视图的 colMax 用了 grid[i][j] | 换成 grid[j][i] 取同一列的遍历 |
| 空数组报错或越界 | 没处理 grid.empty() 的场景 | 函数开头加 if(grid.empty()) return 0 |
| 答案溢出,或者类型太窄 | 忽略了 n 和数值上限 | 这题用 int 即可,答案上限约 7500 |
| 运行时间不稳定 | 在循环里复制了数组或频繁创建对象 | 用引用/指针传递,避免无谓的内存分配 |
5. 同一套思路能解的同类题
5.1 与 807 的关系:在最大值上做文章
如果你觉得这题太简单,还想再巩固一下“网格统计”的手感,最值得做的下一题是 807 Max Increase to Keep City Skyline,翻译过来是“保持城市天际线的最大增高”。那道题同样用到了行最大值和列最大值,只不过反过来让你思考:在不改变某个方向天际线的条件下,每个格子最多能增加多少高度。
核心思路是先算每行最大值和每列最大值,然后遍历每个格子,它能提升到的最高高度是 min(rowMax[i], colMax[j]),提升量就是 min 和当前值之间的差值。你看,整个过程和 883 共用了一套预处理逻辑:行最大、列最大。所以如果 883 的代码你能闭着眼写出来,807 几乎等于换个场景再考一遍同一个模板。
5.2 与 463、766 的差异:网格统计的通用套路
除了 807,网格题里还有一类是统计相邻关系或对角线关系,比如 463 岛屿的周长、766 托普利茨矩阵。这类题看起来和 883 不一样,但底层的思考方式是一致的:你先确定要统计的目标,再决定是在一次遍历中完成还是多次遍历。
比如 463 求岛屿周长,本质上是在数“哪些边是外边”,你可以遍历每个格子,每遇到一个陆地格子就加 4,然后如果右方或下方也是陆地就减去 2。再比如 766 判断所有对角线上的元素是否相等,你只需要检查每个格子和它右下方的格子是否相同。这些题的共性是,它们都要求你在二维数组的框架下,把“形状”问题转化为“数值关系”问题。
我把这类题串起来之后,总结出一个自己的训练模板:
- 第一步,明确要统计的行、列、对角线或相邻关系;
- 第二步,考虑能否用一次双重循环同时完成所有统计;
- 第三步,注意下标映射,尤其是行列互换、对角线偏移;
- 第四步,处理空数组和单一行/列的边界条件。
按这个模板去刷数组模拟类的 easy 题,基本都能在几分钟内找到方向。
做这道 883 时我最大的一个体会是:很多看起来很酷的几何问题,拆开之后骨架其实特别朴素。你不需要会三维建模,也不需要懂光线追踪,只要老老实实把一个二维数组横着看一遍、竖着看一遍,答案就出来了。如果你正在为“题目看半天不知道在说什么”而苦恼,我的建议是先别急着看题解,拿出一张纸,画一个 2×2 的格子,给每个格子填上高度,然后分别从三个方向画一下你能看到的轮廓。画完之后你会发现,代码只是把画图的过程翻译成了 for 循环而已。另外,一个值得养成的习惯是:这类简单题写完后,花 30 秒想想“是否能推广到非方阵”,虽然这道题限制了 n×n,但思考的过程会让你的数组功底更扎实。