刷题刷到一个叫“保持城市天际线”的题目时,我第一反应是:这名字也太宏大了。结果读了几遍题,发现它本质上是一道很典型的贪心/矩阵问题,甚至一个新手花十分钟看懂题意就能写下代码,但真正把它讲清楚、写对,还需要一点细节上的功夫。题目的大致场景是:一个二维整数数组表示每栋建筑的高度,我们要在加高尽可能多建筑的同时,保证加完之后从四个方向看到的城市轮廓和原来一模一样。这个约束听起来像城市规划,本质却是一道关于“行列最大值”的优化题。
如果你刚开始刷题,想练贪心思路,这道题是很合适的入门题;如果你已经刷了不少题,也可以用它复盘“先找上界、再独立求解”的通用套路。下面我会把题目的翻译、数学推导、多语言实现、常见错误和延伸变体都过一遍,尽量让读完的人不仅能把代码写出来,还能在面试里把它讲得漂亮。
1. 题目到底在问什么:先看懂“天际线”三个字
1.1 原题场景还原
先回到题目本身。假设有一张二维网格,每个格子是一块地皮,上面原本有一栋楼,高度就是grid[i][j]。远处的人从北面或南面看向城市,看到的轮廓由每一列的最大高度决定;从东面或西面看向城市,看到的轮廓由每一行的最大高度决定。
举一个经典示例,原始网格长这样:
grid = [ [3, 0, 8, 4], [2, 4, 5, 7], [9, 2, 6, 3], [0, 3, 1, 0] ]先算每一行的最大值:
- 第 0 行:8
- 第 1 行:7
- 第 2 行:9
- 第 3 行:3
再算每一列的最大值:
- 第 0 列:9
- 第 1 列:4
- 第 2 列:8
- 第 3 列:7
所谓“保持天际线”,就是这些行列最大值一个都不能变。在这个前提下,我们可以把每个格子上的楼加高,但不能降低,也不能移动。最终要求返回所有楼增加的高度总和。这个示例的标准答案是 35,后面我会用这个例子手算一遍,方便对照。
1.2 从三维视角到二维矩阵
很多人第一次读题会被“城市天际线”这个场景带偏,以为要处理什么几何图形、投影关系。实际上,题目已经把三维城市抽象成了一个数字矩阵,天际线不再是曲线,而是“最大值”。
想象一下,一个城市有很多建筑,从正南方向看过去,你真正能看到的轮廓,是由每一列中最高的那栋楼决定的。只要这一列的最高楼高度不变,即使其他楼加高到没超过它,你从南面看过去,轮廓还是原来那条线。同理,从正东方向看过去,每一行中最高的楼决定轮廓。
所以,“从四个方向看天际线不变”可以等价翻译成一句话:加高后,每一行的最大值和每一列的最大值,必须和加高前完全一样。这是全题的题眼,也是所有推导的起点。
1.3 关键约束:不能拆楼,只能加高
这道题有三个关键约束,缺一不可:
- 每个格子的高度只能增加,不能减少。
- 建筑的位置不能移动,只能在原有格子中发生。
- 加高后的行最大值、列最大值必须等于原始值,不能更高。
第三个约束尤其重要。你可能会想:“既然要保持天际线,是不是最高楼不能动,其他楼随便长?”不是的。一个楼加高后,可能不影响它所在行的最大值,但可能让所在列的最大值变高,于是天际线就变了。任何一栋楼都同时属于某一行和某一列,所以它同时受两个方向约束。这一点正是题目设计得巧妙的地方。
2. 核心思路拆解:为什么每个格子的上限是 min(行最大, 列最大)
2.1 每个格子有两个“天花板”
对于一个位于(i, j)的格子,假设加高后的高度是H。
从东西方向看,第i行的天际线不能改变,所以H不能超过第i行的原始最大值,记为rowMax[i]。
从南北方向看,第j列的天际线不能改变,所以H不能超过第j列的原始最大值,记为colMax[j]。
两个条件必须同时满足,于是:
H <= rowMax[i] H <= colMax[j] H <= min(rowMax[i], colMax[j])这就是每个格子的独立上限。它只和“这一行的最大高度”以及“这一列的最大高度”有关,和其他格子怎么加高无关。所以我们把原来“全局轮廓不变”的复杂约束,拆分成了每个格子独立的局部约束。
2.2 为什么可以同时达到上限
知道了单个格子的上限是min(rowMax[i], colMax[j]),能不能直接把这个上限作为加高后的目标值?答案是可以的,但需要证明,否则面试时容易被追问。
要证明的核心是:对于第i行,如果每个格子(i, j)都取min(rowMax[i], colMax[j])作为新高度,改完以后第i行的最大值仍然是rowMax[i]。
假设原始第i行中,某个列位置j0达到了行最大值,也就是grid[i][j0] = rowMax[i]。那么这个格子加高后的新高度是:
min(rowMax[i], colMax[j0])因为colMax[j0]是第j0列的最大值,而grid[i][j0]属于这一列,所以必然有colMax[j0] >= grid[i][j0] = rowMax[i]。于是:
min(rowMax[i], colMax[j0]) = rowMax[i]也就是说,那个原本就最高的格子,加高后依然等于原来的行最大值。即使我们把这一行所有其他格子都加到各自上限,也没有任何一个能超过rowMax[i],因为所有上限里的第一个参数就是rowMax[i]。因此这一行的最大值保持不变。对每一列同理。
这样,每个格子都能达到自己的上限,同时所有行列最大值都不变,所以总和一定可以达到理论最大值。
2.3 为什么不能“先加一栋,再算下一栋”
有读者可能会问:这不是贪心吗?那我从左到右,从上到下,一个一个把格子加到只要能加的高度不就行了?
问题在于,如果你真的边加边改原数组,把某个格子改高了,再计算另一个格子时,原始行列最大值可能已经被污染。更关键的是,这道题的正确解不是“逐步决策”,而是“整体公式解”。每个格子能加到的高度,在原始矩阵确定之后就已经固定了,不需要考虑顺序。
可以这样理解:rowMax[i]和colMax[j]是两堵墙,每个格子被夹在两面墙之间,能长多高完全由这两面墙共同决定。墙本身不会因为你在墙角加了一块砖而变化,因为它由整面墙的最高点决定。所以先算墙,再填砖,顺序清晰且不会出错。
3. 多语言代码实现:先算最大值再累加差值
3.1 算法步骤与复杂度
整体实现非常简单,只有两个双重循环:
- 遍历原始矩阵,分别计算每一行的最大值
rowMax和每一列的最大值colMax。 - 再次遍历矩阵,对每个格子计算
min(rowMax[i], colMax[j]) - grid[i][j],累加到答案。
时间复杂度是O(m * n),其中m是行数,n是列数。空间复杂度是O(m + n),因为需要额外存两个数组。如果你愿意,也可以把rowMax和colMax都用一维数组存,不需要再复制矩阵。
这里有一个值得注意的细节:为什么必须在第二遍再算答案?因为第一遍如果边扫边累加,rowMax和colMax可能还没有完整算出来。比如(0, 0)格的答案需要知道整个第 0 列的最大值,而那个最大值可能在(2, 0)才出现。所以必须先完整扫一遍,再扫第二遍。
3.2 参考代码
Python 版本:
def maxIncreaseKeepingSkyline(grid): if not grid or not grid[0]: return 0 m, n = len(grid), len(grid[0]) row_max = [0] * m col_max = [0] * n for i in range(m): for j in range(n): row_max[i] = max(row_max[i], grid[i][j]) col_max[j] = max(col_max[j], grid[i][j]) ans = 0 for i in range(m): for j in range(n): ans += min(row_max[i], col_max[j]) - grid[i][j] return ansC++ 版本:
class Solution { public: int maxIncreaseKeepingSkyline(vector<vector<int>>& grid) { int m = grid.size(); int n = grid[0].size(); vector<int> row_max(m, 0), col_max(n, 0); for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { row_max[i] = max(row_max[i], grid[i][j]); col_max[j] = max(col_max[j], grid[i][j]); } } int ans = 0; for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { ans += min(row_max[i], col_max[j]) - grid[i][j]; } } return ans; } };Java 版本:
class Solution { public int maxIncreaseKeepingSkyline(int[][] grid) { int m = grid.length; int n = grid[0].length; int[] rowMax = new int[m]; int[] colMax = new int[n]; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { rowMax[i] = Math.max(rowMax[i], grid[i][j]); colMax[j] = Math.max(colMax[j], grid[i][j]); } } int ans = 0; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { ans += Math.min(rowMax[i], colMax[j]) - grid[i][j]; } } return ans; } }三份代码的核心逻辑完全一致,只有语法区别。实际面试中,选自己最熟练的语言写清楚即可,但务必把“两个数组”的语义讲明白。
3.3 边界与类型细节
在实际编码中,有几个边界条件值得注意:
- 如果
grid为空,或者grid[0]为空,直接返回 0。Python 里可以这样写,Java 和 C++ 需要额外加判断。 - 在 C++ 中,如果
grid[0].size()在空矩阵上调用会越界,代码需要先判空。 - 题目如果给的数值很大,累加结果可能超过普通
int的范围。建议根据数据范围使用long long或long,不要省这个细节。刷题网站的数据也许不会卡,但换到真实场景就未必了。
以开头的示例来说,手算一遍过程是:
- 每个格子的上限矩阵是
[[8,4,8,7], [7,4,7,7], [9,4,8,7], [3,3,3,3]]。 - 用上限矩阵减去原始矩阵,得到增量矩阵
[[5,4,0,3], [5,0,2,0], [0,2,2,4], [3,0,2,3]]。 - 所有增量求和:5+4+0+3+5+0+2+0+0+2+2+4+3+0+2+3 = 35。
最后返回 35,和题目预期一致。
4. 常见踩坑与排查技巧:这几类错误我全犯过
4.1 先改原数组再算答案
这个错误看起来很低级,但我确实见过不少人写出来。有人会写类似这样的流程:
- 先算
rowMax和colMax。 - 再遍历,把
grid[i][j]直接改成min(rowMax[i], colMax[j])。 - 在改的同时把差值累加。
问题在于第三步里,grid[i][j]已经被覆盖成了新值,所以差值计算会变成“新值减新值”,结果全是 0。正确做法是先把原值记下来,或者在覆盖之前完成累加。
更稳妥的习惯是:不要把输入数据当作临时变量改来改去。除非题目明确要求原地修改,否则读取输入后只做查询,不要写入。这样既避免污染数据,也方便打印调试。
4.2 行列最大值搞混
对于一个(i, j)格子,行最大值一定是rowMax[i],列最大值一定是colMax[j]。一旦写成rowMax[j],答案立刻出错。
排查这类问题有一个很直观的方法:拿一个不对称的矩阵来测试,比如grid = [[1, 2, 3], [4, 5, 6]]。如果代码里行列索引写反,结果会明显不同。尤其是当矩阵不是正方形时,行列长度不同,写反甚至可能触发数组越界。
4.3 代码逻辑正确,但样例不过
如果样例计算出来是 34、33 之类,而不是 35,大概率是边界条件和“天花板”理解有问题。这时不要急着改代码,先把rowMax和colMax打印出来,再手算前几个格子。
比如第一次看这题时,我以为每个格子只需要不能超过“所在行最大和所在列最大中较小的那个”,但写代码时把min写成了max,结果样例输出比我预想大很多。这一瞬间的错误最能暴露问题。记住:两个方向都要限制,必须用较小的限制,而不是较大的。
4.4 写一个“校验函数”来调试
如果你不放心自己写出来的结果,可以写一个辅助函数,用来验证最终加高后的矩阵是否真的保持了天际线。
思路是:
- 用你的算法得到每个格子的增量,生成新矩阵
newGrid。 - 分别计算
newGrid每一行的最大值、每一列的最大值。 - 和原始
grid的行最大值、列最大值逐一比较。
如果任何一个不等,说明算法没有满足约束。如果都相等,再检查总和。这样做能帮你快速定位“到底哪一步让天际线变了”。
这个习惯在刷题时特别有用。很多题目不是“代码过没过”的问题,而是“直觉对不对”的问题。写个校验函数,相当于给你的直觉上了一道保险。
5. 变体与延伸:如果题目换一换,思路还成立吗
5.1 只保持一个方向的天际线
如果题目改成“只保持南北方向(列方向)的天际线不变”,那约束就少了一半。每个格子只需要满足:
H <= colMax[j]此时最大增量总和变成:
sum(colMax[j] - grid[i][j] for all i, j)结果会比原题大不少,因为行方向不再限制你。
反过来,如果只保持东西方向(行方向)的天际线,就只需要考虑rowMax[i]。这类变体虽然简单,但有助于理解“多个约束叠加时为什么取交集”,也就是为什么用min而不是max。
5.2 如果给定行列最大值,反过来构造矩阵
有一个很类似的经典问题:已知每一行和每一列的最大值,要求构造一个矩阵,使得每个位置都是非负整数,并且所有位置元素之和最大。
答案一模一样:每个格子填min(rowMax[i], colMax[j])。这个构造出来的矩阵,天然满足所有行的最大值等于给定值,也满足所有列的最大值等于给定值。这正好是“保持城市天际线”问题的反向版本。
从刷题的角度看,把正反两个版本一起做,能形成完整的知识闭环。正向是“给定矩阵求增量”,反向是“给定最大值求矩阵”,核心公式都是同一个min。
5.3 现实世界中的“天际线约束”
这道题虽然包装成城市规划,但类似逻辑在很多领域都能看到。
比如城市规划中的建筑限高,经常以“不得破坏某条视线走廊”为理由。开发商想增加建筑面积,就需要保证从某个观察点看出去,新的建筑高度不能超过某一排建筑的既有轮廓。这和题目里的“每一列最大值不变”非常像。再比如广告牌摆放,某些城市规定广告牌高度不能超过街区内既有建筑最高点,本质上也是行列最大值的约束。
在数据处理里,也有类似操作:某些图像增强算法要提升像素亮度,但又不能改变某一行或某一列的最大对比度。这时就可以借鉴“先提取行列最大值,再按交集放量”的思路。当然真实场景会更复杂,但题目背后的“约束拆解”思想是通用的。
6. 复盘与面试表达:简单题也要讲出层次
6.1 面试官想听到什么
这道题在面试里不算难题,但正因为简单,面试官更想看你的表达是否清楚。
完整的回答最好包含四层:
- 第一层:把“天际线”翻译成“行列最大值”。
- 第二层:把“保持天际线”翻译成“行列最大值不变”。
- 第三层:把每个格子的约束拆成两个独立不等式,得到
min(rowMax[i], colMax[j])。 - 第四层:证明所有格子同时取到上限时,整体约束依然成立。
如果你能把第四层讲明白,就已经超过了多数背答案的候选人。面试官真正想确认的,是你有没有“自己推导过”,而不是“见过这个题”。
6.2 演示例子怎么选
讲这道题时,不建议拿 4x4 的大矩阵硬讲。更好的是先用一个 2x2 矩阵:
grid = [[1, 2], [3, 4]]行最大值是[2, 4],列最大值是[3, 4]。
于是:
(0,0)上限是min(2,3)=2,增量为 1。(0,1)上限是min(2,4)=2,增量为 0。(1,0)上限是min(4,3)=3,增量为 0。(1,1)上限是min(4,4)=4,增量为 0。
整个矩阵增量总和是 1。为什么只有左上角能加?因为它所在的行最大值是 2,所在列最大值是 3,它可以从 1 长到 2,但又不能超过行最大值 2。右下角本身就是全市最高点,不需要加。用这种小例子讲,听众能很快跟上。
6.3 后续练习方向
如果你想把这类题吃透,可以继续练习以下几个方向:
- 更多二维矩阵题:二维区域和、岛屿数量、螺旋矩阵。
- 更多“约束最大化”贪心题:跳跃游戏、分发饼干、区间调度。
- 更多“先找上界,再证明可达”的推导题:这类题的关键往往不在代码,而在数学上能不能说服自己。
刷题时不要追求“我 AC 了就可以”。同一个思路,试着用口述讲一遍,用不同语言写一遍,再改几个条件问自己“如果变了会怎样”。这样一道简单题能顶三道题。
我个人在复盘这道题时最大的收获,不是记住那行ans += min(row_max[i], col_max[j]) - grid[i][j],而是养成“先写约束、再找上界、最后证明可行”的习惯。刷完之后,试着找一个完全不懂算法的人,把“为什么每栋楼只能长到这个高度”讲清楚。如果能让他点头,这道题才算真正过了关。