GUISSMO

Advent of Code 2023: Day 17

I’ve paused looking at the Advent of Code problems but I thought I should write about Day 17 since this was the problem that had made me use more than “three Jupyter notebooks” because I didn’t bother using Git for versioning.

Here’s my Day 1, Day 3, and Day 5 blog posts. I’ve done all the problems up to Day 2020 so far but I’m not posting every day.

As before, here’s the code for my leaderboard: 1598637-22d94a1d. You use this link to add it once you’re logged in. And if you wake up earlier than me (which is probably the case), you’ll probably be higher up because the way they score the leaderboard is mental.

The Graph

Surely, this was a shortest distance problem but not directly on the “most obvious” graph, which is to take the elements of the grid as the vertices.

I’ve used Dijkstra’s algorithm (and modified it) in various programming comptetitions back in college but I went to skim its Wikipedia page to get reacquainted. I didn’t read it word-for-word because I knew I couldn’t just copy-paste the algorithm. Just enough to remind me of the broad strokes.

I imagined the graph to be composed of nodes labelled with:

The edges would be straightforward to determine, albeit long to explain.

Now, we can prepare our data structure, a dictionary this time, to keep track of which of these nodes have already been visited:

def blank_dico(grid, val=None):
    ret = {}
    for i in range(len(grid)):
        for j in range(len(grid[0])):
            for d in ['up', 'down', 'left', 'right']:
                for s in range(4):
                    ret[(i, j, d, s)] = val
    return ret

For my big input, this means that I had to visit at most 141×141×4×4=318096141 \times 141 \times 4 \times 4 = 318096 nodes and do stuff that many times potentially. It’s doable even though it’s a bit scary.

The Messy First Draft

But before we get to doing stuff, we need to make some preparations. We could either go right or down in the beginning and we have the write to 33 steps at that point.

The rest is just a ceremony to prepare us for the interesting part of the code…

def solve_prob(grid):
    seed = [(0, 0, 'right', 3), (0, 0, 'down', 3)]
    vis = blank_dico(grid, False)
    dist = [[math.inf for c in l]for l in grid]

    max_i = len(grid)
    max_j = len(grid[0])

    to_visit = []

    for s in seed:
        to_visit.append((0, s))

    dist[0][0] = 0

    while len(to_visit) > 0:
        # stuff #

	return dist

…which is the inside of this while loop.

The While Loop

There’s a reason why we chose the elements of to_visit to be a tuple with the shortest distance on the first coordinate. It is because we want to continuously keep to_visit sorted.

I did that by sorting to_visit every time we restart the loop.

We then pop the first element out, figure out where we are in the grid (k=(i,j,d,s)k = (i, j, d, s)), and mark that as the shortest distance from (0,0)(0,0) to (i,j)(i, j) by going the direction dd and still having ss steps left to continue in that direction. That’s a mouthful, but this isn’t the most elegant solution at this point.

That’s for later when this ultimately stops being efficient for the Part 22 problem.

while len(to_visit) > 0:

        to_visit = sorted(to_visit)
        curr = to_visit.pop(0)
        x, k = curr

        i, j, d, s = k

        if x < dist[i][j]:
            dist[i][j] = x

        ### cont'd - A ###

While writing this, I thought about the if statement. I think this is no longer necessary given that to_visit is already sorted. (Update: Removing the if seemed to affect the answer. Homework why. I have some intuition why but I don’t want to delve into that.)

Moving on, we then skip this entry on to_visit if we have actually already visited it. After that check, we mark that key (i,j,d,s)(i,j,d,s) as visited. Classic stuff for this type of algorithm.


### cont'd - A ###

if vis[k]:
	continue

vis[k] = True

### cont'd - B ###

The For Loop Within

And now the for loop where the meat of the logic lies.

But first, a little commercial break. We define these constants (globally) to make our life easier.

dirs = {
    'up': (-1, 0),
    'down': (1, 0),
    'left': (0, -1),
    'right': (0, 1),
}

perp = {
    'up': ['left', 'right'],
    'down': ['left', 'right'],
    'left': ['up', 'down'],
    'right': ['up', 'down'],
}

