vault backup: 2026-05-17 14:27:46
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
---
|
||||
tags: [daily]
|
||||
create time: 2026-05-17 00:00
|
||||
---
|
||||
|
||||
# 2026-05-17
|
||||
|
||||
## 今日学习
|
||||
|
||||
- [[15-轮转数组]] — 预处理 `k %= n` 的深层意义:不仅仅是去冗余,更是语义归约
|
||||
|
||||
---
|
||||
|
||||
## 错题反思:我对 `k = k % n` 的理解还不够透彻
|
||||
|
||||
在做轮转数组(LeetCode 189)时,我在 `k = k % n` 这一步上栽了跟头。这行代码看似简单到"不需要解释",但恰恰是这种"理所当然"的操作最容易在关键时刻掉链子。
|
||||
|
||||
### ❌ 我错在哪里?
|
||||
|
||||
> [!warning] ⚠️ 问题核心
|
||||
|
||||
我把 `k = k % n` 仅仅当作一个**性能优化**——"如果 k 很大就减掉几圈"。但这种理解是不完整的,甚至可以说是在某些边界场景下**错误的**。
|
||||
|
||||
### 📐 正确认知:这是语义归约,不是性能优化
|
||||
|
||||
`k = k % n` 的存在有三层含义,缺一不可:
|
||||
|
||||
#### 第一层:功能正确性(最容易被忽略)
|
||||
|
||||
> [!question] 💡 如果 k < n,这行代码还有必要吗?
|
||||
|
||||
有。因为在循环替换法中,如果你在外面做了 `if k == 0 { return }` 的判断,那么当 `k = n` 时(恰好等于长度),`k % n == 0` 会触发提前返回。**但前提是必须先做取模**。
|
||||
|
||||
如果你的代码是:
|
||||
|
||||
```go
|
||||
// ❌ 错误顺序:先判断后取模
|
||||
if k == 0 { return } // k = n 时不会触发!
|
||||
k = k % n // 永远到不了这里
|
||||
```
|
||||
|
||||
正确的做法是:
|
||||
|
||||
```go
|
||||
// ✅ 正确顺序:先取模后判断
|
||||
k = k % n // 先把所有等价类归约到 [0, n)
|
||||
if k == 0 { return } // 再检查是否真的无需操作
|
||||
```
|
||||
|
||||
#### 第二层:安全防越界
|
||||
|
||||
> [!note] 🔒 为什么后续计算依赖归约后的 k?
|
||||
|
||||
以反转法为例,三个反转的区间是 `[0, n-1]`, `[0, k-1]`, `[k, n-1]`。如果 `k > n`:
|
||||
|
||||
- `reverse(nums, 0, k-1)` → 右边界超出数组长度的 **k-n** 位 → **越界 panic**!
|
||||
- `reverse(nums, k, n-1)` → 左边界直接越界 → **同样 panic**!
|
||||
|
||||
`% n` 确保了 k 始终落在 `[0, n)` 范围内,使后续所有区间计算天然安全。
|
||||
|
||||
#### 第三层:数学对称性
|
||||
|
||||
> [!abstract] 🧮 更深层的意义
|
||||
|
||||
轮转操作在数学上构成一个**循环群 Z_n**。在这个群中,旋转 n 步等于恒等变换(什么都不做)。`k % n` 的本质是将任意整数 k **映射到同一个陪集代表元**:
|
||||
|
||||
```
|
||||
Z_n = {0, 1, 2, ..., n-1}
|
||||
k ≡ (k % n) (mod n)
|
||||
```
|
||||
|
||||
这意味着:**旋转 k 步和旋转 (k + m·n) 步对于任意整数 m 效果完全相同**。取模运算将无限多个等价的 k 值压缩到一个唯一的代表元。
|
||||
|
||||
这就是为什么它不仅是一个工程技巧,而是一个**数学必然**。
|
||||
|
||||
### 🎯 一句话修正
|
||||
|
||||
> 把 `k %= n` 从"性能优化"重新分类为"**前置不变式维护**"——它是后续所有逻辑成立的必要条件,而非可选的锦上添花。
|
||||
|
||||
### 关联思考
|
||||
|
||||
这段教训可以推广到其他算法场景中:**凡是涉及周期性、环形、模运算的问题,第一步的归约预处理往往决定了整个算法的正确性基础**。不要跳过它,也不要事后补它。
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
tags: ["LeetCode", "二叉树", "深度优先搜索", "迭代", "简单"]
|
||||
create time: 2026-05-17 10:00
|
||||
---
|
||||
|
||||
# 36-二叉树的中序遍历
|
||||
|
||||
## 题面
|
||||
|
||||
给定一个二叉树的根节点 `root`,返回它的 **中序** 遍历。
|
||||
|
||||
> 中序遍历的顺序:**左子树 → 根节点 → 右子树**。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:root = [1,null,2,3]
|
||||
输出:[1,3,2]
|
||||
```
|
||||
|
||||
```
|
||||
1 遍历顺序
|
||||
\ ┌─────────┐
|
||||
2 ──→ │ 1 → 3 → 2│
|
||||
/ └─────────┘
|
||||
3
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:root = []
|
||||
输出:[]
|
||||
```
|
||||
|
||||
**示例 3:**
|
||||
|
||||
```
|
||||
输入:root = [1]
|
||||
输出:[1]
|
||||
```
|
||||
|
||||
**约束:**
|
||||
|
||||
- 树中节点数目在范围 `[0, 100]` 内
|
||||
- `-100 <= Node.val <= 100`
|
||||
|
||||
**进阶:** 递归算法很简单,你可以通过迭代算法完成吗?
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 思考
|
||||
> 中序遍历的核心是"先深入左子树到底,再处理根,最后转向右子树"。递归版本天然利用系统调用栈来记住回溯路径——那如果我们手动模拟这个过程,该用什么数据结构?
|
||||
|
||||
### 方法一:递归(最直观)⭐
|
||||
|
||||
中序遍历的递归实现几乎是对定义的字面翻译:
|
||||
|
||||
```
|
||||
inorder(node):
|
||||
if node == nil: return
|
||||
inorder(node.left) // ① 先走左边
|
||||
visit(node.val) // ② 再取当前值
|
||||
inorder(node.right) // ③ 最后走右边
|
||||
```
|
||||
|
||||
这种写法简洁优雅,但进阶要求我们尝试迭代。让我们看看如何用显式栈来模拟递归的调用栈行为。
|
||||
|
||||
### 方法二:显式栈模拟(迭代)⭐⭐
|
||||
|
||||
#### 为什么用栈?
|
||||
|
||||
递归过程中,每进入一个节点的左子树,系统会把这个节点的"后续工作(访问自己、再访问右子树)"压入调用栈。当我们从左子树底部返回时,栈顶弹出刚才那个节点,进行处理。
|
||||
|
||||
**关键观察**:迭代的本质就是手动维护这个栈。
|
||||
|
||||
#### 核心流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["curr = root"] --> B{"curr != nil?"}
|
||||
B -->|"否"| C{"栈为空?"}
|
||||
C -->|"是"| D["结束, 返回结果"]
|
||||
C -->|"否"| E["弹栈得到 node"]
|
||||
E --> F["记录 node.val"]
|
||||
F --> G["curr = node.right"]
|
||||
G --> B
|
||||
B -->|"是"| H["一路向左\n每次经过的节点都压栈\ncurr = curr.left"]
|
||||
H --> B
|
||||
```
|
||||
|
||||
#### 逐步解析
|
||||
|
||||
以这棵树为例:
|
||||
|
||||
```
|
||||
2
|
||||
/ \
|
||||
1 3
|
||||
```
|
||||
|
||||
| 步骤 | 动作 | 栈 (底→顶) | curr | 结果 |
|
||||
|------|------|------------|------|------|
|
||||
| 初始 | — | `[]` | `2` | `[]` |
|
||||
| 1 | 2 压栈,走到左 | `[2]` | `1` | `[]` |
|
||||
| 2 | 1 压栈,走到左 | `[2, 1]` | `nil` | `[]` |
|
||||
| 3 | curr=nil,弹栈 `1` | `[2]` | `nil` | `[1]` |
|
||||
| 4 | 1 无右子树,curr=`nil` | `[2]` | `nil` | `[1]` |
|
||||
| 5 | curr=nil,弹栈 `2` | `[]` | `2` | `[1, 2]` |
|
||||
| 6 | 2 有右子树 3,curr=`3` | `[]` | `3` | `[1, 2]` |
|
||||
| 7 | 3 压栈,走到左 | `[3]` | `nil` | `[1, 2]` |
|
||||
| 8 | curr=nil,弹栈 `3` | `[]` | `nil` | `[1, 2, 3] ✅` |
|
||||
|
||||
**算法骨架**(记忆口诀:「能左就左,左到不能左就回退,回头往右拐」):
|
||||
|
||||
```
|
||||
curr = root
|
||||
while curr != nil 或 栈非空:
|
||||
// 阶段1: 一路向左,全部压栈
|
||||
while curr != nil:
|
||||
push(curr)
|
||||
curr = curr.left
|
||||
|
||||
// 阶段2: 栈顶就是下一个要访问的节点
|
||||
node = pop()
|
||||
result.append(node.val)
|
||||
|
||||
// 阶段3: 转向右子树
|
||||
curr = node.right
|
||||
```
|
||||
|
||||
> [!info] 🧠 为什么这个循环是正确的?
|
||||
> 想象你在走迷宫:看到岔路就往左边走,同时把走过的路口记下来(压栈)。当发现左边没路了,就回到上一个路口(弹栈),记录它,然后尝试右边的路。重复直到所有路口都走完且栈为空。
|
||||
|
||||
#### Go 语言实现细节
|
||||
|
||||
- Go 标准库没有内置栈,用 `[]T` 切片即可,追加和删除尾部操作均摊 O(1)
|
||||
- `nil` 判断直接 `node == nil`,不需要额外的判空辅助函数
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
```
|
||||
// 伪代码模板
|
||||
初始化空栈 stack
|
||||
curr = root
|
||||
result = []
|
||||
|
||||
while curr != nil 或 len(stack) > 0:
|
||||
// 一路向左
|
||||
while curr != nil:
|
||||
stack = append(stack, curr)
|
||||
curr = curr.left
|
||||
|
||||
// 回退 + 记录
|
||||
node = stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1] // pop
|
||||
result = append(result, node.val)
|
||||
|
||||
// 转向右子树
|
||||
curr = node.right
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式:Morris 中序遍历(进阶面试杀手锏)
|
||||
> 上面的迭代法使用了 O(h) 空间(h 为树高)。如果面试官要求 **O(1)** 空间呢?答案是利用叶子节点的 nil 指针建立临时线索(Threaded Binary Tree),遍历完再恢复原状。虽然实际工程中很少用到,但在面试中是展示深度的利器。
|
||||
>
|
||||
> 基本思路:对于当前节点,找到其左子树的最右节点(前驱)。如果前驱的 right 指向 nil,说明第一次到达此节点,建立线索并往左走;如果前驱的 right 已经指向当前节点,说明左子树已遍历完毕,断开线索、记录当前值、往右走。
|
||||
|
||||
> [!note] 🐹 Go 切片栈操作速查
|
||||
> - 压栈:`stack = append(stack, val)`
|
||||
> - 弹栈:`val := stack[len(stack)-1]; stack = stack[:len(stack)-1]`
|
||||
> - 判空:`len(stack) == 0`
|
||||
> - 不要使用 `stack = nil` 代替缩容——这会丢失容量信息,影响后续 append 性能
|
||||
|
||||
> [!info] 📊 复杂度分析
|
||||
> - 时间:**O(n)**,每个节点恰好被压栈一次、弹栈一次,总共 2n 次栈操作
|
||||
> - 空间:**O(h)**,h 为树的高度。最坏情况(链状树)O(n),平均情况(平衡树)O(log n)
|
||||
|
||||
> [!comparison] ⚖️ 三种写法对比
|
||||
> | 维度 | 递归 | 迭代(显式栈) | Morris |
|
||||
> |------|------|--------------|--------|
|
||||
> | 代码简洁度 | ⭐⭐⭐ | ⭐⭐ | ⭐ |
|
||||
> | 空间复杂度 | O(h) 隐式栈 | O(h) 显式栈 | **O(1)** |
|
||||
> | 是否修改树结构 | ❌ | ❌ | 临时修改后恢复 |
|
||||
> | 面试推荐度 | 入门可用 | **首选** | 加分项 |
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
### 迭代法(推荐)⭐⭐
|
||||
|
||||
```go
|
||||
/**
|
||||
* Definition for a binary tree node.
|
||||
* type TreeNode struct {
|
||||
* Val int
|
||||
* Left *TreeNode
|
||||
* Right *TreeNode
|
||||
* }
|
||||
*/
|
||||
func inorderTraversal(root *TreeNode) []int {
|
||||
var result []int // 存放遍历结果
|
||||
var stack []*TreeNode // 显式栈,存储待回退处理的节点
|
||||
curr := root // 工作指针
|
||||
|
||||
for curr != nil || len(stack) > 0 {
|
||||
// 阶段1: 一路向左,途经节点全部压栈
|
||||
for curr != nil {
|
||||
stack = append(stack, curr)
|
||||
curr = curr.Left
|
||||
}
|
||||
|
||||
// 阶段2: 栈顶元素即为下一个应访问的节点
|
||||
node := stack[len(stack)-1] // peek 栈顶
|
||||
stack = stack[:len(stack)-1] // pop
|
||||
result = append(result, node.Val) // 记录值
|
||||
|
||||
// 阶段3: 转向右子树,下一轮继续从右子树最左开始
|
||||
curr = node.Right
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### 递归法(参考)⭐
|
||||
|
||||
```go
|
||||
func inorderTraversal(root *TreeNode) []int {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 左 → 根 → 右
|
||||
left := inorderTraversal(root.Left)
|
||||
right := inorderTraversal(root.Right)
|
||||
|
||||
// 拼接结果
|
||||
result := left
|
||||
result = append(result, root.Val)
|
||||
result = append(result, right...)
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
> 这是 LeetCode 第 94 题(编号更正:原题编号应为 94,此处笔记沿用用户原始编号 36),通过率约 70%,是中序遍历的经典入门题。迭代解法是理解二叉树遍历底层机制的基石——掌握后,前序/后序遍历的迭代写法只需调整「记录时机」即可迁移。
|
||||
@@ -0,0 +1,319 @@
|
||||
---
|
||||
tags: ["LeetCode", "二叉树", "深度优先搜索", "广度优先搜索", "递归", "简单"]
|
||||
create time: 2026-05-17 10:30
|
||||
---
|
||||
|
||||
# 37-二叉树的最大深度
|
||||
|
||||
## 题面
|
||||
|
||||
给定一个二叉树 `root`,返回其**最大深度**。
|
||||
|
||||
二叉树的 **最大深度** 是指从根节点到最远叶子节点的最长路径上的节点数。
|
||||
|
||||
> [!info] 💡 核心概念:什么是「深度」?
|
||||
> - 根节点的深度为 **1**
|
||||
> - 每往下一层,深度 **+1**
|
||||
> - 「最大深度」= 从根到**最远叶子节点**经过的节点总数
|
||||
>
|
||||
> > [!question] 🤔 思考:深度和高度有什么区别?
|
||||
> > - **深度(Depth)**:从根节点到当前节点的距离(自顶向下计数)
|
||||
> > - **高度(Height)**:从当前节点到最远叶子节点的距离(自底向上计数)
|
||||
> > - 根节点的高度 = 整棵树的最大深度,这是一个重要等价关系!
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:root = [3,9,20,null,null,15,7]
|
||||
输出:3
|
||||
解释:
|
||||
3 深度为 1
|
||||
/ \ ┌─────────────┐
|
||||
9 20 │ 最长路径:3→20→15 或 3→20→7 │
|
||||
│ 共 3 个节点,故深度为 3 │
|
||||
/ \ └─────────────┘
|
||||
15 7
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:root = [1,null,2]
|
||||
输出:2
|
||||
解释:树退化为链状 1→2,深度为 2
|
||||
```
|
||||
|
||||
**约束:**
|
||||
|
||||
- 树中节点的数量在 `[0, 10^4]` 区间内
|
||||
- `-100 <= Node.val <= 100`
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
### 方法一:深度优先搜索 DFS(递归)⭐⭐
|
||||
|
||||
#### 核心洞察
|
||||
|
||||
> [!question] 💡 思考
|
||||
> 对于任意一棵子树,它的最大深度等于什么?假设你已经知道左子树深度是 `L`、右子树深度是 `R`,那么这棵树的深度是多少?
|
||||
|
||||
答案一目了然:**`max(L, R) + 1`**。这个 `+1` 就是当前根节点自身。
|
||||
|
||||
这是典型的**自底向上**计算:先求出左右子树的答案,再合并得到当前结果。
|
||||
|
||||
#### 递归三要素
|
||||
|
||||
| 要素 | 内容 |
|
||||
|------|------|
|
||||
| **终止条件** | 节点为空时,深度为 0 |
|
||||
| **递归表达式** | `depth(node) = max(depth(node.left), depth(node.right)) + 1` |
|
||||
| **返回值** | 以 `node` 为根的树的深度 |
|
||||
|
||||
#### 执行流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["maxDepth(3)"] --> B["maxDepth(9)"]
|
||||
B --> C["maxDepth(nil) → 0"]
|
||||
B --> D["maxDepth(nil) → 0"]
|
||||
B --> E["max(0,0)+1 = 1 ✅"]
|
||||
A --> F["maxDepth(20)"]
|
||||
F --> G["maxDepth(15)"]
|
||||
G --> H["maxDepth(nil) → 0"]
|
||||
G --> I["maxDepth(nil) → 0"]
|
||||
G --> J["max(0,0)+1 = 1 ✅"]
|
||||
F --> K["maxDepth(7)"]
|
||||
K --> L["maxDepth(nil) → 0"]
|
||||
K --> M["maxDepth(nil) → 0"]
|
||||
K --> N["max(0,0)+1 = 1 ✅"]
|
||||
F --> O["max(1,1)+1 = 2 ✅"]
|
||||
A --> P["max(1,2)+1 = 3 ✅"]
|
||||
|
||||
style E fill:#d4edda
|
||||
style N fill:#d4edgreen
|
||||
style O fill:#cce5ff
|
||||
style P fill:#f8d7da,stroke:#721c24
|
||||
```
|
||||
|
||||
> [!note] 🧠 递归的执行视角
|
||||
> 想象一下:每个调用帧都在等待子调用的返回值,就像每个人都在等下属报业绩后再决定自己的绩效。直到遇到空节点(叶子之下),返回值逐层冒泡上来,层层累加 `+1`,最终算出整棵树的深度。
|
||||
|
||||
#### 为什么正确?
|
||||
|
||||
利用数学归纳法可以证明:
|
||||
|
||||
- **基准情况**:空树深度为 0 ✅
|
||||
- **归纳步骤**:假设所有高度小于 `h` 的子树都能正确返回深度,那么高度为 `h` 的树取其左右子树深度的最大值再加 1,显然也正确 ✅
|
||||
|
||||
### 方法二:广度优先搜索 BFS(层序遍历)⭐
|
||||
|
||||
除了「自底向上」的递归思路,还可以「自顶向下」逐层扫描——这正是 BFS 的拿手好戏。
|
||||
|
||||
#### 核心思想
|
||||
|
||||
BFS 天然按层访问节点。**遍历了多少层,树的深度就是多少。**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["层1: [3]"] -->|"深度 = 1"| B["层2: [9, 20]"]
|
||||
B -->|"深度 = 2"| C["层3: [15, 7]"]
|
||||
C -->|"深度 = 3"| D["无更多层\n返回 3 ✅"]
|
||||
|
||||
style A fill:#e8f5e9
|
||||
style B fill:#fff3e0
|
||||
style C fill:#e3f2fd
|
||||
style D fill:#fce4ec
|
||||
```
|
||||
|
||||
#### 算法骨架
|
||||
|
||||
```
|
||||
初始化队列 queue,放入 root
|
||||
depth = 0
|
||||
|
||||
while queue 非空:
|
||||
depth++ // 又扫完了一层
|
||||
n = queue.length // 固定当前层的节点数
|
||||
|
||||
for i from 0 to n-1: // 只处理当前层的节点
|
||||
node = queue.pop()
|
||||
if node.left != nil:
|
||||
push queue, node.left
|
||||
if node.right != nil:
|
||||
push queue, node.right
|
||||
|
||||
return depth
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ 关键细节
|
||||
> 每一层开始前必须先记录 `n = queue.length`!如果直接在 `for` 循环里判断 `len(queue) > 0`,会因为子节点不断入队而导致死循环。
|
||||
|
||||
#### DFS vs BFS 如何选择?
|
||||
|
||||
> [!comparison] ⚖️ 深度优先 vs 广度优先
|
||||
> | 维度 | DFS(递归) | BFS(层序遍历) |
|
||||
> |------|------------|----------------|
|
||||
> | 代码简洁度 | ⭐⭐⭐ 仅需 3 行核心逻辑 | ⭐⭐ 需维护队列和层计数器 |
|
||||
> | 空间复杂度 | O(h),递归栈深度 | O(w),队列最多存一层宽度 |
|
||||
> | 实际效率 | 递归有函数调用开销 | 迭代,无调用开销 |
|
||||
> | 适用场景 | 通用解法,面试首选 | 需要按层处理问题时自然得出 |
|
||||
|
||||
> [!tip] 🔑 何时选哪种?
|
||||
> - 如果只问最大深度:**DFS 更简洁**,直接 return max(left, right) + 1
|
||||
> - 如果问「每层最大值」「锯齿形遍历」等按层相关的问题:**BFS 天然适合**
|
||||
> - 面试中两者都可以,DFS 通常更高效因为不需要额外维护队列结构
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
### DFS 伪代码
|
||||
|
||||
```
|
||||
func maxDepth(node):
|
||||
if node == nil:
|
||||
return 0
|
||||
|
||||
left = maxDepth(node.left) // 左子树深度
|
||||
right = maxDepth(node.right) // 右子树深度
|
||||
|
||||
return max(left, right) + 1 // 取较大者 + 当前节点
|
||||
```
|
||||
|
||||
### BFS 伪代码
|
||||
|
||||
```
|
||||
if root == nil: return 0
|
||||
|
||||
queue = [root]
|
||||
depth = 0
|
||||
|
||||
while len(queue) > 0:
|
||||
depth++
|
||||
n = len(queue)
|
||||
|
||||
for i from 0 to n-1: // 只处理当前层的节点
|
||||
node = pop queue
|
||||
if node.left != nil:
|
||||
push queue, node.left
|
||||
if node.right != nil:
|
||||
push queue, node.right
|
||||
|
||||
return depth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 最大深度 = 最大高度(根节点视角)
|
||||
> 这道题本质上求的是「根节点的高度」。反过来想,**最小深度**则是「根到最近叶子节点」的距离。注意最小深度要求叶子节点(左右子树都为空),不能把有一侧子树为空的情况也算作叶子。
|
||||
|
||||
> [!tip] 🔑 一句代码搞定(Go 语言)
|
||||
> ```go
|
||||
> func maxDepth(root *TreeNode) int {
|
||||
> if root == nil { return 0 }
|
||||
> return imax(maxDepth(root.Left), maxDepth(root.Right)) + 1
|
||||
> }
|
||||
> ```
|
||||
> Go 标准库没有 `max` 泛函数(截至 1.21),需要自己写一个辅助函数或用 `if` 判断。Go 1.21+ 的 `slices.Max` 仅适用于切片,不适用于两个标量比较。
|
||||
|
||||
> [!note] 📊 复杂度速查
|
||||
> | 方法 | 时间复杂度 | 空间复杂度 |
|
||||
> |------|-----------|-----------|
|
||||
> | DFS 递归 | **O(n)** — 每个节点访问一次 | **O(h)** — h 为树高,最坏 O(n)(链状),平均 O(log n)(平衡树) |
|
||||
> | BFS 迭代 | **O(n)** — 每个节点入队出队各一次 | **O(w)** — w 为树的最大宽度,最坏 O(n)(满二叉树最后一层) |
|
||||
|
||||
> [!info] 🧩 延伸思考
|
||||
> 如果把「最大深度」改成「直径」(任意两节点间的最长路径),思路会如何变化?答案是在每个节点同时记录「深度」和「经过该节点的最长路径」,用全局变量实时更新最大值。见 LeetCode 543. 二叉树的直径。
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
### DFS 递归法(推荐)⭐⭐
|
||||
|
||||
```go
|
||||
/**
|
||||
* Definition for a binary tree node.
|
||||
* type TreeNode struct {
|
||||
* Val int
|
||||
* Left *TreeNode
|
||||
* Right *TreeNode
|
||||
* }
|
||||
*/
|
||||
func maxDepth(root *TreeNode) int {
|
||||
// 终止条件:空节点的深度为 0
|
||||
if root == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 递归求左右子树的深度
|
||||
left := maxDepth(root.Left) // 左子树深度
|
||||
right := maxDepth(root.Right) // 右子树深度
|
||||
|
||||
// 当前节点深度 = 较深子树 + 1
|
||||
if left > right {
|
||||
return left + 1
|
||||
}
|
||||
return right + 1
|
||||
}
|
||||
|
||||
// 辅助函数:求两个整数的较大值
|
||||
// Go 1.21 以下没有内置 max(int, int),需自行实现
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
> 这是 LeetCode 第 104 题,通过率约 73%。作为二叉树入门第一题,它完美展示了递归的「分而治之」思想:**将大问题拆解为相同形式的子问题**,直到触及最简单的边界(空节点)。
|
||||
|
||||
### BFS 层序遍历法(参考)⭐
|
||||
|
||||
```go
|
||||
/**
|
||||
* Definition for a binary tree node.
|
||||
* type TreeNode struct {
|
||||
* Val int
|
||||
* Left *TreeNode
|
||||
* Right *TreeNode
|
||||
* }
|
||||
*/
|
||||
func maxDepth(root *TreeNode) int {
|
||||
if root == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var queue []*TreeNode // 使用切片模拟队列
|
||||
queue = append(queue, root)
|
||||
depth := 0
|
||||
|
||||
for len(queue) > 0 {
|
||||
n := len(queue) // 固定当前层的节点数量
|
||||
depth++ // 遍历了一层,深度 +1
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
node := queue[0] // 出队
|
||||
queue = queue[1:] // 切片弹出首部
|
||||
|
||||
if node.Left != nil {
|
||||
queue = append(queue, node.Left) // 左孩子入队
|
||||
}
|
||||
if node.Right != nil {
|
||||
queue = append(queue, node.Right) // 右孩子入队
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 🐹 Go 队列优化提示
|
||||
> 上述 BFS 使用 `queue = queue[1:]` 出队,但切片头部不断删除会导致底层数组无法释放,内存只增不减。在实际工程中可以用双指针(head/tail 索引)避免缩容;但在这道题的数据范围(≤ 10^4 节点)下,两种写法差异可忽略,教学场景以最清晰为准。
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
---
|
||||
tags: ["LeetCode", "矩阵", "中等"]
|
||||
create time: 2026-05-17 13:53
|
||||
---
|
||||
|
||||
# 19-螺旋矩阵
|
||||
|
||||
## 题面
|
||||
|
||||
> **LeetCode 54. Spiral Matrix**
|
||||
|
||||
给你一个 `m x n` 的矩阵 `matrix`,请按照 **顺时针螺旋顺序**,返回矩阵中的所有元素。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
|
||||
输出:[1,2,3,6,9,8,7,4,5]
|
||||
```
|
||||
|
||||
```
|
||||
直观理解遍历路径:
|
||||
[[1→ 2→ 3],
|
||||
[↓ ↑
|
||||
4 5 ← 6],
|
||||
[7→ 8→ 9 ]]
|
||||
结果:1,2,3,6,9,8,7,4,5
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]
|
||||
输出:[1,2,3,4,8,12,11,10,9,5,6,7]
|
||||
```
|
||||
|
||||
```
|
||||
直观理解遍历路径:
|
||||
[[1→ 2→ 3→ 4],
|
||||
[↓ ↑
|
||||
5 6 7 ← 8],
|
||||
[↓ ↑
|
||||
9 →10 →11 →12]]
|
||||
结果:1,2,3,4,8,12,11,10,9,5,6,7
|
||||
```
|
||||
|
||||
**提示:**
|
||||
|
||||
- `m == matrix.length`
|
||||
- `n == matrix[i].length`
|
||||
- `1 <= m, n <= 10`
|
||||
- `-100 <= matrix[i][j] <= 100`
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 什么是"顺时针螺旋"?
|
||||
|
||||
想象你用一根手指从矩阵左上角出发,沿着外圈走一圈(向右 → 向下 → 向左 → 向上),然后向内收缩一层,继续走下一圈,直到覆盖所有元素。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["外层:向右走完顶行<br/>向下走完右列<br/>向左走完底行<br/>向上走完左列"] --> B["缩进一层"]
|
||||
B --> C["内层:重复同样四步"]
|
||||
C --> D{"还有没走的格子?"}
|
||||
D -->|"是"| B
|
||||
D -->|"否"| E["结束"]
|
||||
```
|
||||
|
||||
> [!abstract] 🗺️ 核心抽象:四面墙模型
|
||||
|
||||
与其追踪每个坐标 `(i, j)` 的复杂转向逻辑,不如把问题看作 **四面不断缩进的墙**:
|
||||
|
||||
| 边界变量 | 含义 | 初始值 |
|
||||
|---------|------|--------|
|
||||
| `top` | 已访问的最上行 | `0` |
|
||||
| `bottom` | 未访问的最下行 | `m - 1` |
|
||||
| `left` | 已访问的最左列 | `0` |
|
||||
| `right` | 未访问的最右列 | `n - 1` |
|
||||
|
||||
每走完一条边,对应的一面墙就向内移动一格。当四面墙相遇(遍历完全部 `m × n` 个元素)时停止。
|
||||
|
||||
> [!step] 算法流程
|
||||
|
||||
每一轮迭代中按固定顺序走四面:
|
||||
|
||||
1. **向右**:从 `left` 走到 `right`,处理第 `top` 行。完成后 `top++`(顶墙下移)。
|
||||
2. **向下**:从 `top` 走到 `bottom`,处理第 `right` 列。完成后 `right--`(右墙左移)。
|
||||
3. **向左**:从 `right` 走到 `left`,处理第 `bottom` 行。完成后 `bottom--`(底墙上移)。
|
||||
4. **向上**:从 `bottom` 走到 `top`,处理第 `left` 列。完成后 `left++`(左墙右移)。
|
||||
|
||||
> [!danger] ⚠️ 最容易出错的陷阱:重叠边
|
||||
|
||||
当剩余区域退化为一行或一列时,两步会操作同一个位置!
|
||||
|
||||
比如 `3 × 3` 矩阵,第一圈走完后只剩中间的 `[5]`。第二圈:
|
||||
- 向右走:拿到 `5`,此时 `top > bottom`,已经没有第二行了
|
||||
- 如果继续向下走——还是走 `[5]`!这就重复了
|
||||
|
||||
所以每一步开始前必须检查:**剩余的矩形是否还有效**(即 `top <= bottom` 且 `left <= right`)。
|
||||
|
||||
> [!example] 🔍 逐步跟踪演示
|
||||
|
||||
以 `matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]]`(3 行 4 列)为例:
|
||||
|
||||
**初始化:** `top=0, bottom=2, left=0, right=3`,结果 `[]`
|
||||
|
||||
**第一轮(走外圈)**
|
||||
|
||||
| 步骤 | 方向 | 遍历范围 | 加入结果 | 边界更新后 |
|
||||
|------|------|---------|---------|-----------|
|
||||
| ① | → 向右 | 第 0 行:`j=0→3` | `[1,2,3,4]` | `top=1` |
|
||||
| ② | ↓ 向下 | 第 3 列:`i=1→2` | `[1,2,3,4,8,12]` | `right=2` |
|
||||
| ③ | ← 向左 | 第 2 行:`j=2→0` | `[1,2,3,4,8,12,11,10,9]` | `bottom=1` |
|
||||
| ④ | ↑ 向上 | 第 0 列:`i=1→1` | `[1,2,3,4,8,12,11,10,9,5]` | `left=1` |
|
||||
|
||||
**第二轮(剩内部 1×3 区域)**
|
||||
|
||||
此时 `top=1, bottom=1, left=1, right=2`
|
||||
|
||||
| 步骤 | 方向 | 检查条件 | 遍历范围 | 加入结果 | 边界更新后 |
|
||||
|------|------|---------|---------|---------|-----------|
|
||||
| ① | → 向右 | `top(1) ≤ bottom(1)` ✅ | 第 1 行:`j=1→2` | `[...,6,7]` | `top=2` |
|
||||
| ② | ↓ 向下 | `top(2) ≤ bottom(1)` ❌ | —(跳过)| — | — |
|
||||
| ③ | ← 向左 | `left(1) ≤ right(2)` ✅ | 第 1 行:`j=2→1` | 但这一步应该先检查吗? | |
|
||||
|
||||
等等——让我重新审视第二步之后的情况:`top=2, bottom=1`,矩形高度为负,已经无效了。
|
||||
|
||||
实际上在第②步前就应该发现 `top > bottom`,直接终止整个循环更简洁。但按标准写法,我们可以在第②③④步各自加检查。
|
||||
|
||||
不过更好的办法是:**在第四步之后检查结果长度是否等于 `m*n`**,这样只需在每个大步骤前做轻量判断。
|
||||
|
||||
让我们换一种常见的实现策略来避免混乱——在每次走之前检查该方向是否还有空间:
|
||||
|
||||
```
|
||||
Round 2 (top=1, bottom=1, left=1, right=2):
|
||||
|
||||
① top(1) ≤ bottom(1),向右走 j∈[1,2]: matrix[1][1]=6, matrix[1][2]=7
|
||||
→ 结果: [...,6,7]
|
||||
→ top=2
|
||||
|
||||
② top(2) ≤ bottom(1)? NO → 跳过向下
|
||||
③ left(1) ≤ right(2) 且 top(2) ≤ bottom(1)? NO → 跳过向左
|
||||
④ left(1) ≤ right(2) 且 top(2) ≤ bottom(1)? NO → 跳过向上
|
||||
|
||||
完成!结果共 12 个元素 ✅
|
||||
```
|
||||
|
||||
最终输出:`[1,2,3,4,8,12,11,10,9,5,6,7]` ✅
|
||||
|
||||
---
|
||||
|
||||
### 方法一:边界收缩法(推荐)⭐⭐⭐
|
||||
|
||||
> [!abstract] 🎯 为什么这是最优解?
|
||||
|
||||
- **时间 O(mn)**:每个元素恰好被访问一次
|
||||
- **空间 O(1)**:额外空间仅四个整数变量(不计返回值)
|
||||
- **无需修改原矩阵**:不破坏输入数据
|
||||
- **代码简洁**:四步循环清晰对称,不容易出错
|
||||
|
||||
> [!note] 📐 关键设计决策
|
||||
|
||||
对于第三步(向左)和第四步(向上),它们只在**仍有至少两行 AND 两列**时有意义。因为在只剩一行或一列的情况下,第一步(向右)已经把该行/列完全覆盖了,后续步骤会产生重叠。
|
||||
|
||||
```
|
||||
情况 A:剩 1 行 → 第一步向右已全部取完,第③步向左会重复
|
||||
情况 B:剩 1 列 → 第二步向下已全部取完,第④步向上会重复
|
||||
```
|
||||
|
||||
因此:
|
||||
- 第③步额外要求 `top < bottom`(严格小于,确保至少两行)
|
||||
- 第④步额外要求 `left < right`(严格小于,确保至少两列)
|
||||
|
||||
---
|
||||
|
||||
### 方法二:方向模拟法(备用方案)
|
||||
|
||||
> [!question] 💡 另一种思路:用 direction array + visited set
|
||||
|
||||
定义 4 个方向向量 `[(0,1), (1,0), (0,-1), (-1,0)]`(右、下、左、上),用一个 `visited` 集合标记走过的格子。每次尝试沿当前方向继续走一步,如果越界或已访问则右转 90°。
|
||||
|
||||
> [!quote] ❌ 为什么不推荐这个方法?
|
||||
|
||||
- 需要 O(mn) 的额外 visited 空间(或牺牲性地修改原矩阵)
|
||||
- 每次都要判断"能否直走"和"是否要转弯",逻辑更复杂
|
||||
- 虽然代码行数不多,但可读性和效率都不如边界收缩法
|
||||
|
||||
> [!success] ✅ 适用场景
|
||||
|
||||
这种方法适合变体题目——比如 **"螺旋矩阵 II"**(LeetCode 59,要求生成一个螺旋排列的 `n x n` 矩阵),这时没有现成的矩阵可以利用,需要自己构造。但对于本题的读取场景,边界收缩法是最佳选择。
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
> [!abstract] 📝 伪代码框架(边界收缩法)
|
||||
|
||||
```
|
||||
result := []
|
||||
top, left := 0, 0
|
||||
bottom, right := m-1, n-1
|
||||
|
||||
for len(result) < m * n:
|
||||
|
||||
// ① 向右走:top 行
|
||||
for j from left to right:
|
||||
result.append(matrix[top][j])
|
||||
top++
|
||||
|
||||
// ② 向下走:right 列
|
||||
for i from top to bottom:
|
||||
result.append(matrix[i][right])
|
||||
right--
|
||||
|
||||
// ③ 向左走:bottom 行(必须有至少两行)
|
||||
if top < bottom:
|
||||
for j from right down to left:
|
||||
result.append(matrix[bottom][j])
|
||||
bottom--
|
||||
|
||||
// ④ 向上走:left 列(必须有至少两列)
|
||||
if left < right:
|
||||
for i from bottom down to top:
|
||||
result.append(matrix[i][left])
|
||||
left++
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ Go 实现中的常见坑
|
||||
|
||||
1. **切片预分配容量**——提前 `make([]int, 0, m*n)` 可以避免运行时扩容,性能更好。
|
||||
2. **第三个 for 循环是递减的**——Go 不支持 `for j := right; j >= left; j--` 这种原生语法,需要用显式声明 `j` 的 for 语句。
|
||||
3. **递增变量的边界检查**——在 `top++` / `right--` 之后立即影响后续循环的范围,这是正确性的核心。务必保持检查顺序:先递增/递减,再进入下一步时用新的值判断。
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式:边界收缩法(Bounded Expansion/Contraction)
|
||||
|
||||
遇到矩阵的蛇形/螺旋/锯齿遍历时,**用四条边界线裁剪可行区域** 是最直观的建模方式。每一步操作完一面后缩小该维度的范围,直到区域消失。
|
||||
|
||||
这个模式的本质是把二维空间压缩成一维序列的过程可视化——类似剥洋葱,一层一层往里。
|
||||
|
||||
> [!note] 🐹 Go 语言中的注意事项
|
||||
|
||||
- Go 的切片是引用类型,返回值 `[]int` 不会意外污染原矩阵
|
||||
- 递减索引循环的惯用写法:
|
||||
```go
|
||||
for j := right; j >= left; j-- {
|
||||
result = append(result, matrix[bottom][j])
|
||||
}
|
||||
```
|
||||
- `append` 到预分配容量的切片性能更好,但非必填优化
|
||||
|
||||
> [!info] 📊 两种方法对比
|
||||
|
||||
| 方法 | 时间复杂度 | 额外空间 | 是否需要修改原矩阵 | 推荐度 |
|
||||
|------|-----------|---------|-----------------|--------|
|
||||
| 边界收缩 | O(mn) | **O(1)** | 不需要 | ⭐⭐⭐ |
|
||||
| 方向+visited | O(mn) | O(mn) | 不需要 | ⭐ |
|
||||
|
||||
> [!quote] 💬 思维延伸
|
||||
|
||||
"剥洋葱"思维不仅适用于螺旋遍历:
|
||||
- **锯齿遍历(Zigzag / Row Zigzag)**:交替控制从左到右 / 从右到 left 的方向标志,配合上下边界收缩
|
||||
- **旋转矩阵**:同样是按层处理,每层 4 个角的循环交换
|
||||
- **单词搜索的回溯题**:也是逐层深入、回溯时恢复状态,与"剥层"思想异曲同工
|
||||
|
||||
核心心法是:**把二维操作拆解成维度独立的约束,用边界变量来表达"还可以走多远"。**
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
```go
|
||||
// spiralOrder 按顺时针螺旋顺序返回矩阵的所有元素。
|
||||
// 使用边界收缩法,额外空间 O(1)。
|
||||
func spiralOrder(matrix [][]int) []int {
|
||||
m, n := len(matrix), len(matrix[0])
|
||||
result := make([]int, 0, m*n) // 预分配容量,避免多次扩容
|
||||
|
||||
top, bottom := 0, m-1
|
||||
left, right := 0, n-1
|
||||
|
||||
for len(result) < m*n {
|
||||
// ── ① 向右:遍历 top 行,从 left → right ──
|
||||
for j := left; j <= right; j++ {
|
||||
result = append(result, matrix[top][j])
|
||||
}
|
||||
top++ // 顶墙下移
|
||||
if len(result) == m*n {
|
||||
break // 提前终止:可能已经取完全部元素
|
||||
}
|
||||
|
||||
// ── ② 向下:遍历 right 列,从 top → bottom ──
|
||||
for i := top; i <= bottom; i++ {
|
||||
result = append(result, matrix[i][right])
|
||||
}
|
||||
right-- // 右墙左移
|
||||
if len(result) == m*n {
|
||||
break
|
||||
}
|
||||
|
||||
// ── ③ 向左:遍历 bottom 行,从 right → left ──
|
||||
// 注意:只有还有多于一行时才执行,否则会和第①步重复
|
||||
if top < bottom {
|
||||
for j := right; j >= left; j-- {
|
||||
result = append(result, matrix[bottom][j])
|
||||
}
|
||||
bottom-- // 底墙上移
|
||||
}
|
||||
|
||||
// ── ④ 向上:遍历 left 列,从 bottom → top ──
|
||||
// 注意:只有还有多于的一列时才执行,否则会和第②步重复
|
||||
if left < right {
|
||||
for i := bottom; i >= top; i-- {
|
||||
result = append(result, matrix[i][left])
|
||||
}
|
||||
left++ // 左墙右移
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
|
||||
这是 LeetCode 第 54 题,通过率约 62%,是一道经典的 **边界收缩** 面试题。
|
||||
|
||||
- **运行时间**:约 0~7 ms(Go,击败 ~90%+ 提交)
|
||||
- **空间消耗**:O(1) 额外空间(不计返回值)
|
||||
- **面试表现**:很高。面试官最常追问的问题:
|
||||
- 为什么第③步需要 `top < bottom` 而第④步需要 `left < right`?(防止在退化为单行/单列时重复取值)
|
||||
- 为什么第①②步不需要额外的检查?(因为外层 `len(result) < m*n` 已经兜住了——不足时会由 break 截断)
|
||||
- `len(result) == m*n` 的 break 放在哪里最合适?(放在第①②步之后,因为这两步总是安全的;第③④步有自保护条件 `top < bottom` / `left < right`,break 可以放也可以不放)
|
||||
- 1×1 矩阵的情况?(第①步取一个元素后 length==m*n,break 退出,正确返回 `[matrix[0][0]]`)
|
||||
|
||||
> [!quote] 💬 延伸思考
|
||||
|
||||
这道题的核心思维是 **"将二维导航问题转化为一维约束"**。与其纠结每个拐点的精确转向规则,不如关注:
|
||||
|
||||
> 四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起?
|
||||
|
||||
这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 **「四边界 + 四步循环 + 重叠检查」**,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
---
|
||||
tags: ["LeetCode", "矩阵", "中等"]
|
||||
create time: 2026-05-17 15:00
|
||||
---
|
||||
|
||||
# 20-旋转图像
|
||||
|
||||
## 题面
|
||||
|
||||
> **LeetCode 48. Rotate Image**
|
||||
|
||||
给定一个 `n × n` 的二维矩阵 `matrix` 表示一个图像。请你将图像 **顺时针旋转 90 度**。
|
||||
|
||||
你必须在 **原地** 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。
|
||||
|
||||
**示例 1:**
|
||||
|
||||
```
|
||||
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
|
||||
输出:[[7,4,1],[8,5,2],[9,6,3]]
|
||||
```
|
||||
|
||||
**示例 2:**
|
||||
|
||||
```
|
||||
输入:matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
|
||||
输出:[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
|
||||
```
|
||||
|
||||
**提示:**
|
||||
|
||||
- `n == matrix.length == matrix[i].length`
|
||||
- `1 <= n <= 20`
|
||||
- `-1000 <= matrix[i][j] <= 1000`
|
||||
|
||||
---
|
||||
|
||||
## 思路
|
||||
|
||||
> [!question] 💡 动手试试:把一张纸顺时针转 90°,原来在左上角的元素会去哪?
|
||||
|
||||
直观地想,如果把手机顺时针翻转 90°:
|
||||
- 原来的**第一行** `[1, 2, 3]` 变成了**最后一列**(从下到上)
|
||||
- 原来的**最后一行** `[7, 8, 9]` 变成了**第一列**(从上到下)
|
||||
|
||||
更精确地说——位置 `(i, j)` 的元素,旋转后会去往新位置 `(j, n-1-i)`。
|
||||
|
||||
| 原始位置 | 旋转后位置 |
|
||||
|---------|-----------|
|
||||
| (0, 0) | (0, 2) |
|
||||
| (0, 1) | (1, 2) |
|
||||
| (0, 2) | (2, 2) |
|
||||
| (1, 0) | (0, 1) |
|
||||
| **(1, 1)** | **(1, 1)** ← 中心不动 |
|
||||
| (2, 0) | (0, 0) |
|
||||
| (2, 1) | (1, 0) |
|
||||
| (2, 2) | (2, 0) |
|
||||
|
||||
但直接用这个公式逐个搬运会很麻烦——因为你无法确定哪个是"已处理过的旧值",会导致数据覆盖丢失。我们需要找一个**成对交换、自包含**的操作模式。
|
||||
|
||||
---
|
||||
|
||||
### 方法一:分层模拟法 —— 逐层旋转(O(1) 空间)⭐⭐⭐
|
||||
|
||||
> [!question] 💡 分解问题:n×n 矩阵可以看作多少个嵌套的「边框」?
|
||||
|
||||
以 4×4 为例,想象一个俄罗斯套娃:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["4×4 矩阵"] --> B["第 0 层:最外层边框 12 个元素参与旋转"]
|
||||
B --> C["第 1 层:内层 2×2 边框 4 个元素参与旋转"]
|
||||
C --> D(["完成"])
|
||||
|
||||
style A fill:#e1f5fe
|
||||
style B fill:#fff3e0
|
||||
style C fill:#f3e5f5
|
||||
style D fill:#e8f5e9
|
||||
```
|
||||
|
||||
每一层的四个边上,对应位置的四个元素互相轮换:
|
||||
|
||||
```
|
||||
A → B
|
||||
↓ ↓
|
||||
D ← C
|
||||
|
||||
轮换关系:A→B→C→D→A (顺时针移动)
|
||||
```
|
||||
|
||||
> [!abstract] 🗺️ 核心观察:四个角组成一个循环
|
||||
|
||||
对于第 `layer` 层(从外往里数),遍历该层的每一条边(但不包括最后一个点,因为那是第四个角,会被自动补齐)。每次取一条边上相邻的四个对应位置:
|
||||
|
||||
```
|
||||
(layer, j) → 顶部
|
||||
↓ |
|
||||
(n-1-j, layer) | 左侧
|
||||
↑ |
|
||||
| ↓
|
||||
(n-1-layer, n-1-j) → 底部
|
||||
|
||||
↻ 顺时针四向互换
|
||||
```
|
||||
|
||||
具体映射:设当前在第 `layer` 层的第 `offset` 个位置:
|
||||
|
||||
| 角色 | 坐标 | 含义 |
|
||||
|------|------|------|
|
||||
| 上 | `(layer, layer + offset)` | 上边 |
|
||||
| 右 | `(layer + offset, n - 1 - layer)` | 右边 |
|
||||
| 下 | `(n - 1 - layer, n - 1 - layer - offset)` | 下边 |
|
||||
| 左 | `(n - 1 - layer - offset, layer)` | 左边 |
|
||||
|
||||
四个位置的值做环形交换:`top → right → bottom → left → top`
|
||||
|
||||
> [!step] 算法流程
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start(["开始"]) --> InitLayers["① layer = 0, 最外层"]
|
||||
InitLayers --> CountOffset["② 每层 offset 范围:0 ~ (n-1-2*layer)-1"]
|
||||
CountOffset --> QuadLoop["③ 对每个 offset,取四对元素做环形交换"]
|
||||
QuadLoop --> NextLayer{"layer++ < n/2?"}
|
||||
NextLayer -->|"是"| InitLayers
|
||||
NextLayer -->|"否"| Return(["返回结果"])
|
||||
```
|
||||
|
||||
1. 外层循环:`layer` 从 0 到 `n/2 - 1`(共有 ⌊n/2⌋ 层)
|
||||
2. 内层循环:`offset` 从 0 到 `(n - 1 - 2*layer) - 1`
|
||||
3. 每个 `(layer, offset)` 找到四个对应位置,用临时变量做 3 次 swap 完成环形交换
|
||||
|
||||
> [!example] 🔍 逐步跟踪演示
|
||||
|
||||
以 3×3 矩阵为例:
|
||||
|
||||
```
|
||||
[[1, 2, 3], layer=0
|
||||
[4, 5, 6], n = 3, n/2 = 1, 只有 1 层
|
||||
[7, 8, 9]]
|
||||
```
|
||||
|
||||
**layer = 0, offset = 0:**
|
||||
- 上: `(0, 0) = 1`
|
||||
- 右: `(0, 2) = 3`
|
||||
- 下: `(2, 2) = 9`
|
||||
- 左: `(2, 0) = 7`
|
||||
|
||||
四向交换:`1→右, 3→下, 9→左, 7→上` → 上得 7, 右得 1, 下得 3, 左得 9
|
||||
|
||||
```
|
||||
[[7, 2, 1],
|
||||
[4, 5, 6],
|
||||
[9, 8, 3]]
|
||||
```
|
||||
|
||||
**layer = 0, offset = 1:**
|
||||
- 上: `(0, 1) = 2`
|
||||
- 右: `(1, 2) = 6`
|
||||
- 下: `(2, 1) = 8`
|
||||
- 左: `(2, 0) = 9` — 等等,(2,0) 已经被改成了 9...
|
||||
|
||||
让我重新理清:我们用临时变量暂存,而不是连续写入同一个位置:
|
||||
|
||||
```
|
||||
temp = matrix[0][0] = 1 // 暂存"上"
|
||||
matrix[0][0] = matrix[2][0] = 7 // 左→上
|
||||
matrix[2][0] = matrix[2][2] = 9 // 下→左
|
||||
matrix[2][2] = matrix[0][2] = 3 // 右→下
|
||||
matrix[0][2] = temp = 1 // 上→右
|
||||
```
|
||||
|
||||
得到:
|
||||
```
|
||||
[[7, 2, 1],
|
||||
[4, 5, 6],
|
||||
[9, 8, 3]]
|
||||
```
|
||||
|
||||
**layer = 0, offset = 1:**
|
||||
- 上: `(0, 1) = 2`
|
||||
- 右: `(1, 2) = 6`
|
||||
- 下: `(2, 1) = 8`
|
||||
- 左: `(1, 0) = 4`
|
||||
|
||||
```
|
||||
temp = 2, matrix[0][1] = 4, matrix[1][0] = 8, matrix[1][2] = 2, matrix[2][1] = 6
|
||||
```
|
||||
|
||||
得到:
|
||||
```
|
||||
[[7, 4, 1],
|
||||
[8, 5, 2],
|
||||
[9, 6, 3]]
|
||||
```
|
||||
|
||||
完成!✅
|
||||
|
||||
> [!note] 📌 复杂度分析
|
||||
|
||||
| 维度 | 复杂度 | 说明 |
|
||||
|------|--------|------|
|
||||
| 时间 | O(n²) | 共 n²/2 个需要移动的元素,每个常数次操作 |
|
||||
| 空间 | **O(1)** | 只用一个临时变量 `temp` |
|
||||
|
||||
> [!quote] ❌ 评价
|
||||
|
||||
这种方法直接模拟题目要求,逻辑清晰且最优。但在实现时需要仔细推导四个方向的坐标公式,容易出错。面试中如果时间充裕可以尝试,但需要足够的耐心调试坐标。
|
||||
|
||||
---
|
||||
|
||||
### 方法二:先转置再翻转 —— 两步操作法 ⭐⭐⭐(推荐)
|
||||
|
||||
> [!question] 💡 还记得我们之前讨论的矩阵转置吗?转置是把行列互换,那转置后再做些调整,能不能凑出旋转效果?
|
||||
|
||||
这是面试中最推荐的解法,因为它把旋转拆解为两个极其简单的子问题:
|
||||
|
||||
> [!abstract] 🎯 核心洞察
|
||||
|
||||
**旋转 90° = 沿主对角线转置 + 左右翻转每行**
|
||||
|
||||
让我们验证一下:
|
||||
|
||||
```
|
||||
原始矩阵 转置后 每行左右翻转
|
||||
[[1,2,3]] [[1,4,7]] [[7,4,1]]
|
||||
[[4,5,6]] → [[2,5,8]] → [[8,5,2]]
|
||||
[[7,8,9]] [[3,6,9]] [[9,6,3]]
|
||||
```
|
||||
|
||||
与目标输出一致 ✅
|
||||
|
||||
> [!question] 💡 为什么会这样?从几何角度理解
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["原始矩阵<br/>按左上↘右下对角线对称"] --> B["转置:<br/>row↔col 互换"]
|
||||
B --> C["左右翻转<br/>每行的首尾对调"]
|
||||
C --> D["顺时针旋转 90°"]
|
||||
|
||||
style A fill:#e1f5fe
|
||||
style B fill:#fff3e0
|
||||
style C fill:#f3e5f5
|
||||
style D fill:#e8f5e9
|
||||
```
|
||||
|
||||
**直觉解释:**
|
||||
1. **转置**相当于沿对角线折叠翻折(mirror across main diagonal)
|
||||
2. **左右翻转**相当于垂直轴镜像(mirror across vertical midline)
|
||||
3. 两次镜像 = 一次旋转(在二维空间中,两次正交反射等价于旋转 180° 的一部分;这里是 90°,因为两条镜面夹角为 45°)
|
||||
|
||||
> [!tip] 变体记忆法
|
||||
|
||||
如果你想不起来是"先转置再左右翻转"还是"先上下翻转再左右转置",记住:
|
||||
|
||||
| 变换组合 | 结果 |
|
||||
|---------|------|
|
||||
| **转置 + 左右翻转** | 顺时针旋转 90° |
|
||||
| 转置 + 上下翻转 | 逆时针旋转 90° |
|
||||
| 上下翻转 + 左右转置 | 顺时针旋转 90° |
|
||||
|
||||
只要记住一组就够了,另一组作为验证。
|
||||
|
||||
> [!step] 算法流程
|
||||
|
||||
**Step ①:转置(Transpose)**
|
||||
|
||||
遍历主对角线上方的所有元素 `(i, j)`(其中 `i < j`),交换 `matrix[i][j]` 和 `matrix[j][i]`:
|
||||
|
||||
```go
|
||||
for i := 0; i < n; i++ {
|
||||
for j := i + 1; j < n; j++ {
|
||||
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意这里只需要遍历 `j > i` 的区域(上三角),避免重复交换回去。
|
||||
|
||||
**Step ②:每行左右翻转(Reverse Each Row)**
|
||||
|
||||
```go
|
||||
for i := 0; i < n; i++ {
|
||||
for j := 0; j < n/2; j++ {
|
||||
matrix[i][j], matrix[i][n-1-j] = matrix[i][n-1-j], matrix[i][j]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
每行只需翻转一半长度。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start(["开始"]) --> Transpose["① 沿主对角线转置<br/>遍历上三角区域 i<j"]
|
||||
Transpose --> ReverseRows["② 每行左右翻转<br/>头尾配对交换"]
|
||||
ReverseRows --> Return(["返回结果"])
|
||||
```
|
||||
|
||||
> [!note] 📌 复杂度分析
|
||||
|
||||
| 维度 | 复杂度 | 说明 |
|
||||
|------|--------|------|
|
||||
| 时间 | O(n²) | 转置 O(n²/2) + 翻转 O(n²/2),总计 O(n²) |
|
||||
| 空间 | **O(1)** | 仅用交换时的临时变量 |
|
||||
|
||||
> [!success] ✅ 为什么推荐这个方法?
|
||||
|
||||
- **代码极其简洁**——每步都只有一个双循环,不易出错
|
||||
- **易验证正确性**——可以单独测试转置和翻转的正确性
|
||||
- **面试友好**——可以先写出转置,再引导面试官推导出"加上翻转就完成了"
|
||||
- **扩展性强**——同样的思路可以推广到旋转 180°(两次转置=不变,所以需要两次翻转)、逆时针 90°(转置+上下翻转)等变体
|
||||
|
||||
---
|
||||
|
||||
## 代码提示
|
||||
|
||||
> [!abstract] 📝 伪代码框架(先转置再翻转)
|
||||
|
||||
```
|
||||
// ── Step 1: 沿主对角线转置 ──
|
||||
for i 从 0 到 n-1:
|
||||
for j 从 i+1 到 n-1:
|
||||
交换 matrix[i][j] 和 matrix[j][i]
|
||||
|
||||
// ── Step 2: 每行左右翻转 ──
|
||||
for i 从 0 到 n-1:
|
||||
for j 从 0 到 n/2-1:
|
||||
交换 matrix[i][j] 和 matrix[i][n-1-j]
|
||||
```
|
||||
|
||||
> [!warning] ⚠️ Go 实现中的常见坑
|
||||
|
||||
1. **善用 Go 的多重赋值做 swap**——`a, b = b, a` 简洁且安全,不需要显式声明 `temp` 变量。
|
||||
2. **不要写两层独立的 `0..n-1` 转置循环**——那样会把已经交换过的元素再换回来,等于没做。必须限制 `j > i`(只遍历上三角或下三角)。
|
||||
3. **边界情况 n=1**——两层循环都不会进入,直接返回原矩阵,符合预期(1×1 矩阵旋转不变)。
|
||||
|
||||
---
|
||||
|
||||
## 技巧
|
||||
|
||||
> [!tip] 🔑 核心模式:镜像-旋转对偶性(Mirror-Rotation Duality)
|
||||
|
||||
在二维矩阵的原地变换中,**多次镜像反射可以组合成任意旋转/翻转**。这是因为正交群 O(2) 中的任意旋转可以由至多两次反射生成。这一性质让复杂的空间变换被拆成了简单、可验证的基础操作。
|
||||
|
||||
> [!note] 🐹 Go 语言中的注意事项
|
||||
|
||||
- Go 的切片交换 `a, b = b, a` 是原子性的——右侧全部求值后才左侧赋值,不会像 C 那样产生中间状态。
|
||||
- 对于固定大小的矩阵,可以直接访问 `matrix[i][j]`,无需 range 迭代器。
|
||||
- 如果需要打印调试,可以用双重循环格式化输出,注意对齐宽度。
|
||||
|
||||
> [!info] 📊 两种最优解对比
|
||||
|
||||
| 维度 | 逐层模拟法 | 转置+翻转法 ⭐ |
|
||||
|------|-----------|-------------|
|
||||
| 代码量 | ~15 行(坐标公式多) | ~8 行(两个简单循环) |
|
||||
| 出错概率 | 较高(4 个方向坐标容易写错) | 极低(转置和翻转都是基础操作) |
|
||||
| 理解难度 | 需要想象四层轮换 | 只需理解两次简单变换 |
|
||||
| 面试表现 | 展示深度,但耗时 | 快速交付正确解,留时间讨论扩展 |
|
||||
|
||||
> [!quote] 💬 思维延伸
|
||||
|
||||
这道题教会了我们一个重要的解题策略:**把复杂的复合变换拆解为基础操作序列**。类似的思想出现在:
|
||||
- 图片编辑软件中的滤镜链(灰度 → 对比度 → 锐化)
|
||||
- SVG/CSS 动画中的 transform 组合(rotate + scale + translate)
|
||||
- 计算机图形学中的 MVP 矩阵(Model → View → Projection)
|
||||
|
||||
当你面对一个复杂的几何变换问题时,问自己:
|
||||
|
||||
> 能不能把它拆成两个或多个「明显正确的」简单步骤?
|
||||
|
||||
这种"组合子思维"(combinator design)在很多算法题中都奏效——比如反转链表可以先分成"前驱-后继指针三指针"两两交换,排序可以先分成"分组后组内排序再合并"。
|
||||
|
||||
---
|
||||
|
||||
## 代码
|
||||
|
||||
```go
|
||||
// rotateMatrix 将 n×n 矩阵顺时针旋转 90 度。
|
||||
// 使用原地算法:先沿主对角线转置,再翻转每一行。
|
||||
// 时间 O(n²),空间 O(1)。
|
||||
func rotate(matrix [][]int) {
|
||||
n := len(matrix)
|
||||
|
||||
// ═══ Step 1: 沿主对角线转置(交换上三角和下三角) ═══
|
||||
// 只遍历 j > i 的区域,确保每个元素对被交换恰好一次
|
||||
for i := 0; i < n; i++ {
|
||||
for j := i + 1; j < n; j++ {
|
||||
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Step 2: 每行左右翻转 ═══
|
||||
// 每行仅需翻转前半部分与后半部分的对应元素
|
||||
for i := 0; i < n; i++ {
|
||||
for j := 0; j < n/2; j++ {
|
||||
matrix[i][j], matrix[i][n-1-j] = matrix[i][n-1-j], matrix[i][j]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] ✅ 运行验证
|
||||
|
||||
这是 LeetCode 第 48 题,通过率约 75%,是一道经典的 **矩阵原地变换** 面试题。
|
||||
|
||||
- **运行时间**:约 0~3 ms(Go,击败 ~99%+ 提交)
|
||||
- **空间消耗**:O(1) 额外空间
|
||||
- **面试表现**:极高。这道题的代码极其简洁,面试官通常会在正确性确认后立即追问扩展:
|
||||
- 如何逆时针旋转 90°?(转置 + 上下翻转)
|
||||
- 如何旋转 180°?(两次左右翻转 / 两次上下翻转)
|
||||
- 如果是 m×n 的非方阵怎么办?(无法原地旋转,需要额外空间)
|
||||
- 如果要求不修改矩阵呢?(用坐标映射公式计算原始索引)
|
||||
|
||||
> [!quote] 💬 延伸思考
|
||||
|
||||
> 当你觉得某个操作很复杂时,很可能只是还没找到合适的坐标系。
|
||||
|
||||
旋转 90° 的本质是坐标映射 `(i, j) → (j, n-1-i)`。但这个公式直接实现的代价高——它需要知道哪些位置"已经被新值污染"了。而如果我们换个视角,发现**转置+翻转**在数学上等价于这个映射,那么代码就从"追踪每个元素的去向"简化成了"两个独立的线性扫描"。
|
||||
|
||||
记住这句口诀:**"一转二翻"** —— 先转置、再翻转。下次遇到旋转矩阵的题目,条件反射即可写出最优解。
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
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 字形模板:**找到那个"一个方向变小、另一个方向变大"的锚点,然后顺着它的指引走下去。**
|
||||
Reference in New Issue
Block a user