别只会用 put:Zig HashMap 从键值查找到高频统计实战

别只会用 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 集合,而会成为一套可控、可预测的键值存储工具。

相关推荐
ttwuai36 分钟前
Go 后台清空操作日志失败,权限和无 WHERE 删除怎么排查?
开发语言·后端·golang
choumou_M37 分钟前
SpringBoot_7:用户资源的上传与修改
java·spring boot·后端
user_admin_god1 小时前
一体化数据归集接口详细设计说明
java·大数据·spring boot·后端·spring
明月_清风1 小时前
十大经典排序算法 Go 实现全解:从入门到面试通关
后端·算法·排序算法
leeyi2 小时前
把规则搬回家:三个 BC 的贫血→充血重构实录(第103篇)
后端
kakawzw2 小时前
Netty源码笔记
java·服务器·后端
明月_清风2 小时前
算法时间复杂度:给小白的一堂"算快慢"课
后端·算法
Nturmoils2 小时前
内网穿透原来这么简单:Natapp 从注册到公网访问完整教程
后端
寻求出路的程序媛3 小时前
分布式 & 高性能 & 高可用 体系、学习重点、面试点
分布式·后端·面试·性能优化