别只会用 put:Zig HashMap 从键值查找到高频统计实战
在用户 ID 查订单、商品编号查库存、单词查出现次数这类需求中,数据通常不是按照下标访问,而是通过一个"键"找到对应的"值"。
这正是哈希表擅长的事情。
Zig 标准库没有只提供一个笼统的 HashMap,而是根据键的类型和使用场景提供了几种容器:
text
std.AutoHashMap(K, V) 自动为键计算哈希值
std.StringHashMap(V) 专门把字符串切片作为键
std.HashMap(...) 自定义哈希和相等判断
std.ArrayHashMap(...) 额外保留插入顺序
本文按 Zig 0.16.0 编写。这个版本的 AutoHashMap 和 StringHashMap 使用 init(allocator) 保存分配器,后续 put、getOrPut、deinit 等方法不再重复传入 allocator。旧教程中把 allocator 传给每次操作的写法,属于 unmanaged 形式。
一、HashMap 适合解决什么问题?
先看一个数组查找:
zig
const prices = [_]u32{ 10, 20, 30 };
const price = prices[1];
这里的访问方式是"知道下标,直接取值"。如果要通过商品编号查价格,就需要先遍历:
text
商品编号 价格
apple 10
banana 20
orange 30
每次查找都从头比较,数据越多,查找成本越高。
哈希表把键和值放在一起:
text
key value
apple 10
banana 20
orange 30
查找时,哈希表先根据 key 计算哈希值,再定位到内部位置,通常不需要遍历全部元素。平均情况下,插入、查询和删除都接近 O(1)。
这不是绝对保证。哈希冲突、扩容、哈希函数质量和数据分布都会影响实际性能;但对于普通键值查找,HashMap 通常比线性遍历更合适。
二、Zig 0.16 的基本写法
最常用的整数键映射是 AutoHashMap:
zig
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
var scores: std.AutoHashMap(u32, u32) = .init(allocator);
defer scores.deinit();
try scores.put(1001, 88);
try scores.put(1002, 92);
try scores.put(1003, 76);
if (scores.get(1002)) |score| {
std.debug.print("score = {d}\n", .{score});
}
}
逐行理解:
text
std.AutoHashMap(u32, u32)
key 是 u32
value 是 u32
.init(allocator)
创建空哈希表,不申请堆内存
put(key, value)
写入键值对,必要时扩容
get(key)
查找值,返回 ?V
deinit()
释放哈希表的底层内存
HashMap 保存底层桶、索引和键值对,初始化时接收 allocator,因此同一份容器代码可以搭配通用堆分配器、ArenaAllocator 或固定缓冲区分配器。
三、put:新增或覆盖键值
put 的行为是"有则覆盖,无则新增":
zig
var map: std.AutoHashMap([]const u8, i32) = .init(allocator);
defer map.deinit();
try map.put("status", 1);
try map.put("status", 2);
std.debug.print("{d}\n", .{map.get("status").?});
结果是 2。
因此,put 适合配置更新、缓存写入和"最后一次值生效"的场景。
如果重复键应该被视为错误,可以使用 putNoClobber:
zig
try map.putNoClobber("status", 1);
try map.putNoClobber("status", 2);
第二次调用会返回错误,因为 status 已经存在。
不需要分配的新键值写入,可以使用 putAssumeCapacity:
zig
try map.ensureTotalCapacity(100);
map.putAssumeCapacity(1, 100);
这个方法不会检查容量,也不会处理分配失败。只有在容量已经确认足够时才适合使用。
四、get、contains 和 getPtr
get:读取值的副本
zig
if (map.get("status")) |status| {
std.debug.print("status = {d}\n", .{status});
}
get 返回 Optional:
text
找到键 ?V 中有值
找不到键 null
不能直接假设键一定存在:
zig
const value = map.get("missing").?;
如果键不存在,强制解包会触发运行时错误。更稳妥的写法是 if、orelse 或错误转换:
zig
const value = map.get("missing") orelse 0;
contains:只关心键是否存在
zig
if (map.contains("status")) {
std.debug.print("status exists\n", .{});
}
contains 比"先 get 再忽略值"表达得更清楚。
getPtr:直接修改值
get 返回值的副本。需要原地修改时,使用 getPtr:
zig
if (map.getPtr("count")) |count| {
count.* += 1;
}
这个指针指向哈希表中的 value,不是临时副本。修改完成后,map 中的值也会变化。
不过,哈希表发生可能重新分配或重排的操作后,之前取得的指针可能失效:
zig
const count = map.getPtr("count");
try map.put("another-key", 10);
// count 可能已经不能继续使用
不要长期保存跨越 put、remove、ensureTotalCapacity 的内部指针。
五、字符串键:优先使用 StringHashMap
使用 \[\]const u8 作为键时,不建议直接写 AutoHashMap(\[\]const u8, V)。原因是普通自动哈希对切片类型存在歧义:到底比较切片地址,还是比较切片内容?
StringHashMap 明确按照字符串内容计算哈希并比较:
zig
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
var ages: std.StringHashMap(u8) = .init(allocator);
defer ages.deinit();
try ages.put("Alice", 18);
try ages.put("Bob", 20);
const key = "Alice";
if (ages.get(key)) |age| {
std.debug.print("{s}: {d}\n", .{ key, age });
}
}
StringHashMap 的键类型是 \[\]const u8,比较的是字节内容:
text
"abc" 和另一个位置上的 "abc"
内容相同
可以命中同一个键
但 StringHashMap 不拥有字符串键的内存。键只是保存了一份切片,原始字节必须在哈希表使用期间保持有效。
安全示例:
zig
try ages.put("Alice", 18);
字符串字面量位于程序的静态存储区,生命周期足够长。
风险示例:
zig
var buffer = try allocator.alloc(u8, 32);
defer allocator.free(buffer);
@memcpy(buffer[0..5], "Alice");
try ages.put(buffer[0..5], 18);
在 ages 还要使用时,buffer 已经被释放,键就变成了悬空切片。
如果键来自动态内存,需要让哈希表拥有这段键内存,或者保证原始缓冲区的生命周期覆盖整个哈希表。清理时还要先释放每个动态键,再调用 map.deinit(),因为 deinit 不会自动释放键和值内部的资源。
六、getOrPut:计数、缓存和懒加载的关键 API
单词计数是 getOrPut 最典型的使用场景:
zig
const entry = try counts.getOrPut(word);
if (entry.found_existing) {
entry.value_ptr.* += 1;
} else {
entry.value_ptr.* = 1;
}
AutoHashMap 和 StringHashMap 都是保存 allocator 的托管类型。Zig 0.16 的完整写法如下:
zig
var counts: std.StringHashMap(u32) = .init(allocator);
defer counts.deinit();
const entry = try counts.getOrPut(word);
if (entry.found_existing) {
entry.value_ptr.* += 1;
} else {
entry.value_ptr.* = 1;
}
如果代码使用 HashMapUnmanaged 或 AutoHashMapUnmanaged,才需要在会分配内存的操作中显式传入 allocator;本文主要讲解的是普通的托管 HashMap。
getOrPut 返回结果中最重要的字段:
text
found_existing
true 表示原键已经存在
false 表示刚刚插入新键
key_ptr
指向表中键的指针
value_ptr
指向表中值的指针
index
当前条目的内部索引,不应当当作稳定 ID
新增条目时 value 通常是 undefined,必须先初始化再读取:
zig
const entry = try map.getOrPut(key);
if (!entry.found_existing) {
entry.value_ptr.* = default_value;
}
需要新增时直接给定值,可以使用 getOrPutValue:
zig
const entry = try map.getOrPutValue(key, 0);
entry.value_ptr.* += 1;
七、删除元素:remove 和 fetchRemove
remove:只返回是否删除成功
zig
if (map.remove("Alice")) {
std.debug.print("removed\n", .{});
}
键存在时返回 true,不存在时返回 false。被删除的 value 不会返回。
fetchRemove:删除并取回键和值
zig
if (map.fetchRemove("Alice")) |removed| {
std.debug.print(
"removed {s}: {d}\n",
.{ removed.key, removed.value },
);
}
如果 value 或 key 本身还持有动态资源,fetchRemove 可以先取回条目,完成资源释放后再结束处理:
zig
if (map.fetchRemove(key)) |removed| {
allocator.free(removed.value);
}
注意:这只适用于 value 确实由 allocator 分配的情况。静态字符串、借用切片不能直接 free。
fetchPut:覆盖时取回旧值
zig
if (try map.fetchPut( "Alice", 20)) |old| {
std.debug.print("old value = {d}\n", .{old.value});
}
fetchPut 适合配置热更新和缓存替换:新值写入,同时拿到旧值,方便释放旧资源或记录变更。
八、遍历 HashMap
使用 iterator 遍历键值对:
zig
var iterator = map.iterator();
while (iterator.next()) |entry| {
std.debug.print("{s} => {d}\n", .{
entry.key_ptr.*,
entry.value_ptr.*,
});
}
键和值也可以分别遍历:
zig
var key_iterator = map.keyIterator();
while (key_iterator.next()) |key_ptr| {
std.debug.print("key = {s}\n", .{key_ptr.*});
}
var value_iterator = map.valueIterator();
while (value_iterator.next()) |value_ptr| {
value_ptr.* += 1;
}
哈希表遍历顺序不能依赖。插入、删除、扩容、哈希实现变化,都可能改变顺序。如果业务需要按照插入顺序输出,使用 ArrayHashMap,或者另外维护一个 ArrayList 保存顺序。
遍历期间不要修改 map:
text
iterator.next()
↓
取得内部指针
↓
put / remove 可能重排或重新分配
↓
旧 iterator 和旧指针可能失效
安全做法是先收集要修改的键,再在遍历结束后统一修改;或者使用 getPtr 对已有 value 做局部修改,但仍不能触发会改变布局的操作。
九、ArrayHashMap:需要稳定输出顺序时
ArrayHashMap 同样是键值映射,但底层额外维护了连续的条目数组,因此能够按插入顺序遍历:
zig
var map: std.ArrayHashMap([]const u8, u32) = .empty;
defer map.deinit();
try map.put( "first", 1);
try map.put( "second", 2);
try map.put( "third", 3);
适合这些场景:
- 配置文件需要按原顺序输出;
- JSON 字段需要保持插入顺序;
- 命令行帮助信息需要稳定排序;
- 测试结果需要固定顺序;
- 数据量不大,但遍历顺序很重要。
普通 HashMap 更偏向快速查找,ArrayHashMap 更偏向"查找 + 顺序"。两者没有绝对的优劣,取决于是否需要稳定遍历。
十、自定义键类型:AutoHashMap 不是万能的
AutoHashMap 可以处理整数、枚举、结构体等拥有明确自动哈希规则的类型:
zig
const UserKey = struct {
tenant_id: u32,
user_id: u64,
};
var users: std.AutoHashMap(UserKey, []const u8) = .init(allocator);
defer users.deinit();
try users.put(.{
.tenant_id = 10,
.user_id = 1001,
}, "Alice");
但并不是所有类型都能直接自动哈希。切片、包含指针的复杂结构体、需要特殊相等规则的类型,通常需要自定义 Context。
自定义 HashMap 的结构大致如下:
zig
const Context = struct {
pub fn hash(_: @This(), key: u32) u64 {
return std.hash.Wyhash.hash(0, std.mem.asBytes(&key));
}
pub fn eql(_: @This(), a: u32, b: u32) bool {
return a == b;
}
};
var map: std.HashMap(u32, []const u8, Context, 80) = .init(allocator);
defer map.deinit();
try map.putContext( 1, "one", .{});
哈希函数必须满足:相等的键一定产生相同哈希值。相等判断和哈希函数不一致时,数据可能已经存在却永远查不到。
十一、哈希表的内存和所有权
HashMap 会申请自己的内部内存,但不会递归释放 key 和 value 指向的外部资源:
text
map.deinit()
释放哈希表内部桶和索引
不会 free 动态字符串键
不会 free value 中的指针
不会调用结构体的 deinit 方法
例如:
zig
const Entry = struct {
name: []u8,
data: []u8,
};
var map: std.StringHashMap(Entry) = .init(allocator);
defer map.deinit();
结束前需要先处理每个 Entry 的资源:
zig
var iterator = map.iterator();
while (iterator.next()) |entry| {
allocator.free(entry.key_ptr.*);
allocator.free(entry.value_ptr.name);
allocator.free(entry.value_ptr.data);
}
然后再调用 map.deinit()。
更好的做法是把资源释放封装到结构体中:
zig
const Entry = struct {
name: []u8,
data: []u8,
fn deinit(self: Entry, allocator: std.mem.Allocator) void {
allocator.free(self.name);
allocator.free(self.data);
}
};
这样清理逻辑更不容易遗漏:
zig
var iterator = map.iterator();
while (iterator.next()) |entry| {
allocator.free(entry.key_ptr.*);
entry.value_ptr.deinit(allocator);
}
map.deinit();
十二、实战 Demo:统计文本中的单词频率
这个 Demo 完成一件非常典型的事情:读取一段文本,切分单词,使用 StringHashMap 统计每个单词出现的次数,再输出统计结果。
src/main.zig
zig
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const text =
"zig is simple and zig is fast and simple";
var counts: std.StringHashMap(u32) = .init(allocator);
defer counts.deinit();
var words = std.mem.tokenizeAny(u8, text, " ");
while (words.next()) |word| {
const entry = try counts.getOrPut( word);
if (entry.found_existing) {
entry.value_ptr.* += 1;
} else {
entry.value_ptr.* = 1;
}
}
var iterator = counts.iterator();
while (iterator.next()) |entry| {
std.debug.print("{s}: {d}\n", .{
entry.key_ptr.*,
entry.value_ptr.*,
});
}
}
运行输出的顺序可能不同:
text
zig: 2
is: 2
simple: 2
and: 3
fast: 1
这里的 word 是 text 的切片,StringHashMap 没有复制单词内容。由于 text 是函数内部的字符串字面量,生命周期足够长,直到 main 结束都有效。
如果文本来自文件读取缓冲区,情况就不同了。文件缓冲区一旦释放,map 中的键也会全部失效。需要保存独立键时,可以复制:
zig
const owned_word = try allocator.dupe(u8, word);
errdefer allocator.free(owned_word);
const entry = try counts.getOrPut( owned_word);
if (entry.found_existing) {
allocator.free(owned_word);
entry.value_ptr.* += 1;
} else {
entry.value_ptr.* = 1;
}
由于 getOrPut 比较的是字符串内容,重复单词会命中旧键。重复键的临时副本需要手动释放;最终清理时还需要释放 map 中真正保存的键:
zig
var iterator = counts.iterator();
while (iterator.next()) |entry| {
allocator.free(entry.key_ptr.*);
}
counts.deinit();
实际项目中也可以先规范化大小写、去掉标点,再放入哈希表,否则 Zig、zig、ZIG 会被当成三个不同的键。
十三、实战 Demo:简单的内存缓存
下面模拟一个按用户 ID 缓存用户名称的场景:
zig
const std = @import("std");
const User = struct {
name: []u8,
fn deinit(self: User, allocator: std.mem.Allocator) void {
allocator.free(self.name);
}
};
pub fn main() !void {
const allocator = std.heap.page_allocator;
var cache: std.AutoHashMap(u64, User) = .init(allocator);
defer {
var iterator = cache.iterator();
while (iterator.next()) |entry| {
entry.value_ptr.deinit(allocator);
}
cache.deinit();
}
const name = try allocator.dupe(u8, "Alice");
try cache.put(1001, .{ .name = name });
if (cache.getPtr(1001)) |user| {
std.debug.print("user = {s}\n", .{user.name});
}
if (cache.fetchRemove(1001)) |removed| {
removed.value.deinit(allocator);
}
}
这个例子里,map 负责保存键和值,User 负责释放 name。fetchRemove 让条目脱离 map 后再清理资源,避免删除后失去释放动态内存所需的指针。
十四、预分配、清空和容量
批量插入前可以预留容量:
zig
try map.ensureTotalCapacity(expected_count);
预分配的作用:
- 减少扩容次数;
- 降低重新哈希的次数;
- 避免批处理过程中频繁分配;
- 在使用 AssumeCapacity 方法时提供容量保证。
清空 map 有两种方式:
zig
map.clearRetainingCapacity();
map.clearAndFree();
区别如下:
text
clearRetainingCapacity()
删除所有键值对
保留底层内存
适合循环复用
clearAndFree()
删除所有键值对
释放底层内存
适合彻底结束当前批次
如果 key 或 value 内部有动态资源,清空之前必须先释放这些资源;HashMap 的 clear 和 deinit 都不会递归清理资源。
十五、HashMap、ArrayList 和 ArrayHashMap 怎么选?
text
普通数组
长度固定
按下标访问
无堆分配
ArrayList
长度动态变化
按下标访问
保持元素顺序
AutoHashMap
key-value 映射
快速查找
不保证遍历顺序
StringHashMap
字符串内容作为 key
适合配置、缓存、统计
ArrayHashMap
key-value 映射
额外保留插入顺序
简单判断:
text
通过 index 找数据 ArrayList
通过 key 找数据 HashMap
key 是字符串 StringHashMap
还要求按插入顺序遍历 ArrayHashMap
十六、常见错误
把 get 当成一定成功
get 返回 Optional,键不存在是正常情况,不是异常。使用 if 或 orelse 处理更稳妥。
重复 put 却忘记会覆盖
需要拒绝重复键时使用 putNoClobber,需要获取旧值时使用 fetchPut。
把 HashMap 的遍历顺序当成稳定顺序
哈希表的顺序不能用于排序、分页或生成固定签名。需要顺序时使用 ArrayHashMap 或额外排序。
字符串键指向已经释放的缓冲区
StringHashMap 只保存字符串切片,不复制内容,也不拥有内容。动态字符串必须明确所有权。
保存内部指针跨越结构修改
put、remove、扩容和重新哈希可能使 key_ptr、value_ptr、Entry 和 iterator 失效。
deinit 之后再次使用 map
deinit 会释放底层存储,并让 map 进入不可继续使用的状态。需要复用时使用 clearRetainingCapacity,不要调用 deinit。
把 O(1) 当成任何情况下都绝对稳定
哈希冲突、扩容和哈希计算本身都有成本。大批量数据建议预留容量,复杂键则应设计合理的哈希函数和相等判断。
总结
HashMap 的核心不是"把数据塞进一个容器",而是建立一条清晰的键值访问路径:
text
key
↓
hash(key)
↓
定位桶
↓
比较 eql
↓
得到 value
Zig 0.16 中最值得记住的使用模板是:
zig
var map: std.AutoHashMap(Key, Value) = .init(allocator);
defer map.deinit();
try map.put( key, value);
if (map.get(key)) |found| {
// 使用 found
}
高频统计使用 getOrPut,字符串键使用 StringHashMap,需要稳定遍历顺序使用 ArrayHashMap;涉及动态字符串和结构体时,必须额外处理资源所有权。只要把"键是否存在、值是否覆盖、指针是否失效、资源由谁释放"这四个问题想清楚,HashMap 就不再只是 API 集合,而会成为一套可控、可预测的键值存储工具。