目录

题目描述

562. 矩阵中最长的连续1线段

题意分析

给一个只含 01 的矩阵 mat,找出其中最长的一条全部由 1 组成的直线段,返回它的长度。直线只有四种走向:水平、垂直、主对角线(左上到右下)、反对角线(右上到左下)。线段必须是连续的,中间不能被 0 打断。

先把「线段」这个概念的自由度数清楚:起点可以是任意格子、方向有四种、长度不定。如果按「枚举起点 × 枚举方向 × 一直走到底」去做,同一段连续的 1 会被它的每个前缀重复走一遍,做了大量无用功。

突破口在于换一个枚举口径:不枚举线段的起点,而是枚举线段的终点。任何一条水平线段都有唯一的最右格子,任何一条垂直线段都有唯一的最下格子——以「终点 + 方向」为标识,每条极大线段被恰好数到一次,重复彻底消失。而且这样定义后,「以 (i, j) 为终点、沿某方向的连续 1 长度」只依赖同方向上前一个格子的同名量,天然构成一步递推。

约束方面,矩阵是二维线性规模,目标显然是把每个格子只处理常数次的 $O(mn)$。四个方向互不干扰,可以在同一趟遍历里并行维护,不需要跑四遍。

边界:矩阵可能为空(行数为 0),要先挡掉再取 mat[0].length;第一行、第一列、最后一列的格子没有对应方向的前驱,这些方向要从 1 起算;格子为 0 时四个方向全部断开,长度都归零——用「跳过、保持数组默认值 0」来表达最省事。特别注意反对角线的前驱是右上方的格子 (i-1, j+1),它的列号加一,越界判断的方向和另外三个相反,是最容易写错的一处。

解法:四方向动态规划

核心思路

暴力做法是:对每个值为 1 的格子,沿四个方向各自一路走到底数长度。设最长线段长度为 L,则总代价是 $O(mn \cdot L)$,最坏情况(整个矩阵全是 1)退化成 $O(mn \cdot \max(m, n))$。更要命的是,一条长度为 L 的线段被它内部的每个格子重复扫描,同样的连续性被反复确认了 L 遍。

瓶颈就在这个重复确认上。观察一条水平线段:以 (i, j) 结尾的水平连续 1 的长度,等于以 (i, j-1) 结尾的长度加一——只要 (i, j) 本身是 1。左边那个量在遍历到 (i, j) 之前就已经算好了,直接取用即可,一步 $O(1)$ 就完成了原本 $O(L)$ 的扫描。四个方向各自成立同样的关系。

于是定义状态:dp[i][j][k] = 以格子 (i, j) 为终点、沿第 k 个方向的最长连续 1 的长度。四个方向依次编号为:0 水平(前驱在左 (i, j-1))、1 垂直(前驱在上 (i-1, j))、2 主对角线(前驱在左上 (i-1, j-1))、3 反对角线(前驱在右上 (i-1, j+1))。

转移统一写成一句话:mat[i][j] == 1,则 dp[i][j][k] = dp[前驱][k] + 1;前驱越界时视作 0;若 mat[i][j] == 0,则四个方向都是 0

这里有个关键的正确性检查:四个方向的前驱都必须在当前格子之前被计算过。按行从上到下、每行从左到右的自然顺序遍历时,(i, j-1)(i-1, j)(i-1, j-1) 都在前面,没有问题;而反对角线的前驱 (i-1, j+1) 虽然列号更大,但行号更小,属于上一行,同样已经算完——这就是为什么四个方向可以在一趟遍历里全部搞定,不需要为反对角线单独倒着扫一遍。

循环不变量:处理完 (i, j) 时,所有行号小于 i 的格子、以及第 i 行中列号不大于 j 的格子,其四个方向的 dp 值都已是最终值。答案就是遍历过程中所有 dp 值的最大者,边算边打擂台即可。

