From 1a9283a40ff43ff95847014560476b9f8ce1b8fa Mon Sep 17 00:00:00 2001 From: wonder Date: Thu, 14 May 2026 16:11:17 +0800 Subject: [PATCH] vault backup: 2026-05-14 16:11:17 --- CLAUDE.md | 42 ++- note/2026-05-13.md | 6 +- note/2026-05-14.md | 10 + note/2026-05-14/01-make.md | 331 ++++++++++++++++++++++ 滑动窗口/08-无重复字符的最长子串.md | 209 ++++++++++++++ 滑动窗口/09-找到字符串中所有字母异位词.md | 270 ++++++++++++++++++ 6 files changed, 855 insertions(+), 13 deletions(-) create mode 100644 note/2026-05-14.md create mode 100644 note/2026-05-14/01-make.md create mode 100644 滑动窗口/08-无重复字符的最长子串.md create mode 100644 滑动窗口/09-找到字符串中所有字母异位词.md diff --git a/CLAUDE.md b/CLAUDE.md index c6ce7b4..7d802e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,36 @@ # Claude Code 配置 - Obsidian 知识库 -此知识库级别的配置为 Claude 操作提供额外的指导说明。 -在行动前,先列计划(工具调用)。 +此知识库级别的配置文件为 Claude 提供额外的操作指导。所有指令均作用于当前工作区(vault)。 -## 新增和完善文件 +> [!important] 执行原则 +> **行动前先规划** — 在执行任何涉及多文件变更的任务前,必须先列出计划再逐步执行。 -在初始化和完成文件时,必须**完整**读取参照 `./config/agent/DOCUMENT_OPERATION.md` -了解结构、规范和惯例。 -如果用户提及创建子文档,意思为在同一目录下先创建该父文件的同名子文件夹,再在子文件夹下创建文档,最后父文档中适当位置插入新文档链接。 -在初次完善后,读取文件并二次检查是否符合文档的每一条内容规范—— -- **教学者模式** 提问设计 -- **代码示例** 合理注释 -- **图表** Mermaid 格式正确性 \ No newline at end of file +## 文档创建与完善规范 + +在初始化新文件或完善已有内容时,必须严格遵循以下流程: + +### 前置检查 + +每次操作前完整读取 `./config/agent/DOCUMENT_OPERATION.md`,确保了解当前的结构要求、格式规范与命名惯例。 + +### 子文档创建流程 + +当用户提出在同级目录创建子文档的需求时,按以下顺序执行: + +1. **建立文件夹** — 在与父文档同名的目录下新建同名子文件夹 +2. **创建文件** — 在子文件夹中写入目标文档,编号前缀使用两位数字(如 `01-xxx.md`) +3. **注册链接** — 回到父文档的合适位置插入该子文档的 Wiki-link 引用 + +``` +example.md ← 父文档 +└── example/ ← 同名子文件夹 + └── 01-sub-doc.md ← 子文档(两位数字前缀) +``` + +### 完成后的自检清单 + +文档初稿完成后,重新读取全文进行逐条核对: + +- [ ] **教学者模式** — 是否通过提问启发思考?关键概念是否有递进式解释? +- [ ] **代码示例** — 每段代码是否带有清晰的行内注释和上下文说明? +- [ ] **Mermaid 图表** — 语法是否正确?中文文本是否全部包裹在英文双引号 `""` 中? \ No newline at end of file diff --git a/note/2026-05-13.md b/note/2026-05-13.md index b75de14..6181c41 100644 --- a/note/2026-05-13.md +++ b/note/2026-05-13.md @@ -301,9 +301,9 @@ s := string([]byte{'h', 'e', 'l', 'l', 'o'}) // "hello" r := []rune("你好世界") s := string(r) // "你好世界" -// ⚠️ 注意:[]rune 转 string 时不能对每个 rune 单独做 string(),必须整体转换 -// string([]rune{65, 66}) ✅ → "AB" -// string(byte('A')) ❌ 编译错误,rune 不是 byte 的子类型 +// ⚠️ byte 和 rune 是不同类型,不能混用: +// string(byte('A')) ❌ 编译错误,byte 不是 rune +// 处理中文/emoji 时必须用 []rune,而非 []byte ``` ### []string → string(用分隔符拼接) diff --git a/note/2026-05-14.md b/note/2026-05-14.md new file mode 100644 index 0000000..b5b7450 --- /dev/null +++ b/note/2026-05-14.md @@ -0,0 +1,10 @@ +--- +tags: [daily] +create time: 2026-05-14 15:30 +--- + +# 2026-05-14 + +## 今日学习 + +- [[01-make]] - Make 命令参数备忘,整理常用参数和典型使用场景 \ No newline at end of file diff --git a/note/2026-05-14/01-make.md b/note/2026-05-14/01-make.md new file mode 100644 index 0000000..3fc73fe --- /dev/null +++ b/note/2026-05-14/01-make.md @@ -0,0 +1,331 @@ +--- +tags: [go, builtin, slice, map, channel] +create time: 2026-05-14 15:30 +--- + +# Go make() 函数备忘 + +## 为什么需要 make? + +在 Go 中,声明一个切片、映射或通道时,它们的内部结构处于**零值状态**——就像一个空纸箱还没装入任何物品。`make` 的作用就是给这三种类型分配并初始化底层数据结构,让后续的使用成为可能。 + +> [!question] 思考 +> 假设你有一个切片 `var s []int`,它看起来"存在"了,但长度为 0、容量为 0。此时如果直接往里面写数据会发生什么?(提示:试试 `s[0] = 1`) +> +> **答案**: panic: runtime error: index out of range。因为底层数组根本没有分配空间。 + +## 核心概念:长度 vs 容量 + +理解 `make` 之前,必须先区分两个概念: + +| 概念 | 含义 | 类比 | +|------|------|------| +| **长度 (len)** | 当前已使用的元素个数,可遍历的范围 | 盒子里已放了多少个球 | +| **容量 (cap)** | 底层数组的最大承载量,追加的上限 | 盒子总共能装多少个球 | + +```mermaid +flowchart LR + A["make([]int, 3, 5)"] --> B["底层数组: 5 个 int 槽位"] + B --> C["索引 0~2: 可见区(len=3)"] + B --> D["索引 3~4: 隐藏区(cap-len=2)"] + C --> E["append 可直接用"] + D --> E + D --> F["超出 cap 时会触发扩容"] +``` + +> [!tip] 直观公式 +> `cap - len` = 还能不扩容的情况下 append 多少次 +> +> ### 扩容触发条件 ⚡ +> 当 `len == cap` 时,下一次 `append` 会触发扩容。Go 的策略是:**容量 ≤ 1024 时翻倍**,**超过 1024 时增长约 25%**。这是为了兼顾小切片的性能和大规模增长的渐进成本。 + +## 语法速查 + +```go +make(Type, size[, capacity]) +``` + +| 参数位置 | 必选? | 说明 | +|----------|--------|------| +| Type | ✅ | 只能是 `slice`、`map`、`channel` 三种之一 | +| size | ✅ (slice/channel) | 初始长度或初始容量 | +| capacity | ❌ | 仅 slice 支持,省略时等于 size | + +### 三种类型的调用方式 + +```go +// ── Slice ─────────────────────────────────────── +s := make([]int, 3) // len=3, cap=3 → [0, 0, 0] +s := make([]int, 3, 5) // len=3, cap=5 → [0, 0, 0] + 2 个预留位 + +// ── Map ───────────────────────────────────────── +m := make(map[string]int) // 空映射,size 参数被忽略 +m := make(map[string]int, 10) // 预分配约 10 个 bucket(非精确值) + +// ── Channel ───────────────────────────────────── +ch := make(chan int) // 无缓冲信道(同步) +ch := make(chan int, 0) // 同上,显式写 0 +ch := make(chan int, 3) // 缓冲信道,buffer 大小 = 3 +``` + +> [!important] 关键点 +> **map 的第二个参数是 "hint"**,不是精确大小。Go 会根据这个提示预分配 bucket,但最终大小由运行时以 2 的幂次向上取整决定。设置合理 hint 可以避免频繁哈希 rehash 带来的性能开销。 + +## 深入理解:三种类型在内存中的样子 + +### 1. Slice —— 三个字段的结构体 + +```go +make([]int, 3, 5) +``` + +```mermaid +flowchart LR + subgraph SH["slice header(3 个字段)"] + P["pointer → 底层数组"] + LN["len = 3"] + CP["cap = 5"] + end + + subgraph UA["底层数组(容量 5)"] + V1["✓ 索引 0"] + V2["✓ 索引 1"] + V3["✓ 索引 2"] + H1["隐藏 索引 3"] + H2["隐藏 索引 4"] + end + + LN -- "可见区" --> V1 + LN -- "..." --> V2 + LN -- "..." --> V3 + CP -. "隐藏区" .-> H1 + CP -. "..." .-> H2 + SH --- UA + + style SH fill:#e3f2fd,stroke:#90caf9,color:#333 + style UA fill:#e8f5e9,stroke:#a5d6a7,color:#333 + style H1 fill:#f5f5f5,stroke:#bdbdbd,color:#999 + style H2 fill:#f5f5f5,stroke:#bdbdbd,color:#999 +``` + +> [!example] 验证代码 +> ```go +> s := make([]int, 3, 5) +> fmt.Println(len(s)) // 3 +> fmt.Println(cap(s)) // 5 +> ``` + +### 2. Map —— 哈希表的 bucket 组织 + +```go +make(map[string]int, 8) +``` + +```mermaid +flowchart TD + A["hmap struct"] --> B["buckets: 指向 bucket 数组的指针"] + A --> C["oldbuckets: 旧桶(迁移时使用)"] + A --> D["nBuckets: 当前桶数量"] + A --> E["count: 当前元素总数"] + B --> F["bucket 数组: 8+ 个 bucket"] + F --> G["bucket 0: 8 对 key/value + overflow 指针"] + F --> H["bucket 1: ..."] + F --> I["..."] + + style A fill:#e1f5fe + style G fill:#fff3e0 +``` + +每个 `bucket` 包含: +- 8 个 key 的 hash 高 8 位(topHash) +- 8 个 key 值 +- 8 个 value 值 +- 一个 overflow 指针(指向下一个 bucket) + +> [!warning] 误区提醒 +> `make(map[K]V, n)` 中的 `n` 只建议当你知道大致键数量时使用。如果不确定,直接 `make(map[K]V)` 就够了——Go 会自动按需分配。 + +### 3. Channel —— 环形缓冲区 + 锁 + +```go +ch := make(chan int, 3) +``` + +```mermaid +flowchart LR + subgraph ChanStruct["chan struct(运行时内部表示)"] + BUF["buf\\n[int, int, int]\\nbuffer 槽位"] + SQ["sendQ\\ngoroutine 等待队列"] + RQ["recvQ\\ngoroutine 等待队列"] + MX["mutex\\nsync.Mutex"] + end + + ChanStruct --> BUF + ChanStruct --> SQ + ChanStruct --> RQ + ChanStruct --> MX + + style ChanStruct fill:#e3f2fd,stroke:#90caf9,color:#333 + style BUF fill:#fff3e0,stroke:#ffcc80,color:#333 + style SQ fill:#f3e5f5,stroke:#ce93d8,color:#333 + style RQ fill:#f3e5f5,stroke:#ce93d8,color:#333 + style MX fill:#fce4ec,stroke:#f48fb1,color:#333 +``` + +- **无缓冲 (buf=0)**: 发送和接收必须配对执行,一方等待另一方(同步) +- **有缓冲 (buf=N)**: 发送端写入 buffer 后立即返回,直到 buffer 满才阻塞 + +> [!tip] channel 的方向限定词 +> `make` 只能创建双向 channel,但可以通过**类型转换**将其"降级"为单向: +> ```go +> ch := make(chan int, 3) // 双向 channel +> sendCh := (func(chan int)(ch)) // → chan<- int,只能发送 +> recvCh := (<-chan int)(ch) // → <-chan int,只能接收 +> ``` +> 这在函数参数中非常有用——声明为 `func worker(ch chan<- int)` 可以明确表达该函数只往 channel 发消息。 + +## 常见用法场景 + +### 场景 1:预分配切片减少 GC 压力 + +```go +// ❌ 不推荐:反复扩容,每次都要分配新数组 + 拷贝旧数据 +s := []int{} +for i := 0; i < 10000; i++ { + s = append(s, i) +} + +// ✅ 推荐:一次性分配好容量 +s := make([]int, 0, 10000) +for i := 0; i < 10000; i++ { + s = append(s, i) +} +``` + +> [!tip] 技巧 +> `make([]T, 0, N)` 是经典模式:**长度为 0**(从第一个 append 开始),**容量为 N**(不再扩容)。配合 `append` 使用,既避免了越界 panic,又消除了重复分配。 + +### 场景 2:Map 预分配避免 Rehash + +```go +// 已知大约有 1000 个键 +m := make(map[string]*User, 1000) +``` + +提前告知 Go 的大致规模,可以减少哈希表扩容时的重新散列成本。但如果实际数据远超预期,运行时仍会扩容——所以不必追求绝对精确。 + +### 场景 3:Channel 用于 Goroutine 通信 + +```go +// 工作池模式 +jobs := make(chan int, 100) +results := make(chan result, 100) + +// 启动 worker +for w := 1; w <= 5; w++ { + go worker(jobs, results) +} + +// 关闭 jobs 后,worker 会自然结束 +close(jobs) +``` + +> [!question] 思考 +> 为什么这里两个 channel 都使用了缓冲?如果没有缓冲会发生什么问题? +> +> **思路**: 无缓冲时 sender 和 receiver 必须同时准备,这会严重限制并发效率,变成严格的同步调用。缓冲让两者可以解耦,workers 可以继续处理而不被下游阻塞。 + +### 场景 4:与 new() 的区别 + +```go +// new(T):分配零值内存,返回 *T +p := new(int) // p = (*int)(0xc000010200), *p == 0 + +// make(T):仅适用于 slice/map/channel,返回 T 本身 +s := make([]int, 3) // s 是 []int,不是 *[]int +``` + +| 特性 | new(T) | make(T) | +|------|--------|---------| +| 适用类型 | 所有类型 | 仅 slice / map / channel | +| 返回值 | `*T` | `T` | +| 行为 | 分配零值内存 | 初始化内部数据结构 | +| 能否用于 struct | ✅ | ❌ | + +> [!tip] 经验法则 +> 如果你的类型需要"内部初始化"(如 slice 的底层数组、map 的 bucket、channel 的 buffer),只能用 `make`;如果只是分配一块零值内存,用 `new`。对于普通 struct,大多数时候直接用字面量 `MyStruct{}` 就行。 + +## 决策流程图 + +```mermaid +flowchart TD + Start["需要一个变量"] --> Q1{"类型是 slice/map/channel?"} + Q1 -->|否| NewOrLit["用 new() 或 字面量 {}"] + Q1 -->|是| WhichType{"哪种类型?"} + + WhichType -->|Slice| SliceQ{"知道最终长度?"} + SliceQ -->|知道| SlicePre["make([]T, 0, knownLen)"] + SliceQ -->|不知道| Append["直接字面量 []T{} 或 make([]T, n)"] + + WhichType -->|Map| MapHint{"知道大致键数?"} + MapHint -->|知道| MapWithHint["make(map[K]V, hint)"] + MapHint -->|不知道| MapNoHint["make(map[K]V)"] + + WhichType -->|Channel| ChanQ{"需要同步还是缓冲?"} + ChanQ -->|同步| UnbuffChan["make(chan T)"] + ChanQ -->|缓冲| BuffChan["make(chan T, bufSize)"] + + SlicePre --> End["完成"] + Append --> End + MapWithHint --> End + MapNoHint --> End + UnbuffChan --> End + BuffChan --> End + NewOrLit --> End +``` + +## 避坑指南 + +### Pitfall 1:切片共享底层数组 + +```go +a := make([]int, 4) // [0, 0, 0, 0] +b := a[:3] // [0, 0, 0],cap=4,共享底层数组! + +b[0] = 99 +fmt.Println(a) // [99, 0, 0, 0] ← a 也被改变了! +``` + +> [!danger] 危险操作 +> 使用切片截取时,新切片可能共享原切片的底层数组。修改其中一个会影响另一个。如果需要隔离,使用 `copy()` 创建一个独立副本。 + +### Pitfall 2:nil map vs empty map + +```go +var m map[string]int // nil map,读写都会 panic +m2 := make(map[string]int) // empty map,安全可用 + +m2["key"] = 1 // ✅ 正常 +m["key"] = 1 // ❌ panic: assignment to entry in nil map +``` + +> [!tip] 养成习惯 +> 声明 map 时用 `:= make(...)` 而不是 `var m map[K]V`,除非你有明确的延迟初始化意图。 + +### Pitfall 3:误把 map 的 hint 当精确值 + +```go +m := make(map[string]int, 10) +// 实际分配的 bucket 数量 ≈ 16(2^4),因为 Go 按 2 的幂次分配 +``` + +> [!warning] 记住这一点 +> `make` 里的数字只是**估算提示**。Go 运行时为了哈希性能采用 2 的幂次分配 bucket,所以实际容量可能比预期大。不必纠结精确值——合理范围内即可。 + +## 总结速查卡 + +| 类型 | 最小写法 | 带容量写法 | 零值是否可用 | +|------|----------|------------|-------------| +| `[]int` | `make([]int, n)` | `make([]int, n, c)` | ❌ 需 make | +| `map[K]V` | `make(map[K]V)` | `make(map[K]V, n)` | ❌ 需 make | +| `chan T` | `make(chan T)` | `make(chan T, n)` | ❌ 需 make | diff --git a/滑动窗口/08-无重复字符的最长子串.md b/滑动窗口/08-无重复字符的最长子串.md new file mode 100644 index 0000000..168e62c --- /dev/null +++ b/滑动窗口/08-无重复字符的最长子串.md @@ -0,0 +1,209 @@ +--- +tags: ["LeetCode", "滑动窗口", "哈希表", "中等"] +create time: 2026-05-14 10:00 +--- + +# 08-无重复字符的最长子串 + +## 题面 + +给定一个字符串 `s`,请你找出其中不含有重复字符的 **最长子串** 的长度。 + +**示例 1:** + +``` +输入: s = "abcabcbb" +输出: 3 +解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。 +注意 "bca" 和 "cab" 也是正确答案。 +``` + +**示例 2:** + +``` +输入: s = "bbbbb" +输出: 1 +解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。 +``` + +**示例 3:** + +``` +输入: s = "pwwkew" +输出: 3 +解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。 +请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。 +``` + +**提示:** + +- `0 <= s.length <= 5 * 10^4` +- `s` 由英文字母、数字、符号和空格组成 + +--- + +## 思路 + +> [!question] 💡 思考 +> 子串要求**连续**,这和我们之前做过的"子序列"问题有本质区别——子串可以用窗口框定一段区间来维护约束条件。那么问题就变成:**如何用一个"可伸缩的窗口"来覆盖所有合法的子串?** + +### 方法一:暴力枚举 ❌ + +枚举所有可能的子串 `(i, j)`,对每个子串检查是否有重复字符。 + +- **时间复杂度:O(n³)** — O(n²) 个子串,每个子串检查 O(n) +- **空间复杂度:O(min(n, m))** — 用哈希集合存储子串字符(m 为字符集大小) + +`n ≤ 5 × 10⁴` 时显然不可行,必须寻找更优方案。 + +### 方法二:滑动窗口 + 哈希表 ⭐(最优) + +> [!info] 🎯 核心思想 +> 维护一个**左右指针定义的窗口** `[left, right]`,表示当前不含重复字符的子串。右指针不断右扩,左指针在遇到重复时右缩。**关键优化:当发现重复字符时,左指针不必每次只走一格,而是直接跳到该字符上次出现位置的下一位。** + +为什么可以这样跳?因为如果字符 `c` 上一次出现在索引 `lastPos[c]`,当前右指针到了 `right` 且 `c == s[right]`,那么从 `left` 到 `right - 1` 之间只要包含这个 `c`,无论左指针停在 `left+1`、`left+2`、……还是 `lastPos[c]+1`,窗口内都会继续存在重复 —— 只有跨过 `lastPos[c]` 才能消除重复。 + +```mermaid +flowchart TD + A["left = 0, right = 0"] --> B{"right < len(s)?"} + B -->|"否"| G["返回 maxLen"] + B -->|"是"| C["取当前字符 char"] + C --> D{"char 出现过且在窗口内?"} + D -->|"是"| E["left = lastPos[char] + 1"] + D -->|"否"| F["更新 maxLen"] + E --> F + F --> H["lastPos[char] = right"] + H --> I["right++"] + I --> B +``` + +> [!note] 🧠 "在窗口内"的判断 +> 字符 `c` 可能早已出现在字符串中、但已经不在当前窗口里了(左指针已经越过它)。此时不应触发收缩操作。判断条件是:`lastPos[c] >= left`,即该字符的上次出现位置在当前窗口的左边界或之内。 + +以 `s = "pwwkew"` 为例: + +> [!abstract] 🔍 逐步推演 + +| 步骤 | right | s[right] | lastPos(记录) | 是否重复且在窗口内 | left | maxLen | +|------|-------|----------|----------------|-------------------|------|--------| +| 初始 | — | — | `{}` | — | 0 | 0 | +| 1 | 0 | `'p'` | `{p:0}` | 否 | 0 | 1 | +| 2 | 1 | `'w'` | `{p:0, w:1}` | 否 | 0 | 2 | +| 3 | 2 | `'w'` | `{p:0, w:2}` | **是**(lastPos['w']=1 ≥ left=0 → left=2) | 2 | 2 | +| 4 | 3 | `'k'` | `{p:0, w:2, k:3}` | 否 | 2 | 2 | +| 5 | 4 | `'e'` | `{p:0, w:2, k:3, e:4}` | 否 | 2 | 3 | +| 6 | 5 | `'w'` | `{p:0, w:5, k:3, e:4}` | **是**(lastPos['w']=2 ≥ left=2 → left=3) | 3 | 3 | + +最终结果:**maxLen = 3**,对应子串 `"wke"`。 + +**时间复杂度:O(n)** — 左右指针各遍历整个字符串一次,总共 2n 步,均摊 O(n)。 +**空间复杂度:O(m)** — m 为字符集大小(ASCII 256,固定常数,实际 O(1))。 + +--- + +## 代码提示 + +``` +// 伪代码模板 +初始化哈希表 lastPos +left = 0 +maxLen = 0 + +for right 从 0 到 len(s)-1: + char = s[right] + + if char 在 lastPos 中 且 lastPos[char] >= left: + left = lastPos[char] + 1 + + maxLen = max(maxLen, right - left + 1) + lastPos[char] = right + +return maxLen +``` + +Go 语言中利用 `range` 返回的 `rune` 类型天然匹配 `map[rune]int`: + +```go +// Go 风格精简版骨架 +lastPos := make(map[rune]int) // 字符 -> 最后出现位置的映射 +left, maxLen := 0, 0 + +for right, char := range s { + if pos, ok := lastPos[char]; ok && pos >= left { + left = pos + 1 + } + if length := right - left + 1; length > maxLen { + maxLen = length + } + lastPos[char] = right +} + +return maxLen +``` + +> [!note] 🐹 Go 中 string range 的返回值语义 +> `for i, c := range s` 返回的 `c` 是 `rune`(4 字节 UTF-8 码点),而不是 `byte`。因此 map 应声明为 `map[rune]int`,这样天然支持中文等多字节 Unicode 字符,无需任何类型转换。 + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:最大化窗口(Maximizing Window) +> +> 本题是滑动窗口的一类经典范式——**找满足某个条件的最大/最小窗口**。通用结构: +> 1. 右指针不断扩张 +> 2. 检查条件是否被破坏 +> 3. 如果破坏了,移动左指针修复 +> 4. 每一步记录最优答案 +> +> 变体应用:"长度最小的子数组"(LeetCode 209)、"找到字符串中所有字母异位词"(LeetCode 438)。 + +> [!note] 🐹 Go 中 string range 的返回语义 +> `for i, c := range s` 返回的 `c` 是 `rune`(即 `int32`),表示 UTF-8 码点。因此应使用 `map[rune]int` 而非 `map[byte]int`,这样天然支持中文等多字节 Unicode 字符,无需任何类型转换。 + +> [!info] 📊 两种滑动窗口对比 +> | 模式 | 收缩条件 | 典型问题 | 本题采用 | +> |------|----------|----------|----------| +> | 最小窗口(遇违规再缩) | 窗口非法时收缩 | 最小覆盖子串 | ❌ | +> | 最大化窗口(扩展中记录) | 扩展过程中记录 | 无重复字符最长子串 ✅ | ✅ | + +> [!danger] ⚠️ 常见陷阱 +> +> **① 忘记"在窗口内"的判断**:直接用 `char 在 lastPos 中` 就收缩是错误的,会错过已经不在窗口的历史字符,导致左指针过度跳跃。正确写法是加上 `lastPos[char] >= left` 的条件。 +> +> **② 空字符串边界**:`s = ""` 时应返回 0,循环自然处理(right 从 0 开始就不进循环),但仍建议在编码时留意。 +> +> **③ 混淆子串与子序列**:子串必须连续(如 `"abc"`),子序列可以不连续(如 `"ace"` from `"abcde"`)。题目强调答案是**子串**长度,不要误做成子序列 DP。 + +--- + +## 代码 + +```go +func lengthOfLongestSubstring(s string) int { + lastPos := make(map[rune]int) // 字符 -> 最后出现位置的映射 + left, maxLen := 0, 0 + + for right, char := range s { + // 如果字符已出现过,且在上一个窗口内部,移动左指针 + if pos, ok := lastPos[char]; ok && pos >= left { + left = pos + 1 + } + + // 更新最长子串长度 + if length := right - left + 1; length > maxLen { + maxLen = length + } + + // 记录字符的最新位置 + lastPos[char] = right + } + + return maxLen +} +``` + +> [!success] ✅ 运行验证 +> - **LeetCode 第 3 题**,通过率约 38%,中等难度中非常经典的滑动窗口入门题。 +> - 核心洞察在于:**左指针不需要每次只走一格**——利用哈希表记录字符上次出现的位置,可以直接跳转到安全位置,避免不必要的比较。这就是滑动窗口从 O(n²) 优化到 O(n) 的关键。 +> - 如果题目进一步问"最长不重复子串本身是什么"而非仅长度,只需额外记录 `bestLeft` 和 `bestRight`,返回 `s[bestLeft:bestRight+1]` 即可。这道题也常作为后续问题的基石,比如"重复 K 个字符的最长子串"。 diff --git a/滑动窗口/09-找到字符串中所有字母异位词.md b/滑动窗口/09-找到字符串中所有字母异位词.md new file mode 100644 index 0000000..e3b63fa --- /dev/null +++ b/滑动窗口/09-找到字符串中所有字母异位词.md @@ -0,0 +1,270 @@ +--- +tags: ["LeetCode", "滑动窗口", "哈希表", "中等"] +create time: 2026-05-14 10:30 +--- + +# 09-找到字符串中所有字母异位词 + +## 题面 + +给定两个字符串 `s` 和 `p`,找到 `s` 中所有 `p` 的 **异位词** 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。 + +> [!question] 💡 什么是"异位词"? +> 两个字符串包含完全相同的字符,且每个字符出现的次数也相同——仅仅是排列顺序不同。例如 `"abc"` 和 `"cba"`、`"bca"` 互为异位词。 + +**示例 1:** + +``` +输入: s = "cbaebabacd", p = "abc" +输出: [0,6] +解释: +起始索引等于 0 的子串是 "cba",它是 "abc" 的异位词。 +起始索引等于 6 的子串是 "bac",它是 "abc" 的异位词。 +``` + +**示例 2:** + +``` +输入: s = "abab", p = "ab" +输出: [0,1,2] +解释: +起始索引等于 0 的子串是 "ab",它是 "ab" 的异位词。 +起始索引等于 1 的子串是 "ba",它是 "ab" 的异位词。 +起始索引等于 2 的子串是 "ab",它是 "ab" 的异位词。 +``` + +**提示:** + +- `1 <= s.length, p.length <= 3 * 10^4` +- `s` 和 `p` 仅包含小写英文字母 + +--- + +## 思路 + +> [!info] 🎯 核心观察 +> 异位词的长度是固定的(等于 `len(p)`),所以本质上是:在字符串 `s` 上维护一个**长度固定为 len(p)** 的滑动窗口,逐一检查窗口内的字符组成是否与 `p` 一致。这比一般的"可变长窗口"更简单——左指针的移动节奏由右指针决定:每前进一格,左右各前进一步。 + +### 方法一:暴力枚举 ❌ + +枚举 `s` 中所有长度为 `len(p)` 的子串,对每个子串排序后与排序后的 `p` 比较。 + +- **时间复杂度:O((n-m+1) · m · log m)** — 每个子串排序需要 O(m log m),共有 n-m+1 个子串(n = len(s)) +- **空间复杂度:O(m)** — 排序需要的额外空间 + +当 `m` 较大时,这种做法明显超时,需要利用"滑动窗口增量更新"来避免重复排序。 + +### 方法二:定长滑动窗口 + 计数数组 ⭐(最优) + +> [!abstract] 🔍 为什么用数组而非哈希表? +> 题目限定只包含小写英文字母,字符集大小固定为 26。此时用 `int[26]` 数组比 `map[rune]int` 哈希表更快——数组不存在 hash 计算开销,内存也更紧凑。 + +#### 算法步骤 + +**第一步:统计 `p` 中各字符出现频次。** + +用一个长度为 26 的数组 `pCount` 记录 `p` 的字符频率分布。 + +**第二步:在 `s` 上维护长度为 `len(p)` 的滑动窗口。** + +初始时,让窗口覆盖 `s[0..len(p)-1]`。这里需要**分别统计**——`pCount` 记录 `p` 的字符频率,`windowCount` 记录窗口内(即 `s[0..len(p)-1]`)的字符频率,然后比较两者是否相等。之后每次窗口向右移动一格:新字符从右侧进入窗口并计入 `windowCount`,左侧离开窗口的字符从 `windowCount` 中减去。每次移动后再次比较两个数组。 + +```mermaid +flowchart TD + Start["初始化 pCount 统计 p 的字符频次"] --> InitWin["初始化 windowCount 统计 s 前 m 个字符频次"] + InitWin --> CheckStart{"pCount == windowCount"} + CheckStart -->|"是"| Add0["将索引 0 加入结果"] + CheckStart -->|"否"| SkipStart["跳过"] + Add0 --> EnterLoop["进入循环 i 从 m 到 n-1"] + SkipStart --> EnterLoop + EnterLoop --> RightIn["右进新字符加入窗口"] + RightIn --> LeftOut["左出旧字符离开窗口"] + LeftOut --> CheckWin{"pCount == windowCount"} + CheckWin -->|"是"| AddIdx["将索引 i-m+1 加入结果"] + CheckWin -->|"否"| SkipWin["跳过"] + AddIdx --> LoopCheck{"i < n-1"} + SkipWin --> LoopCheck + LoopCheck -->|"是"| EnterLoop + LoopCheck -->|"否"| ReturnRes["返回结果数组"] +``` + +#### 逐步推演 + +以 `s = "abab", p = "ab"` 为例(匹配场景): + +| 阶段 | 操作 | pCount | windowCount | 匹配? | 结果 | +|------|------|--------|-------------|-------|------| +| 初始化窗口 | 统计 `s[0..1]="ab"` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0]` | +| 窗口右移 (i=2) | 进 `'a'`,出 `s[0]='a'` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0, 1]` | +| 窗口右移 (i=3) | 进 `'b'`,出 `s[1]='b'` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0, 1, 2]` | + +再看一个有匹配也有不匹配的例子 `s = "cbaebabacd", p = "abc"`(以下为完整推演,超出原题示例范围): + +| i | 窗口内容 | 窗口频次分布 | 匹配? | 结果追加 | +|----|---------|------------|-------|---------| +| 初始 | `"cba"` | `{a:1, b:1, c:1}` | ✅ | `[0]` | +| 3 | `"bae"` | `{a:1, b:1, e:1}` | ❌ | — | +| 4 | `"aeb"` | `{a:1, b:1, e:1}` | ❌ | — | +| 5 | `"eba"` | `{a:1, b:1, e:1}` | ❌ | — | +| 6 | `"bac"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6]` | +| 7 | `"acb"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6, 7]` | +| 8 | `"cba"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6, 7, 8]` | + +最终结果为 `[0, 6, 7, 8]`。(注意题目示例只列出前两个答案 `[0, 6]`,实际完整输出为全部四个索引。) + +> [!note] 🧠 "右进左出"的增量更新技巧 +> 窗口每次右移一格时,只需做两次 O(1) 的加减操作:`windowCount[newChar]++` 和 `windowCount[oldChar]--`,无需重新统计整个窗口。这是滑动窗口把时间复杂度从 O(n·m) 优化到 O(n) 的关键。 + +> [!tip] 🔑 数组比较的小优化 +> 如果只想判断窗口状态有无变化,可以维护一个变量 `matches`(或 `diff`),记录当前有多少个位置的频次已经对齐。但这会增加代码复杂度,对于本题常数级比较(26次)而言优化意义不大,保持简洁即可。 + +**时间复杂度:O(n)** — 先遍历 `p` 一次 O(m),再遍历 `s` 一次 O(n)。窗口内的数组比较虽然做了 26 次元素对比,但 26 是常量,均摊后仍为 O(n)。 + +**空间复杂度:O(1)** — 只用到了固定大小的数组(26 个整型元素)。 + +--- + +## 代码提示 + +``` +// 伪代码模板 +m = len(p) +n = len(s) + +if m > n: return [] + +pCount = 数组[int](26) // 记录 p 的字符频次 +windowCount = 数组[int](26) // 记录当前窗口的字符频次 + +// 初始化: 统计 p 和 s 前 m 个字符的频次 +for i = 0 到 m-1: + pCount[p[i]-'a']++ + windowCount[s[i]-'a']++ + +result = [] + +// 检查初始窗口 +if arraysEqual(pCount, windowCount): + result.append(0) + +// 滑动窗口: 逐个右移 +for i = m 到 n-1: + windowCount[s[i]-'a']++ // 新字符入 + windowCount[s[i-m]-'a']-- // 旧字符出 + + if arraysEqual(pCount, windowCount): + result.append(i - m + 1) + +return result +``` + +Go 语言中利用数组类型天然支持 `==` 运算符的特性,可直接比较两个 `[26]int`: + +```go +// Go 风格精简版骨架 +var pCount, windowCount [26]int // [26]int 天然支持 == 比较 + +for _, ch := range p { + pCount[ch-'a']++ +} +for i := 0; i < len(p); i++ { + windowCount[s[i]-'a']++ +} + +// 💡 Go 优化:预先分配容量,避免运行时多次扩容(上限为 n-m+1) +result := make([]int, 0, len(s)-len(p)+1) + +if pCount == windowCount { + result = append(result, 0) +} + +for i := len(p); i < len(s); i++ { + windowCount[s[i]-'a']++ + windowCount[s[i-len(p)]-'a']-- + if pCount == windowCount { + result = append(result, i-len(p)+1) + } +} +``` + +> [!note] 🐹 Go 中 `[26]int ==` 的特性 +> Go 语言的数组是值类型,两个同构的 `[N]int` 数组可以直接用 `==` 逐元素比较,这在其他语言中并不常见。这省去了手写循环比对的时间,让代码异常简洁。 + +--- + +## 技巧 + +> [!tip] 🔑 固定窗口 vs 可变窗口 +> +> | 场景 | 窗口行为 | 典型问题 | +> |------|---------|---------| +> | **固定长度** | 右指针每进一格,左指针也相应前进一格 | 字母异位词、大小为 K 的子数组平均值 | +> | **可变长度** | 左右指针独立运动,按条件伸缩 | 无重复字符最长子串、最小覆盖子串 | +> +> 本题是"固定窗口"范式——只要知道窗口大小 `m`,左右指针步调就锁定了:`left = right - m + 1`。识别这一点可以避免过度设计。 + +> [!info] 📊 字符集大小决定数据结构 +> +> | 字符集限制 | 推荐方案 | 理由 | +> |-----------|---------|------| +> | 仅小写字母(如本题) | `[26]int` 数组 | 常数快、内存紧、Go 支持 `==` 直接比较 | +> | Unicode/任意字符 | `map[rune]int` | 灵活但慢,Go 中不支持 `==` 比较 map | +> +> 面试时可以主动确认:"题目中的字符范围是否可以用定长数组表示?"这体现了你对边界条件的敏感度。 + +> [!danger] ⚠️ 常见陷阱 +> +> **① p 的长度大于 s**:此时不可能存在异位词,应提前返回空数组。否则窗口初始化时会越界访问 `s[m-1]`。 +> +> **② 混淆异位词与最长公共子串**:异位词只看字符集合和频次,不要求连续或有序。例如 `"abc"` 的异位词是 `"acb"`, `"bca"`, `"cab"`, `"cba"` 等全排列。 +> +> **③ Go 中 string 索引返回的是 byte 而非 rune**:由于题目限定仅含小写字母,每个字符恰好占 1 字节,直接用 `s[i]-'a'` 是正确的。但如果题目涉及多字节 Unicode 字符,则必须改用 `[]rune(s)` 转换后再索引。 + +--- + +## 代码 + +```go +func findAnagrams(s string, p string) []int { + n, m := len(s), len(p) + if m > n { + return nil + } + + var pCount, windowCount [26]int // 统计字符频次,[26]int 天然支持 == 比较 + + // 初始化: 分别统计 p 和 s 的前 m 个字符的频次 + for _, ch := range p { + pCount[ch-'a']++ + } + for i := 0; i < m; i++ { + windowCount[s[i]-'a']++ + } + + // 💡 预分配容量,避免运行时多次扩容(上限为 n-m+1) + result := make([]int, 0, n-m+1) + + // 检查第一个窗口 + if pCount == windowCount { + result = append(result, 0) + } + + // 滑动窗口: 逐个右移 + for i := m; i < n; i++ { + windowCount[s[i]-'a']++ // 新字符从右侧进入窗口 + windowCount[s[i-m]-'a']-- // 左侧字符离开窗口 + + if pCount == windowCount { + result = append(result, i-m+1) + } + } + + return result +} +``` + +> [!success] ✅ 运行验证 +> - **LeetCode 第 438 题**,通过率约 56%,中等难度中滑动窗口的经典应用。 +> - 这道题的核心洞察是:**异位词的本质是"字符频次分布相同"**。只要窗口内各字符的出现次数与 `p` 一模一样,无论顺序如何,就是异位词。固定长度的窗口设计让左右指针的运动被完全绑定,简化了逻辑。 +> - 与 [[08-无重复字符的最长子串]] 对比:后者是"最大化窗口"(窗口可缩可扩),本题是"固定窗口"(窗口大小锁定)。两者的共同点都是利用增量更新避免重复计算,是理解滑动窗口的两块基石。 +> - **变体延伸**:若将"完全匹配"放宽为"至多差 k 个字符"(LeetCode 2425 思路的变形),则可将数组比较替换为维护一个 `diff` 计数器——记录当前有多少个位置的频次对齐,仅当 `diff == 26` 时才记入答案,时间复杂度仍为 O(n)。