目录

题目描述

807. 保持城市天际线

题意分析

grid[i][j] 是坐落在第 i 行第 j 列的一栋楼的高度。允许把任意多栋楼加高任意高度(只能加不能减),但要求加高之后,从东、南、西、北四个方向看到的天际线轮廓与原来完全一致。问所有楼房增加的高度之和最大是多少。

先把「天际线」翻译成数组语言。从北或南看过去,第 j 列的所有楼重叠在一起,看到的轮廓高度就是这一列的最大值 colMax[j];从东或西看过去,第 i 行的所有楼重叠,轮廓高度是这一行的最大值 rowMax[i]。四个方向看似是四组约束,其实只有两组:每一行的最大值不能变、每一列的最大值不能变。北和南给出的是同一个 colMax,东和西给出的是同一个 rowMax

再注意「只能加高」这个方向性:单调加高只可能让某行某列的最大值变大,绝不可能变小。所以「最大值不变」等价于「最大值不变大」,也就是每个格子的新高度都不许超过它所在行的原最大值,也不许超过它所在列的原最大值。约束从「等式」松弛成了「不等式」,这是本题能一步到位的根本原因。

约束里 gridn × n 方阵、n 不超过 50、高度在 0 到 100 之间。规模小到怎么写都不会超时,说明考点不是优化而是能不能把约束拆干净。高度非负这一点也很有用,它让最大值数组可以安全地从 0 开始初始化。

边界:格子本身可能就是行或列的最大值,此时它一点都不能加高,增量为 0;高度可以是 0,grid 中全 0 时答案就是 0。

解法:行列最大值

核心思路

暴力思路是把「加高多少」当成待求变量,对每个格子二分它能加到多高,每试一次就重新扫一遍整个矩阵验证天际线有没有变。这样的代价是 $O(m \cdot n \cdot \log H \cdot m \cdot n)$,而且更麻烦的是——加高格子 A 之后再判断格子 B,两者会不会互相影响?只要不确认这一点,贪心就没有依据。

瓶颈其实不是速度,而是这层「是否互相影响」的顾虑。把它拆开看:格子 (i, j) 的高度只出现在两个统计量里——第 i 行的最大值和第 j 列的最大值。它对第 i' 行(i' ≠ i)、第 j' 列(j' ≠ j)的轮廓毫无贡献。于是每个格子的约束是完全独立的:只要 grid[i][j] 的新值同时满足 ≤ rowMax[i]≤ colMax[j],它就不会破坏任何一条轮廓,而且这个判断跟别的格子怎么改毫无关系。

由此得到每个格子的高度上界:$\min(rowMax[i],\ colMax[j])$。既然要总增量最大,又互不干扰,就让每个格子都顶到自己的上界,增量 $\min(rowMax[i], colMax[j]) - grid[i][j]$ 逐格累加。这个值一定非负,因为 grid[i][j] 本身既参与了 rowMax[i] 的取最大也参与了 colMax[j] 的取最大,必然同时不超过两者。

但还差一步论证:所有格子同时顶到上界之后,rowMaxcolMax 真的没变吗?上界只保证了「不会变大」,还要说明「不会变小」——也就是原来的最大值仍然被某个格子取到。取第 i 行的最大值所在的格子 (i, j^*),有 grid[i][j^*] = rowMax[i]。因为它也是第 j^* 列的一个元素,所以 colMax[j^*] ≥ grid[i][j^*] = rowMax[i],于是它的上界 $\min(rowMax[i], colMax[j^*]) = rowMax[i]$,也就是这个格子根本没被加高,第 i 行的最大值仍然原封不动是 rowMax[i]。列的论证完全对称。

至此不变量成立:把每个格子独立抬到 $\min(rowMax[i], colMax[j])$ 后,所有 rowMaxcolMax 都保持原值,因而四个方向的天际线不变。既然每个格子都取到了它各自的上界,总和自然是可能的最大值——这是「逐项上界可同时达到,故上界之和即为最优」的典型结构,贪心在这里不需要交换论证,因为根本不存在取舍。

