DEV Community

Cover image for Using only vim to solve AdventOfCode Challenges | Episode 3
Cipherlogs
Cipherlogs

Posted on • Originally published at cipherlogs.com

Using only vim to solve AdventOfCode Challenges | Episode 3

1. Getting Started

To begin, access today's challenge and carefully read the instructions below before attempting to solve it.

New here? scenario1 and scenario2 might be unfamiliar, these sprints are designed to stretch your limits – you'll need to tackle the problem from both angles, or your solution won't fly. So, if you're content to stay within your comfort zone, this sprint isn't for you. Start with the first episode and give it a go yourself. Spend at least 15 minutes grappling with both scenarios before peeking at the solutions.

Remember, make sure that you are in sync with your team, if you don't have a team yet submit your application here https://forms.gle/bwegPDvbgEuWKDF47

Good luck!

1.1 Rules

  1. In the first scenario, refrain from creating low-level functions that involve iterating through strings to extract duplicated characters. Instead, leverage built-in Vimscript functions creatively or utilize Unix utilities. Alternatively, if achieving the desired results promptly requires a one-liner in a programming language, that approach is acceptable. However, avoid reinventing the wheel; the emphasis is on efficiency in this speed-focused scenario.

  2. In the second scenario, pay attention to the following considerations:

  • The initial aspect of the challenge involves splitting each line in half. However, the script should be flexible enough to allow for splitting into thirds, quarters, or any other specified interval. Ensure your script is capable of dynamically handling different split configurations.

  • For the first part of the challenge, when splitting each line into segments of size 'n,' retain only those chunks that match the specified size. For instance, if 'abcdefgh' is split into groups of 3, the desired result should be ['abc', 'def'].

  • The second part of the challenge focuses on joining lines in groups of 3. Extend the script's capability to accommodate future needs for joining lines in groups of 5, 7, or any other specified interval n. Ensure your script is adaptable to varying group sizes.

1.2 Sample Data

The provided sample data should be used for testing the commands. Once you feel comfortable and confident, you can apply these commands to your actual input data.

Input:

vJrwpWtwJgWrhcsFMMfFFhFp
jqHRNqRjqzjGDLGLrsFMfFZSrLrFZsSL
PmmdzqPrVvPwwTWBwg
wMqvLMZHhHMvwLHjbvcjnnSBnvTQFn
ttgJtRGJQctTZtZT
CrZsJsPPZsGzwwsLwLmpwMDw
Enter fullscreen mode Exit fullscreen mode

Expected output:

  • part1: 157

  • part2: 70

2. Solutions

This guide assumes you've already watched the accompanying walk-through YouTube video. If you haven't, please do.

2.1 The First Scenario

TL;DR

Scenario1 Part1:
  1. Define a function Char2nr to calculate character priorities.

  2. Use getline('.') to get the current line.

  3. Split the line into left and right halves.

  4. Use grep to find unique characters in both halves.

  5. Execute the command and replace the line with the output.

  6. Apply the process to all lines.

  7. Convert unique characters to priorities using :%s/.*/\=Char2nr(submatch(0))

  8. Sum up the priorities using %! paste -sd+ | bc

Scenario1 Part2:
  1. Merge three lines at a time using :g/^/.,+2j.

  2. Calculate the total score using let total += split(getline('.'))->reduce(Intersect)->Char2nr().

  3. View the final score with :echo total

2.1.1 Part One

Let's begin by addressing the obvious.

As we progress through the challenge, we will eventually need to convert characters to their corresponding priority levels.

  • Lowercase item types 'a' through 'z' have priorities ranging from 1 through 26.

  • Uppercase item types 'A' through 'Z' have priorities ranging from 27 through 52.

" 96 because -char2nr('a') + 1 == 96
" 38 because -char2nr('A') + 26 + 1 == 38

