Files
leetcode-go/note/2026-05-13.md
T

471 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 个数,最后两层用双指针收尾,每层都加上同样的去重逻辑即可。