Dazbo's Advent of Code solutions, written in Python

Day 13: Knights of the Dinner Table

regexPermutations and CombinationsGraphsDefaultdictLambda FunctionsGraphs with NetworkXTyping and Type HintingCollections.abc

We’re given a list of people who will sit around a round table. We’re asked to find the optimum seating arrangement, based on *total happiness*. Each person will be sat between two other people. People gain or lose happiness points, depending on who they are sat next to.

The input looks like this:

```
Alice would gain 54 happiness units by sitting next to Bob.
Alice would lose 79 happiness units by sitting next to Carol.
Alice would lose 2 happiness units by sitting next to David.
Bob would gain 83 happiness units by sitting next to Alice.
Bob would lose 7 happiness units by sitting next to Carol.
Bob would lose 63 happiness units by sitting next to David.
Carol would lose 62 happiness units by sitting next to Alice.
Carol would gain 60 happiness units by sitting next to Bob.
Carol would gain 55 happiness units by sitting next to David.
David would gain 46 happiness units by sitting next to Alice.
David would lose 7 happiness units by sitting next to Bob.
David would gain 41 happiness units by sitting next to Carol.
```

**What is the total change in happiness for the optimal seating arrangement of the actual guest list?**

The real input data doesn’t have that many people. There’s only 8 people. And so, there are only `8! = 40320`

different ways to order these 8 people. In modern day computing, this is nothing!

We can treat the arrangement of people as an weighted directed graph, also known as a **weighted digraph**. I.e.

- Each person is a node in the graph.
- The edges have magnitude, where the magnitude is the
*happiness*that person A gets by sitting next to person B. - It is a directed graph, because the magnitude of the edge from A to B will be different from the magnitude of the edge from B to A. Or, to put it another way, A may like B, but B might hate A!

As with similar undirected graph challenges, we can use a solution something like…

- Use regex to extract the happiness scores in each direction.
- Build an adjacency list to store the happiness score for each adjacent pair.
- Determine all 40000-ish arrangements (permutations) of the 8 people.
- Calculate the sum of happiness for each arrangement.

First, let’s look at the function that creates our adjacency list, to get the happiness score for any pair of people:

```
def get_happiness_by_person(data) -> dict[str, dict[str, int]]:
""" Here we build an adjacency list. We will map each person to every other person.
Since this is a directed graph, we'll use a defaultdict(dict).
Args:
data (list): A list of happiness statements
Returns:
dict: dict[person_x][person_y: happiness]
"""
# Alice would gain 54 happiness units by sitting next to Bob.
happiness = defaultdict(dict)
happiness_pattern = re.compile(r"^(\w+) would (\w+) (\d+) happiness units by sitting next to (\w+)")
for line in data:
person_1, gain_or_lose, value, person_2 = happiness_pattern.findall(line)[0]
if gain_or_lose == "gain":
value = int(value)
else:
value = -(int(value))
happiness[person_1][person_2] = value
return happiness
```

This code:

- Uses regex to extract the names and happiness values from each statement. Nothing new here.
- Determines with the person
*gains*or*loses*happiness, and sets the value to positive or negative, accordingly. - We then use a defaultdict that sets the key to a
*person A*, and then sets the value to an empty (default)`dict`

. For each*person B*, we set add a key of*person B*and set the value to be the*happiness*score.

Having built our adjacency list, we create `set`

of all the people in our data:

```
# build up a dict of hapiness scores for each person
happiness_by_person = get_happiness_by_person(data)
# create a set of all the people
people = set(happiness_by_person.keys())
```

Now we want to determine the first person. Because everyone is sat in a circle, we can arbitrarily choose someone to be the *head of the table*. All our seating permutations will assume this starting position. By restricting the first person to always be the same person, we dramatically reduce the number of permutations we need to work through. That’s because there’s one fewer persons in our set of people to establish permutations for.

If we didnt’ fix the first position, we would have to work through `8! = 40320`

permutations. But by fixing the position of person 1, there are now only 7 remaining people to calculate permuations for. And `7! = 5040`

.

```
# we don't care where the first person sits, since it's a circle.
# So let's just make person_1 the 'head' of the table
person_1 = people.pop()
# get all permutations for remaining people around the table, as list of tuples
# We expect n! perms
perms = list(permutations(people))
```

Finally, we can determine the total happiness score for each of our permutations:

