从暴力到Z字形消元:力扣240「搜索二维矩阵II」的降维打击之路

5 阅读10分钟

从暴力到Z字形消元:力扣240「搜索二维矩阵II」的降维打击之路

一道题告诉你,为什么从右上角开始搜索是一个“开挂”般的操作

前言

在连续攻克了矩阵的“置零”(73)、“螺旋遍历”(54)和“旋转图像”(48)之后,今天我们迎来了一道看似相似、实则内核完全不同的经典题目——力扣240. 搜索二维矩阵 II(Search a 2D Matrix II)

这道题在LeetCode上标记为中等(Medium),但它的出现频率极高,是Amazon、Google、Microsoft、字节跳动的面试常客。为什么大厂对它青睐有加?因为它完美地考察了候选人对**“有序数据分布”**的敏感度。

很多同学看到“有序矩阵”,第一反应是“二分查找”,但当你准备写二分时,却发现它和力扣74(搜索二维矩阵 I)有着本质的区别:

  • 力扣74:每行从左到右递增,且下一行的第一个元素大于上一行的最后一个元素(全局有序)。→ 可以直接展平做二分。
  • 力扣240:每行从左到右递增,每列从上到下递增,但下一行的第一个元素不一定大于上一行的最后一个元素(局部有序,类似杨氏矩阵)。

这种“部分有序”的特性,堵死了“全局二分”的偷懒之路,但也催生了一个极其优雅的解法——从右上角开始的Z字形消元。今天,我们就从最暴力的遍历开始,一步步进化到这个“开挂”般的线性解法,让你彻底理解其中的数学之美。

题目回顾

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target。该矩阵具有以下特性:

  1. 每行中的整数从左到右按升序排列。
  2. 每列中的整数从上到下按升序排列。

示例矩阵:

[
  [1,  4,  7, 11, 15],
  [2,  5,  8, 12, 19],
  [3,  6,  9, 16, 22],
  [10, 13, 14, 17, 24],
  [18, 21, 23, 26, 30]
]

给定 target = 5,返回 true;给定 target = 20,返回 false

约束条件: m == matrix.lengthn == matrix[i].length1 <= n, m <= 300

核心难点:为什么不能直接套用二分法?

在力扣74中,因为二维矩阵可以完美展开成一维有序数组,所以我们可以用 mid 映射到 (mid/n, mid%n) 来进行二分。

但在本题中,虽然行和列分别有序,但matrix[i][n-1](行尾)可能大于matrix[i+1][0](下一行行首)。例如示例中,第一行行尾是15,第二行行首是2,15 > 2。这意味着整个矩阵不存在全局单调性,我们无法通过一次二分排除掉一半的数据。

那怎么办呢?既然无法“一刀切”,我们就只能利用局部的有序性,每次排除一行或一列。这就是“Z字形消元”的核心出发点。

第一层:暴力遍历法 —— 最朴素的“地毯式搜索”

不管数据怎么排列,最无脑的方法永远管用:遍历整个矩阵,挨个比较。

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        for (int i = 0; i < matrix.length; i++) {
            for (int j = 0; j < matrix[0].length; j++) {
                if (matrix[i][j] == target) return true;
            }
        }
        return false;
    }
}

复杂度分析:

  • 时间复杂度: O(m * n)。对于最大 300x300 的矩阵,勉强能接受,但如果数据量达到 10^5 级别,直接超时。
  • 空间复杂度: O(1)

点评: 这道题的暴力法没有任何技术含量,但它确立了我们的底线——无论如何,最坏情况我们总要看完所有元素。接下来的优化,就是看在哪些情况下我们可以提前终止或者跳过大片区域

第二层:逐行二分法 —— 利用行内有序性

既然每一行是有序的,那我们可以对每一行单独进行二分查找。这个思路虽然还是需要遍历所有行,但每一行的查找效率从 O(n) 降到了 O(log n)

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        for (int[] row : matrix) {
            // 简单的剪枝:如果目标不在当前行范围内,直接跳过
            if (row[0] > target || row[row.length - 1] < target) {
                continue;
            }
            int left = 0, right = row.length - 1;
            while (left <= right) {
                int mid = left + (right - left) / 2;
                if (row[mid] == target) return true;
                if (row[mid] < target) left = mid + 1;
                else right = mid - 1;
            }
        }
        return false;
    }
}

复杂度分析:

  • 时间复杂度: O(m * log n)。比暴力法进步了一大截,尤其是在 n 很大时效果显著。
  • 空间复杂度: O(1)

点评: 这是面试中比较容易想到的优化方案,代码也很安全。如果面试官不要求极致优化,这个解法已经能拿80分了。但面试官往往会继续追问:“能不能做到 O(m+n)?”

第三层:Z字形消元法(右上角起点) —— 真正的“降维打击”

终于到了这道题最精彩的部分!我们可以利用矩阵“行递增、列递增”的特性,从右上角(或左下角)开始,像走迷宫一样一步步逼近目标。

核心思想:把右上角当作“哨兵”

我们选定矩阵的右上角 matrix[0][n-1] 作为起点。观察这个位置的性质:

  • 它是当前行(第一行)的最大值
  • 它是当前列(最后一列)的最小值

基于这个“鞍点”特性,我们可以进行如下逻辑推理:

  1. 如果 current == target,找到了,返回 true
  2. 如果 current > target,说明当前列的所有元素(因为从上到下递增,下面的元素都大于等于 current)都大于 target,所以当前列可以全部排除col--(向左移动)。
  3. 如果 current < target,说明当前行的所有元素(因为从左到右递增,左边的元素都小于等于 current)都小于 target,所以当前行可以全部排除row++(向下移动)。