The dirs dictionary converts directions to numbers. The perp dictionary tells us which directions we can go to (with 33 steps) if we turn. Surely, there would have been a high IQ way to do this than hardcoding, but we’re dealing with four things so I could argue that hardcoding and not thinking about that high IQ way would be faster anyway.

While writing this post, I started commenting on the for loop and it seems that there are several things that seem redundant. And as we know, redundancy has to be addressed. But not now. The following code works, with all its imperfections for getting an answer to part 11.


### cont'd - B ###

## We can either turn (element in perp[d]) or continue (d).
for pdir in perp[d] + [d]:
	accu = 0
	## We can continue this many steps.

	for steps in range(1, s+1):

		## Ugly special cases for when we are at the starting point.
		if (i, j) != (0, 0) and pdir == d:
			continue
		if (i == 0 and j == 0) and pdir != d:
			continue

	  ## Our new position if we follow that new direction
		ni, nj = (i + dirs[pdir][0]*steps, j + dirs[pdir][1]*steps)

		## If statement to make sure we don't go out of bounds.
		if 0 <= ni and ni < max_i and 0 <= nj and nj < max_j:
			## Accumulate the cost of moving so far.
			accu += grid[ni][nj]

			## New k.
			new_k = (ni, nj, pdir, s - steps)

			## This code works but this should probably be outside the inner loop lol.
			if pdir != d:
				new_k = (ni, nj, pdir, 3)

			to_visit.append((x + accu, new_k))

After 256256 seconds (on a laptop on battery), I get my big input answer for Part 1.

A Redemption Arc

After that very messy first draft, Part 22 gives me a chance to redeem myself because it’s different enough, but still kinda similar to Part 11.

One thing that made my Part 11 so slow was that I was sorting every time. With NN being our magic number (141×141×4×4=318096141 \times 141 \times 4 \times 4 = 318096) from above, We expect to be sorting at most NN times. That gives us an O(N2logN)O(N^2 \log N) algorithm, roughly. And for this problem, that algorithmic complexity is like Michael Jackson released back in 1987 — bad. That means we shouldn’t be needing to sort at every iteration.

And so we replace our to_visit with a priority queue. After some research, one implementation of this is heapq. Pushing into a priority queue would be equivalent to a binary search (to find its position) which would be an O(logN)O(\log N) operation and an insert operation (O(N)O(N) in Python?). So O(N)O(N) in total. Much better than O(logN)O(\log N). I’m probably overestimating things but at least we find that things are much better.

After that, we draw the rest of the owl:

MIN_STEPS = 4
MAX_STEPS = 10

def solve_prob(grid):
    seed = [(0, 0, 'right', MAX_STEPS), (0, 0, 'down', MAX_STEPS)]
    vis = blank_dico(grid, False, MAX_STEPS)
    dist = [[math.inf for c in l]for l in grid]

    max_i = len(grid)
    max_j = len(grid[0])

    to_visit = []

    for s in seed:
        heapq.heappush(to_visit, (0, s))

    dist[0][0] = 0

    while len(to_visit) > 0:

        curr = heapq.heappop(to_visit)
        x, k = curr

        i, j, d, s = k

        if x < dist[i][j] and s <= MAX_STEPS - MIN_STEPS:
            dist[i][j] = x

        if i == max_i - 1 and j == max_j - 1:
            break

        if vis[k]:
            continue

        vis[k] = True

        for pdir in perp[d]:
            accu = 0
            for steps in range(1, MAX_STEPS + 1):
                ni, nj = (i + dirs[pdir][0]*steps, j + dirs[pdir][1]*steps)
                if 0 <= ni and ni < max_i and 0 <= nj and nj < max_j:
                    accu += grid[ni][nj]
                if steps < MIN_STEPS:
                    continue
                if 0 <= ni and ni < max_i and 0 <= nj and nj < max_j:
                    new_k = (ni, nj, pdir, MAX_STEPS - steps)
                    heapq.heappush(to_visit, (x + accu, new_k))

    return dist

This majestic (compared to the previous) code took some time to do but I think it’s readable enough that we could understand what it does. Hopefully.

Back to Top | Blog RSS Feed