```
happiness_for_seating = {}
for perm in perms:
# this allows us to remove reverse permutations
if perm <= perm[::-1]:
perm = list(perm) # convert perm from tuple to list, to make it mutable
perm.insert(0, person_1) # such that we can insert the head of the table
# now convert back to tuple to make it hashable. Otherwise we can't use it as dict key
happiness_for_perm[tuple(perm)] = compute_happiness_for_perm(perm, happiness_by_person)
```

It works like this:

- We create a
`dict`

to map each permutation against a*total happiness score*. - We iterate through each of our permutations. Recall that with one fixed position and 7 remaining positions, we only have 5040 permutations to work through.
- We then add another efficiency: we skip
*reverse*permutations.- E.g. if we had a sequence
`ABCDEF`

, then we would ignore a sequence`FEDCBA`

. Because, for people sat around a table, these are the same. - How do we do this? Easy! We check if our current permutation is less than the reverse permutation, using the
`[::-1]`

construct to return the reversed list. We only go with the permutation that is arbitrarily the smaller one.

- E.g. if we had a sequence
- We now must add back in our
*head of the table*to our current permutation. Remember that this person was previously excluded, when the permutations were calculated. We do this by using the`insert()`

method to insert this person at the front of the`list`

. - Then we compute the total happiness for this arrangement. Here’s the code that achieves this:

```
def compute_happiness_for_seating(seating_arrangement, happiness_by_person):
happiness = 0
for i, current_person in enumerate(seating_arrangement):
if i < len(seating_arrangement) - 1:
current_next_person = seating_arrangement[i+1]
else:
current_next_person = seating_arrangement[0]
happiness += happiness_by_person[current_person][current_next_person]
happiness += happiness_by_person[current_next_person][current_person]
return happiness
```

This works by iterating over each person in our list of people, determining who the next (adjacent) person is, and retrieving the happiness score (relative to the first person) from our adjacently dictionary. Note that if we’ve reached the last person in our list, the code then sets the next person to be the first person in the list. This works, because they are sat in a circle.

Having now built up a dictionary that maps each permutation to a total score, we’re finally able to determine the permutation with the highest score.

```
optimum_happiness_seating = max(happiness_for_seating.items(), key=lambda x: x[1])[0]
print("Part 1")
print(f"Optimum happiness = {happiness_for_seating[optimum_happiness_seating]} with seating: {optimum_happiness_seating}")
```

The use of `max()`

is interesting. Here’s how it works:

- We pass in our
`dict`

of`perm:score`

as the first parameter, and using the dict’s`items()`

method, which returns all the`dict`

K:V pairs as a tuple. - We then set the key for our
`max()`

function to be a lambda function that simply returns the second value from each tuple, i.e. the happiness total.

This is how we tell `max()`

to retrieve the largest value, based on the `dict`

value, rather than based on the `dict`

key.

And that’s it!

Now we’re told we need to insert ourselves in the seating arrangement. We’re told we’re ambivalent about who we sit next to, and they’re ambivalent about us. I.e. the happiness score will be 0 in both directions, for anyone we’re sat next to.

**What is the total change in happiness for the optimal seating arrangement that actually includes yourself?**

To solve this:

- Add person 1 - the head of the table - back in to the
`set`

of`people`

. - Extend the adjacency dictionary, by adding me as a neighbour to each other person.
- Once again, remove person 1 as the head of the table.
- Add
*me*to the`set`

of`people`

.

And then we can simply repeat the same code as in Part 1.

After a bit of refactoring, the final solution looks like this:

