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。这是新手最常犯的错误之一。
为什么会这样? 因为 nil map 没有分配底层哈希表,写入时找不到地方存储数据。这和 nil slice 可以用 append 不同——append 会先分配内存。
方式二:用 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, "赵六") // 安全,什么都不做
注意:delete 会修改原始 map,但它是安全的——即使键不存在也不会 panic。
获取 map 的长度
ages := map[string]int{
"张三": 25,
"李四": 30,
}
fmt.Println(len(ages)) // 2
注意:len 返回的是键值对的数量,不是底层容量。
遍历 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 赋值给另一个变量,或者传给函数时,复制的是引用,不是整个 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),浅拷贝可能不够,需要深拷贝:
// 深拷贝示例
func deepCopy(original map[string][]int) map[string][]int {
copy := make(map[string][]int, len(original))
for k, v := range original {
newSlice := make([]int, len(v))
copy(newSlice, v)
copy[k] = newSlice
}
return copy
}
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 的性能通常更好。
sync.Map 的优缺点
优点:
- 不用手动管理锁
- 读多写少场景性能优秀
- 支持原子性的
LoadOrStore操作
缺点:
- 键和值都是
interface{},类型不安全 - 不需要锁的单个 goroutine 场景有额外开销
- 写密集型场景性能不如
sync.Mutex
嵌套 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("王五不存在")
}
常见问题(FAQ)
Q: 为什么 nil map 不能写入但可以读取?
A: 读取 nil map 返回零值是安全的(不需要分配内存)。写入需要找到存储位置,但 nil map 没有底层哈希表,所以 panic。
Q: map 的容量参数有什么用?
A: 这是一个性能提示。预分配容量可以减少扩容次数(扩容需要重新哈希所有元素,代价较高)。
Q: 为什么 map 没有 cap() 函数?
A: map 的底层实现会动态扩容,容量没有实际意义。len() 返回的是实际元素数量。
Q: 如何在遍历时删除元素?
A: Go 允许在遍历时删除元素,但不要在遍历时添加元素,这会导致不可预期行为。
Q: map 可以比较吗?
A: map 本身不能比较(只能用 == nil)。要比较两个 map,需要手动遍历比较。
func mapsEqual(a, b map[string]int) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
实战:词频统计器
让我们用 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)
}
}
实战:用 map 实现 Set
Go 没有内置 Set 类型,但可以用 map 轻松实现:
package main
import "fmt"
// StringSet 字符串集合
type StringSet struct {
m map[string]struct{}
}
func NewStringSet() *StringSet {
return &StringSet{m: make(map[string]struct{})}
}
func (s *StringSet) Add(val string) {
s.m[val] = struct{}{}
}
func (s *StringSet) Remove(val string) {
delete(s.m, val)
}
func (s *StringSet) Contains(val string) bool {
_, ok := s.m[val]
return ok
}
func (s *StringSet) Size() int {
return len(s.m)
}
func (s *StringSet) Values() []string {
vals := make([]string, 0, len(s.m))
for k := range s.m {
vals = append(vals, k)
}
return vals
}
func main() {
set := NewStringSet()
set.Add("apple")
set.Add("banana")
set.Add("apple") // 重复添加会被忽略
fmt.Println("大小:", set.Size()) // 2
fmt.Println("包含 apple:", set.Contains("apple")) // true
fmt.Println("包含 cherry:", set.Contains("cherry")) // false
set.Remove("banana")
fmt.Println("删除后:", set.Values())
}
实战:用 map 统计网站访问频率
在实际开发中,map 常用于频率统计。下面是一个统计 URL 访问次数的例子:
package main
import (
"fmt"
"sort"
"time"
)
// URLCounter 统计 URL 访问次数
type URLCounter struct {
counts map[string]int
first map[string]time.Time
}
func NewURLCounter() *URLCounter {
return &URLCounter{
counts: make(map[string]int),
first: make(map[string]time.Time),
}
}
func (uc *URLCounter) Record(url string) {
if _, exists := uc.counts[url]; !exists {
uc.first[url] = time.Now()
}
uc.counts[url]++
}
func (uc *URLCounter) Get(url string) (int, time.Time, bool) {
count, ok := uc.counts[url]
if !ok {
return 0, time.Time{}, false
}
return count, uc.first[url], true
}
// TopURLs 返回访问最多的 N 个 URL
func (uc *URLCounter) TopURLs(n int) []struct {
URL string
Count int
} {
type pair struct {
URL string
Count int
}
var pairs []pair
for url, count := range uc.counts {
pairs = append(pairs, pair{url, count})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].Count > pairs[j].Count
})
if n > len(pairs) {
n = len(pairs)
}
result := make([]struct {
URL string
Count int
}, n)
for i := 0; i < n; i++ {
result[i] = struct {
URL string
Count int
}{pairs[i].URL, pairs[i].Count}
}
return result
}
func main() {
counter := NewURLCounter()
counter.Record("/home")
counter.Record("/about")
counter.Record("/home")
counter.Record("/products")
counter.Record("/home")
fmt.Println("=== 访问统计 ===")
for url, count := range counter.counts {
fmt.Printf("%s: %d 次\n", url, count)
}
fmt.Println("\n=== 热门页面 ===")
for _, top := range counter.TopURLs(3) {
fmt.Printf("%s: %d 次\n", top.URL, top.Count)
}
}
map 性能优化技巧
1. 预分配容量
如果你知道 map 大约会有多少元素,预分配容量可以减少扩容次数:
// 不好:频繁扩容
m := make(map[string]int)
for i := 0; i < 100000; i++ {
m[fmt.Sprintf("key%d", i)] = i
}
// 好:预分配
m := make(map[string]int, 100000)
for i := 0; i < 100000; i++ {
m[fmt.Sprintf("key%d", i)] = i
}
2. 使用结构体作为键
当需要多个字段构成键时,使用结构体比拼接字符串更高效:
// 好:结构体键
type Key struct {
UserID int
Date string
}
cache := make(map[Key]Data)
// 不好:字符串拼接
cache := make(map[string]Data)
cache[fmt.Sprintf("%d_%s", userID, date)] = data
3. 值类型优化
如果值很小(如 int),直接存储值比存储指针更高效:
// 好:直接存储值
m := make(map[string]int)
// 不好:存储指针(额外的间接访问和 GC 开销)
m := make(map[string]*int)
4. 删除并回收内存
Go 的 map 删除元素后不会自动缩容。如果 map 先大量增长又大量删除,内存可能居高不下:
// 方案:重建 map
func shrinkMap(m map[string]int) map[string]int {
if len(m) == 0 {
return m
}
newMap := make(map[string]int, len(m))
for k, v := range m {
newMap[k] = v
}
return newMap
}
注意:Go 1.21+ 引入了 clear(m) 函数清空 map,但不会释放内存。
小结
今天我们全面学习了 Go 语言的 map:
- 声明和创建:
make创建 map,字面量初始化 - 基本操作:添加、读取、修改、删除、获取长度
- comma ok 模式:安全地判断键是否存在
- 遍历:用
for range,但顺序是随机的 - 键的限制:必须是可比较的类型
- 引用类型:赋值和传参不会复制 map
- 并发安全:用
sync.Mutex或sync.Map保护 - 嵌套 map:map 的值可以是另一个 map
- Set 实现:用
map[T]struct{}高效实现集合
延伸阅读:
map 是 Go 语言中非常常用的数据结构。在需要快速查找、建立映射关系的场景下,它是你的首选工具。
记住:map 不是并发安全的——这大概是今天最重要的一句话。
练习时间
- 电话号码簿:实现一个简单的电话簿,支持添加联系人、查找联系人、删除联系人、列出所有联系人
- 字符统计:统计字符串 “Hello, 世界!” 中每个字符的出现次数
- 去重:用 map 实现一个函数,接收一个字符串切片,返回去重后的切片
- 两数之和:给定一个整数切片和一个目标值,找出切片中两个数相加等于目标值的索引(经典算法题)
- 分组:给定一个单词列表,按首字母分组
- Set 运算:实现集合的并集、交集、差集操作
下一篇预告
下一篇文章,我们将学习 Go 语言的指针。很多人一听到"指针"就害怕,但实际上 Go 的指针比 C/C++ 简单多了。我们会讨论:
- 什么是指针
&和*操作符- 值传递 vs 引用传递
new和make的区别- 什么时候该用指针
我们下篇见!👋
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。