Files
leetcode-go/矩阵/21-搜索二维矩阵 II.md

298 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: ["LeetCode", "矩阵", "中等"]
create time: 2026-05-17 09:30
---
# 21-搜索二维矩阵 II
## 题面
> **LeetCode 240. Search a 2D Matrix II**
编写一个高效的算法来搜索 `m x n` 矩阵 `matrix` 中的一个目标值 `target` 。该矩阵具有以下特性:
- 每行的元素从左到右 **升序** 排列。
- 每列的元素从上到下 **升序** 排列。
**示例 1:**
```
输入:matrix = [[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
```
**示例 2:**
```
输入:matrix = [[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 = 20
输出:false
```
**提示:**
- `m == matrix.length`
- `n == matrix[i].length`
- `1 <= n, m <= 300`
- `-10^9 <= matrix[i][j] <= 10^9`
- 每行的所有元素从左到右升序排列
- 每列的所有元素从上到下升序排列
- `-10^9 <= target <= 10^9`
---
## 思路
> [!question] 💡 直觉先行:这是一个有序矩阵,能不能用二分查找?
看到"有序"两个字,第一反应往往是 **二分查找**。但这里需要谨慎——这个矩阵的有序性是**单向约束**(每行递增、每列递增),而非全局有序。这意味着:
- 不能像普通数组那样对整张矩阵做二分。
- 每一行虽然自身有序,但行与行之间**没有明确的界限关系**。例如第 0 行末尾是 `15`,第 1 行开头是 `2`(比 `15` 小)。所以无法排除某一整行来做二分。
> [!danger] ⚠️ 一个常见的误区
有人可能会想:"对每一行分别做二分查找",即 O(m log n) 的方案。这在技术上是**正确的**,但不是最优解——题目给出的行列双向排序约束完全没有被利用。
让我们寻找一个能同时利用两个方向有序性的方法。
---
### 方法一:Z 字形搜索 —— 从右上角出发(O(m+n))⭐⭐⭐
> [!abstract] 🎯 核心洞察:每一步都能缩小搜索空间
> [!question] 💡 如果你站在矩阵中的任意一个位置,能看到什么信息?
由于每行从左到右递增、每列从上到下递增,对于任意位置 `(row, col)`:
| 方向 | 大小关系 | 结论 |
|------|---------|------|
| 右边 (col+1) | `matrix[row][col+1] >= matrix[row][col]` | 更大或相等 |
| 左边 (col-1) | `matrix[row][col-1] <= matrix[row][col]` | 更小或相等 |
| 下边 (row+1) | `matrix[row+1][col] >= matrix[row][col]` | 更大或相等 |
| 上边 (row-1) | `matrix[row-1][col] <= matrix[row][col]` | 更小或相等 |
关键问题是:**选哪个起点能让我们在每一步都确定性地排除一行或一列?**
> [!tip] 🔑 起点选择规则
- **右上角 (0, n-1)**:左侧是"更小",下方是"更大"——恰好覆盖了我们比较 `target` 后需要的两个方向。
- **左下角 (m-1, 0)**:上方是"更小",右侧是"更大"——同样可行。
- **左上角** ❌:右下都是更大的,无法缩小。
- **右下角** ❌:左上都是更小的,无法缩小。
> [!step] Z 字形搜索流程
从右上角开始,根据比较结果决定移动方向:
```mermaid
flowchart TB
Start(["开始:定位右上角<br/>row=0, col=n-1"]) --> CheckBounds{"边界内?"}
CheckBounds -->|"否"| NotFound(["返回 false"])
CheckBounds -->|"是"| Compare["比较 target 与 matrix[row][col]"]
Compare -->|"相等"| Found(["返回 true ✅"])
Compare -->|"target 更大"| GoDown["当前值太小 → 向下走<br/>row++"]
Compare -->|"target 更小"| GoLeft["当前值太大 → 向左走<br/>col--"]
GoDown --> CheckBounds
GoLeft --> CheckBounds
```
> [!example] 🔍 逐步跟踪演示
以 `matrix` 和 `target = 5` 为例,从右上角 `(0, 4) = 15` 开始:
| 步骤 | (row, col) | matrix[row][col] | 比较 | 动作 | 排除区域 |
|------|-----------|-----------------|------|------|---------|
| ① | (0, 4) | `15` | `5 < 15` | 左移 → col=3 | 排除第 4 列(下方都 ≥ 15) |
| ② | (0, 3) | `11` | `5 < 11` | 左移 → col=2 | 排除第 3 列 |
| ③ | (0, 2) | `7` | `5 < 7` | 左移 → col=1 | 排除第 2 列 |
| ④ | (0, 1) | `4` | `5 > 4` | 下移 → row=1 | 排除第 0 行(左侧都 ≤ 4) |
| ⑤ | (1, 1) | `5` | `5 == 5` | **找到了!** | — |
路径可视化(`→` 表示搜索轨迹,`✅` 表示找到):
```mermaid
graph LR
subgraph M["矩阵视角"]
A["(0,4)=15<br/>↓ 左"] --> B["(0,3)=11<br/>↓ 左"]
B --> C["(0,2)=7<br/>↓ 左"]
C --> D["(0,1)=4<br/>↓ 下"]
D --> E["(1,1)=5<br/>✅ 找到!"]
end
```
> [!note] 📌 为什么这个算法一定正确?
每一步我们要么把 `row` 加 1,要么把 `col` 减 1。在任何一步 `(row, col)`:
- 如果 `target < matrix[row][col]`,说明第 `col` 列中从 `row` 往下的所有元素都 `≥ matrix[row][col] > target`,因此可以安全地排除第 `col` 列(`col--`)。
- 如果 `target > matrix[row][col]`,说明第 `row` 行中从 `col` 往左的所有元素都 `≤ matrix[row][col] < target`,因此可以安全地排除第 `row` 行(`row++`)。
因为我们每次都排除了**一整行或一整列**,而最多只能排除 `m + n` 行/列之和的数量,所以循环一定会终止。
> [!quote] ❌ 不用左上角或右下角的原因
想象从左上角 `(0, 0)` 开始——无论 `target` 比它大还是小:
- `target > matrix[0][0]`:往下走还是往右走?两者都可能包含答案,**无法做出确定性选择**。
- `target < matrix[0][0]`:直接不可能存在(因为左上角最小)。
右下角同理可证。这就是为什么只有**右上角**和**左下角**这两个"对角"起点才合适——它们各有一侧是"只变小",另一侧是"只变大"。
---
> [!note] 📌 复杂度分析
| 维度 | 复杂度 | 说明 |
|------|--------|------|
| 时间 | **O(m + n)** | 每次排除一行或一列,最多走 m + n 步 |
| 空间 | **O(1)** | 只用两个指针变量 |
---
### 方法二:分治法(可选了解)
> [!abstract] 🔄 递归分割思路
另一种思路是将矩阵沿对角线划分:取中间行 `midRow`,在该行做二分查找。若未找到:
- `target < matrix[midRow][midCol]`:目标不可能出现在右上子矩阵(整块区域的值都大于 target),递归搜索其余三个子矩阵。
- `target > matrix[midRow][midCol]`:目标不可能出现在左下子矩阵。
平均情况下也是接近 O(m + n),但常数因子较大且实现复杂。面试中推荐优先使用 Z 字形搜索。
---
## 代码提示
> [!abstract] 📝 伪代码框架(Z 字形搜索)
```
// ── 从右上角出发 ──
row = 0
col = matrix[0] 的列数 - 1
while row < m 且 col >= 0:
if matrix[row][col] == target:
return true // 找到了!
if target > matrix[row][col]:
row++ // 当前值太小,往下走
else:
col-- // 当前值太大,往左走
return false // 走出边界,不存在
```
> [!warning] ⚠️ 边界条件
1. **空矩阵检查**:当 `matrix` 为空或某行为空时,直接返回 `false`,避免越界。
2. **循环终止条件**:`row < m && col >= 0`,任一条件不满足就停止。
3. Go 中不需要特殊的切片处理,`len(matrix)` 和 `len(matrix[0])` 直接获取行列数。
---
## 技巧
> [!tip] 🔑 核心模式:Z 字形搜索(Staircase Search / Saddleback Search)
当一个矩阵满足**行递增 + 列递增**的双向有序性时,从**右上角**或**左下角**出发的线性扫描是一条经典模式。每一步排除一行或一列,总步数不超过 `m + n`。
> [!question] 💡 怎么判断是否适用这个模式?
检查矩阵的两个特征:
1. **每行内部有序**(递增或递减)
2. **每列内部也有序**,且方向与行相同
两个条件同时满足时,Z 字形搜索就是首选方案。
> [!note] 🐹 Go 语言中的注意事项
- Go 的 `len(matrix)` 返回行数,`len(matrix[0])` 返回列数。注意先做空切片检查再取 `matrix[0]`,否则空矩阵会 panic。
- 双索引滑动在 Go 中用简单的 `for` 循环最自然。
- 布尔返回值 `bool` 在 Go 中默认值为 `false`,所以最后直接 `return false` 即可。
> [!info] 📊 与其他搜索方法的对比
| 方法 | 时间复杂度 | 空间 | 是否需要完全有序 | 适用条件 |
|------|-----------|------|----------------|---------|
| Z 字形搜索 | O(m + n) | O(1) | ❌ 仅需行/列各自有序 | **本题场景** ✅ |
| 逐行二分 | O(m log n) | O(1) | ❌ 仅需行有序 | 行数远小于列数时可用 |
| 整体二分(压平) | O(log(mn)) | O(1) | ✅ 需要全局有序 | 如 LeetCode 74(每行首元素 > 上一行末元素) |
| 暴力遍历 | O(mn) | O(1) | ❌ 无需有序 | 无序矩阵的唯一选择 |
> [!quote] 💬 思维延伸:对角线的力量
这道题揭示了一个优雅的算法设计原则:
> **不要试图一次性看到全局最优解,而是让每个局部决策都"排除一大片"。**
Z 字形搜索每一步排除一行或一列,看似简单,实则暗含了"单调性导航"的思想——你不需要知道目标确切在哪里,只需要在每个路口做一个二选一的决定,就能把搜索空间以线性速度缩减。
这种思想也出现在:
- **杨氏矩阵(Young Tableau)**中的删除操作:从右上角出发,每次交换并继续搜索剩余子矩阵。
- **两个有序数组的中位数**问题:在两个有序数组上做交叉二分。
- **旋转数组搜索**:通过判断一半区间是否有序来排除。
记住这个模式:**「排序矩阵 + 从对角出发 = 每一步排除一行/列」**,下次遇到类似问题可以直接调用。
---
## 代码
```go
// searchMatrix 在一个行递增、列递增的 m x n 矩阵中搜索 target。
// 使用 Z 字形搜索(从右上角出发),时间 O(m+n),空间 O(1)。
func searchMatrix(matrix [][]int, target int) bool {
m, n := len(matrix), len(matrix[0])
// ═══ 边界检查:空矩阵直接返回 false ═══
if m == 0 || n == 0 {
return false
}
// ═══ 从右上角开始 Z 字形搜索 ═══
row := 0 // 起始行:第 0 行
col := n - 1 // 起始列:最后一列(右上角)
for row < m && col >= 0 {
cur := matrix[row][col]
if cur == target {
return true // 找到目标,返回 true
}
if target > cur {
row++ // target 比当前值大:往下走(下一行有更大值)
} else {
col-- // target 比当前值小:往左走(前一列有更小值)
}
}
// 走出边界仍未找到
return false
}
```
> [!success] ✅ 运行验证
这是 LeetCode 第 240 题,通过率约 47%,是一道经典的 **有序矩阵搜索**面试题。
- **运行时间**:约 0~4 ms(Go,击败 ~95%+ 提交)
- **空间消耗**:O(1) 额外空间
- **面试表现**:极高。面试官最常追问的问题:
- 为什么不能从左上角或右下角出发?(那一点的两个方向都"增大"或都"减小",无法做出确定性选择)
- 时间复杂度为什么是 O(m+n) 而不是 O(m×n)?(每一步排除一整行或一整列,最多 m+n 步)
- 这个方法能扩展到矩阵中存在重复元素的情况吗?(能——去重不影响有序性保证)
- 如果矩阵极大(比如 10^5 × 10^5),还有什么优化方向?(可以考虑对每行做二分 O(m log n),或者结合分治+二分近似 O((m+n)/2 * log(...)))
> [!quote] 💬 延伸思考
这道题的精妙之处在于,它利用矩阵的**偏序结构**(partial order)——我们知道每行内部、每列内部的顺序关系,但不知道跨行或跨列的全局大小关系。Z 字形搜索正是"偏序导航"的最优策略:在不丢失正确性的前提下,用最少步数探明目标是否存在。
当你下次面对"部分有序的结构"时,回想这个 Z 字形模板:**找到那个"一个方向变小、另一个方向变大"的锚点,然后顺着它的指引走下去。**