用 map[string]struct{} 一次遍历保序去重:边遍历边判断存在性(!seen[v])、边追加结果,预分配切片容量,结构体需可比较或提取字段构造 key,高并发须同步,字符串须先归一化。

用 map[string]struct{} 一次遍历保序去重
Go 没有内置去重函数,但用 map[string]struct{} 配合单次遍历,就能在 O(n) 时间内完成、且天然保留原始顺序。关键不是“先塞 map 再取 key”,而是边扫边判、边追加。
常见错误是写成 if seen[v] == false ——未存在的 key 访问会自动返回零值,!seen[v] 才安全;也别用 map[string]bool,虽然语义清晰,但每个 bool 占 1 字节,百万字符串多占几 MB 内存,GC 压力会上升。
-
seen := make(map[string]struct{})必须显式声明为 struct{} 类型 - 插入只写
seen[s] = struct{}{},不关心值内容 - 判断存在性用
_, ok := seen[s],不是seen[s] == true - 结果切片预分配容量:
result := make([]string, 0, len(src)),减少扩容次数
结构体切片按字段去重时不能直接用 map[MyStruct]struct{}
结构体能当 map key 的前提是所有字段都可比较:不含 []int、map[string]int、func() 或其他不可比较类型。比如 type User { ID int; Name string } 可以;但带 slice 字段的就不行,编译直接报错 invalid map key type。
想按某个字段(如 ID)去重,就别把整个结构体塞进 map,提取字段拼接或组合成可比较 key:
- 简单场景:
key := fmt.Sprintf("%d|%s", u.ID, u.Status)——但注意浮点精度和字段顺序风险 - 更稳做法:
key := strconv.Itoa(u.ID) + "|" + u.Status - 绝对不要用
json.Marshal(u)当 key:nil slice 和空 slice 序列化结果不同,字段顺序也不稳定
高并发下 map 写入必须加锁或换 sync.Map
多个 goroutine 同时往同一个 map[string]struct{} 写,不出几秒就会触发 fatal error: concurrent map writes。这不是偶发 bug,是确定性崩溃。
选哪种同步方式取决于读写比例和 key 分布:
- 纯单 goroutine 场景:不用锁,
map最高效 - 读多写少、key 类型固定(如 string):用
sync.Map,但注意它不支持遍历全部 key,导出结果还得自己维护一份[]string - 写频繁或需强一致性:用
sync.RWMutex包裹普通 map,读用R Lock,写用Lock;若 key 量大,考虑按首字母或哈希分片,避免全局锁瓶颈 - 别在高频循环里反复
make(map[string]struct{}):局部 map 没问题,但每次都 new 会抬高 GC 频率
字符串语义去重前必须做归一化处理
原始字符串看着不同,语义可能完全一致:/api/user/123 和 /api/user/456 不该合并,但 /api/order?id=123&sort=asc 和 /api/order?sort=asc&id=123 就该视为相同。直接拿原始字符串当 key,去重就失效了。
归一化不是可选项,是必要前置步骤:
- 路径类:用正则把数字 ID 替换为占位符,如
re.ReplaceAllString("/api/order/789", "/api/order/{:id}") - 查询参数类:拆解后按 key 排序再拼接,
?a=1&b=2→?a=1&b=2,?b=2&a=1→ 同样结果 - UUID/token 类:用固定正则提取标准格式部分,丢弃前后噪声
- Unicode 注意:
"é"和"e\u0301"在 Go 中是两个不同字符串,map 不会合并,需提前 normalize
真正难的不是写去重逻辑,而是定义清楚“什么算重复”——业务语义没对齐,代码写得再快也没用。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











