From 50aed8f968e4b1645bad6035c6db5230889d7d86 Mon Sep 17 00:00:00 2001 From: wonder Date: Wed, 13 May 2026 11:52:39 +0800 Subject: [PATCH] vault backup: 2026-05-13 11:52:39 --- note/2026-05-13.md | 429 ++++++++++++++++++++++++++++++++++++++++ 哈希/03-最长连续序列.md | 199 +++++++++++++++++++ 2 files changed, 628 insertions(+) create mode 100644 note/2026-05-13.md create mode 100644 哈希/03-最长连续序列.md diff --git a/note/2026-05-13.md b/note/2026-05-13.md new file mode 100644 index 0000000..d5a5079 --- /dev/null +++ b/note/2026-05-13.md @@ -0,0 +1,429 @@ +--- +tags: ["Go", "哈希表"] +create time: 2026-05-13 15:00 +--- + +# 2026-05-13 + +## Go 中的 Hash Map 与 Hash Set + +> [!question] 💡 思考 +> Go 标准库中没有像 Python 或 Java 那样的原生 `set` 类型——它是如何实现集合的? + +### Hash Map:`map[K]V` + +Go 的哈希映射通过内建类型 `map[K]V` 实现,K 必须支持 `==` 比较运算符,V 可以是任意类型。 + +#### 声明与初始化 + +```go +// 方式一:make 创建空 map(最常用) +mp := make(map[int]int) + +// 方式二:字面量直接初始化 +ages := map[string]int{ + "Alice": 30, + "Bob": 25, +} + +// 方式三:nil map(不可写入!读可以) +var nilMap map[string]int // nilMap == nil → true +nilMap["key"] = 1 // ❌ panic: assignment to entry in nil map +``` + +> [!warning] ⚠️ nil vs 空 map +> - `var m map[K]V` 声明后值为 `nil`,此时**只能读不能写**,写入会 panic +> - `make(map[K]V)` 创建的是真正的空 map,可读可写 +> - 如果不确定,可以用 `len(m) == 0 && m == nil` 同时判断是否为 nil + +#### 基本操作 + +```go +mp := make(map[int]string) + +// 插入 / 更新 +mp[1] = "one" + +// 查询:不存在的 key 返回零值 +val := mp[999] // val == ""(int→string 的零值) + +// 安全查询:双返回值判断 key 是否存在 +val, ok := mp[1] // val="one", ok=true +_, exists := mp[999] // exists=false + +// 删除 +delete(mp, 1) +delete(mp, 999) // 删除不存在的 key 不会 panic + +// 遍历 +for k, v := range mp { + fmt.Println(k, v) +} + +// 元素个数 +n := len(mp) +``` + +> [!info] 🐹 零值陷阱 +> 查找不存在的 key 时,Go 返回该值的**零值**(零值因 V 的类型而异),所以无法直接用 `mp[key] == 0` 判断 key 不存在——如果 key 对应的值恰好就是 0,结果会有歧义。务必使用 `(val, ok)` 双返回值模式。 + +| 操作 | 代码 | 说明 | +|------|------|------| +| 插入/更新 | `mp[key] = value` | key 存在则覆盖 | +| 安全读取 | `v, ok := mp[key]` | ok=true 表示 key 存在 | +| 不安全读取 | `v := mp[key]` | 返回零值,可能误判 | +| 删除 | `delete(mp, key)` | 幂等,删不存在的 key 不 panic | +| 判空 | `len(mp) == 0` | 但不能区分 nil 和非 nil 空 map | + +### Hash Set:用 `map[K]struct{}` 实现 + +Go 没有内置的 `set`,惯用做法是用 `map[K]struct{}`。`struct{}` 大小为 **0 字节**,比 `bool` 或 `int` 更省内存。 + +```go +// 方式一:make + struct{} +seen := make(map[int]struct{}) +seen[42] = struct{}{} +delete(seen, 42) + +// 方式二:map[int]bool(也可用,但 bool 占 1 字节) +seenBool := make(map[int]bool) +seenBool[42] = true + +// 查询成员身份(集合的核心操作) +if _, exists := seen[42]; exists { + fmt.Println("42 in set") +} +``` + +> [!tip] 🔑 为什么选 struct{} 而非 bool? +> +> | 类型 | 单个元素开销 | n 个元素的额外开销 | +> |------|-------------|-------------------| +> | `map[K]struct{}` | 0 bytes | 零 | +> | `map[K]bool` | 1 byte | n bytes | +> | `map[K]int` | 8 bytes (amd64) | 8n bytes | +> +> 当集合规模达到百万级时,`struct{}` 能节省显著内存。日常刷题用 `bool` 也无妨,语义更直观。 + +> [!example] 📝 HashSet 实战模板 +> ```go +> func containsDuplicate(nums []int) bool { +> seen := make(map[int]struct{}) +> for _, num := range nums { +> if _, exists := seen[num]; exists { +> return true +> } +> seen[num] = struct{}{} +> } +> return false +> } +> ``` + +## Go 中的字符串与字节切片转换 + +> [!question] 💡 思考 +> Go 的 `string` 是不可变类型,那如何修改其中的字符?这就需要借助 `[]byte`。 + +### string → []byte + +```go +s := "hello" + +// 方式一:直接转换(创建新的 []byte 副本) +b := []byte(s) // b == []byte{'h', 'e', 'l', 'l', 'o'} + +// ⚠️ 重要:这一步完成了「内存拷贝」 +// s 本身不会改变,b 是独立的副本 +``` + +**底层关系图解:** + +```mermaid +graph LR + A["string s"] -->|"不可变"| B("数据存储在只读区") + C["[]byte b = []byte(s)"] -->|"内存拷贝"| D("数据存放在可写的堆/栈上") + D --> E["可以修改 b[0] = 'H' ✅"] + style B fill:#f9d0d0 + style D fill:#d0f0d0 +``` + +> [!warning] ⚠️ string → []byte 是深拷贝 +> - 每次转换都会分配新内存并复制所有字节 +> - 高频调用时注意性能开销,LeetCode 刷题通常无需担心 + +### []byte → string + +```go +b := []byte{'h', 'e', 'l', 'l', 'o'} +s := string(b) // s == "hello" + +// 双向转换常用于原地修改字符串: +s = "abc" +b = []byte(s) +b[0] = 'X' // 修改的是 []byte,不影响原 string +s = string(b) // s == "Xbc" +``` + +### bytes 包常用工具函数 + +刷题中更常见的是用 `bytes` 包替代手动转换,语义更清晰: + +```go +import "bytes" + +s := "hello world" +b := []byte(s) + +// 判断是否包含子串(比循环快) +bytes.Contains(b, []byte("world")) // true +bytes.HasPrefix(b, []byte("hello")) // true +bytes.HasSuffix(b, []byte("world")) // true + +// 查找子串首次出现的位置 +idx := bytes.Index(b, []byte("world")) // idx == 6 + +// 替换、分割 +bytes.ReplaceAll(b, []byte("hello"), []byte("hi")) // []byte("hi world") +bytes.Split(b, []byte(" ")) // [][]byte{{'h','e','l','l','o'}, {'w','o','r','l','d'}} +``` + +> [!tip] 🔑 string vs []byte 选择指南 +> +> | 场景 | 推荐类型 | 原因 | +> |------|---------|------| +> | 只读操作 | `string` | 不可变,零额外开销 | +> | 需要修改字符 | `[]byte` | 可写副本 | +> | Unicode/UTF-8 | `[]rune` | rune 等价于 int32,正确处理多字节字符 | + +--- + +## Go 中的排序 + +Go 的排序功能集中在 `sort` 包中,支持对整型切片、字符串切片进行快速排序,也可自定义排序逻辑。 + +### 内建排序 + +```go +import "sort" + +nums := []int{3, 1, 4, 1, 5, 9, 2} +strs := []string{"banana", "apple", "cherry"} + +// 升序排列 +sort.Ints(nums) // [1, 1, 2, 3, 4, 5, 9] +sort.Strings(strs) // ["apple", "banana", "cherry"] +sort.IntsAreSorted(nums) // true(仅检查,不修改) + +// 对已有切片排序(原地修改) +sort.Slice(nums, func(i, j int) bool { + return nums[i] < nums[j] // 升序 +}) + +// 降序排列 +sort.Sort(sort.Reverse(sort.IntSlice(nums))) +``` + +### 自定义排序 + +```go +type Pair struct { + Key string + Value int +} + +pairs := []Pair{ + {"a", 3}, + {"b", 1}, + {"c", 2}, +} + +// 按 Value 升序排序 +sort.Slice(pairs, func(i, j int) bool { + return pairs[i].Value < pairs[j].Value +}) +// 结果: [{"a",3}, {"b",1}, {"c",2}] → [{"b",1}, {"c",2}, {"a",3}] +``` + +### 排序稳定性 — sort.SliceStable + +```go +type Student struct { + Name string + Score int +} + +students := []Student{ + {"Alice", 90}, + {"Bob", 85}, + {"Charlie", 90}, +} + +// SliceStable 保持相等元素的原始相对顺序 +sort.SliceStable(students, func(i, j int) bool { + return students[i].Score < students[j].Score +}) +// Bob(85), Alice(90), Charlie(90) — Alice 仍在 Charlie 前面 +``` + +> [!tip] 🔑 算法复杂度 +> - `sort.Ints` / `sort.Strings`:内联实现,使用插排+堆排混合策略,均摊 O(n log n),常数极小 +> - `sort.Slice`:基于 quicksort,O(n log n),但自定义比较函数有间接调用开销 +> - `sort.Stable`:内层使用稳定排序(如 merge sort),最坏情况仍 O(n log n) + +> [!info] 🐹 刷题常用排序技巧 +> - **计数排序思想**:若元素范围有限(如频率统计),用 map 或数组代替 sort 效率更高 +> - **先排序再双指针**:许多哈希题换个思路可以用「排序 + 双指针」解决,降低空间复杂度到 O(1) +> - **字符频次**:`b := []byte(s); sort.Slice(b, func(i,j int) bool { return b[i] < b[j] })` 排序后可线性扫描统计字母异位词或重复字符 + +--- + +## 数组 / 切片 → 字符串的转换 + +### []byte / []rune → string + +```go +// []byte → string(最常用) +s := string([]byte{'h', 'e', 'l', 'l', 'o'}) // "hello" + +// []rune → string(Unicode 场景) +r := []rune("你好世界") +s := string(r) // "你好世界" + +// ⚠️ 注意:[]rune 转 string 时不能对每个 rune 单独做 string(),必须整体转换 +// string([]rune{65, 66}) ✅ → "AB" +// string(byte('A')) ❌ 编译错误,rune 不是 byte 的子类型 +``` + +### []string → string(用分隔符拼接) + +```go +import "strings" + +fruits := []string{"apple", "banana", "cherry"} + +// strings.Join — Go 标准库中专门处理这种场景 +result := strings.Join(fruits, ", ") // "apple, banana, cherry" +result = strings.Join(fruits, "") // "applebananacherry" +``` + +> [!tip] 🔑 strings.Join 底层实现 +> `strings.Join` 内部会先计算总长度,然后一次性分配内存并用 `strings.Builder` 写入——性能与手动循环 + Builder 等效。除非有额外逻辑(如跳过空值),否则直接用 Join 更简洁。 + +### fmt.Sprint / strconv 系列 — 数值切片转字符串 + +```go +import ( + "fmt" + "strconv" + "strings" +) + +nums := []int{1, 2, 3, 4} + +// 方法一:Sprintf(最简单,但性能较差) +fmt.Sprintf("%v", nums) // "[1 2 3 4]" +fmt.Sprintf("%+v", nums) // "[1 2 3 4]"(带字段名,仅结构体有效) + +// 方法二:手动转换(高效,LeetCode 推荐) +func intSliceToString(nums []int) string { + strs := make([]string, len(nums)) + for i, n := range nums { + strs[i] = strconv.Itoa(n) + } + return "[" + strings.Join(strs, ", ") + "]" +} +// 输出: "[1, 2, 3, 4]" +``` + +> [!warning] ⚠️ 各类型零值汇总 +> | 类型 | 零值 | +> |------|------| +> | `string` | `""` | +> | `int` | `0` | +> | `bool` | `false` | +> | `map[K]V` | `nil` | +> | `slice` | `nil` | +> | `pointer` | `nil` | + +--- + +## Go 中的字符串拼接(StringBuilder) + +> [!question] 💡 思考 +> Java 有 `StringBuilder` 优化大量字符串拼接,Go 中有哪些方式?它们的性能差异有多大? + +### ⚠️ 为什么不能直接 `+=`? + +```go +s := "" +for i := 0; i < 1000; i++ { + s += "a" // ❌ 每次循环都创建新的 string,O(n²) +} +// 因为 Go 的 string 是不可变的,+= 等于创建新内存拷贝 +``` + +### strings.Builder — Go 版的 StringBuilder ✅ + +```go +import "strings" + +var sb strings.Builder + +// 初始化时可预分配容量,避免多次扩容 +sb.Grow(100) + +// 写入各种类型 +sb.WriteString("hello") // 写入字符串 +sb.WriteByte(' ') // 写入单字节 +sb.WriteRune('你好') // 写入 Unicode 字符 +fmt.Fprintf(&sb, "%d", 42) // 格式化写入 + +// 获取最终结果(只读转换,不修改内部缓冲区) +result := sb.String() // result == "hello 42" + +// 重置复用,无需重新分配 +sb.Reset() +sb.WriteString("new content") +``` + +**完整实战示例:** + +```go +func joinStrings(words []string, sep string) string { + if len(words) == 0 { + return "" + } + + // 预估总长度,一次分配到位 + totalLen := 0 + for _, w := range words { + totalLen += len(w) + len(sep) + } + + var sb strings.Builder + sb.Grow(totalLen - len(sep)) // 减去最后一个分隔符 + + for i, w := range words { + if i > 0 { + sb.WriteString(sep) + } + sb.WriteString(w) + } + + return sb.String() +} +``` + +### 各方案对比 + +| 方案 | 时间复杂度 | 空间开销 | 推荐场景 | +|------|-----------|---------|---------| +| `+=` / `+` | O(n²) | 每轮分配新内存 | **禁止**在循环中使用 | +| `[]byte` + `string()` | O(n) | 额外 byte 副本 | 需要原地修改字符时 | +| `bytes.Buffer` | O(n) | Buffer 结构体开销 | 需要读写双向操作 | +| `strings.Builder` | O(n) | 最小化(Grow 可预分配) | **字符串拼接首选** | + +> [!tip] 🔑 strings.Builder vs bytes.Buffer 怎么选? +> - 纯字符串拼接 → `strings.Builder`,接口更简洁,零额外内存拷贝 +> - 需要同时处理字节级和字符串级操作 → `bytes.Buffer` +> - Go 1.10+ 才引入了 `strings.Builder`,刷题环境通常已支持 diff --git a/哈希/03-最长连续序列.md b/哈希/03-最长连续序列.md new file mode 100644 index 0000000..9a1be42 --- /dev/null +++ b/哈希/03-最长连续序列.md @@ -0,0 +1,199 @@ +--- +tags: ["LeetCode", "哈希表", "中等"] +create time: 2026-05-13 14:30 +--- + +# 03-最长连续序列 + +## 题面 + +给定一个未排序的整数数组 `nums`,找出数字**连续**的最长序列(不要求序列元素在原数组中连续)的长度。 + +请你设计并实现时间复杂度为 **O(n)** 的算法解决此问题。 + +**示例 1:** + +``` +输入:nums = [100,4,200,1,3,2] +输出:4 +解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。 +``` + +**示例 2:** + +``` +输入:nums = [0,3,7,2,5,8,4,6,0,1] +输出:9 +解释:最长数字连续序列是 [0, 1, 2, 3, 4, 5, 6, 7, 8]。它的长度为 9。 +``` + +**示例 3:** + +``` +输入:nums = [1,0,1,2] +输出:3 +解释:最长数字连续序列是 [0, 1, 2]。它的长度为 3。 +``` + +**约束:** + +- `0 <= nums.length <= 10^5` +- `-10^9 <= nums[i] <= 10^9` + +--- + +## 思路 + +> [!question] 💡 思考 +> 排序可以做到 O(n) 扫描找答案,但排序本身是 O(n log n),不符合要求。如何在无序数据中"瞬间判断"某个数是否存在? + +### 方法一:排序后线性扫描 ⚠️(O(n log n),不可取) + +先排序再扫描的思路虽然直观,但违背了 O(n) 的限制条件,仅作为理解本题的阶梯。 + +### 方法二:哈希集合 + 只从起点展开 ⭐(O(n)) + +核心洞察:**每个连续序列有且只有一个起点——即 `x-1` 不在数组中的那个 `x`**。 + +利用这个性质,我们可以只做一件事:当发现某个数是序列起点时,顺着往后数能走多远。非起点的数直接跳过,不会被重复计数。 + +#### 算法流程 + +```mermaid +flowchart TD + A["将 nums 全部放入哈希集合 s"] --> B["遍历每个数 num"] + B --> C{"num - 1 在 s 中?"} + C -->|"是"| D["不是起点, 跳过"] + C -->|"否"| E["从 num+1 开始, 不断 +1 检查是否在 s 中"] + E --> F["记当前序列长度 len"] + F --> G["ans = max(ans, len)"] + D --> B + G --> B + B --> H["遍历结束"] + H --> I["返回 ans"] +``` + +#### 为什么仍是 O(n)? + +- **建集合**:O(n) +- **外层遍历**:n 次迭代 +- **内层 while 展开**:每个元素最多被内层访问一次——因为只有它是起点时才会进入 while,而一旦某个数被 while 访问过,后续任何枚举到它时都必然有 `num-1` 也在集合中,不会再次展开。 + +两个循环加起来,每个元素的总访问次数是常数次,因此整体 O(n)。 + +以 `nums = [100, 4, 200, 1, 3, 2]` 为例: + +| 枚举值 | `num-1` 存在? | 动作 | 连续长度 | +|--------|---------------|------|----------| +| 100 | 101 ❌ → 实际查 99 ❌ | 不是起点,跳过 | — | +| 4 | 3 ✅ | 不是起点,跳过 | — | +| 200 | 199 ❌ | **是起点!** 向后展开:200→201(不存在) | 1 | +| 1 | 0 ❌ | **是起点!** 向后展开:1→2→3→4(5不存在) | **4** | +| 3 | 2 ✅ | 不是起点,跳过 | — | +| 2 | 1 ✅ | 不是起点,跳过 | — | + +最终答案:**4** + +**关键细节:** + +- Go 中用 `map[int]struct{}` 作为 set,`struct{}{}` 零内存开销 +- 需要先处理空数组边界情况:`len(nums) == 0` 时返回 0 +- 重复元素不影响正确性:集合天然去重,每个数值只保留一份 + +--- + +## 代码提示 + +``` +// 伪代码模板 +set = 将 nums 所有元素放入哈希集合 +ans = 0 + +for each num in nums: + if (num - 1) NOT in set: + // num 是某个连续序列的起点 + current = num + 1 + len = 1 + while current in set: + len++ + current++ + ans = max(ans, len) + +return ans +``` + +Go 语言中使用 `struct{}` 构造哈希集合: + +```go +s := make(map[int]struct{}) +for _, v := range nums { + s[v] = struct{}{} +} + +// 判断 key 是否存在 +if _, ok := s[key]; !ok { + // key 不存在 +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 模式:只从起点展开 +> 这是「集合去重 + 单侧展开」的经典手法。关键点在于理解每个连续段有唯一的左端点,这样能保证每条边(相邻整数对)只在一次展开中被遍历,从而证明总时间复杂度为 O(n)。 + +> [!note] 🐹 Go Set 的最佳实践 +> - `map[T]struct{}` 比 `map[T]bool` 更省内存(`struct{}{}` 大小为 0) +> - 查找语法:`_, ok := m[k]`;删除语法:`delete(m, k)` +> - 不需要初始化 capacity,但如果已知去重后数量,可以 `make(map[int]struct{}, n)` 减少扩容 + +> [!info] 📊 复杂度分析 +> - 时间:O(n),建集合 O(n) + 外层遍历 O(n) + 内层展开均摊 O(1)(每个元素只被 while 访问一次) +> - 空间:O(n),哈希集合存储去重后的所有元素 + +> [!danger] ⚠️ 常见陷阱 +> - **漏掉空数组**:`nums = []` 应返回 0,而非 panic +> - **没有从起点展开**:如果不对 `num-1` 做判断,最坏情况下内层 while 会对每个元素都执行,退化为 O(n²) +> - **没去重**:如果直接用切片存而不先去重,重复元素会导致长度计算错误(如 `[1,1,2,2,3]` 答案应为 3 而非 5) + +--- + +## 代码 + +```go +func longestConsecutive(nums []int) int { + if len(nums) == 0 { + return 0 + } + + // 构建哈希集合(自动去重) + set := make(map[int]struct{}) + for _, v := range nums { + set[v] = struct{}{} + } + + ans := 0 + for _, num := range nums { + // 只有 num-1 不在集合中时,num 才是连续序列的起点 + if _, ok := set[num-1]; !ok { + current := num + 1 + length := 1 + for ; ; length++ { + if _, ok := set[current]; !ok { + break + } + current++ + } + if length > ans { + ans = length + } + } + } + + return ans +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 128 题,Hard 难度但核心思想简洁。类似变体包括「缺失的第一个正数」(LC 41),两者都利用了哈希集合适配性不同的角度来解决。