Advent of Code 2020 - Day 8

By Eric Burden | December 9, 2020

In the spirit of the holidays (and programming), I’ll be posting my solutions to the Advent of Code 2020 puzzles here, at least one day after they’re posted (no spoilers!). I’ll be implementing the solutions in R because, well, that’s what I like! What I won’t be doing is posting any of the actual answers, just the reasoning behind them.

Also, as a general convention, whenever the puzzle has downloadable input, I’m saving it in a file named input.txt.

Day 8 - Handheld Halting

Find the problem description HERE.

Part One - Lame-Boy

Maybe it’s just me and my fuzzy memories of high school Computer I class, but I’m getting a strong BASIC vibe from this puzzle. Here, we get a list of ‘instructions’ for a boot sequence on a handheld gaming device, whose sole purpose, it seems, is to jump around a bit before allowing the player to access their game. Probably teaching delayed gratification, or something. Anyway, let’s hurry up and parse some instructions!

Simulation Approach

The first approach we’ll use is to simulate running the instructions sequentially and ‘profiling’ the results to see what’s happened.

library(stringr)  # For `str_extract()`
library(purrr)    # For `map()`, `map2()`

test_input <- c(
  "nop +0",
  "acc +1",
  "jmp +4",
  "acc +3",
  "jmp -3",
  "acc -99",
  "acc +1",
  "jmp -4",
  "acc +6"
)
real_input <- readLines('input.txt')

# Helpful structure, a list containing one function per instruction type
# assigned by name
funs <- list(
  nop = function(accumulator, pointer, arg) {
    list(accumulator = accumulator, pointer = pointer + 1)
  },
  acc = function(accumulator, pointer, arg) {
    list(accumulator = accumulator + arg, pointer = pointer + 1)
  },
  jmp = function(accumulator, pointer, arg) {
    list(accumulator = accumulator, pointer = pointer + arg)
  }
)

# Helper function, given a vector of lines from the input (format 'acc +6'), 
# returns # a nested list object where each list item represents a single 
# instruction and each sublist item contains a [['fun']], which is the name of 
# the instruction and an [['arg']], which is the argument for that line, 
# ex. list(list(fun = 'acc', arg = 6), list(fun = 'nop', arg = -5))
parse_instructions <- function(funs, input) {
  fun <- str_extract(input, 'nop|acc|jmp')
  arg <- as.integer(str_extract(input, '[+-]\\d+'))
  
  result <- map2(fun, arg, list)
  result <- map(result, setNames, c('fun', 'arg'))
  result
}

# Main function, given a set of instructions (produced by 
# `parse_instructions()`), runs the 'code' described and returns the result
# as a list containing the current `accumulator` value, the `pointer` to the
# current line in the instructions, a list of instruction line numbers in the
# order they ran as `run_lines`, and a flag for whether the instruction set
# was terminated by running all the instructions as `successfully_completed`.
profile_instructions <- function(instructions) {
  # These values are all in a list to make it easier to return the entire
  # package at the end
  profile <- list(
    accumulator = 0,
    pointer = 1,
    run_lines = numeric(0),
    successfully_completed = FALSE
  )
  
  # For each instruction in the list, follow that instruction and move
  # to the next instruction as appropriate
  while(profile$pointer <= length(instructions)) {
    current_line <- profile$pointer
    result <- with(
      instructions[[profile$pointer]], 
      funs[[fun]](profile$accumulator, profile$pointer, arg)
    )
    
    # If we encounter a loop, return early with the profile
    if (profile$pointer %in% profile$run_lines) { return(profile) }
    
    # Update the profile
    profile$run_lines <- c(profile$run_lines, current_line)
    profile$accumulator <- result$accumulator
    profile$pointer <- result$pointer
  }
  
  # If the instructions run to completion, mark the profile as 
  # `successfully_completed`
  profile$successfully_completed <- TRUE
  profile
}


instructions <- parse_instructions(funs, test_input)
final_profile <- profile_instructions(instructions)
answer <- final_profile$accumulator

To be transparent, this is the profile_instructions() function I ended up with after getting to part two; when solving part one I hobbled by on a function that only returned the last value of accumulator prior to exit. This is really the way I should have started, though, since that profile list really gives some helpful information back, and it provides everything we need to fix the poor little boy’s issue (which we were definitely going to have to do in part two no matter what).

Graph Approach

A bit dissatisfied with the way part two turned out (code-wise), I felt there had to be a cleaner way to get at the answers to this puzzle. Well. there is another way, but I’ll leave it up to you to judge if it’s cleaner. Using a graph, we can represent the full path of our instruction set. By assigning the amount added to the accumulator with each step to the weight of the graph edges, we can check the value just prior to reaching the point where the program loops.

library(igraph)    # For all the graph-related functions 
library(magrittr)  # For the %>% operator

instructions_to_graph <- function(instructions) {
  origins <- seq(length(instructions))        # Current instruction index
  names <- map_chr(instructions, ~ .x$name)   # Instruction name
  vals <- map_int(instructions, ~ .x$arg)     # Instruction value
  shifts <- ifelse(names == 'jmp', vals, 1)   # Amount to move by after instruction
  acc <- ifelse(names == 'acc', vals, 0)  # Amount to add to accumulator
  
  # Build a matrix where one column is the origins and the second column is the
  # represents the index of the next instruction in the sequence, determined
  # by the type and amount of the current instruction
  edge_matrix <- matrix(c(origins, origins + shifts), ncol = 2)

  # Make a graph! Converts the `edge_matrix` to a graph; assigns the 'name'
  # of the instruction to each vertex with a special name ('end') for the 
  # vertex representing the space after the last instruction, assigns
  # the 'shift' of each instruction to each vertex as a `$shift` attribute,
  # assigns the amount added to the accumulator for each instruction to 
  # each vertex as the '$acc' attribute
  graph_from_edgelist(edge_matrix) %>%
    set_vertex_attr('name', value = c(names, 'end')) %>% 
    set_vertex_attr('shift', value = c(shifts, 0)) %>% 
    set_vertex_attr('acc', value = c(acc, 0))
}

