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

Map 字典: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。这是新手最常犯的错误之一。

为什么会这样? 因为 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 是一个布尔值:如果键存在,oktrue;如果键不存在,okfalse。这是 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:

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

延伸阅读:

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

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

练习时间

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

下一篇预告

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

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

我们下篇见!👋

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南