Map 字典:Go 里的哈希表实战
想象一下你走进一家大型图书馆。你想找一本特定的书,如果一本书一本书地找,可能要花上一整天。但如果有一个索引系统——你告诉管理员书名,他直接告诉你书在哪个架子上,是不是快多了?
Map(映射/字典) 就是程序里的"索引系统"。它存储的是键值对(key-value pairs),通过键可以快速找到对应的值。在 Python 里叫 dict,在 Java 里叫 HashMap,在 JavaScript 里叫 Object,在 Go 里就叫 map。
Go 的 map 底层是用哈希表(hash table)实现的,平均查找、插入、删除的时间复杂度都是 O(1)。也就是说,不管 map 里有一百个元素还是一亿个元素,查找速度都差不多——这就是哈希表的魔力。
让我们开始学习吧!
声明和创建 map
map 的类型
map 的类型声明格式是 map[键类型]值类型:
var m map[string]int // 键是 string,值是 int
var ages map[string]int // 人的姓名 → 年龄
var scores map[string]float64 // 课程名 → 分数
方式一:直接声明(零值是 nil)
var m map[string]int
fmt.Println(m) // map[]
fmt.Println(m == nil) // true
// ⚠️ 注意:nil map 不能直接写入!
// m["hello"] = 1 // ❌ panic: assignment to entry in nil map
直接声明的 map 是 nil,读取是安全的(返回零值),但写入会 panic。这是新手最常犯的错误之一。
方式二:用 make 创建(推荐)
m := make(map[string]int)
m["hello"] = 1
m["world"] = 2
fmt.Println(m) // map[hello:1 world:2]
用 make 创建的 map 已经分配了内存,可以直接写入。
你还可以预指定容量,减少扩容次数:
m := make(map[string]int, 100) // 预分配 100 个元素的空间
💡 小贴士:和切片不同,map 的容量参数只是一个"提示",不是严格限制。map 会自动扩容以容纳更多的元素。但预分配可以提升性能,特别是当你知道 map 会有多大的时候。
方式三:字面量初始化
// 短小精悍,适合已知数据的情况
ages := map[string]int{
"张三": 25,
"李四": 30,
"王五": 28,
}
fmt.Println(ages["张三"]) // 25
基本操作
添加和修改元素
m := make(map[string]int)
// 添加新元素
m["苹果"] = 5
m["香蕉"] = 3
// 修改已有元素
m["苹果"] = 10
fmt.Println(m) // map[苹果:10 香蕉:3]
读取元素
ages := map[string]int{
"张三": 25,
"李四": 30,
}
// 读取存在的键
fmt.Println(ages["张三"]) // 25
// 读取不存在的键,返回值类型的零值
fmt.Println(ages["王五"]) // 0(int 的零值)
⚠️ 注意:读取不存在的键不会报错,而是返回零值。这有时候会导致问题——你怎么知道这个键是真的不存在,还是它的值刚好是零值?
这就是"comma ok"模式出场的时候了:
// comma ok 模式
age, ok := ages["王五"]
if ok {
fmt.Println("王五的年龄是", age)
} else {
fmt.Println("王五不存在于 map 中")
}
// 输出:王五不存在于 map 中
ok 是一个布尔值:如果键存在,ok 是 true;如果键不存在,ok 是 false。这是 Go 语言中处理 map 查找的标准模式。
删除元素
用 delete 函数删除元素:
ages := map[string]int{
"张三": 25,
"李四": 30,
"王五": 28,
}
delete(ages, "李四")
fmt.Println(ages) // map[张三:25 王五:28]
// 删除不存在的键也不会报错
delete(ages, "赵六") // 安全,什么都不做
获取 map 的长度
ages := map[string]int{
"张三": 25,
"李四": 30,
}
fmt.Println(len(ages)) // 2
遍历 map
用 for range 可以遍历 map 的所有键值对:
scores := map[string]int{
"数学": 95,
"英语": 88,
"语文": 92,
"物理": 85,
}
// 遍历键和值
for subject, score := range scores {
fmt.Printf("%s: %d\n", subject, score)
}
// 只遍历键
for subject := range scores {
fmt.Println(subject)
}
// 只遍历值(用 _ 忽略键)
for _, score := range scores {
fmt.Println(score)
}
⚠️ 遍历顺序是随机的!
这是 Go map 的一个重要特性:遍历顺序是随机的,而且每次运行可能不同。
m := map[string]int{"a": 1, "b": 2, "c": 3}
// 运行多次,输出顺序可能不同
for k, v := range m {
fmt.Printf("%s: %d ", k, v)
}
这不是 bug,是 Go 团队故意设计的。目的是防止开发者依赖 map 的遍历顺序。在 Go 1.0 时代,map 的遍历顺序是确定的,很多人因此写出了依赖顺序的代码,后来当 Go 团队优化 map 实现时,这些代码就出了问题。
从 Go 1.12 开始,每次遍历都会随机化起始位置,让你的代码更早暴露顺序依赖的问题。
如果你需要有序遍历,可以先取出所有的键,排序后再遍历:
scores := map[string]int{
"数学": 95,
"英语": 88,
"语文": 92,
"物理": 85,
}
// 1. 取出所有键
keys := make([]string, 0, len(scores))
for k := range scores {
keys = append(keys, k)
}
// 2. 排序
sort.Strings(keys)
// 3. 按键的顺序遍历
for _, k := range keys {
fmt.Printf("%s: %d\n", k, scores[k])
}
map 的键限制
不是所有类型都可以做 map 的键。map 的键必须是可比较的(comparable)类型。
可以作为键的类型:
- 基本类型:
int,float64,string,bool等 - 指针
- 数组(元素类型可比较)
- 结构体(所有字段类型都可比较)
- 接口(底层类型可比较)
不能作为键的类型:
- 切片(slice)
- map
- 函数(func)
- 数组/结构体中包含以上类型
// ✅ 可以
m1 := make(map[string]int)
m2 := make(map[int]string)
m3 := make(map[[3]int]string) // 数组作为键
// ❌ 不可以
// m4 := make(map[]int]string) // 切片不能作为键
// m5 := make(map[map]int]string) // map 不能作为键
map 是引用类型
map 是引用类型。当你把 map 赋值给另一个变量,或者传给函数时,复制的是引用,不是整个 map:
m1 := map[string]int{"a": 1, "b": 2}
m2 := m1 // m2 和 m1 指向同一个 map
m2["c"] = 3
fmt.Println(m1) // map[a:1 b:2 c:3] ← m1 也变了!
fmt.Println(m2) // map[a:1 b:2 c:3]
如果你想创建一个独立的副本,需要手动复制:
m1 := map[string]int{"a": 1, "b": 2}
m2 := make(map[string]int, len(m1))
for k, v := range m1 {
m2[k] = v
}
m2["c"] = 3
fmt.Println(m1) // map[a:1 b:2] ← m1 没变
fmt.Println(m2) // map[a:1 b:2 c:3]
map 的并发安全问题
这是 Go 语言中一个非常重要的话题:map 不是并发安全的。
如果多个 goroutine 同时读写同一个 map,程序会出现不可预期的行为,甚至崩溃。Go 运行时还会检测并发读写并主动 panic:
fatal error: concurrent map read and map write
解决方案一:使用 sync.Mutex
最常见的方法是用互斥锁保护 map 的访问:
package main
import (
"fmt"
"sync"
)
// SafeMap 线程安全的 map
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func NewSafeMap() *SafeMap {
return &SafeMap{
m: make(map[string]int),
}
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[key] = value
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
value, ok := sm.m[key]
return value, ok
}
func (sm *SafeMap) Delete(key string) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.m, key)
}
func main() {
sm := NewSafeMap()
var wg sync.WaitGroup
// 多个 goroutine 并发写入
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key%d", i)
sm.Set(key, i)
}(i)
}
wg.Wait()
// 读取
value, ok := sm.Get("key50")
fmt.Printf("key50: value=%d, ok=%v\n", value, ok)
}
解决方案二:使用 sync.Map
Go 1.9 引入了 sync.Map,它是一个并发安全的 map 实现:
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存储
m.Store("name", "张三")
m.Store("age", 25)
// 读取
if value, ok := m.Load("name"); ok {
fmt.Println("name:", value)
}
// 读取或加载默认值
value, _ := m.LoadOrStore("city", "北京")
fmt.Println("city:", value)
// 删除
m.Delete("age")
// 遍历
m.Range(func(key, value interface{}) bool {
fmt.Printf("%v: %v\n", key, value)
return true
})
}
sync.Map 适合以下场景:
- 读多写少
- 多个 goroutine 读取、写入、修改不同的键
对于其他场景,用 sync.Mutex + 普通 map 的性能通常更好。
嵌套 map
map 的值可以是另一个 map,形成嵌套结构:
// 学生 → 科目 → 成绩
scores := make(map[string]map[string]int)
scores["张三"] = map[string]int{
"数学": 95,
"英语": 88,
}
scores["李四"] = map[string]int{
"数学": 92,
"物理": 90,
}
// 访问
fmt.Println(scores["张三"]["数学"]) // 95
// 安全访问(检查每一层是否存在)
if student, ok := scores["王五"]; ok {
if score, ok := student["数学"]; ok {
fmt.Println("王五的数学成绩:", score)
} else {
fmt.Println("王五没有数学成绩")
}
} else {
fmt.Println("王五不存在")
}
实战:词频统计器
让我们用 map 写一个实用的词频统计程序:
package main
import (
"fmt"
"sort"
"strings"
)
// WordCount 统计文本中每个单词的出现次数
func WordCount(text string) map[string]int {
words := strings.Fields(strings.ToLower(text))
counts := make(map[string]int)
for _, word := range words {
// 清理标点符号
word = strings.Trim(word, ".,!?;:\"'()[]")
if word != "" {
counts[word]++
}
}
return counts
}
// TopN 返回出现次数最多的 N 个单词
func TopN(counts map[string]int, n int) []struct {
Word string
Count int
} {
// 把 map 转换成切片
type wordCount struct {
Word string
Count int
}
var wcs []wordCount
for word, count := range counts {
wcs = append(wcs, wordCount{word, count})
}
// 按出现次数排序(降序)
sort.Slice(wcs, func(i, j int) bool {
return wcs[i].Count > wcs[j].Count
})
// 返回前 N 个
if n > len(wcs) {
n = len(wcs)
}
result := make([]struct {
Word string
Count int
}, n)
for i := 0; i < n; i++ {
result[i] = wcs[i]
}
return result
}
func main() {
text := `
Go is an open source programming language that makes it easy
to build simple, reliable, and efficient software.
Go is statically typed and compiled, with a syntax similar to C.
Go has garbage collection, and it supports concurrent programming.
`
counts := WordCount(text)
fmt.Println("=== 所有单词及其出现次数 ===")
for word, count := range counts {
fmt.Printf("%-15s: %d\n", word, count)
}
fmt.Println("\n=== 出现次数最多的 5 个单词 ===")
top5 := TopN(counts, 5)
for i, wc := range top5 {
fmt.Printf("%d. %-15s: %d 次\n", i+1, wc.Word, wc.Count)
}
}
小结
今天我们全面学习了 Go 语言的 map:
- 声明和创建:
make创建 map,字面量初始化 - 基本操作:添加、读取、修改、删除、获取长度
- comma ok 模式:安全地判断键是否存在
- 遍历:用
for range,但顺序是随机的 - 键的限制:必须是可比较的类型
- 引用类型:赋值和传参不会复制 map
- 并发安全:用
sync.Mutex或sync.Map保护 - 嵌套 map:map 的值可以是另一个 map
map 是 Go 语言中非常常用的数据结构。在需要快速查找、建立映射关系的场景下,它是你的首选工具。
记住:map 不是并发安全的——这大概是今天最重要的一句话。
练习时间
- 电话号码簿:实现一个简单的电话簿,支持添加联系人、查找联系人、删除联系人、列出所有联系人
- 字符统计:统计字符串 “Hello, 世界!” 中每个字符的出现次数
- 去重:用 map 实现一个函数,接收一个字符串切片,返回去重后的切片
- 两数之和:给定一个整数切片和一个目标值,找出切片中两个数相加等于目标值的索引(经典算法题)
- 分组:给定一个单词列表,按首字母分组
下一篇预告
下一篇文章,我们将学习 Go 语言的指针。很多人一听到"指针"就害怕,但实际上 Go 的指针比 C/C++ 简单多了。我们会讨论:
- 什么是指针
&和*操作符- 值传递 vs 引用传递
new和make的区别- 什么时候该用指针
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。