AoC 2024: Day 11

Main thread for Day 11 of the 2024 advent of code. Feel free to discuss the challenge and ask questions. If particular discussions become large, we can break them off into new threads.

Some Rules:

  1. Please try to keep spoilers within spoiler tags. This is done by typing [spoiler]text to be blurred[\spoiler]. If you have longer sections you can also use the [details=“Title”] tags.
  2. Have Fun

Day 11 Challenge

Templates:

Resources:

Previous days discussions

Day 1: AoC 2024: Day 1
Day 2: AoC 2024: Day 2
Day 3: AoC 2024: Day 3
Day 4: AoC 2024: Day 4
Day 5: AoC 2024: Day 5
Day 6: AoC 2024: Day 6
Day 7: AoC 2024: Day 7
Day 8: AoC 2024: Day 8
Day 9: AoC 2024: Day 9
Day 10: AoC 2024: Day 10

1 Like

Bruteforcing the second part is a bad bad idea. Had to completely rethink my approach.

Solution-1
const std = @import("std");

//const input = @embedFile("./small.inp");
const input = @embedFile("./big.inp");

fn ndigits(number: u64) u64 {
    if (number == 1) {
        return 1;
    }
    var number_cpy: u64 = number;
    var ndig: u64 = 0;
    while (number_cpy > 0) {
        ndig += 1;
        number_cpy = @divFloor(number_cpy, 10);
    }
    return ndig;
}

fn print_stones(stones: std.ArrayList(u64)) void {
    for (stones.items) |stone| {
        std.debug.print("{d} ", .{stone});
    }
    std.debug.print("\n", .{});
}

