
Advent of Zig - All day 1's
⚠️ WARNING ⚠️: THIS IS A REALLY BAD SET OF SOLUTIONS TO THE ADVENT OF CODE PROBLEMS. TAKE THE LEARNINGS AND INSIGHTS, NOT THE ACTUAL CODE
I spent a few evenings solving all of Advent of Code Day-1 problems (2015→2024) in Zig. Similar puzzle pattern every year, different data shapes, new mistakes. It was perfect as a pressure cooker to learn the language, in fact, any language.
This is what stuck with me: the little footguns, the “ahhh that’s elegant” moments, and the things I’d do differently now.
If you just want to see the code (not recommended unless you read this) here is the link
The setup
The project is a simple year/day layout:
src/
aoc2015/day1a.zig … aoc2024/day1a.zig
main.zig
build.zig
build.zig.zon
I run exactly one solution per build, timed with std.time.Timer
in src/main.zig
:
pub fn main() !void {
var timer = try std.time.Timer.start();
try run(day1a24);
const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0;
std.debug.print("It took {d:.3}ms\n", .{ms});
}
It’s deliberately barebones. No CLI. No file I/O at runtime. I lean on @embedFile
to bake the input at compile time.
Tradeoff:
@embedFile("./i1.txt")
is fantastic for AoC, terrible for anything with changing inputs. For “real” utilities, read from stdin or a path and make the pipeline Unix-friendly.
Tokenizers vs splitters
Zig gives you a small but sharp set of functions for chunking text. I used all of these across years, and each has a personality:
std.mem.split(u8, s, ", ")
→ keeps empties, splits on the exact delimiterstd.mem.splitAny(u8, s, "\n")
→ splits on any char in the setstd.mem.tokenize(u8, s, "\n")
→ collapses runs of delimiters; skips emptiesstd.mem.tokenizeScalar(u8, s, '\n')
→ liketokenize
, but one delimiter
Examples from the repo:
- 2018/2019 use
split("\n")
+ manual empty-line handling. - 2021a uses
tokenize("\n")
so empties never hitparseInt
. - 2022a/b use
splitAny("\n")
and treat empty lines as separators. - 2024a tokenizes on all whitespace
" \t\r\n"
which is perfect for “two columns, messy spaces”.
Rule I follow now: If the format is “human text” where empties are signal (blank group separators), use
split
/splitAny
. If it’s “machine lines” where empties are noise, usetokenize
/tokenizeScalar
.
Error unions, options, and the “first line” problem
2017→2024 all have some flavor of “read N, compare with previous”. My first pass in 2021/day1a:
var prev: usize = 0;
var increased: usize = 0;
// …
if (curr > prev) increased += 1;
// hack: subtract one later, because prev was 0 at start
print("… {d}\n", .{increased - 1});
It works, but it’s a smell. Cleaner in Zig:
var prev: ?usize = null;
var inc: usize = 0;
while (it.next()) |line| {
const curr = try std.fmt.parseInt(usize, line, 10);
if (prev) |p| if (curr > p) inc += 1;
prev = curr;
}
No “fixups” at the end, and the intent is obvious.
Related: 2021/day1b does it.next().?
three times to seed the 3-window. It’s fine for AoC, but it will crash if the file has <3 lines. The “Zig way” is to make invalid input impossible or explicit—check and return an error.
Allocators 101
I tried a few:
-
std.heap.page_allocator
for quick throwaway allocations. Used in 2016/day1b for astd.ArrayList(Position)
to track visited coordinates. -
GeneralPurposeAllocator
(GPA) when I needed a hashmap (2018/day1b). GPA is overkill for tiny programs, but it’s a nice sandbox to learndefer _ = gpa.deinit()
patterns and catch leaks. -
ArrayListUnmanaged
(2024/day1a) to avoid owning the allocator. This is my favorite trick for AoC: pass the allocator only at append time, fewer moving parts.
If I redid 2016/day1b (find the first location visited twice), I would not use ArrayList(Position)
+ linear scan:
fn isVisitedTwice(visited: std.ArrayList(Position), pos: Position) bool {
for (visited.items) |it| if (it.x == pos.x and it.y == pos.y) return true;
return false;
}
That’s O(n²) on steps. Two better options:
- Hash the coordinate into an integer key →
AutoHashMap(i64, void)
:
inline fn key(x: i32, y: i32) i64 {
return (@as(i64, x) << 32) | @as(i64, @bitCast(y));
}
- Packed bitset if the grid is bounded (often it is). A flat
[]bool
with an origin offset is blazing fast and allocator-free.
Sorting and scoring
This one is a sweet “sum of absolute differences” problem. The repo solution:
- Parse two columns.
- Sort both (
std.mem.sort(_, asc)
). - Sum
abs(l - r)
pairwise.
That’s the mathematically optimal pairing. Things I like:
ArrayListUnmanaged
+std.sort.asc(u32)
reads clean.- No allocations after parse.
- Works at input sizes way beyond AoC.
If the range of IDs were small (say 0..100_000), a counting sort could beat comparison sort. If memory is tight or numbers are huge, the current approach is the right call.
When you see a random + 5
in the code: 😅
Yup, that file prints sum + 5
. It’s a hilarious reminder that “it compiles” is not the finish line. I left it that way for a while as my personal linter: if my tests aren’t catching this, I don’t have tests.
What I’d do now: Put the logic in a
fn solve(input: []const u8) usize
and add an inlinetest
block with the AoC sample. Then wirebuild.zig
’sb.addTest
to run them. No snapshot testing, just assert exact numbers for a couple of inputs.
Parse loops that won’t stab you later
A recurring pattern:
var it = std.mem.split(u8, input, "\n");
while (it.next()) |line| {
if (line.len == 0) continue;
const v = try std.fmt.parseInt(i64, line, 10);
sum += v;
}
Is fine, but I like a tiny helper so I can’t forget the empty-line guard:
fn linesNonEmpty(s: []const u8) std.mem.SplitIterator(u8) {
// wrap tokenizeScalar and skip empties
return std.mem.tokenizeScalar(u8, s, '\n');
}
Then all my “one number per line” puzzles collapse to:
var it = linesNonEmpty(input);
while (it.next()) |line| sum += try std.fmt.parseInt(i64, line, 10);
Micro-abstractions beat micro-bugs hehe.
2016 was a tough one (at least for me)
The 2016/day1a direction handling feels elegant:
const Direction = enum(u32) { North = 3, South = 1, East = 0, West = 2 };
'R' => dir = @enumFromInt((@intFromEnum(dir) + 1) % 4);
'L' => dir = @enumFromInt((@intFromEnum(dir) + 3) % 4);
Modulo arithmetic on the enum ordinals is a cute trick. Two tweaks I’d make:
-
Single-step movement for part b was done with a loop per step (good), but the visited check was O(n). Use a set (see above).
-
Avoid
try std.fmt.parseInt
on slices with trailing commas/spaces by trimming once:
const dist = try std.fmt.parseInt(i32, std.mem.trim(u8, instr[1..], " \t\r\n"), 10);
Less grief if inputs are messy.
2023/day1b: words + digits + overlaps
This one trips a lot of people up because oneight
should count as 1
and 8
. The repo’s approach scans every index and checks startsWith
for each word and isDigit
—that’s correct and handles overlaps:
for (line, 0..) |c, i| {
for (nums, 1..) |word, j| if (std.mem.startsWith(u8, line[i..], word)) { … }
if (std.ascii.isDigit(c)) { … }
}
If you wanted to push it:
- Build a tiny trie of number words to shave the inner loop to O(1) on average.
- Or go spicy and SIMD-search digits first, then only word-scan neighborhoods.
But honestly, this is one of those “clear > clever” places.
Alternative approaches I’d take if I started fresh
-
Consolidate common helpers into
src/helpers.zig
or similar, and import them for each puzzle. The repo currently repeats a lot of split/parse boilerplate. -
Swap linear visited checks for hash sets (2016/day1b). Or compress coordinates to a single integer.
-
Replace brute-force n²/n³ in 2020/day1 with hashing/two-pointers:
- Part A: put all numbers in a
AutoHashMap
and check2020 - x
→ O(n). - Part B: sort and fix one pointer, two-sum the rest → O(n²) but much faster constants.
- Part A: put all numbers in a
-
Cleaner first-line handling (2021/day1a) with
?usize
instead of the “subtract one” hack. -
Consistency on tokenizers: pick
tokenize
for machine lines,split
where empties carry meaning. Right now each file uses a slightly different flavor.
Nuances I like about Zig after this exercise
-
defer
for everything. Allocators, timers, visited sets. The control flow stays linear; no hidden RAII. -
Enums +
@intFromEnum
are pragmatic. The 2016 turning trick is neat but still readable. -
Error unions and options nudge you away from footguns without drowning you in ceremony.
-
No vague “magic”. You will write the loop. You will pick the allocator. You will decide if empties are meaningful.
-
Explicit. Everyone loves this about zig. No hidden memory anywhere.
Wrapping up
This “Advent of Zig” pass wasn’t about leaderboard times. It was about writing ten tiny programs that force you to touch the language’s sharp edges: parsing, errors, allocation, small data structures.
I came out with a few scars (hello + 5
), a better mental model of the stdlib’s string APIs, and a strong preference for boring, explicit code that I can profile in my head.
If you’re learning Zig and you want a real workout, do exactly this: take AoC, implement all problems in the same repo, and iterate your patterns. You’ll see your own habits—good and bad—crystal clear.
And when you catch yourself subtracting 1 to “fix” a counter… stop, breathe, and use an option. Same when randomly having to +5
😅
I still need to gain a lot of fluency with the langugage, I would like to get to that point where I just translate ideas into code, I’m far from that still, need to look up a lot of stuff and ask AI for many things.
Thanks for reading.