跳转至

Go 实现与实战练习

学习目标

学完本章后,学习者应该能够:

  1. 使用 Go 标准库实现常见算法需求。
  2. 用泛型封装简单数据结构。
  3. 编写表格驱动测试验证算法代码。
  4. 使用 benchmark 初步比较不同实现。

Go 标准库优先

工程中不要一上来手写所有结构。Go 标准库已经提供了很多基础能力:

需求 标准库
排序 sort
container/heap
链表 container/list
字符串处理 strings
字节处理 bytes
哈希 hashcrypto
时间 time
并发同步 sync

高级工程师应该知道什么时候使用标准库,什么时候自己封装,什么时候引入成熟第三方库。

表格驱动测试

算法代码很适合用表格驱动测试。

func TestBinarySearch(t *testing.T) {
    tests := []struct {
        name   string
        nums   []int
        target int
        want   int
    }{
        {name: "found", nums: []int{1, 2, 3}, target: 2, want: 1},
        {name: "missing", nums: []int{1, 2, 3}, target: 4, want: -1},
        {name: "empty", nums: nil, target: 1, want: -1},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := BinarySearch(tt.nums, tt.target)
            if got != tt.want {
                t.Fatalf("got %d, want %d", got, tt.want)
            }
        })
    }
}

测试要覆盖:

  • 正常输入。
  • 空输入。
  • 边界输入。
  • 重复元素。
  • 不存在目标。

Benchmark

当你不确定两种实现性能差异时,可以写 benchmark。

func BenchmarkHasDuplicateFast(b *testing.B) {
    nums := make([]int, 10000)
    for i := range nums {
        nums[i] = i
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = hasDuplicateFast(nums)
    }
}

运行:

go test -bench=. -benchmem
go test -bench=. -benchmem

-benchmem 可以显示内存分配情况。

泛型实现 Set

Go 泛型适合封装简单通用结构。

type Set[T comparable] struct {
    items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{items: make(map[T]struct{})}
}

func (s *Set[T]) Add(v T) {
    s.items[v] = struct{}{}
}

func (s *Set[T]) Remove(v T) {
    delete(s.items, v)
}

func (s *Set[T]) Contains(v T) bool {
    _, ok := s.items[v]
    return ok
}

func (s *Set[T]) Len() int {
    return len(s.items)
}

注意:这个 Set 不是并发安全的。

并发安全

如果多个 goroutine 同时访问 map,需要加锁:

type SafeSet[T comparable] struct {
    mu    sync.RWMutex
    items map[T]struct{}
}

func (s *SafeSet[T]) Contains(v T) bool {
    s.mu.RLock()
    defer s.mu.RUnlock()
    _, ok := s.items[v]
    return ok
}

但不要看到并发就盲目加锁。锁会带来复杂度和性能成本。也可以通过 channel 把状态管理收敛到单 goroutine。

Go 后端实际应用例子

例子一:为批量接口参数去重

批量查询接口经常需要对 ID 去重,避免重复查询数据库或缓存:

func UniqueIDs(ids []int64) []int64 {
    seen := make(map[int64]struct{}, len(ids))
    result := make([]int64, 0, len(ids))

    for _, id := range ids {
        if _, ok := seen[id]; ok {
            continue
        }
        seen[id] = struct{}{}
        result = append(result, id)
    }

    return result
}

这个例子背后就是 map 实现 Set 的思想。它比双重循环更适合大批量 ID,但会额外使用 O(n) 空间。

例子二:用 benchmark 比较去重实现

如果有人提出“用双重循环避免 map 分配更快”,不要只凭感觉争论,可以写 benchmark 验证:

func BenchmarkUniqueIDs(b *testing.B) {
    ids := make([]int64, 0, 10000)
    for i := 0; i < 10000; i++ {
        ids = append(ids, int64(i%1000))
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = UniqueIDs(ids)
    }
}

用数据判断取舍,是高级工程师写算法代码时很重要的习惯。

实战任务

完成一个小型算法工具包:

  1. Set[T comparable]
  2. Stack[T any]
  3. Queue[T any]
  4. BinarySearch
  5. TopK

要求:

  1. 每个结构都有单元测试。
  2. 测试覆盖空输入和边界条件。
  3. 至少为一个函数写 benchmark。
  4. README 中写清楚复杂度。
参考实现方向

可以按下面目录组织:

algokit/
  set.go
  stack.go
  queue.go
  search.go
  topk.go
  set_test.go
  stack_test.go
  queue_test.go
  search_test.go
  topk_test.go
  README.md

Set[T comparable] 可以用 map[T]struct{}Stack[T] 可以用 slice 尾部入栈和出栈;Queue[T] 可以先用 slice 实现,进阶版改成环形队列;BinarySearch 要求输入有序;TopK 可以先用排序实现,再用堆优化。

README 中应写明:

结构或函数 时间复杂度 空间复杂度
Set Contains O(1) average O(n)
Stack Push/Pop O(1) amortized O(n)
Queue Push/Pop O(1) amortized O(n)
BinarySearch O(log n) O(1)
TopK by sort O(n log n) O(n) 或 O(1),取决于是否复制

表格驱动测试至少覆盖空输入、单元素、重复元素、目标不存在、k 大于列表长度等情况。benchmark 可以优先写 UniqueIDsTopKBinarySearch

面试题

1. 什么时候应该手写数据结构,什么时候应该使用标准库?

参考答案

优先使用标准库,因为标准库经过充分测试,维护成本低。只有当标准库不能满足业务语义、性能要求或封装表达时,才考虑自己实现。

例如排序应优先使用 sort,堆可以用 container/heap。如果需要业务语义明确的 Set,可以用 map 封装一个轻量结构。

2. 为什么算法代码适合表格驱动测试?

参考答案

算法函数通常输入输出明确,很适合用多组测试数据覆盖不同场景。表格驱动测试可以把正常、异常、边界、空输入、重复输入等情况集中表达,结构清晰,也方便新增用例。

它还能避免只测试“快乐路径”,提高算法代码可靠性。

3. Benchmark 能解决什么问题?

参考答案

Benchmark 用于比较实现的性能,包括耗时和内存分配。它能帮助我们验证优化是否真的有效,而不是凭感觉判断。

但 benchmark 也要谨慎设计,避免编译器优化掉测试逻辑,输入数据要接近真实场景,结果要结合可读性和维护成本一起判断。

4. Go 泛型适合用在哪些数据结构上?

参考答案

泛型适合封装类型无关、逻辑一致的数据结构,比如 Set、Stack、Queue、Heap 包装等。它可以减少重复代码,同时保留类型安全。

但泛型不应滥用。如果业务结构和行为强相关,直接写具体类型可能更清晰。

5. 如何让基于 map 的 Set 并发安全?

参考答案

可以用 sync.RWMutex 保护 map,读操作加读锁,写操作加写锁。也可以使用 sync.Map,但它更适合特定读多写少或 key 稳定的场景。

另一种方式是通过 channel 把所有操作集中到一个 goroutine 中,避免共享内存。选择哪种方式要看读写比例、性能要求和代码复杂度。