--- 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 ( "cmp" // slices.Sort 需要 "sort" ) nums := []int{3, 1, 4, 1, 5, 9, 2} strs := []string{"banana", "apple", "cherry"} // sort 包内建排序 sort.Ints(nums) // [1, 1, 2, 3, 4, 5, 9] sort.Strings(strs) // ["apple", "banana", "cherry"] sort.IntsAreSorted(nums) // true(仅检查,不修改) // slices.Sort(Go 1.21+)— 基本类型一行搞定 slices.Sort(nums) // [1, 1, 2, 3, 4, 5, 9] slices.Sort(strs) // ["apple", "banana", "cherry"] ``` > [!info] 🔑 slices.Sort vs sort.Slice > > | 维度 | `slices.Sort` | `sort.Slice` | > |------|-------------|-------------| > | 来源 | `cmp/slices`(Go 1.21+) | `sort`(Go 1.0) | > | 类型约束 | `cmp.Ordered`(基本比较类型) | `any`(任意类型) | > | 自定义排序 | ❌ 不支持 | ✅ 传入比较函数 | > | 性能 | 内部高度优化 | 闭包间接调用有开销 | > > **选择原则**:基本类型简单排序 → `slices.Sort`;结构体多字段 / 降序 / 旧版 Go → `sort.Slice`。 ### 自定义排序 #### sort.Slice ```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` / `slices.Sort`:内联实现,使用插排+堆排混合策略,均摊 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) // "你好世界" // ⚠️ byte 和 rune 是不同类型,不能混用: // string(byte('A')) ❌ 编译错误,byte 不是 rune // 处理中文/emoji 时必须用 []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`,刷题环境通常已支持 --- ## 🧠 今日收获:排序 + 去重的联动思维 源自 [[双指针/06-三数之和]],最核心的启发是:**「排序」不只是为算法服务,更是为「去重」铺路。** ### 为什么排序后去重如此优雅? 排序让**相同的值聚集在一起**。当我们已经遍历过一个值并得到结果后,下一个相同的值必然紧跟其后——只需比较前一个位置就能跳过整个重复段,无需额外空间(如 map 或 set)。 ### 三层去重模式 在"固定一点、双指查找"的问题中,每个决策点都可能产生重复解,需要在**每一层都加去重判断**: | 层级 | 代码 | 时机 | |------|------|------| | **外层 i** | `if i > 0 && nums[i] == nums[i-1]: continue` | 枚举固定点时,前驱已作为固定点完整枚举过 | | **左指针 left** | `left++; while left < right && nums[left] == nums[left-1]: left++` | 找到解后右移,跳过所有与刚走过的 left 相同的值 | | **右指针 right** | `right--; while left < right && nums[right] == nums[right+1]: right--` | 找到解后左移,跳过所有与刚跳过的 right 相同的值 | ### 关键细节 > [!warning] ⚠️ 容易出错的地方 > 1. **先去重再移动指针 → 漏解**。正确顺序:记录答案 → 去重 → 继续搜索。 > 2. **比较对象要搞对**:left 和 `nums[left-1]` 比(刚走过的),right 和 `nums[right+1]` 比(刚跳过的)。 > 3. **i 的范围是 n-2 而非 n**:最后两个位置留给 left 和 right。 > > 这条模式可以推广到四数之和(LeetCode 18)、K 数之和——依次固定 k-2 个数,最后两层用双指针收尾,每层都加上同样的去重逻辑即可。