Recipe: Set Operations on the Cheap
The problem
You need sets of small integers and set arithmetic over them: which hours two calendars are both free, which of 64 seats are taken, which permissions two roles share. A hash set works, but it allocates, chases pointers, and turns “intersect two sets” into a loop with lookups.
When the universe of possible members is small and known (hours 0 to 23, seats 0 to 63), a bit set does the same job as one integer and some bitwise operations.
The plan
- Pick the universe size at compile time:
std.StaticBitSet(24)for hours of a day. At 24 bits the whole set is a singleu24; no allocator exists anywhere in this recipe. - Start from the
emptyconstant andsetthe members. - Combine with
intersectWithandunionWith, which return new sets, or their in-place cousinssetIntersectionandsetUnion. - Ask questions with
isSet,count, anditerator.
const std = @import("std");
const Hours = std.StaticBitSet(24);
fn setHours(set: *Hours, hours: []const u5) void {
for (hours) |h| set.set(h);
}
fn printHours(out: *std.Io.Writer, label: []const u8, set: Hours) !void {
try out.print("{s} ({d} free):", .{ label, set.count() });
var it = set.iterator(.{});
while (it.next()) |h| try out.print(" {d}", .{h});
try out.print("\n", .{});
}
pub fn main(init: std.process.Init) !void {
var buf: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &file_writer.interface;
// Two calendars, each a set of free hours in a day. 24 possible
// members, so the whole set is one u24 under the hood; every
// operation below is a bitwise instruction or two.
var ana: Hours = .empty;
var raj: Hours = .empty;
setHours(&ana, &.{ 9, 10, 11, 14, 15, 16 });
setHours(&raj, &.{ 10, 11, 13, 15, 19 });
try printHours(out, "ana", ana);
try printHours(out, "raj", raj);
// Overlap: the hours where a meeting can happen.
try printHours(out, "both free", ana.intersectWith(raj));
// Union answers the opposite question.
try printHours(out, "either free", ana.unionWith(raj));
// Single-membership checks are one bit test.
try out.print("raj free at 14: {}\n", .{raj.isSet(14)});
try out.flush();
}What changed in the API
Older material constructs these with initEmpty() and initFull(). Those
methods are gone on current master; the type now exposes empty and full
declaration constants instead, used with the .empty enum-literal-style
syntax:
var hours: std.StaticBitSet(24) = .empty;
The same pattern (.empty replacing init functions) applies across the
standard library’s containers, so it is worth internalizing once.
Why this beats a hash set here
The intersection in the snippet compiles to an and instruction on a u24.
count() is a popcount. Iteration visits set bits directly rather than
scanning buckets. And because StaticBitSet picks its representation from
the size (a single integer up to pointer width, an array of words beyond
it), std.StaticBitSet(365) for days of a year works identically, just
across several words.
The trade is that members must be small integers you can enumerate at compile time. The moment keys are strings or unbounded, you are back to hash maps.
Variations
- Runtime-sized universes:
std.DynamicBitSettakes an allocator and a length chosen at runtime. - Difference and complement:
differenceWithanswers “free for ana but not raj”;complement()flips the whole set. - First fit:
findFirstSet()returns the lowest member, useful for “earliest common hour” without iterating.