let Char2nr = {c -> char2nr(c) - (tolower(c) ==# c ? 96 : 38)}
Enter fullscreen mode Exit fullscreen mode

We use the ==# operator to perform a case-sensitive comparison, as opposed to the == operator.

Now, let's proceed with the following steps:

Initiate the recording of a macro with qq, then extract useful information that we'll need later:

let str = getline('.') | let mid = len(str)/2
Enter fullscreen mode Exit fullscreen mode

Having acquired the content of the current line and its midpoint, let's split it in half and save the left and right sides:

let [left, right] = [str[:mid+1], str[mid:]]
Enter fullscreen mode Exit fullscreen mode

To extract unique characters from both the left and right sides, we can employ grep. In this case we want to highlight the unique characters so we'll use grep -o. Constructing the command within VIM requires formatting, for a more cleaner look we'll use printf() instead of concatenation. Here's the formatted command:

let cmd = printf(".!echo '%s' | grep -o '[%s]' | sort -u | head -1", left, right)
Enter fullscreen mode Exit fullscreen mode

The ! signals to Vim that this is a shell command, and the . indicates that Vim should replace the current line with the output of our constructed command.

Now, Execute the command using :exe cmd and move to the next line and conclude the macro with q

With the macro defined, apply it to the remaining lines using :% norm @q. This process transforms all the lines into a single unique character.

The next step is to convert all unique characters to their corresponding priority and sum them up:

:%s/.*/\=Char2nr(submatch(0))/ | %! paste -sd+ | bc
Enter fullscreen mode Exit fullscreen mode

Congratulations, Part One is now solved!

Before advancing to the next part, let's review and identify potential enhancements. During the initial attempt, it's common to miss the broader perspective, especially in this scenario where we are emphasizing speed.

The command we composed to find unique characters in two chunks of strings can be encapsulated in a function:

let Intersect = {a, b -> printf("echo '%s' | grep -o '[%s]' | sort -u | head -1", a, b)->system()}
Enter fullscreen mode Exit fullscreen mode

This function now returns a string instead of mutating the actual line invoked from. Furthermore, it operates as a reducer, a concept from functional programming. Reducers should be designed to accumulate, akin to recording a macro that can be repeated.

However, there's a subtlety. If we apply our reducer to chunks of strings (2 by 2), there's a potential issue. The reducer returns strings with new lines (due to grep and sort), and merging these results might be inconsistent. Additionally, using head in this situation is discouraged. More details can be found in the exercises section.

To address this, the function must consistently return either an empty string or a string, regardless of the input. Let's remove any new lines from the output using tr -d '\n':

let Intersect = {a, b -> printf("echo '%s' | grep -o '[%s]' | sort -u | tr -d '\n'", a, b)->system()}
Enter fullscreen mode Exit fullscreen mode

Understanding the underlying mechanics is crucial; it provides more value than just learning about specific utilities.

Now we can proceed to the next part.

2.1.2 Part Two

Our current objective is to consolidate three lines at a time, extract unique characters from each merged line, and finally, display the cumulative score for all lines. This might sound like a lot.

Well, let's see what vim has to say about that:

:let total = 0
:g/^/.,+2j | let total += split(getline('.'))->reduce(Intersect)->Char2nr()
Enter fullscreen mode Exit fullscreen mode

Congrats, Part Two is solved!

These commands efficiently merge lines in groups of three and calculate the total score. The final score is stored in the 'total' variable. To view the result, simply run :echo total.

2.2 The Second Scenario

Let's begin by revisiting familiar ground. You still remember Char2nr right?

let Char2nr = {c -> char2nr(c) - (tolower(c) ==# c ? 96 : 38)}
Enter fullscreen mode Exit fullscreen mode

Now, onto the Intersect function. While we could reuse the one from the first scenario, for the sake of learning, let's explore an alternative implementation:

let Intersect = {a, b ->split(a, '\zs')->filter({_, c -> c =~# "[".b."]"})->join('')}
Enter fullscreen mode Exit fullscreen mode
  • We use '\zs' to split at the start of each character, transforming, for instance, "abc" into ['a', 'b', 'c'].

  • The filter step retains only the characters matching our regex pattern, with the c =~ 'regex', the use of # is to ensure case sensitivity.

  • Finally, we reassemble the characters into a single string using join().

With both Intersect and Char2nr in place, let's streamline the process further by creating a utility function to determine the priority level of each line:

let GetPriority = {str -> split(str)->reduce(g:Intersect)->g:Char2nr()}
Enter fullscreen mode Exit fullscreen mode

Going forward, we only need to interact with GetPriority, simplifying our code.

Now, let's approach the challenge from a different perspective, considering the additional rules introduced. Despite seeming disparate, joining lines and splitting strings boil down to manipulating delimiters.

To address this, let's design a function that adds a space delimiter to any list every n elements. For example, calling SpaceEvery(2, ['a', 'b', 'c', 'd']) should yield ['a', 'b', ' ', 'c', 'd']:

let SpaceEvery = {n, xs ->
  \ range(n, len(xs) - n, n)
  \ ->map({i, x -> x + i})
  \ ->reduce({acc, i -> insert(xs, ' ', i)}, copy(xs))
  \}
Enter fullscreen mode Exit fullscreen mode

This versatile function can now be applied to each part of the process, enhancing flexibility and maintainability.

Note: In case SpaceEvery() is expecting shorter strings, then it is better to use range(n, len(xs), n)

2.2.1 Part One

The first step is to split a line in half

let InHalf = {xs -> g:SpaceEvery(len(xs)/2, split(xs, '\zs'))->join('')}
Enter fullscreen mode Exit fullscreen mode

2.2.2 Part Two

the second part is to join lines in groups of 3

let JoinBy3 = {xs -> g:SpaceEvery(3, xs)->join(',')->split()->map("split(v:val,',')->join()")}
Enter fullscreen mode Exit fullscreen mode

2.2.3 Finale Part

Now that we have created all the necessary utilities, let’s apply them to our input file.

For the first part, we can say:

echo
      \ readfile("inputdemo")
      \ ->map("InHalf(v:val)")
      \ ->map("GetPriority(v:val)")
      \ ->reduce({a, b -> a + b})
Enter fullscreen mode Exit fullscreen mode

And for the second part:

echo
      \ same as before
      \ ->JoinBy3()
      \ same as before
      \ same as before
Enter fullscreen mode Exit fullscreen mode

Congrats, Not only did we solve the original challenge, but we also overcame the additional constraints and rules.

2.2.4 Finale Script

the final script is as follow:

let Intersect = {a, b ->
      \ split(a, '\zs')->filter({_, c -> c =~# "[".b."]"})->join('')}

let Char2nr = {c ->
      \ char2nr(c) - (tolower(c) ==# c ? 96 : 38)}

let GetPriority = {str ->
      \ split(str)->reduce(g:Intersect)->g:Char2nr()}

let SpaceEvery = {n, xs ->
      \ range(n, len(xs) - n, n)
      \ ->map({i, x -> x + i})
      \ ->reduce({acc, i -> insert(xs, ' ', i)}, copy(xs))}

let InHalf = {xs ->
      \ g:SpaceEvery(len(xs)/2, split(xs, '\zs'))->join('')}

let JoinBy3 = {xs ->
      \ g:SpaceEvery(3, xs)
      \ ->join(',')
      \ ->split()
      \ ->map("split(v:val,',')->join()")}


echo
      \ readfile("inputdemo")
      \ ->JoinBy3()
      \ ->map("GetPriority(v:val)")
      \ ->reduce({a, b -> a + b})
Enter fullscreen mode Exit fullscreen mode

4. Bonus

For a more detailed explanation of this section, please refer to the walkthrough video.

In the second scenario, we decided to reinvent the wheel. This is entirely acceptable because we are in a learning process, and my objective is to equip you with the skills necessary for the challenges that lie ahead. when we start the second season, we will delve into the development of plugins and tackle more complex tasks. It is my intention that these foundational concepts become second nature to you, akin to drinking water.

Allow me to illustrate a real-life scenario. When expediency is essential, opting for a quick-and-dirty approach is viable. Instead of employing the readfile() function, we can load a file into memory using buffers and manipulate it as if it were open(technically it is open).

Let's explore how we can use commands in a script to address a particular problem. Please note that I will demonstrate this using the original challenge, not the augmented version from the second scenario, as the latter involves additional elements.

Imagine that your script file, 'myscript.vim', is open. If you input the following lines:

badd inputfile | buff inputfile
buff myscript
Enter fullscreen mode Exit fullscreen mode

This is equivalent to :e inputfile, followed by buff myscript, swiftly transitioning between the two files. Executing this file might seem uneventful, but VIM's efficiency creates an illusion of no movement due to its rapid switching between files.

Now, what's the significance of this?

We can insert commands in between and modify our files using vim commands. Observe the following:

badd inputfile | buff inputfile
norm ggdd
buff myscript
Enter fullscreen mode Exit fullscreen mode

In this scenario, our script switches to inputfile, navigates to the first line, deletes it, and then switches back to our myscript file. Remarkably efficient, isn't it?

Now, let's apply this technique to solve part 2 of our challenge:

badd inputfile | buffinputfile
let total = 0
g/^/.,+2j | let total += split(getline('.'))->reduce(Intersect)->Char2nr()
norm u
buff myscript
echo total
Enter fullscreen mode Exit fullscreen mode

The norm u command ensures that after modifying the buffer, we undo the changes to maintain the original state of the buffer. Our primary focus is on calculating the result without altering the file.

This method of scriptwriting is invaluable, especially when you need to create scripts in this format or when hardcoding macros prove to be efficient. We'll explore examples later to highlight the efficiency of this approach.

5. Exercises

From time to time, I'll share the solutions on Twitter, however, I want you to spend some time with the exercises on your own and with your team.

  1. In the first scenario, instead of employing Char2nr to calculate priority, achieve the same result using only :%s. A helpful hint is to utilize the '\C' flag to specify that the substitution should be case-sensitive.

  2. Rather than consolidating three lines using the global command :g, explore an alternative approach using only regular expressions.

  3. When we wanted to make Intersect acts as a reducer

    3.1. why we weren't capable of using head -1

    3.2. If Intersect finds many occurrences it will return them all as a string, how can we make Intersect only returns one character while keeping the function useful if used as a reducer?

    3.3. In our case why is it okay to not use tr -d '\n'?

  4. In the second scenario, if we find multiple unique occurrences we only choose the first one, this time select the one that appears the most.

  5. Instead of SpaceEvery. Create a function that can put any delimiter for each n interval.

  6. Using buffers only, write the solution for scenario1 part1 and part2, without opening the input file and make sure that you use the same solution that we came up with. (you can either hardcode macors using let @q=... or use norm to record it.)

Top comments (0)