Hero

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 delimiter
  • std.mem.splitAny(u8, s, "\n") → splits on any char in the set
  • std.mem.tokenize(u8, s, "\n") → collapses runs of delimiters; skips empties
  • std.mem.tokenizeScalar(u8, s, '\n') → like tokenize, but one delimiter

Examples from the repo:

  • 2018/2019 use split("\n") + manual empty-line handling.
  • 2021a uses tokenize("\n") so empties never hit parseInt.
  • 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, use tokenize/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 a std.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 learn defer _ = 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:

  1. 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));
}
  1. 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 inline test block with the AoC sample. Then wire build.zig’s b.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:

  1. 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).

  2. 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 check 2020 - x → O(n).
    • Part B: sort and fix one pointer, two-sum the rest → O(n²) but much faster constants.
  • 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.