Map 字典:Go 里的哈希表实战

全面掌握 Go 语言的 map 数据结构:声明、操作、遍历、并发安全及常见陷阱

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 是一个布尔值:如果键存在,oktrue;如果键不存在,okfalse。这是 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:

  1. 声明和创建make 创建 map,字面量初始化
  2. 基本操作:添加、读取、修改、删除、获取长度
  3. comma ok 模式:安全地判断键是否存在
  4. 遍历:用 for range,但顺序是随机的
  5. 键的限制:必须是可比较的类型
  6. 引用类型:赋值和传参不会复制 map
  7. 并发安全:用 sync.Mutexsync.Map 保护
  8. 嵌套 map:map 的值可以是另一个 map

map 是 Go 语言中非常常用的数据结构。在需要快速查找、建立映射关系的场景下,它是你的首选工具。

记住:map 不是并发安全的——这大概是今天最重要的一句话。

练习时间

  1. 电话号码簿:实现一个简单的电话簿,支持添加联系人、查找联系人、删除联系人、列出所有联系人
  2. 字符统计:统计字符串 “Hello, 世界!” 中每个字符的出现次数
  3. 去重:用 map 实现一个函数,接收一个字符串切片,返回去重后的切片
  4. 两数之和:给定一个整数切片和一个目标值,找出切片中两个数相加等于目标值的索引(经典算法题)
  5. 分组:给定一个单词列表,按首字母分组

下一篇预告

下一篇文章,我们将学习 Go 语言的指针。很多人一听到"指针"就害怕,但实际上 Go 的指针比 C/C++ 简单多了。我们会讨论:

  • 什么是指针
  • &* 操作符
  • 值传递 vs 引用传递
  • newmake 的区别
  • 什么时候该用指针

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页