fn blink(stones: *std.ArrayList(u64)) !void {
    var i: usize = 0;
    while (i < stones.items.len) : (i += 1) {
        // rule 1:
        if (stones.items[i] == 0) {
            stones.items[i] = 1;
        } else {
            // rule 2:
            const ndig = ndigits(stones.items[i]);
            if (@mod(ndig, 2) == 0) {
                const split: u64 = std.math.pow(u64, 10, @divTrunc(ndig, 2));
                try stones.insert(i + 1, @mod(stones.items[i], split));
                stones.items[i] = @divTrunc(stones.items[i], split);
                i += 1;
            } else {
                // rule 3:
                stones.items[i] *= 2024;
            }
        }
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var stones = std.ArrayList(u64).init(allocator);
    defer _ = stones.deinit();

    var lines = std.mem.tokenizeScalar(u8, input, '\n');
    var stone_strs = std.mem.tokenizeScalar(u8, lines.next() orelse "1", ' ');
    while (stone_strs.next()) |stone_str| {
        try stones.append(try std.fmt.parseInt(u64, stone_str, 10));
    }

    for (0..25) |_| {
        //print_stones(stones);
        try blink(&stones);
    }

    std.debug.print("Number of stones: {d}\n", .{stones.items.len});
}
Solution-2
const std = @import("std");

//const input = @embedFile("./small.inp");
const input = @embedFile("./big.inp");

fn ndigits(number: u64) u64 {
    if (number == 1) {
        return 1;
    }
    var number_cpy: u64 = number;
    var ndig: u64 = 0;
    while (number_cpy > 0) {
        ndig += 1;
        number_cpy = @divFloor(number_cpy, 10);
    }
    return ndig;
}

const stone_t = struct {
    value: u64,
    count: u64,
};

fn print_stones(stones: std.ArrayList(stone_t)) void {
    for (stones.items) |stone| {
        std.debug.print("{d}x{d} ", .{ stone.count, stone.value });
    }
    std.debug.print("\n", .{});
}

fn compare_stones(_: void, lhs: stone_t, rhs: stone_t) bool {
    return lhs.value < rhs.value;
}

fn sort_stones(stones: *std.ArrayList(stone_t)) void {
    std.mem.sort(stone_t, stones.items, {}, compare_stones);
}

fn compress_stones(stones: *std.ArrayList(stone_t)) void {
    var istone = stones.items.len - 1;
    while (istone > 0) : (istone -= 1) {
        if (stones.items[istone].value == stones.items[istone - 1].value) {
            stones.items[istone - 1].count += stones.items[istone].count;
            _ = stones.orderedRemove(istone);
        }
    }
}

fn blink(stones: *std.ArrayList(stone_t)) !void {
    var i: usize = 0;
    while (i < stones.items.len) : (i += 1) {
        // rule 1:
        if (stones.items[i].value == 0) {
            stones.items[i].value = 1;
        } else {
            // rule 2:
            const ndig = ndigits(stones.items[i].value);
            if (@mod(ndig, 2) == 0) {
                const split: u64 = std.math.pow(u64, 10, @divTrunc(ndig, 2));
                try stones.insert(i + 1, stone_t{ .count = stones.items[i].count, .value = @mod(stones.items[i].value, split) });
                stones.items[i].value = @divTrunc(stones.items[i].value, split);
                i += 1;
            } else {
                // rule 3:
                stones.items[i].value *= 2024;
            }
        }
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var stones = std.ArrayList(stone_t).init(allocator);
    defer _ = stones.deinit();

    var lines = std.mem.tokenizeScalar(u8, input, '\n');
    var stone_strs = std.mem.tokenizeScalar(u8, lines.next() orelse "1", ' ');
    while (stone_strs.next()) |stone_str| {
        const value = try std.fmt.parseInt(u64, stone_str, 10);
        try stones.append(stone_t{ .count = 1, .value = value });
    }

    print_stones(stones);
    for (0..75) |iblink| {
        sort_stones(&stones);
        compress_stones(&stones);
        std.debug.print("blink {d}\n", .{iblink});
        try blink(&stones);
        //print_stones(stones);
    }

    var nstones: usize = 0;
    for (stones.items) |stone| {
        nstones += stone.count;
    }
    std.debug.print("Number of stones: {d}\n", .{nstones});
}

Just in case it is helpful to anybody: you can get the number of digits of a number in any base, by using truncate(log(number, base)) + 1. You can use any log in there, given how logarithms work.

In zig, for base 10, you can simply use:

const digits = std.math.log10(number) + 1;

If number is an integral type, zig will give back an integral type (so no truncate() needed).

1 Like

Managed to use all the pieces I’ve learnt over the previous solutions: ArrayList, AutoHashMap, anonymous structs!

My solution
const std = @import("std");

pub fn main() !void {
    // get allocator
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    const allocator = arena.allocator();
    defer _ = arena.deinit();

    // get command-line args
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    // see if filename is on command-line
    if (args.len == 2) {
        const filename = args[1];
        solveDay(allocator, filename) catch |err| {
            switch (err) {
                error.FileNotFound => {
                    std.debug.print("Error: File {s} was not found.\n", .{filename});
                    std.process.exit(1);
                },
                else => {
                    std.debug.print("Error: in processing file.\n", .{});
                    std.process.exit(1);
                },
            }
        };
    } else {
        std.debug.print("Provide a filename of input data.\n", .{});
    }
}

fn solveDay (allocator: std.mem.Allocator, filename: []const u8) !void {
    var stones = try readStones(allocator, filename);
    defer stones.deinit();
    var history = std.AutoHashMap(StoneBlink, usize).init(allocator);
    defer history.deinit();

    std.debug.print("Part 1: {d}\n", .{countBlinks(&stones, 25, &history)});
    std.debug.print("Part 2: {d}\n", .{countBlinks(&stones, 75, &history)});
}

const StoneBlink = struct {
    stone: usize,
    blinks: usize,
};

// input data is a list of stone labels, space separated
fn readStones(allocator: std.mem.Allocator, filename: []const u8) !std.ArrayList(usize) {
    const file = try std.fs.cwd().openFile(filename, .{});
    defer file.close();
    const stat = try file.stat();
    const data = try file.readToEndAlloc(allocator, stat.size);

    // setup result
    var stones = std.ArrayList(usize).init(allocator);

    var numbers = std.mem.tokenize(u8, data, " \n");
    while (numbers.next()) |number| {
        const n = try std.fmt.parseInt(usize, number, 10);
        try stones.append(n);
    }

    return stones;
}

// adds up the number of stones found by 'blinking' each stone in turn
fn countBlinks(stones: *std.ArrayList(usize), blinks: usize, history: *std.AutoHashMap(StoneBlink, usize)) usize {
    var total: usize = 0;

    for (stones.items) |stone| {
        total += numberStones(stone, blinks, history);
    }

    return total;
}

// returns a total number of stones by 'blinking' given stone 
fn numberStones(stone: usize, blinks: usize, history: *std.AutoHashMap(StoneBlink, usize)) usize {
    // count 1, as finished blinking
    if (blinks == 0) { 
        return 1;
    } 
    // check if we already know the result
    if (history.get(.{ .stone = stone, .blinks = blinks})) |count| {
        return count;
    } 
    // otherwise, this is the first time with this (stone, blinks) combination
    // so compute the value
    var result: usize = 0;
    if (stone == 0) {
        result = numberStones(1, blinks-1, history);
    } else if (hasEvenDigits(stone)) {
        const pair = splitDigits(stone);
        result = numberStones(pair.left, blinks-1, history) + numberStones(pair.right, blinks-1, history);
    } else {
        result = numberStones(stone*2024, blinks-1, history);
    }
    // and store the result
    history.put(.{ .stone = stone, .blinks = blinks}, result) catch {
        std.debug.print("Error: in adding to history.\n", .{});
        std.process.exit(1);
    };

    return result;
}

fn hasEvenDigits (num: usize) bool {
    return (std.math.log10_int(num)) % 2 == 1;
}

fn splitDigits (num: usize) struct{left: usize, right: usize} {
    const multiplier = std.math.powi(usize, 10, ((std.math.log10_int(num) + 1) / 2)) catch {
        std.debug.print("Error: in adding to splitDigits.\n", .{});
        std.process.exit(1);
    };
    return .{ .left = num / multiplier, .right = num % multiplier };
}

Nice little DP problem, is this how you use StringHashMap?

part 1 & 2
const std = @import("std");
const mem = std.mem;

input: []const u8,
allocator: mem.Allocator,

fn nbDigits(n: u64) u64 {
    if (n == 0) return 0;
    return std.math.log10(n) + 1;
}

fn p(map: *std.StringHashMap(u64), n: u64, i: u64, allocator: mem.Allocator) u64 {
    if (i == 0) return 1;

    const key = std.fmt.allocPrint(allocator, "{d}-{d}", .{ n, i }) catch unreachable;
    if (map.contains(key)) {
        return map.get(key).?;
    }
    var res: u64 = 0;
    const nbD = nbDigits(n);
    if (n == 0) {
        res = p(map, 1, i - 1, allocator);
    } else if ((nbD & 1) == 0) {
        const shift = std.math.pow(u64, 10, nbD / 2);
        res = p(map, n / shift, i - 1, allocator) + p(map, n % shift, i - 1, allocator);
    } else {
        res = p(map, n * 2024, i - 1, allocator);
    }
    map.put(key, res) catch unreachable;
    return res;
}

pub fn part1(this: *const @This()) !?i64 {
    // last character is annoying
    var it = mem.tokenizeScalar(u8, this.input[0 .. this.input.len - 1], ' ');
    var map = std.StringHashMap(u64).init(this.allocator);
    defer {
        var mit = map.iterator();
        while (mit.next()) |entry| {
            this.allocator.free(entry.key_ptr.*);
        }
        map.deinit();
    }

    var res: u64 = 0;
    const i: u64 = 25;
    while (it.next()) |n| {
        const num = std.fmt.parseInt(u64, n, 10) catch {
            continue;
        };
        res += p(&map, num, i, this.allocator);
    }
    return @intCast(res);
}

pub fn part2(this: *const @This()) !?i64 {
    // last character is annoying
    var it = mem.tokenizeScalar(u8, this.input[0 .. this.input.len - 1], ' ');
    var map = std.StringHashMap(u64).init(this.allocator);
    defer {
        var mit = map.iterator();
        while (mit.next()) |entry| {
            this.allocator.free(entry.key_ptr.*);
        }
        map.deinit();
    }

    var res: u64 = 0;
    const i: u64 = 75;
    while (it.next()) |n| {
        if (n[0] < '0' or n[0] > '9') continue;
        const num = std.fmt.parseInt(u64, n, 10) catch unreachable;
        res += p(&map, num, i, this.allocator);
    }
    return @intCast(res);
}

Nice one! Like others here, the initial approach cut it for part 1, but definitely not for part 2

const std = @import("std");
const u = @import("util.zig");

var alloc: std.mem.Allocator = undefined;

const data = @embedFile("data/day11.txt");
// const data = "125 17";

const Pile = std.AutoArrayHashMap(usize, usize);

fn put(pile: *Pile, index: usize, count: usize) !void {
    const res = try pile.getOrPut(index);
    if (res.found_existing) {
        res.value_ptr.* += count;
    } else {
        res.value_ptr.* = count;
    }
}

fn blink(pile: *Pile) !Pile {
    var new_pile = Pile.init(u.gpa);
    var it = pile.iterator();
    while (it.next()) |entry| {
        const index = entry.key_ptr.*;
        const count = entry.value_ptr.*;
        if (index == 0) {
            try put(&new_pile, 1, count);
        } else {
            const len = if (index == 0) 1 else std.math.log10_int(index) + 1;
            if (len % 2 == 0) {
                // split in two piles
                const pivot = std.math.powi(usize, 10, len / 2) catch unreachable;
                const left: usize = index / pivot;
                const right: usize = index % pivot;
                try put(&new_pile, left, count);
                try put(&new_pile, right, count);
            } else {
                // multiply by 2024
                try put(&new_pile, index * 2024, count);
            }
        }
    }
    return new_pile;
}

fn print(pile: Pile) void {
    var it = pile.iterator();
    while (it.next()) |bucket| {
        u.print("{}={} ", .{ bucket.key_ptr.*, bucket.value_ptr.* });
    }
    u.print("\n", .{});
}

fn count_stones(pile: Pile) void {
    var it = pile.iterator();
    var sum: usize = 0;
    while (it.next()) |bucket| {
        sum += bucket.value_ptr.*;
    }
    u.print("\ncount: {}\n", .{sum});
}

fn part2() !void {
    // parse
    var pile = Pile.init(u.gpa);
    var it = std.mem.tokenizeAny(u8, data, " \n");
    while (it.next()) |str| {
        const index = try u.parseInt(usize, str, 0);
        try put(&pile, index, 1);
    }

    for (0..75) |i| {
        const new_pile = try blink(&pile);
        // print(pile);
        pile.deinit();
        pile = new_pile;
        if (i == 24) count_stones(pile);
    }
    count_stones(pile);
}

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(u.gpa);
    arena.deinit();
    alloc = arena.allocator();
    try part2();
}

ah, this is much more clever than what i did:

not really a spoiler, but

i noticed that std.math.maxInt(u64) has only 20 digits, so i just converted to a string and back using std.fmt.bufPrint and std.fmt.parseInt on a stack-allocated buffer xD

Got too attached to my recursive solution to part 1 and tried to make it work with memoization and other stuff, but that wasn’t cutting it; so it took me a while to take a step back and just throw a hash map at the problem.

https://zigbin.io/987d48

I’ve got a random question about zig practices if anyone has the time to check this code, but I’ve separated the deinit()'s on the hash maps I used for this problem. I’m wondering if this is considered poor practice?

part 1 and 2
const std = @import("std");
const print = std.debug.print;
const parseInt = std.fmt.parseInt;

fn setOrInc(key: std.AutoHashMap(u64, u64).GetOrPutResult, inc: u64) void {
    if (key.found_existing) {
        key.value_ptr.* += inc;
    } else {
        key.value_ptr.* = inc;
    }
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const input_file = @embedFile("input.txt");

    // setup buckets
    var buckets = std.AutoHashMap(u64, u64).init(allocator);
    defer buckets.deinit();

    // load grid
    var it = std.mem.tokenizeScalar(u8, input_file, ' ');
    while (it.next()) |line| {
        const stone = try std.fmt.parseInt(u64, line, 10);
        const key = try buckets.getOrPut(stone);
        setOrInc(key, 1);
    }

    const number_of_blinks: usize = 75;

    for (0..number_of_blinks) |_| {
        var key_it = buckets.keyIterator();
        var next_buckets = std.AutoHashMap(u64, u64).init(allocator);
        while (key_it.next()) |key| {
            const stone = key.*;
            const multiplier = buckets.get(stone).?;

            if (stone == 0) {
                const next_key = try next_buckets.getOrPut(1);
                setOrInc(next_key, multiplier);
                continue;
            }

            var buffer: [40]u8 = undefined; // Buffer large enough to hold the a u64
            const stone_str = std.fmt.bufPrint(&buffer, "{d}", .{stone}) catch unreachable;
            if (stone_str.len % 2 == 0) {
                const left_stone = try parseInt(u64, stone_str[0 .. stone_str.len / 2], 10);
                const right_stone = try parseInt(u64, stone_str[stone_str.len / 2 ..], 10);

                const next_left_key = try next_buckets.getOrPut(left_stone);
                setOrInc(next_left_key, multiplier);

                const next_right_key = try next_buckets.getOrPut(right_stone);
                setOrInc(next_right_key, multiplier);
                continue;
            }

            const next_key = try next_buckets.getOrPut(stone * 2024);
            setOrInc(next_key, multiplier);
        }
      buckets.deinit();
      buckets = next_buckets;
    }

    var value_it = buckets.valueIterator();
    var result: u64 = 0;

    while (value_it.next()) |count| {
        result += count.*;
    }
    // part 1 - 183248
    // part 2 - 218811774248729
    print("{d}\n", .{result});
}

e.g. I’m clearing the hashmap allocated for storing a iteration at the end of a loop with deinit(), but then assigning the new hashmap to the current hashmap variable.

        a.deinit();
        a = b;

seems fine to me. the point is you want to make sure that the deinit() is always called when it needs to be. so in your code, the only thing i would want to see that isn’t there is this:

// your code
var next_buckets = std.AutoHashMap(u64, u64).init(allocator);
// new
errdefer next_buckets.deinit();
1 Like

This one runs in 2.5ms - total time for both parts https://zigbin.io/01adc4. i eliminated most allocations and use custom log/pow methods.

$ zig build-exe 11.zig -OReleaseFast -femit-bin=/tmp/aoc && poop -d 2000 '/tmp/aoc 11.txt'
Benchmark 1 (789 runs): /tmp/aoc 11.txt
  measurement          mean ± σ            min … max           outliers
  wall_time          2.50ms ± 96.0us    2.30ms … 3.13ms          9 ( 1%)

2.1ms. That was quick and satisfying. I had a feeling that the second part would just crank up the iterations. The way it was formulated made us think that order was important, and that maybe a tree or some complicated memoization would be necessary. Happy that I did not fell for it (I almost did). I don’t think custom log/pow would make a big difference, but did not try.

https://zigbin.io/3afe8b

Hi everyone!

I’ve been working on optimizing my solution (currently, part 2 takes about 1ms, but that’s still too slow for my liking! :stuck_out_tongue: ). However, I’ve run into a problem: I’m unable to build the following code in ReleaseFast mode. It seems like ensureTotalCapacity isn’t working as expected.

Any ideas or suggestions would be greatly appreciated!

const std = @import("std");

pub fn main() !void {
    var arenaAllocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arenaAllocator.deinit();
    var allocator = arenaAllocator.allocator();
    const fileContent = @embedFile("input.txt");

    var timer = try std.time.Timer.start();
    const part1 = try solvePart1(fileContent, &allocator);
    const part1Time = timer.lap() / std.time.ns_per_us;
    const part2 = try solvePart2(fileContent, &allocator);
    const part2Time = timer.lap() / std.time.ns_per_us;

    std.debug.print("Part1: {d}\nPart2: {d}\nTime1: {d}us\nTime2: {d}us\n", .{ part1, part2, part1Time, part2Time });
}

const Part = enum { Part1, Part2 };

inline fn digits(number: usize) usize {
    return std.math.log10(number) + 1;
}

// return index of an already seen number, otherwise process it next blink
inline fn indexOf(number: usize, indices: *std.AutoHashMap(usize, usize), todo: *std.ArrayList(usize)) !usize {
    const size = indices.*.count();

    const entry = try indices.*.getOrPut(number);
    if (entry.found_existing) {
        return entry.value_ptr.*;
    }
    entry.value_ptr.* = size;

    todo.*.appendAssumeCapacity(number);
    return size;
}

fn solve(comptime part: Part, input: []const u8, allocator: *std.mem.Allocator) !usize {
    var result: usize = 0;
    var stones = try std.ArrayList([2]?usize).initCapacity(allocator.*, 5000);
    var indices = std.AutoHashMap(usize, usize).init(allocator.*);
    // whould make the code more efficient, but cannot get it compiled in ReleaseMode
    // try indices.ensureTotalCapacity(5000);
    var newStones = try std.ArrayList(usize).initCapacity(allocator.*, 8);
    var stonesAmount = try std.ArrayList(usize).initCapacity(allocator.*, 8);

    defer stones.deinit();
    defer indices.deinit();
    defer newStones.deinit();
    defer stonesAmount.deinit();

    var numberIndex: usize = 0;
    var numberIterator = std.mem.tokenizeScalar(u8, input, ' ');
    while (numberIterator.next()) |numberString| : (numberIndex += 1) {
        const number = try std.fmt.parseInt(usize, numberString, 10);
        try indices.put(number, numberIndex);
        newStones.appendAssumeCapacity(number);
        stonesAmount.appendAssumeCapacity(1);
    }

    const iterations = switch (part) {
        .Part1 => 25,
        .Part2 => 75,
    };

    for (0..iterations) |_| {
        const numbers = newStones;
        newStones = try std.ArrayList(usize).initCapacity(allocator.*, 200);

        for (numbers.items) |number| {
            if (number == 0) {
                const toAdd = [2]?usize{ try indexOf(1, &indices, &newStones), undefined };
                stones.appendAssumeCapacity(toAdd);
                continue;
            }

            const digitCount = digits(number);
            if (digitCount % 2 == 0) {
                const left = @divFloor(number, std.math.pow(usize, 10, digitCount / 2));
                const right = @mod(number, std.math.pow(usize, 10, digitCount / 2));
                const toAdd = [2]?usize{ try indexOf(left, &indices, &newStones), try indexOf(right, &indices, &newStones) };
                stones.appendAssumeCapacity(toAdd);
                continue;
            }

            const toAdd = [2]?usize{ try indexOf(number * 2024, &indices, &newStones), undefined };
            stones.appendAssumeCapacity(toAdd);
        }

        const indicesSize = indices.count();
        var next = try std.ArrayList(usize).initCapacity(allocator.*, indicesSize);
        for (0..indicesSize) |_| {
            next.appendAssumeCapacity(0);
        }

        for (stones.items, 0..) |stone, stoneIndex| {
            const amount = stonesAmount.items[stoneIndex];
            next.items[stone[0].?] += amount;
            if (stone[1]) |second| {
                next.items[second] += amount;
            }
        }

        stonesAmount = next;
    }

    for (stonesAmount.items) |item| {
        result += item;
    }

    return result;
}

fn solvePart1(input: []const u8, allocator: *std.mem.Allocator) !usize {
    return solve(Part.Part1, input, allocator);
}

fn solvePart2(input: []const u8, allocator: *std.mem.Allocator) !usize {
    return solve(Part.Part2, input, allocator);
}

test "test-input" {
    // yeah, yeah.. I know
    var arenaAllocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arenaAllocator.deinit();
    var allocator = arenaAllocator.allocator();
    const fileContentTest = @embedFile("test.txt");

    const part1 = try solvePart1(fileContentTest, &allocator);
    const part2 = try solvePart2(fileContentTest, &allocator);

    try std.testing.expectEqual(part1, 55312);
    try std.testing.expectEqual(part2, 65601038650482);
}

What helped for me was flattening the indices hash map into a plain lookup table (and also pre-computing it early). Pre-compute takes ~0.2 ms, and then part 2 is another 0.2ms then (~0.5ms total).