用一个 m × n × 4 的三维数组存状态,最直观。由于每个格子只依赖上一行和本行左侧,其实可以压成两行的滚动数组,但矩阵规模不大时没必要,清晰度优先。

解题步骤

  • 先判矩阵为空(mat.length == 0)直接返回 0为什么:下一行就要取 mat[0].length 来确定列数,空矩阵会直接越界;同时空矩阵里没有任何 1,答案本就是 0
  • dp[m][n][4] 三维数组,答案变量 answer = 0为什么:Java 与 Go 的数值数组默认全零,恰好等于「这个格子在这个方向上没有连续 1」的语义,省掉显式初始化;answer 初值取 0 保证全零矩阵能正确返回 0
  • 按行从上到下、每行从左到右双重循环为什么:这个顺序保证四个方向的前驱格子——左、上、左上、右上——全部落在已计算区域内。右上那个前驱列号更大但行号更小,已在上一行算完,所以一趟就够。
  • 遇到 mat[i][j] == 0 直接 continue为什么0 会打断所有方向的连续性,四个 dp 值都该是 0,而数组默认值本就是 0,跳过即可,既表达了语义又省掉四次赋值。
  • 水平方向:dp[i][j][0] = (j > 0 ? dp[i][j-1][0] : 0) + 1为什么:接上左邻格的同方向长度再加自己这一格;j == 0 时左边越界,视作长度 0,于是本格从 1 起算——用三目表达式而不是 if 分支,能让「越界即 0」这条规则在四行代码里保持一致的形状。
  • 垂直方向:dp[i][j][1] = (i > 0 ? dp[i-1][j][1] : 0) + 1为什么:前驱是正上方;第一行没有上方邻居,从 1 起算。
  • 主对角线:dp[i][j][2] = (i > 0 && j > 0 ? dp[i-1][j-1][2] : 0) + 1为什么:主对角线沿左上到右下延伸,前驱在左上角,行列都要非零才存在。
  • 反对角线:dp[i][j][3] = (i > 0 && j + 1 < n ? dp[i-1][j+1][3] : 0) + 1为什么:反对角线沿右上到左下延伸,前驱在右上角,所以列号的边界条件是 j + 1 < n 而不是 j > 0——这是四个方向里唯一一个向右看的,写成 j > 0 是本题最高频的错误。
  • 用四个方向的值更新 answer为什么:最长线段可能出现在任何格子的任何方向,必须逐一打擂台;放在格子内部更新,遍历结束就直接得到答案,不需要再扫一遍 dp
  • 返回 answer

mat = [[0,1,1,0],[0,1,1,0],[0,0,0,1]] 走一遍m = 3n = 4,答案为 3)。为省篇幅只记录值为 1 的格子,dp 四元组按 [水平, 垂直, 主对角, 反对角] 排列。

第 0 行:(0,1)1,左邻 (0,0)0 所以水平为 0 + 1 = 1i = 0 无上方,垂直、主对角、反对角都从 1 起算,得 [1, 1, 1, 1]answer = 1(0,2)1,左邻 (0,1) 的水平是 1,得水平 2;其余三方向因 i = 0 均为 1,得 [2, 1, 1, 1]answer = 2

第 1 行:(1,1)1。水平看左邻 (1,0)0,得 1;垂直看上方 (0,1) 的垂直是 1,得 2;主对角看左上 (0,0),那里是 0 格子、dp 保持默认 0,得 1;反对角看右上 (0,2) 的反对角是 1,得 2。四元组 [1, 2, 1, 2]answer 仍是 2(1,2)1。水平看左邻 (1,1) 的水平 1,得 2;垂直看上方 (0,2) 的垂直 1,得 2;主对角看左上 (0,1) 的主对角 1,得 2;反对角看右上 (0,3),那里是 0,得 1。四元组 [2, 2, 2, 1]answer 仍是 2

