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

14 KiB
Raw Blame History

tags, create time
tags create time
Go
哈希表
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 可以是任意类型。

声明与初始化

// 方式一: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

基本操作

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 更省内存。

// 方式一: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 实战模板

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

s := "hello"

// 方式一:直接转换(创建新的 []byte 副本)
b := []byte(s) // b == []byte{'h', 'e', 'l', 'l', 'o'}

// ⚠️ 重要:这一步完成了「内存拷贝」
// s 本身不会改变,b 是独立的副本

底层关系图解:

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

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 包替代手动转换,语义更清晰:

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 包中,支持对整型切片、字符串切片进行快速排序,也可自定义排序逻辑。

内建排序

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

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

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

// []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(用分隔符拼接)

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 系列 — 数值切片转字符串

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 中有哪些方式?它们的性能差异有多大?

⚠️ 为什么不能直接 +=?

s := ""
for i := 0; i < 1000; i++ {
    s += "a" // ❌ 每次循环都创建新的 string,O(n²)
}
// 因为 Go 的 string 是不可变的,+= 等于创建新内存拷贝

strings.Builder — Go 版的 StringBuilder ✅

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")

完整实战示例:

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 个数,最后两层用双指针收尾,每层都加上同样的去重逻辑即可。