解题步骤

  • 第一趟:求 rowMaxcolMax。一次双重循环里同时更新两个数组——遍历到 grid[i][j] 时既拿它更新 rowMax[i] 也更新 colMax[j]。两个数组都初始化为全 0,这依赖题目保证高度非负;若允许负数就必须初始化为极小值或首元素。
  • 两趟必须分开。不能一边算最大值一边累加答案:处理第 0 行时下面的行还没扫到,colMax 是残缺的,算出来的上界会偏小。只有第一趟完整结束,rowMaxcolMax 才是全局真值。
  • 第二趟:逐格累加增量。对每个 (i, j)min(rowMax[i], colMax[j]) - grid[i][j] 累加。这里的下标搭配必须是 rowMax[i]colMax[j],一个用行号一个用列号,写成同一个下标是最常见的手滑。
  • min 而不是 max。两条约束是「同时满足」的合取关系,能容纳的最大值是两个上界中较小的那个。
  • 累加的是增量而不是新高度。题目问的是「增加的高度之和」。
  • 不需要真的修改 grid,也不需要第三趟验证——正确性已由上面的论证保证。

以官方样例 grid = [[3,0,8,4],[2,4,5,7],[9,2,6,3],[0,3,1,0]] 走一遍。

第一趟得到 rowMax = [8, 7, 9, 3](各行最大值 8、7、9、3),colMax = [9, 4, 8, 7](各列最大值 9、4、8、7)。
第二趟第 0 行(rowMax[0] = 8):(0,0) 上界 min(8,9) = 8,增量 8-3 = 5(0,1) 上界 min(8,4) = 4,增量 4-0 = 4(0,2) 上界 min(8,8) = 8,增量 8-8 = 0(它正是本行最大值,一点不能动);(0,3) 上界 min(8,7) = 7,增量 7-4 = 3。本行小计 12。
第 1 行(rowMax[1] = 7):上界依次为 7、4、7、7,增量 7-2 = 54-4 = 07-5 = 27-7 = 0。本行小计 7。
第 2 行(rowMax[2] = 9):上界依次为 9、4、8、7,增量 9-9 = 04-2 = 28-6 = 27-3 = 4。本行小计 8。
第 3 行(rowMax[3] = 3):上界依次为 3、3、3、3(这一行的最大值只有 3,全被它压住),增量 3-0 = 33-3 = 03-1 = 23-0 = 3。本行小计 8。
总计 12 + 7 + 8 + 8 = 35,与期望答案一致。

反过来验证天际线:改造后每行的最大值依次是 8、7、9、3,每列的最大值依次是 9、4、8、7,与原来完全相同——因为每行、每列原本的最高那栋楼(如 (0,2) 的 8、(2,0) 的 9)增量恰好为 0,轮廓被它们钉死了。

代码实现

class Solution {
    // 对每个格子,其可增加的高度为 min(rowMax[i], colMax[j]) - grid[i][j]。
    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 answer = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                answer += Math.min(rowMax[i], colMax[j]) - grid[i][j];
            }
        }

        return answer;
    }
}
func maxIncreaseKeepingSkyline(grid [][]int) int {
    // 对每个格子,其可增加的高度为 min(rowMax[i], colMax[j]) - grid[i][j]。
    m := len(grid)
    n := len(grid[0])
    rowMax := make([]int, m)
    colMax := make([]int, n)

    for i := 0; i < m; i++ {
        for j := 0; j < n; j++ {
            if grid[i][j] > rowMax[i] {
                rowMax[i] = grid[i][j]
            }
            if grid[i][j] > colMax[j] {
                colMax[j] = grid[i][j]
            }
        }
    }

    answer := 0
    for i := 0; i < m; i++ {
        for j := 0; j < n; j++ {
            limit := rowMax[i]
            if colMax[j] < limit {
                limit = colMax[j]
            }
            answer += limit - grid[i][j]
        }
    }

    return answer
}

复杂度分析

  • 时间复杂度:$O(m \cdot n)$,其中 $m$、$n$ 为矩阵行列数。两趟完整的双重循环,每格各做常数次比较与加减,没有任何回退或重算——凭的是「每个格子的上界只由 rowMax[i]colMax[j] 决定,与其他格子的取值无关」,因此一次算出即为终值。
  • 空间复杂度:$O(m + n)$,两个长度分别为 $m$ 和 $n$ 的最大值数组。凭的是把二维矩阵的约束压成了两组一维统计量;如果允许原地改写 grid 并接受两趟以上的扫描,这部分也无法再省——行列最大值必须先全部就位才能开始累加。