第 2 行:(2,3)1。水平看左邻 (2,2)0,得 1;垂直看上方 (1,3)0,得 1;主对角看左上 (1,2) 的主对角是 2,得 3——answer 更新为 3;反对角要看右上 (1,4)j + 1 = 4 不小于 n = 4,越界,得 1

遍历结束返回 3,对应主对角线 (0,1) → (1,2) → (2,3) 这三个 1。这里恰好体现了两处细节:(2,3) 的反对角线因为在最右列而正确地从 1 起算(若把边界写成 j > 0 就会去读 dp[1][4] 直接越界);而主对角线的答案是靠 (1,2) 那一格早就算好的 2 一步接出来的,完全没有沿线回扫。

代码实现

class Solution {
    public int longestLine(int[][] mat) {
        if (mat.length == 0) {
            return 0;
        }

        int m = mat.length;
        int n = mat[0].length;
        // dp[i][j][k]:以 (i, j) 为终点、沿第 k 个方向的最长连续 1 长度。
        int[][][] dp = new int[m][n][4];
        int answer = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                // 0 打断所有方向,dp 保持默认值 0 即可。
                if (mat[i][j] == 0) {
                    continue;
                }

                dp[i][j][0] = (j > 0 ? dp[i][j - 1][0] : 0) + 1;
                dp[i][j][1] = (i > 0 ? dp[i - 1][j][1] : 0) + 1;
                dp[i][j][2] = (i > 0 && j > 0 ? dp[i - 1][j - 1][2] : 0) + 1;
                // 反对角线的前驱在右上方,边界条件是 j + 1 < n。
                dp[i][j][3] = (i > 0 && j + 1 < n ? dp[i - 1][j + 1][3] : 0) + 1;

                for (int k = 0; k < 4; k++) {
                    answer = Math.max(answer, dp[i][j][k]);
                }
            }
        }

        return answer;
    }
}
func longestLine(mat [][]int) int {
	if len(mat) == 0 {
		return 0
	}

	m, n := len(mat), len(mat[0])
	// dp[i][j][k]:以 (i, j) 为终点、沿第 k 个方向的最长连续 1 长度。
	dp := make([][][4]int, m)
	for i := range dp {
		dp[i] = make([][4]int, n)
	}

	answer := 0

	for i := 0; i < m; i++ {
		for j := 0; j < n; j++ {
			// 0 打断所有方向,dp 保持默认值 0 即可。
			if mat[i][j] == 0 {
				continue
			}

			dp[i][j][0] = 1
			if j > 0 {
				dp[i][j][0] = dp[i][j-1][0] + 1
			}

			dp[i][j][1] = 1
			if i > 0 {
				dp[i][j][1] = dp[i-1][j][1] + 1
			}

			dp[i][j][2] = 1
			if i > 0 && j > 0 {
				dp[i][j][2] = dp[i-1][j-1][2] + 1
			}

			// 反对角线的前驱在右上方,边界条件是 j < n-1。
			dp[i][j][3] = 1
			if i > 0 && j < n-1 {
				dp[i][j][3] = dp[i-1][j+1][3] + 1
			}

			for k := 0; k < 4; k++ {
				if dp[i][j][k] > answer {
					answer = dp[i][j][k]
				}
			}
		}
	}

	return answer
}

复杂度分析

  • 时间复杂度:$O(m \cdot n)$。凭什么:双重循环恰好访问每个格子一次,格子内部是固定的四次转移加四次比较,全是常数操作;没有任何沿线回扫或重复遍历。相比暴力的 $O(mn \cdot \max(m,n))$,省下的正是「同一条线段被内部每个格子重复确认」的那部分。
  • 空间复杂度:$O(m \cdot n)$。dp 数组为每个格子保存四个方向的长度,共 $4mn$ 个整数,常数因子为 4。

