From a419a41738c89fd890c9df3b5cb79618534bd1a1 Mon Sep 17 00:00:00 2001 From: wonder Date: Sun, 17 May 2026 14:27:46 +0800 Subject: [PATCH] vault backup: 2026-05-17 14:27:46 --- note/2026-05-17.md | 82 +++++++ 二叉树/36-二叉树的中序遍历.md | 256 +++++++++++++++++++++ 二叉树/37-二叉树的最大深度.md | 319 ++++++++++++++++++++++++++ 矩阵/19-螺旋矩阵.md | 349 ++++++++++++++++++++++++++++ 矩阵/20-旋转图像.md | 420 ++++++++++++++++++++++++++++++++++ 矩阵/21-搜索二维矩阵 II.md | 297 ++++++++++++++++++++++++ 6 files changed, 1723 insertions(+) create mode 100644 note/2026-05-17.md create mode 100644 二叉树/36-二叉树的中序遍历.md create mode 100644 二叉树/37-二叉树的最大深度.md create mode 100644 矩阵/19-螺旋矩阵.md create mode 100644 矩阵/20-旋转图像.md create mode 100644 矩阵/21-搜索二维矩阵 II.md diff --git a/note/2026-05-17.md b/note/2026-05-17.md new file mode 100644 index 0000000..0ef1316 --- /dev/null +++ b/note/2026-05-17.md @@ -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` 从"性能优化"重新分类为"**前置不变式维护**"——它是后续所有逻辑成立的必要条件,而非可选的锦上添花。 + +### 关联思考 + +这段教训可以推广到其他算法场景中:**凡是涉及周期性、环形、模运算的问题,第一步的归约预处理往往决定了整个算法的正确性基础**。不要跳过它,也不要事后补它。 diff --git a/二叉树/36-二叉树的中序遍历.md b/二叉树/36-二叉树的中序遍历.md new file mode 100644 index 0000000..a071b71 --- /dev/null +++ b/二叉树/36-二叉树的中序遍历.md @@ -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%,是中序遍历的经典入门题。迭代解法是理解二叉树遍历底层机制的基石——掌握后,前序/后序遍历的迭代写法只需调整「记录时机」即可迁移。 diff --git a/二叉树/37-二叉树的最大深度.md b/二叉树/37-二叉树的最大深度.md new file mode 100644 index 0000000..c24bdbb --- /dev/null +++ b/二叉树/37-二叉树的最大深度.md @@ -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 节点)下,两种写法差异可忽略,教学场景以最清晰为准。 diff --git a/矩阵/19-螺旋矩阵.md b/矩阵/19-螺旋矩阵.md new file mode 100644 index 0000000..236818d --- /dev/null +++ b/矩阵/19-螺旋矩阵.md @@ -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["外层:向右走完顶行
向下走完右列
向左走完底行
向上走完左列"] --> 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] 💬 延伸思考 + +这道题的核心思维是 **"将二维导航问题转化为一维约束"**。与其纠结每个拐点的精确转向规则,不如关注: + +> 四面墙在哪里?每走一步,哪面墙该推进?什么时候四面墙碰在一起? + +这种建模方式可以把看似复杂的遍历逻辑简化为四个对称的循环。记住这个模板 **「四边界 + 四步循环 + 重叠检查」**,你可以快速解决螺旋相关的绝大多数变体题——包括打印螺旋矩阵、螺旋遍历、螺旋填充等。 diff --git a/矩阵/20-旋转图像.md b/矩阵/20-旋转图像.md new file mode 100644 index 0000000..55d06f9 --- /dev/null +++ b/矩阵/20-旋转图像.md @@ -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["原始矩阵
按左上↘右下对角线对称"] --> B["转置:
row↔col 互换"] + B --> C["左右翻转
每行的首尾对调"] + 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["① 沿主对角线转置
遍历上三角区域 i ReverseRows["② 每行左右翻转
头尾配对交换"] + 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)`。但这个公式直接实现的代价高——它需要知道哪些位置"已经被新值污染"了。而如果我们换个视角,发现**转置+翻转**在数学上等价于这个映射,那么代码就从"追踪每个元素的去向"简化成了"两个独立的线性扫描"。 + +记住这句口诀:**"一转二翻"** —— 先转置、再翻转。下次遇到旋转矩阵的题目,条件反射即可写出最优解。 diff --git a/矩阵/21-搜索二维矩阵 II.md b/矩阵/21-搜索二维矩阵 II.md new file mode 100644 index 0000000..39e8db0 --- /dev/null +++ b/矩阵/21-搜索二维矩阵 II.md @@ -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(["开始:定位右上角
row=0, col=n-1"]) --> CheckBounds{"边界内?"} + CheckBounds -->|"否"| NotFound(["返回 false"]) + CheckBounds -->|"是"| Compare["比较 target 与 matrix[row][col]"] + Compare -->|"相等"| Found(["返回 true ✅"]) + Compare -->|"target 更大"| GoDown["当前值太小 → 向下走
row++"] + Compare -->|"target 更小"| GoLeft["当前值太大 → 向左走
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
↓ 左"] --> B["(0,3)=11
↓ 左"] + B --> C["(0,2)=7
↓ 左"] + C --> D["(0,1)=4
↓ 下"] + D --> E["(1,1)=5
✅ 找到!"] + 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 字形模板:**找到那个"一个方向变小、另一个方向变大"的锚点,然后顺着它的指引走下去。**