Categories
Competitive Programming Computer Science Programming

Dynamic Programming is not Black Magic

This year’s Advent of Code has been brutal (compare the stats of 2023 with that of 2022, especially day 1 part 1 vs. day 1 part 2). It included a problem to solve with dynamic programming as soon as day 12, which discouraged some people I know. This specific problem was particularly gnarly for Advent of Code, with multiple special cases to take into account, making it basically intractable if you are not already familiar with dynamic programming.

However, dynamic programming itself is mostly natural when you understand what it does. And many common algorithms are actually just the application of dynamic programming to specific problems, including omnipresent path-finding algorithms such as Dijkstra’s algorithm.

This motivated me to write a gentler introduction and a detailed explanation of solving Day 12.

Let me start with a rant. You can skip to the following section if you want to get to the meat of the article.

The Rant

Software engineering is terrible at naming things.

Now, let’s take a look at “dynamic programming”. What can we learn from the name? “Programming” must refer to a style of programming, such as “functional programming”, or maybe “test-driven development”. Then, “dynamic” could mean:

Guess what. It means nothing of that, and it has nothing to do with being “dynamic”. It is an idea that you can use to design an algorithm, so there is a link to “programming”; I will grant it that.

Edit: There is a reason why it is named this way. When you look at the historical meaning of “programming”, the expression made sense. See niconiconi’s comment.

So, what is it?

Basic Caching

Let’s say we want to solve a problem by splitting it in smaller similar problems. Basically, a recursive function. Often, we end-up having to solve the same smaller problems many times.

The typical example is Fibonacci, where you want to evaluate f(n), which is defined as f(n - 1) + f(n - 2). If we implement it naively, we will end up evaluating f(1) many times:

Call tree for evaluating f(6), the 6-th Fibonacci number. To evaluate, f(6), we need to evaluate both f(5) and f(4). To evaluate f(5), we will need f(4) and f(3). Already, we see that we are going to need f(4) in two places. If we go further, we see that we will need f(1) 8 eight times, which happens to be f(6).

In fact, in this case, since only f(1) adds anything to the overall result, the number of times we will need f(1) is equal to f(n). And f(n) grows very fast as n grows.

Of course, we can avoid doing this. We can just cache the results (or memoize f, in terrible academic vernacular).

In the example, once we have evaluated f(4) once, there is no need to evaluate it again, saving 3 evaluations of f(1). By doing the same for f(3) and f(2), we get down to 2 evaluations of f(1). In total, f(…) is evaluated 7 times (f(0), f(1), f(2), f(3), f(4), f(5), f(6)), which is just f(n) + 1.

This is theoretically (asymptotically) optimal. But we can look at this in a different way.

Optimized Caching

With memoization, we keep the recursion: “to solve f(6), I need f(5), which will itself need f(4) […] and f(3) […], and f(4), which will itself need f(3) […] and f(2) […].”. Basically, we figure out what we need just when we need them.

Instead, we can make the simple observation that we will need f(0) and f(1) for all other evaluations of f(…). Once we have them, we can evaluate f(2), which will need for all other evaluations of f(…).

You can think of it as plucking the leaves (the nodes without descendants) from the call tree we saw before, and repeat until there are no more nodes. In other words, perform a topological sort.

With the example, if we have some array F where we can store our partial results:

  • F[0] = f(0) = 0
  • F[1] = f(1) = 1
  • F[2] = f(2) = f(1) + f(0) = F[1] + F[0] = 1 + 0 = 1
  • F[3] = f(3) = f(2) + f(1) = F[2] + F[1] = 1 + 1 = 2
  • F[4] = f(4) = f(3) + f(2) = F[3] + F[2] = 2 + 1 = 3
  • F[5] = f(5) = f(4) + f(3) = F[4] + F[3] = 3 + 2 = 5
  • F[6] = f(6) = f(5) + f(4) = F[5] + F[4] = 5 + 3 = 8

With this approach, we do not have any recursive call anymore. And that is dynamic programming.

It also forces us to think clearly about what information we will be storing. In fact, in the case of Fibonacci we can notice that we only need the two last previous values. In other words:

  • F[0] = f(0) = 0
  • F[1] = f(1) = 1
  • F[2] = f(2) = previous + previous_previous = 1 + 0 = 1
  • F[3] = f(3) = previous + previous_previous = 1 + 1 = 2
  • F[4] = f(4) = previous + previous_previous = 2 + 1 = 3
  • F[5] = f(5) = previous + previous_previous = 3 + 2 = 5
  • F[6] = f(6) = previous + previous_previous = 5 + 3 = 8

So, we can discard other values and just keep two of them. Doing this in Python, we get:

def fibo(n):
    if n == 0:
        return 0
    previous_previous = 0
    previous = 1
    for _ in range(n - 1):
        current = previous_previous + previous
        (previous, previous_previous) = (current, previous)
    return previous

I like that this gives us a natural and systematic progression from the mathematical definition of the Fibonacci function, to the iterative implementation (not the optimal one, though).

Now, Fibonacci is more of a toy example. Let’s have a look at

Edit Distance

The edit distance between two strings is the smallest number of edits needed to transform one string into the other one.

There are actually several versions, depending on what you count as an “edit”. For instance, if you only allow replacing a character by another, you get Hamming distance; evaluating the Hamming distance between two strings is algorithmically very simple.

Things become more interesting if you allow insertion and deletion of characters as well. This is the Levenstein distance. Considering this title of the present article, this is of course something that can be solved efficiently using ✨ dynamic programming ✨.

To do that, we’ll need to find how we can derive a full solution from solutions to smaller-problems. Let’s say we have two strings: A and B. We’ll note d(X, Y) the edit distance between strings X and Y, and we’ll note x the length of string X. We need to formulate d(A, B) from any combination of d(X, Y) where X is a substring of A and Y a substring of B1.

We’re going to look at a single character. We’ll use the last one. The first one would work just as well but using a middle one would not be as convenient. So, let’s look at A[a – 1] and B[b – 1] (using zero-indexing). We have four cases:

  • A[a - 1] == B[b - 1], then we can ignore that character and look at the rest, so d(A, B) = d(A[0..a - 1], B[0..b - 1])
  • A[a - 1] != B[b - 1], then we could apply any of the three rules. Since we want the smallest number of edits, we’ll need to select the smallest value given by applying each rule:
    • substitute the last character of A by that of B, in which case d(A, B) = d(A[0..a - 1], B[0..b - 1]) + 1
    • delete the last character of A, in which case d(A, B) = d(A[0..a - 1], B) + 1
    • insert the last character of B, in which case d(A, B) = d(A, B[0..b - 1]) + 1
  • A is actually empty (a = 0), then we need to insert all characters from B2, so d(A, B) = b
  • B is actually empty (b = 0), then we need to delete all characters from A, so d(A, B) = a

By translating this directly to Python, we get:

def levenstein(A: str, B: str) -> int:
    a = len(A)
    b = len(B)
    if a == 0:
        return b
    elif b == 0:
        return a
    elif A[a - 1] == B[b - 1]:
        return levenstein(A[:a - 1], B[:b - 1])
    else:
        return min([
            levenstein(A[:a - 1], B[:b - 1]) + 1,
            levenstein(A[:a - 1], B) + 1,
            levenstein(A, B[:b - 1]) + 1,
        ])


assert levenstein("", "puppy") == 5
assert levenstein("kitten", "sitting") == 3
assert levenstein("uninformed", "uniformed") == 1
# way too slow!
# assert levenstein("pneumonoultramicroscopicsilicovolcanoconiosis", "sisoinoconaclovociliscipocsorcimartluonomuenp") == 36

As hinted by the last test, this version becomes very slow when comparing long strings with lots of differences. In Fibonnacci, we were doubling the number of instances for each level in the call tree; here, we are tripling it!

In Python, we can easily apply memoization:

from functools import cache

@cache
def levenstein(A: str, B: str) -> int:
    a = len(A)
    b = len(B)
    if a == 0:
        return b
    elif b == 0:
        return a
    elif A[a - 1] == B[b - 1]:
        return levenstein(A[:a - 1], B[:b - 1])
    else:
        return min([
            levenstein(A[:a - 1], B[:b - 1]) + 1,
            levenstein(A[:a - 1], B) + 1,
            levenstein(A, B[:b - 1]) + 1,
        ])


assert levenstein("", "puppy") == 5
assert levenstein("kitten", "sitting") == 3
assert levenstein("uninformed", "uniformed") == 1
# instantaneous!
assert levenstein("pneumonoultramicroscopicsilicovolcanoconiosis", "sisoinoconaclovociliscipocsorcimartluonomuenp") == 36

Now, there is something that makes the code nicer, and more performant, but it is not technically necessary. The trick is that we do not actually need to create new strings in our recursive functions. We can just pass arounds the lengths of the substrings, and always refer to the original strings A and B. Then, our code becomes:

from functools import cache

def levenstein(A: str, B: str) -> int:
    @cache
    def aux(a: int, b: int) -> int:
        if a == 0:
            return b
        elif b == 0:
            return a
        elif A[a - 1] == B[b - 1]:
            return aux(a - 1, b - 1)
        else:
            return min([
                aux(a - 1, b - 1) + 1,
                aux(a - 1, b) + 1,
                aux(a, b - 1) + 1,
            ])
    return aux(len(A), len(B))


assert levenstein("", "puppy") == 5
assert levenstein("kitten", "sitting") == 3
assert levenstein("uninformed", "uniformed") == 1
# instantaneous!
assert levenstein("pneumonoultramicroscopicsilicovolcanoconiosis", "sisoinoconaclovociliscipocsorcimartluonomuenp") == 36

The next step is to build the cache ourselves:

def levenstein(A: str, B: str) -> int:
    # cache[a][b] = levenstein(A[:a], B[:b])
    # note the + 1 so that we can actually do cache[len(A)][len(B)]
    # the list comprehension ensures we create independent rows, not references to the same one
    cache = [[None] * (len(B) + 1) for _ in range(len(A) + 1)]
    def aux(a: int, b: int) -> int:
        if cache[a][b] == None:
            if a == 0:
                cache[a][b] = b
            elif b == 0:
                cache[a][b] = a
            elif A[a - 1] == B[b - 1]:
                cache[a][b] = aux(a - 1, b - 1)
            else:
                cache[a][b] = min([
                    aux(a - 1, b - 1) + 1,
                    aux(a - 1, b) + 1,
                    aux(a, b - 1) + 1,
                ])
        return cache[a][b]
    return aux(len(A), len(B))


assert levenstein("", "puppy") == 5
assert levenstein("kitten", "sitting") == 3
assert levenstein("uninformed", "uniformed") == 1
# instantaneous!
assert levenstein("pneumonoultramicroscopicsilicovolcanoconiosis", "sisoinoconaclovociliscipocsorcimartluonomuenp") == 36

The last thing we need to do is to replace the recursion with iterations. The important thing is to make sure we do that in the right order3:

def levenstein(A: str, B: str) -> int:
    # cache[a][b] = levenstein(A[:a], B[:b])
    # note the + 1 so that we can actually do cache[len(A)][len(B)]
    # the list comprehension ensures we create independent rows, not references to the same one
    cache = [[None] * (len(B) + 1) for _ in range(len(A) + 1)]
    for a in range(0, len(A) + 1):
        for b in range(0, len(B) + 1):
            if a == 0:
                cache[a][b] = b
            elif b == 0:
                cache[a][b] = a
            elif A[a - 1] == B[b - 1]:
                # since we are at row a, we have already filled in row a - 1
                cache[a][b] = cache[a - 1][b - 1]
            else:
                cache[a][b] = min([
                    # since we are at row a, we have already filled in row a - 1
                    cache[a - 1][b - 1] + 1,
                    # since we are at row a, we have already filled in row a - 1
                    cache[a - 1][b] + 1,
                    # since we are at column b, we have already filled column b - 1
                    cache[a][b - 1] + 1,
                ])
    return cache[len(A)][len(B)]


assert levenstein("", "puppy") == 5
assert levenstein("kitten", "sitting") == 3
assert levenstein("uninformed", "uniformed") == 1
# instantaneous!
assert levenstein("pneumonoultramicroscopicsilicovolcanoconiosis", "sisoinoconaclovociliscipocsorcimartluonomuenp") == 36

Now, if you really want to grok dynamic programming, I invite you to try it yourself on the following problems, preferrably in this order:

  1. longest common subsequence (not to be confused with longest common substring, but you can do that one too with dynamic programming)
  2. line wrap
  3. subset sum
  4. partition
  5. knapsack

Once you are comfortable with dynamic programming, Day 12 should become much less daunting!

Advent of Code, Day 12

In the Advent of Code of December 12th, 2023, you have to solve 1D nonograms. Rather than rephrasing the problem, I will let you read the official description.

.??..??...?##. 1,1,3

This can be solved by brute-force. The proper technique for that is backtracking, another terrible name. But the asymptotic complexity is exponential (for n question marks, we have to evaluate 2n potential solutions). Let’s see how it goes with this example:

  • .??..??...?##. 1,1,3 the first question mark could be either a . or a #; in the second case, we “consume” the first group of size 1, and the second question mark has to be a .
    1. ..?..??...?##. 1,1,3 the next question mark could be either a . or a #; in the second case, we “consume” the first group of size 1, and the next character has to be a ., which is the case
      1. .....??...?##. 1,1,3 the backtracking algorithm will continue to explore the 8 cases, but none of them is a valid solution
      2. ..#..??...?##. (1),1,3
        • and so on…
    2. .#...??...?##. (1),1,3
      • and so on…

There are 32 candidates, which would make 63 list items. I’ll spare you that. Instead, I want to draw your attention to the items 2.2 and 2:

  • 2.2. ..#..??...?##. (1),1,3
  • 2. .#...??...?##. (1),1,3

They are extremely similar. In fact, if we discard the part that has already been accounted for, they are more like:

  • 2.2. .??...?##. 1,3
  • 2. ..??...?##. 1,3

There is an extra . on the second one, but we can clearly see that it is actually the same problem, and has the same solutions.

In other words, just like with Fibonacci, the total number of cases is huge, but many of them will just be repeats of other ones. So we are going to apply memoization. And then, dynamic programming.

When we implement the “backtracking” algorithm we’ve overviewed above, we get something like this (not my code):

def count_arrangements(conditions, rules):
    if not rules:
        return 0 if "#" in conditions else 1
    if not conditions:
        return 1 if not rules else 0

    result = 0

    if conditions[0] in ".?":
        result += count_arrangements(conditions[1:], rules)
    if conditions[0] in "#?":
        if (
            rules[0] <= len(conditions)
            and "." not in conditions[: rules[0]]
            and (rules[0] == len(conditions) or conditions[rules[0]] != "#")
        ):
            result += count_arrangements(conditions[rules[0] + 1 :], rules[1:])

    return result

Note the program above handles ? by treating it as both . and #. The first case is easy, but the second case need to check that it matches the next rules; and for that, it needs to check that there is a separator afterwards, or the end of the string.

Since it’s Python, to memoize, we just need to add @cache.

To make it dynamic programing, we use the same trick as in the example of the edit distance: we pass the offset in the string, and the offset in the rules as parameters in the recursion. This becomes:

def count_arrangements(conditions, rules):
    @cache
    def aux(i, j):
        if not rules[j:]:
            return 0 if "#" in conditions[i:] else 1
        if not conditions[i:]:
            return 1 if not rules[j:] else 0

        result = 0

        if conditions[i] in ".?":
            result += aux(i + 1, j)
        if conditions[i] in "#?":
            if (
                rules[j] <= len(conditions[i:])
                and "." not in conditions[i:i + rules[j]]
                and (rules[j] == len(conditions[i:]) or conditions[i + rules[j]] != "#")
            ):
                result += aux(i + rules[j] + 1, j + 1)

        return result
    return aux(0, 0)

Then, we implement our own cache and fill it in the right order:

def count_arrangements(conditions, rules):
    cache = [[0] * (len(rules) + 1) for _ in range(len(conditions) + 1)]
    # note that we are in the indices in reverse order here
    for i in reversed(range(0, len(conditions) + 1)):
        for j in reversed(range(0, len(rules) + 1)):
            if not rules[j:]:
                result = 0 if "#" in conditions[i:] else 1
            elif not conditions[i:]:
                result = 1 if not rules[j:] else 0
            else:
                result = 0
                if conditions[i] in ".?":
                    # since we are at row i, we already filled in row i + 1
                    result += cache[i + 1][j]
                if conditions[i] in "#?":
                    if (
                        rules[j] <= len(conditions[i:])
                        and "." not in conditions[i:i + rules[j]]
                    ):
                        if rules[j] == len(conditions[i:]):
                            # since we are at row i, we already filled in row i + rules[j] > i
                            result += cache[i + rules[j]][j + 1]
                        elif conditions[i + rules[j]] != "#":
                            # since we are at row i, we already filled in row i + rules[j] + 1 > i
                            result += cache[i + rules[j] + 1][j + 1]
            cache[i][j] = result
    return cache[0][0]

And, voilà! You can also have a look at a Rust implementation (my code, this time).

Note: In this case, it looks like the dynamic programming version is slower than the memoized one. But that’s probably due to it being written in unoptimized Python.

Note: Independently from using a faster language and micro-optimizations, the dynamic programming version allows us to see that we only need the previous column. Thus, we could replace the 2D array by two 1D arrays (one for the previous column, and one for the column being filled).

Conclusion

I’ll concede that dynamic programming is not trivial. But it is far from being unreachable for most programmers. Being able to understand how to split a problem in smaller problems will enable you to use memoization in various contexts, which is already a huge improvement above a naive implementation.

However, mastering dynamic programming will let us understand a whole class of algorithms, better understand trade-offs, and make other optimizations possible. So, if you have not already done them, I strongly encourage you to practice on these problems:

  1. longest common subsequence (not to be confused with longest common substring, but you can do that one too with dynamic programming)
  2. line wrap
  3. subset sum
  4. partition
  5. knapsack

And don’t forget to benchmark and profile your code!


  1. Excluding, of course, d(A, B) itself ↩︎
  2. B could be empty as well, in which case we need to insert 0 characters ↩︎
  3. Note that we could permute the inner and outer loops as shown below. In this case, it works just as well:
    for b in range(0, len(B) + 1):
    for a in range(0, len(A) + 1):
    ↩︎
Categories
Competitive Programming

Code Golfing in Python (1)

Code Golf

I spend way too much time playing Clash of Code on CodinGame as you can see in the “featured image” above. I still haven’t caught up with Haskell, and I struggle to stay above Allis. Both are really strong, and seem to know Ruby very well!

My favorite mode is “Shortest”. The goal is to write a program against a given specification within 15 minutes. But, unlike the “Fastest” and “Reverse” modes, the main criterion is the number of characters in the submitted code.

So, if the problem is to write a program that asks for an integer n and then prints n! (the factorial of n), you are not going to submit the following snippet (69 characters):

from math import factorial
n = int(input())
f = factorial(n)
print(f)

At the very least, you are going to remove the extraneous spaces and avoid unnecessary declarations (47 characters):

import math
print(math.factorial(int(input())))

Or you might notice that importing a module and calling a method with a long name costs you many characters and that you are better off implementing it yourself (45 characters):

n=int(input())
p=1
while n:p*=n;n-=1
print(p)

This is less readable and less efficient, but it is terser. And that’s all that count!

Note: to be more specific, if two people get the same character count for their submissions, the one who submitted it first gets the advantage; however, this seldom happens

Uglifying Python

I am now going to list some of the techniques I commonly use when code golfing in Python.

1. Remove Extraneous Spaces

There is no need to put spaces after or before parentheses, brackets, quotes. You do not need spacing after a number either. Assume you are given an input of the form:

n
a b c d e…

And your program should print True if n+1 is among a, b, c, d, e… and False otherwise. If you follow the PEP 8, you could write:

print(int(input()) + 1 in [int(x) for x in input().split()])

Instead, we can do:

print(int(input())+1in[int(x)for x in input().split()])

Note that no space is needed between 1 and the keyword in.

2. Reduce Indentation

Indentation is semantic in Python. But there is no point in putting more than one space per level of indentation (88 characters):

t=0
for i in range(w):
    for j in range(h):
        t+=i+j
        print(i+j)
print(t)

This could just as well be written as (73 characters):

t=0
for i in range(w):
 for j in range(h):
  t+=i+j
  print(i+j)
print(t)

Better yet, since there are no further blocks (if, for, while) in the inner loop, we can put it in a single line (68 characters):

t=0
for i in range(w):
 for j in range(h):t+=i+j;print(i+j)
print(t)

Note: combining several lines of code with a semi-colon is normally useless, since we save the newline character but it pay the price of the semi-colon; however, when an indented block, we also save the characters of the indent, which makes it worth it to put several statements on a single line.

But it’s even better if we can get rid of the indent altogether (63 characters):

print(sum(print(i+j)or i+j for i in range(w)for j in range(h)))

Note: here, I combined the print statement with the elements of the same by exploiting the fact and print always returns None, which is falsy (that is, it evaluates as False when evaluated as a boolean), which means that the expression will always evaluate as i+j after printing the value.

3. Exploit Iterables

Earlier, I wrote the following to read a list of integers from the standard input (32 characters):

[int(x)for x in input().split()]

This is a list comprehension, which has the advantage of being easy to read quickly once you understand the syntax. But, before PEP 202, it was already possible to do it without a loop block, using the functional-style map() and filter() built-in methods. Both take as argument a method and an iterable, and apply the method to each element of the iterable. map() simply returns the result of this evaluation, while filter() returns the original element if the result is truthy, otherwise, it skips it. I have rarely found a use for filter() while code golfing, but map() lets me write the following (24 characters):

map(int,input().split())

This expression evaluates to a generator of integers corresponding to the numbers in the line. So, if all you want is their sum, you could just write (29 characters):

sum(map(int,input().split()))

Note that, if you need to iterate over the integers several times, or if you need to access them by index, then you need to exhaust the iterable into a list. It is still shorter than a list comprehension however (30 characters):

list(map(int,input().split()))

But many things are iterable in Python. If you need to count the number of unique characters of a string s, you can just write (11 characters):

len(set(s))

In fact, you can exploit it for when you want to iterate a fixed number of times and do not care about the loop index. For instance, instead of writing (40 characters):

for _ in range(99):print(int(input())%2)

You could just as well write (36 characters):

for i in[0]*99:print(int(input())%2)

Note: there used to be a reduce(), method in the built-ins of Python, that would let you aggregate a range in the fashion of sum(), max(), min(), any(), all(). With it, you could easily have computed the product of the elements of an iterable. However, it was moved into the functools module in Python 3 because comprehensions are deemed more Pythonic.

4. Use Shorthands

Suppose you need to read a single string, then a list of string (their count on a line, then a string per line), and count how many of the strings contain the initial string. You could do (58 characters):

s=input()
print(sum(s in input()for _ in[0]*int(input())))

Note: the input() of [0]*int(input()) will be evaluated before that of s in input() since it is necessary to start the (generator) comprehension in the first place.

Note: s in input() is a boolean expression but, in Python, True is equal to 1 and False is equal to 0; thus, we can easily count the number of time a predicate is met.

Now, you might notice that we have written input thrice. Since Python methods and classes are themselves objects, we can affect them to a variable and write (54 characters):

I=input
s=I()
print(sum(s in I()for _ in[0]*int(I())))

We save 4 characters for each call to input and assigning it to I costs us 8 characters, so we save 4 characters here. We would break even if there were only two calls to input. But it allows us another neat (horrible) trick (50 characters):

I=input
s=I()
I(sum(s in I()for _ in[0]*int(I())))

Seriously. Here, we are exploiting the fact that input takes an optional position argument, which will be used as the prompt for the user. For instance, if you are asking the name of the user, you would write: name=input('Name: '), which is essentially equivalent to print('Name: ', end='');name = input(). Since there is nothing more to read, the program will crash, but not before having written the result on the standard input.

Note: this trick only works as the final output of a program (you cannot use it several times in a loop for instance), and you need to give it a single argument (you might need to format the output first).

5. Use f-strings

I referred to the idea of passing several arguments to print at the end of the previous section. This is something very handy, even outside code golf, when you’d like to print several values separated by spaces:

x = 4
y = 6
name = 'Hector'
print(x, y, name)

This will result in printing 4 6 Hector to the standard output. You can easily apply it to an enumerable:

l = [1, 2, 3, 4]
print(*l)
# Output:
# 1 2 3 4

If you prefer to separate the elements with a comma, you can use an optional keyword parameter of print:

l = [1, 2, 3, 4]
print(*l, sep=',')
# Output:
# 1,2,3,4

However, if you have a generator comprehension that outputs strings, you could save writings extraneous parentheses with str.join():

l = [1, 2, 3, 4]
print(*(chr(96+i)for i in l),sep=',')
# versus
print(','.join(chr(96+i)for i in l))
# Output:
# a,b,c,d

Where are the f-strings you promised in all this?!

A reader annoyed by my prolixity

Well, at some point, you will want to output something in a particular manner, or you will need to format a string to pass it as a single argument to input() (60 characters):

print('Values are x= '+str(x)+' and y='+str(y)+' for '+name)

Or, using %-formatting (51 characters):

print('Values are x=%d and y=%d for %s'%(x,y,name))

This is advantageously rewritten with an f-string as (47 characters):

print(f'Values are x={x} and y={y} for {name}')

Note: since Python 3.8 (CodinGame uses 3.7.4 as of writing this article), you can write print(f'Values are {x=} and {y=} for {name}'), saving two characters.

But f-string are much more than string interpolation. They are %-formatting on steroids. So, you can still pad left or right, with spaces or zeroes, toggle positive sign, manage decimal precision, convert to hexadecimal (lower or upper case). But you can also convert to octal and decimal, center strings and call repr(). So instead of the well-known way to convert to binary (10 characters):

bin(x)[2:]

You can now write (8 characters):

f'{x:b}'