```
"""
Author: Darren
Date: 12/02/2021
Solving https://adventofcode.com/2015/day/13
A list of people sat around the table.
Happiness scores depend on who sets next to whom. E.g.
Alice would gain 54 happiness units by sitting next to Bob.
Solution:
Use a defaultdict to store happiness scores each person. E.g.
happiness[Alice][Bob] = 54
Use a set to store all people.
Find all permutations of people around the table using itertools.permutations().
Create a dict happiness_for_perms
For each perm:
We don't want to process reverse order of perms, so check using <= vs last element
For each person around the table clockwise:
Add up the happiness of the adjacent people
Store happiness for this perm
Part 1:
Find happiness of optimal seating arrangement
Part 2:
Add myself, and assume that all happiness relationships are 0, wherever I go.
Add myself to the dict for every other person in the set.
Add me to the set.
Repeat Part 1.
"""
import os
import time
import re
from itertools import permutations
from collections import defaultdict
from operator import itemgetter
SCRIPT_DIR = os.path.dirname(__file__)
INPUT_FILE = "input/input.txt"
# INPUT_FILE = "input/sample_input.txt"
def main():
input_file = os.path.join(SCRIPT_DIR, INPUT_FILE)
with open(input_file, mode="rt") as f:
data = f.read().splitlines()
print("Part 1")
# build up a dict of hapiness scores for each person
happiness_by_person = get_happiness_by_person(data)
# create a set of all the people
people = set(happiness_by_person.keys())
# we don't care where the first person sits, since it's a circle.
# So let's just make person_1 the 'head' of the table
person_1 = people.pop()
# get all permutations for remaining people around the table, as list of tuples
# We expect n! perms
happiness_for_perm, optimum_happiness_perm = find_optimum_happiness(happiness_by_person, person_1, people)
print(f"Optimum happiness = {happiness_for_perm[optimum_happiness_perm]} with seating: {optimum_happiness_perm}")
print("\nPart 2")
# Need to add person_1 back in, so that we can add values for Me sitting next to Person_1
people.add(person_1)
add_me_to_happiness_by_person(happiness_by_person, people)
people.remove(person_1)
people.add('Me')
happiness_for_perm, optimum_happiness_perm = find_optimum_happiness(happiness_by_person, person_1, people)
print(f"Optimum happiness = {happiness_for_perm[optimum_happiness_perm]} with seating: {optimum_happiness_perm}")
def find_optimum_happiness(happiness_by_person, person_1, people):
""" Determine all permutations of seating.
Reduce number of perms by removing person_1.
Compute happiness score for each permutation.
Determine the permutation with the greatest happiness score.
Args:
happiness_by_person (dict): Happiness adjacency map
person_1 (str): Arbitrary head of the table
people (set): The people to seat around the table
Returns:
tuple: (score, optimum_seating_permutation)
"""
perms = list(permutations(people))
happiness_for_perm = {}
for perm in perms:
# this allows us to remove reverse permutations
if perm <= perm[::-1]:
perm = list(perm) # convert perm from tuple to list, to make it mutable
perm.insert(0, person_1) # such that we can insert the head of the table
# now convert back to tuple to make it hashable. Otherwise we can't use it as dict key
happiness_for_perm[tuple(perm)] = compute_happiness_for_perm(perm, happiness_by_person)
optimum_happiness_perm = max(happiness_for_perm.items(), key=lambda x: x[1])[0]
return happiness_for_perm, optimum_happiness_perm
def add_me_to_happiness_by_person(happiness_by_person: dict, people):
for person in people:
happiness_by_person[person]['Me'] = 0
happiness_by_person['Me'][person] = 0
def compute_happiness_for_perm(seating_arrangement, happiness_by_person):
happiness = 0
for i, current_person in enumerate(seating_arrangement):
if i < len(seating_arrangement) - 1:
current_next_person = seating_arrangement[i+1]
else:
current_next_person = seating_arrangement[0]
happiness += happiness_by_person[current_person][current_next_person]
happiness += happiness_by_person[current_next_person][current_person]
return happiness
def get_happiness_by_person(data) -> dict[str, dict[str, int]]:
""" Here we build an adjacency list.
We will map each person to every other person.
Since this is a directed graph, we'll use a defaultdict(dict).
Args:
data (list): A list of happiness statements
Returns:
dict: dict[person_x][person_y: happiness]
"""
# Alice would gain 54 happiness units by sitting next to Bob.
happiness = defaultdict(dict)
happiness_pattern = re.compile(r"^(\w+) would (\w+) (\d+) happiness units by sitting next to (\w+)")
for line in data:
person_1, gain_or_lose, value, person_2 = happiness_pattern.findall(line)[0]
if gain_or_lose == "gain":
value = int(value)
else:
value = -(int(value))
happiness[person_1][person_2] = value
return happiness
if __name__ == "__main__":
t1 = time.perf_counter()
main()
t2 = time.perf_counter()
print(f"Execution time: {t2 - t1:0.4f} seconds")
```

And my solution output looks like this:

```
Part 1
Optimum happiness = 664 with seating: ('Frank', 'Carol', 'Mallory', 'David', 'Alice', 'Bob', 'George', 'Eric')
Part 2
Optimum happiness = 640 with seating: ('Frank', 'Eric', 'George', 'Bob', 'Alice', 'David', 'Mallory', 'Carol', 'Me')
Execution time: 0.0438 seconds
```