instructions <- parse_instructions(real_input)
instr_graph <- instructions_to_graph(instructions)
operation_path_vertices <- subcomponent(instr_graph, 1, mode = 'out')
answer <- sum(operation_path_vertices$acc)

The subcomponent() function takes a graph, a starting vertex (1 is the first one), and a mode. 'out' means, basically, start with the given vertex and move outwards. With these arguments, it returns a list of vertices that are reachable from the starting vertex. We then sum the values of the $acc component of each vertex returned to get the accumulator value collected from traveling that path.

Graph of the test input

Part Two - Can We Fix It? (Yes, We Can!)

Simulation Approach

We knew we would end up here. Somehow, we’ve realized that there’s only one corrupted instruction. That’s great, because fixing two corrupted instructions would be way harder (I think). For the simulation approach, we profile the running of the instruction set (just like from part one) and iterate back through the rules that were executed in reverse order of execution, flipping instructions from ’nop’ to ‘jmp’. Each time we flip an instruction, we profile the instructions again to see if that fixed it.

# Main function, iterates backwards through the `run_lines` of the 
# input profile, flipping 'nop's and 'jmp's when we come to them, checking
# the result by profiling again. If the program runs successfully, return
# the winning profile list early.
adjust_instructions <- function(fail_profile, instructions) {
  for (line in rev(fail_profile$run_lines)) {
    test_instructions <- instructions  # Copy the instructions before modifying
    current_fun <- instructions[[line]]$fun  # Get the instruction type
    
    # Flip 'jmp's to 'nop's, and vice versa
    if (current_fun == 'jmp') {
      test_instructions[[line]]$fun <- 'nop'
    } 
    if (current_fun == 'nop') {
      test_instructions[[line]]$fun <- 'jmp'
    }
    
    new_profile <- profile_instructions(test_instructions)  # Test the change
    if (new_profile$successfully_completed) { 
      print(paste0('It was ', line, '!'))  # Yells about the line that was fixed
      return(new_profile) 
    }
  }
  
  new_profile
}

instructions <- parse_instructions(funs, real_input)
fail_profile <- profile_instructions(instructions)
new_profile <- adjust_instructions(fail_profile, instructions)
answer <- new_profile$accumulator

The print() command was 100% unnecessary, but I wanted to know which instruction caused all these problems.

Graph Approach

The graph approach for part two felt a lot more complicated, but that probably had more to do with my lack of familiarity with igraph than the complexity of the problem. Actually, from a conceptual standpoint, the final logic is pretty straightforward. Note that, unlike the simulation approach, we’re not actually ‘flipping’ the ‘jmp’ to ’nop’ instructions and vice versa. Instead, we’re just adding an edge to the ‘jmp’ and ’nop’ vertices, making them act like both a ‘jmp’ and ’nop’ vertex at the same time. Neat! We could delete the existing edge if we really wanted to, but it’s unnecessary in this case.

# Helper function, adds edges from 'jmp' vertices as if they were 'nop'
# vertices and adds edges to 'nop' vertices as if they were 'jmp' vertices
flip_instruction <- function(i, instr_graph) {
  v <- V(instr_graph)[[i]]
  if (v$name == 'jmp') {  
    # Add an edge connecting to the next vertex in sequence
    mod_graph <- add_edges(instr_graph, c(i, i+1))
  }
  if (v$name == 'nop') {
    # Add an edge connection to the next vertex based on `shift` value
    mod_graph <- add_edges(instr_graph, c(i, i + v$shift))
  }
  mod_graph
}

get_path_to_end <- function(instr_graph) {
  # Get the vertex indices for the longest path starting at index 1,
  # AKA the indexes for all the vertices that can be reached from the
  # starting point
  steps <- subcomponent(instr_graph, 1, mode = 'out')  # Reachable vertices

  for (i in rev(steps)) {                # Stepping backwards
    if (instructions[[i]]$name %in% c('jmp', 'nop')) {
      # Flip the instruction at index `i` then check to see if there is a
      # `path` from the first vertex to the 'end' vertex. If there is, 
      # return the list of vertices in that path.
      test_graph <- flip_instruction(i, instr_graph)
      path <- all_simple_paths(test_graph, 1, 'end', mode = 'out')
      if (length(path) > 0) { return(path[[1]]) }
    }
  }
}

instructions <- parse_instructions(real_input)
instr_graph <- instructions_to_graph(instructions)
path_to_end <- get_path_to_end(instr_graph)
answer2 <- sum(path_to_end$acc)

By default, all_simple_paths() would return all possible direct paths between the two vectors, but since we’re changing only one edge at a time between checks, we shouldn’t end up with more than one path. Plus, the problem told us this would work, so…

Graph of the test input, after &lsquo;flipping&rsquo; the vertex

Wrap-Up

This puzzle was fun for nostalgia’s sake, if nothing else. Also, graphs! After wrapping up the simulation approach, I wasn’t completely satisfied, feeling that there had to be a more elegant way to solve this one. I’m not sure that the graph approach is more elegant, per se, but it was a lot of fun to reason about. Plus, I learned a lot about igraph. Win-win! Note: It might be possible for the graph approach, part two to simply ‘flip’ all the ‘jmp’ and ’nop’ vertices, calculate all the paths, and choose the shortest one. I say might; it definitely works on the test input, but attempting it with the real input was too much for my laptop to handle. Your mileage may vary.

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

comments powered by Disqus