关键点总结

  • 先把题面的自然语言翻译成数组语言:「四个方向的天际线」= 行最大值数组 + 列最大值数组,东西同源、南北同源,四组约束实为两组。审题阶段的化简往往比后面的编码更值钱。
  • 「只能增加」把「保持不变」松弛成「不超过」,等式约束变不等式约束,问题从构造变成了求上界。看到单调方向的操作限制就要往这上面想。
  • 每个格子只影响它所在的一行一列,格子之间互不干扰,所以「逐项取上界」直接就是全局最优,不需要交换论证或反证。这类「约束可分解」的结构是贪心最干净的一种。
  • 贪心题一定要补上「上界能同时取到」的论证:行最大值所在的格子因为 $\min$ 的另一半必然更大而增量为 0,天际线被它自己钉住。面试时这段话是拿分点。
  • 必须两趟:统计量要先全局就位,再基于它做决策。一趟边算边用是这类题最普遍的错误。
  • 面试视角:这道题代码只有十几行,面试官真正在听的是三句话——天际线等价于行列最大值、只增不减所以约束是上界、每格独立所以逐格顶到 $\min$ 即最优。主动把这三句说完再写代码,比默默写完等提问要好;如果被追问「怎么证明这是最大值」,就把那个「行最大值格子增量为 0」的论证讲出来。

易错点总结

  • 一趟边算最大值边累加grid = [[1,2],[3,4]] 处理 (0,0) 时第 1 行还没扫到,colMax[0] 只有 1,上界算成 min(2,1) = 1,增量 0;正确的上界是 min(2,3) = 2,增量 1。最终答案少算,且随输入行序变化而变化。
  • min 写成 maxgrid = [[1,2],[3,4]](0,0) 会被抬到 max(2,3) = 3,第 0 行的最大值从 2 变成 3,从东西方向看的轮廓被抬高,方案非法。
  • 下标写成 min(rowMax[i], colMax[i]):在官方样例里 (0,1) 会用 colMax[0] = 9 而不是 colMax[1] = 4,上界从 4 变成 8,这个格子被抬到 8,第 1 列最大值从 4 暴涨到 8,答案也从 35 变大。方阵输入不会越界,所以这个错误跑起来「有结果」,最难被发现。
  • 累加新高度而不是增量grid = [[1,2],[3,4]] 会返回 2+2+3+4 = 11,而正确答案是 1+0+0+0 = 1
  • 只维护 rowMax、忘了 colMaxgrid = [[1,2],[3,4]](0,0) 抬到 2、(1,0) 抬到 4,第 0 列最大值从 3 变成 4,南北方向的轮廓改变;答案算成 2,比正确的 1 多。
  • colMax 数组开成 new int[m]:本题是 n × n 方阵所以侥幸不越界,一旦换成 m ≠ n 的矩阵变体(比如 3 行 5 列)就会在 colMax[3] 处直接数组越界。
  • rowMax / colMax 初始化成 Integer.MAX_VALUE 或首行首列的值再取 max:前者让所有上界都变成另一个数组的值,grid = [[1,2],[3,4]] 会把 (0,0) 抬到 3;后者在写成 rowMax[i] = grid[0][i] 这种行列混淆的形式时同样出错。高度非负,全 0 初始化才是与题目约束匹配的写法。
  • 在第二趟里就地改写 grid[i][j] = limit 之后又重算一次最大值:改写后的矩阵行列最大值虽然不变,但如果再走一遍累加逻辑,增量会被算成 0 并覆盖原答案,出现「第二次调用返回 0」的诡异行为。本题根本不需要改写 grid
  • 误以为东、西是两组不同约束而分别记录行首、行尾的可见高度[[1,3,2]] 从左看和从右看都是 3,行内其余楼被最高的那栋完全遮挡,两个方向给出的是同一个值。多建两个数组只会引入不一致。

相似题目

题目 难度 考察点
861. 翻转矩阵后的得分 中等 同样按行列拆解贪心,但操作是整行整列取反且有先后顺序,各列不再互相独立
73. 矩阵置零 中等 也靠两组行列标记,难点在于要求 $O(1)$ 空间,得把标记压进矩阵首行首列
867. 转置矩阵 简单 只做行列下标互换,不涉及任何统计量,是行列思维的最简形态
48. 旋转图像 中等 要求原地变换,考的是四点循环交换或「转置 + 翻转」的分解,而非聚合统计
566. 重塑矩阵 简单 考一维下标与二维坐标的互相换算,配合可行性判断
498. 对角线遍历 中等 聚合方向从行列换成对角线(i + j 为常数),边界转向规则是主要难点
54. 螺旋矩阵 中等 用四条边界变量控制访问顺序,考的是循环收缩而非统计量复用