NetworkX is a cool library that allows us to build a graph, and then solve problems with that graph. It can also be used to visualise our graph visually. So here, I’ve built a second solution using NetworkX.

First, let’s look at everything except visualising the graph:

```
from collections.abc import Iterable
from itertools import permutations
from pathlib import Path
import time
import re
import networkx as nx
import matplotlib.pyplot as plt
SCRIPT_DIR = Path(__file__).parent
INPUT_FILE = Path(SCRIPT_DIR, "input/input.txt")
HAPPINESS = "happiness"
SHOW_GRAPH = True
def main():
with open(INPUT_FILE, mode="rt") as f:
data = f.read().splitlines()
graph = build_graph(data)
print("\nPart 1")
people = set(graph.nodes.keys())
person_a = people.pop()
max_happiness = get_seating_with_max_happiness(graph, people, person_a)
print(f"Optimum happiness: {max_happiness}")
print("\nPart 2")
# Need to add person_a back in, so that we can add values for Me sitting next to person_a
people.add(person_a)
graph = add_me_to_happiness_by_person(graph)
people = set(graph.nodes.keys())
people.remove(person_a)
max_happiness = get_seating_with_max_happiness(graph, people, person_a)
print(f"Optimum happiness: {max_happiness}")
if SHOW_GRAPH:
draw_graph(graph, max_happiness[0])
def get_seating_with_max_happiness(graph, people, person_a):
happiness_for_perm = {}
for perm in permutations(people): # E.g. for route ABC
# Use path_weight to get the total of all the edges that make up the route
if perm <= perm[::-1]:
perm = list(perm)
perm.insert(0, person_a)
perm.append(person_a)
# Get happiness in the first direction, e.g. person A -> B
total_happiness_forward = nx.path_weight(graph, perm, weight=HAPPINESS)
# Now get happiness in the reverse direction, e.g. person B -> A
total_happiness_reverse = nx.path_weight(graph, perm[::-1], weight=HAPPINESS)
# For total happiness for this seating arrangement, we need to add forward and reverse
happiness_for_perm[tuple(perm)] = total_happiness_forward + total_happiness_reverse
max_journey = max(happiness_for_perm.items(), key=lambda x: x[1])
return max_journey
def build_graph(data: list) -> nx.DiGraph:
"""
Build graph of of all people, including happiness scores between each person.
"""
graph = nx.DiGraph()
happiness_pattern = re.compile(r"^(\w+) would (\w+) (\d+) happiness units by sitting next to (\w+)")
# Add each edge, in the form of a location pair
for line in data:
person_1, gain_or_lose, value, person_2 = happiness_pattern.findall(line)[0]
if gain_or_lose == "gain":
value = int(value)
else:
value = -(int(value))
graph.add_edge(person_1, person_2, happiness=value)
return graph
def add_me_to_happiness_by_person(graph: nx.DiGraph) -> nx.DiGraph:
""" Extend the graph by adding "Me",
with happiness weight of 0 for all pairings that include Me.
"""
people = list(graph.nodes()) # make a copy of the names
for person in people:
graph.add_edge(person, "Me", happiness=0)
graph.add_edge("Me", person, happiness=0)
return graph
if __name__ == "__main__":
t1 = time.perf_counter()
main()
t2 = time.perf_counter()
print(f"Execution time: {t2 - t1:0.4f} seconds")
```

Some notes about this code:

- I’ve imported
`Collections.abc.Iterable`

, so that I can use`Iterable`

in type hinting of`draw_graph()`

function later. - As always, to use
*NetworkX*, we need to import it, as well as`matplotlib.pyplot`

for the visualisation. - We’ve defined a constant called
`HAPPINESS`

, which we can use as the key for our*weight*attribute. Remember that we’re working with a*weighted directed graph*, so edge edge needs a weight, and we can give this weight any name we like. - Our
`build_graph()`

function parses the input data using*regex*and creates a`nx.DiGraph`

return value. The code here is almost identical to our`get_happiness_by_person()`

function from the first solution, except that with each input line, we add the values to the graph using`add_edge()`

, rather than adding the values to a`defaultdict`

. Note how we use*type hinting*to tell our linter that the expected return value should be of*type*`nx.DiGraph`