这个逻辑的精妙之处在于: 每一次比较,我们都能确定地排除一整行或一整列。最多经过 m + n 步,要么找到目标,要么把矩阵“削”成空的。

图解流程

以示例矩阵为例,搜索 target = 5

步骤当前位置 (row, col)当前值比较操作排除区域
1(0, 4)1515 > 5排除第4列(全列都>5)col → 3
2(0, 3)1111 > 5排除第3列col → 2
3(0, 2)77 > 5排除第2列col → 1
4(0, 1)44 < 5排除第0行(全行都<5)row → 1
5(1, 1)55 == 5找到!返回 true

注意看,我们只用了 5 步就找到了目标,而矩阵有 25 个元素!

代码实现

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
            return false;
        }
        
        int rows = matrix.length;
        int cols = matrix[0].length;
        
        // 从右上角出发
        int row = 0;
        int col = cols - 1;
        
        while (row < rows && col >= 0) {
            int current = matrix[row][col];
            if (current == target) {
                return true;
            } else if (current > target) {
                // 当前列向下都大于 target,排除该列
                col--;
            } else {
                // 当前行向左都小于 target,排除该行
                row++;
            }
        }
        return false;
    }
}
class Solution:
    def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
        if not matrix or not matrix[0]:
            return False
        
        rows, cols = len(matrix), len(matrix[0])
        row, col = 0, cols - 1
        
        while row < rows and col >= 0:
            if matrix[row][col] == target:
                return True
            elif matrix[row][col] > target:
                col -= 1
            else:
                row += 1
        return False

复杂度分析:

  • 时间复杂度: O(m + n)。最坏情况下,我们可能走遍矩阵的“右上边界”到“左下边界”,即 m + n 步。
  • 空间复杂度: O(1)

为什么从右下角(左下角)也可以?

  • 左下角 (m-1, 0) 出发:它是当前行的最小值、当前列的最大值。如果 current > target,排除当前行(row--);如果 current < target,排除当前列(col++)。
  • 左上角右下角出发行不行?不行。因为左上角是最小值,它无法确定排除哪一行或哪一列(current < target 时,向右和向下都大于它,无法抉择)。右下角是最大值,同理。

所以,一定要从“一个角上是该行最大且该列最小”或“该行最小且该列最大”的位置出发。这被称为矩阵的“鞍点”属性。

第四层(深度思考):与二分法的结合与变种

虽然 O(m+n) 已经是最优解了,但在某些特定场景下,我们可以把“剪枝”做得更极致。

优化思路:先二分跳跃,再Z字形消元

如果矩阵非常大(例如 m=1000, n=1000),O(m+n) 其实已经非常快了。但如果面试官追问“能不能利用行有序做更多剪枝”,你可以回答:

我们可以先对第一列进行二分查找,找到 target 可能出现的行的范围(即 matrix[i][0] <= target <= matrix[i][n-1]),然后只在这些候选行中执行逐行二分或Z字形搜索。这在某些数据分布下能进一步优化常数。

不过,因为 O(m+n) 本身就是线性复杂度中的天花板,这种混合优化在实际工程中意义有限,但提出来能展示你对问题的深入思考。

深度总结:三种解法进化图谱

解法时间复杂度空间复杂度核心思想面试推荐度
暴力遍历法O(m·n)O(1)双重循环遍历所有元素⭐(不满足高效要求)
逐行二分法O(m·log n)O(1)利用行内有序,每行二分⭐⭐⭐(稳健且易写)
Z字形消元法O(m+n)O(1)从右上角“鞍点”出发,每次排除一行或一列⭐⭐⭐⭐⭐(最优解,面试必推)

从这道题中我们学到了什么?

  1. “部分有序”不等于“无法二分”。当全局二分无法使用时,我们要学会利用数据分布的局部特征。本题中,局部特征就是“右上角是行最大列最小”,这个“鞍点”是打开局面的钥匙。

  2. 排除法的力量。很多时候,我们不需要精确命中,只需要证明某一部分绝对不包含答案,就可以把它丢弃。这道题中的Z字形消元,本质上就是一个“不断缩小搜索空间”的过程。

  3. 矩阵边角往往是解题的入口。在力扣的矩阵题中(旋转48、螺旋54、置零73),边界总是扮演着特殊角色。对于搜索类矩阵题,从边界入手往往能发现独特的单调性。

  4. 复杂度从 O(m·n) 到 O(m+n) 的跃迁。把二维的乘积复杂度降维成一维的加法复杂度,这就是“降维打击”的威力所在。在面试中,能讲清楚为什么每一步能排除一整行/列,是打动面试官的关键。

最后的一些心里话

力扣240和力扣74放在一起对比学习,效果翻倍。力扣74教你“全局有序怎么二分”,力扣240教你“局部有序怎么消元”。如果你能在面试中把这两道题的异同点(全局有序 vs 杨氏矩阵)给面试官分析清楚,绝对是一个大大的加分项。

下次遇到这种“行递增、列递增”的矩阵,别再傻傻地写二分或者暴力了。记住今天的口诀:

右上角,站岗哨;大了左移,小了下跳。

这条“Z”字形的路径,就是通往最优解的最短路。


如果你觉得这篇题解帮你彻底搞懂了搜索二维矩阵,欢迎点赞、收藏、转发!评论区聊聊你还见过哪些从“角”入手的巧妙算法题? 🚀