关键点总结

  • 「找最长的某种连续结构」优先考虑以每个位置为终点来定义状态。终点唯一,所以每条极大结构只被数到一次,重复计数和重复扫描同时消失。这是 300、53、562 这一大类题的共同起手式。
  • 多个方向互不干扰时,把方向做成状态的一个维度,在同一趟遍历里并行维护,而不是跑四遍。判断能否合并的标准只有一条:所有方向的前驱是否都落在同一种遍历序的「已计算区域」里。
  • 遍历顺序必须由依赖关系倒推,而不是凭习惯。本题按行优先正序遍历之所以可行,是因为反对角线的前驱虽然列号更大,但行号更小、属于上一行。能主动检查这一点,是 DP 题不写出 bug 的根本。
  • 用数组默认值编码「不可达 / 长度为零」的语义,遇到 0 直接跳过,比写四行显式赋值更简洁也更不容易漏。
  • 四个方向的越界判断中,只有反对角线是向右看的(j + 1 < n)。凡是同形代码里出现一个「反过来」的分支,都要单独在纸上画一遍再落笔。

易错点总结

  • 反对角线的边界写成 j > 0 并取 dp[i-1][j-1]mat = [[0,1],[1,0]] → 反对角线与主对角线取了同一个前驱,(1,0) 处算不出反对角线长度 2,答案偏小。
  • 反对角线取 dp[i-1][j+1] 却漏掉 j + 1 < n 判断:任何在最右列且值为 1 的格子 → 直接数组越界抛异常。
  • 遇到 0 时不跳过、也不清零,直接沿用上一格的值mat = [[1,0,1]](0,2) 的水平长度接上了 (0,1),算出 2,但中间隔着 0,正确答案是 1
  • 遇到 0 时把 answer 也重置mat = [[1,1,0,1]] → 前面已经拿到的 2 被清掉,最终返回 1。答案是全局最大值,绝不能随局部状态回退。
  • dp 值不加 +1mat = [[1]] → 所有方向都是 0,返回 0,正确答案是 1。当前格子自身必须计入长度。
  • 没有判空就取 mat[0].lengthmat = [] → 数组越界;正确行为是返回 0
  • answer 只在某一个方向上更新(比如只看水平)mat = [[1],[1],[1]] → 水平长度恒为 1,返回 1,而垂直方向的 3 被完全忽略。
  • 状态定义成「以 (i, j) 为起点」却仍按正序遍历:起点定义要求先知道右下方的结果 → 依赖方向与遍历方向相反,读到的全是初值 0,所有长度都变成 1
  • 用一个二维 dp 存四个方向的最大值而不是分开存mat 中某格水平长 3、垂直长 1 → 下一格垂直方向误接了 3,长度凭空变长,答案偏大。四个方向必须各自独立地递推。
  • 对每个 1 沿四个方向回扫求长度:全 1 的 $200 \times 200$ 矩阵 → 复杂度 $O(mn \cdot \max(m,n))$,规模再大一档就超时,且大量重复确认同一段连续性。

相似题目

题目 难度 考察点
221. 最大正方形 中等 同为「以右下角为终点」的矩阵 DP,但转移取三个方向的最小值而非各自独立
1277. 统计全为 1 的正方形子矩阵 中等 状态与 221 完全相同,但答案是所有 dp 值求和而非取最大
85. 最大矩形 困难 目标是任意长宽的矩形,需逐行压成柱状图再用单调栈,DP 递推不再够用
485. 最大连续 1 的个数 简单 本题水平方向的一维退化版,一个计数器即可,可当作理解「以终点计数」的入门
487. 最大连续1的个数 II 中等 允许翻转一个 0,状态需增加「已用几次翻转」这一维
1004. 最大连续1的个数 III 中等 允许翻转 k0,转为滑动窗口维护「窗口内 0 的个数不超过 k」
329. 矩阵中的最长递增路径 困难 路径可任意拐弯,依赖关系无固定遍历序,只能用记忆化搜索而非递推
931. 下降路径最小和 中等 同为逐行递推,但每格从上一行三个相邻列中取最优,路径允许左右摆动