. - As before, we want a
`set`

of all the people around the table. Instead of pulling the keys from a`defaultdict`

, we can pull the`nodes`

from the graph. - As before, we
`pop()`

the first person from the`set`

, in order to define the “head of the table”. - Now we want to find the optimum seating arrangement, which we do with the
`get_seating_with_max_happiness()`

function. This works nearly identically to our previous`find_optimum_happiness()`

function, but with these differences:- We create a
`happiness_for_perm`

dictionary, which will store each permutation as a key, and the total happiness for that permutation (by adding up happiness scores in both directions) as the value. Note that we have to turn the current permutation into a`tuple`

before we use it as a key for the dictionary, because dictionaries require hashable objects as keys. - When we add our “head of the table” back at the beginning of each permutation,
we also add the same person at the end, to close the loop. Our
`networkx`

algorithm requires this. - Then, instead of our own
`compute_happiness_for_perm()`

function to add up all the happiness scores in both directions, we can instead use`nx.path_weight()`

, and pass in the current permutation as a path. - We also have to pass the permutation in reverse, because we need to add up the happiness scores in both directions.
- We add the current seating permutation to our
`happiness_for_perm`

dict. - Finally, we return the permutation with the
`max()`

happiness, just as we did before. Thus, Part 1 is solved.

- We create a
- For Part 2, we need to add
*me*to the table. This works just as it did in the previous solution, except that we add*me*as two new edges, rather than as new`dictionary`

items.

Now let’s visualise it:

```
def draw_graph(graph: nx.DiGraph, arrangement: Iterable):
""" Takes the original graph and a seating arrangement,
builds a new graph with the nodes in the correct arrangement order, and draws it.
"""
# Get the edges from only the adjacent people in our seating arrangement
edges = list(nx.utils.pairwise(arrangement))
# Get dict of {(A, B): happiness} to use as edge labels
edge_labels = {(person_1, person_2): graph[person_1][person_2][HAPPINESS]
for (person_1, person_2) in edges}
# Get dict of {(B, A): happiness}
reverse_edges = [(b, a) for a, b in edges] # to use as edge labels
reverse_edge_labels = {(person_2, person_1): graph[person_2][person_1][HAPPINESS]
for (person_1, person_2) in edges}
all_edges = edges + reverse_edges # a single list of all edges, in both directions
seating_graph = nx.DiGraph() # Create a new graph, to which we will add only relevant edges
for (person_1, person_2) in all_edges:
happiness_score = graph[person_1][person_2][HAPPINESS]
seating_graph.add_edge(person_1, person_2, happiness=happiness_score)
pos = nx.circular_layout(seating_graph) # arrange our nodes - in the right order - in a circle
# Nodes and node labels
nx.draw_networkx_nodes(seating_graph, pos, node_color="y")
nx.draw_networkx_labels(seating_graph, pos, font_family="sans-serif")
# Edges - use arc3 to curve them, otherwise we end up with a double-arrowed line
nx.draw_networkx_edges(seating_graph, pos, edgelist=edges, # forward edges
width=1, edge_color="r", connectionstyle='arc3, rad = 0.3',
min_source_margin=15, min_target_margin=15)
nx.draw_networkx_edges(seating_graph, pos, edgelist=reverse_edges, # reverse edges
width=1, edge_color="b", connectionstyle='arc3, rad = 0.3',
min_source_margin=15, min_target_margin=15)
# Edge weight labels
nx.draw_networkx_edge_labels(seating_graph, pos, edge_labels,
font_color="r", verticalalignment="top", horizontalalignment="left")
nx.draw_networkx_edge_labels(seating_graph, pos, reverse_edge_labels,
font_color="b", verticalalignment="bottom", horizontalalignment="right")
ax = plt.gca()
plt.axis("off")
plt.tight_layout()
plt.show()
```

The code is well documented, so you should be able to follow it.

Our final output looks something like this:

```
Part 1
Optimum happiness: (('Carol', 'Frank', 'Eric', 'George', 'Bob', 'Alice', 'David', 'Mallory', 'Carol'), 664)
Part 2
Optimum happiness: (('Carol', 'Mallory', 'David', 'Alice', 'Bob', 'George', 'Eric', 'Frank', 'Me', 'Carol'), 640)
Execution time: 1.2427 seconds
```

And it draws a visualisation that looks like this: