from simple to olympiad-level problems

1. STUDY GUIDE

  • for schoolchildren, students, and their teachers

  • everything needed for learning and self-study in a complete yet compact form

  • program development methodology

  • worked examples and exercises for independent practice

2. Introduction

Programming currently attracts considerable interest among students and schoolchildren. Many of them are capable of independently studying the necessary theoretical material and developing practical skills using both educational (school and university) and personal computers. However, a significant limiting factor is the lack of appropriate literature. Most available programming literature is essentially a retelling of manuals for one programming language or another, for example [2]. Libraries occasionally carry books that also describe algorithmic methods. But such books are often not tied to any programming language, for example [15], or are tied to outdated programming languages [3]. The best, in the author’s opinion, of the relatively accessible textbooks on algorithm design today [8-11] unfortunately contain no information on the methodology of program development and debugging. A serious drawback of the monograph [11] for practical use by first-year university students and schoolchildren is also the high theoretical level of the material’s presentation. A common shortcoming of books as such is the static nature of the material, that is, the student’s inability to self-assess the quality of their mastery of the knowledge being studied and to reinforce the corresponding skills. The development of new information technologies and electronic means of distributing information, as well as the author’s many years of using the above-mentioned and other similar materials (primarily [8-10]), led to the need to create a new study guide. The author hopes that it will better allow each student to realize their individual creative potential through the following factors.

  • The first chapter contains all the material necessary for the initial stage of work. The student (schoolchild) is not assumed to have any prior knowledge from computer science courses — everything needed is presented in full, but in a compact form. From the very first pages, the reader is oriented toward independent work in the Python environment (IDLE, VS Code, or command line) and ongoing practical reinforcement of the studied material.

  • Information is presented in order of increasing difficulty.

  1. Block structure and nesting of difficulty levels in the theoretical presentation. This provides the opportunity for those who have understood the material to skip more detailed discussion, and for those who have not to delve deeper.

  2. Autonomous feedback with learners. All theoretical information, as well as all practical assignments, are hosted in the course "Computers and Programming" on the "Distance Learning" project website, which has been operating on the servers of Francisk Skorina Gomel State University since October 1999. A learner can, using Internet-online (http://dl.gsu.unibel.by) or email (dl-service@gsu.unibel.by), submit their own solution to any of the problems presented in the book and many others at any convenient time! The automatic solution checking system operates daily and around the clock, without holidays or days off, and typically checks solutions within a few minutes. For those who have access neither to the Internet nor to email, there is the option to obtain the necessary information (theory, problem statements, tests, solution programs, batch files for self-testing) as a supplement to the book on floppy disks or CD.

The integration of these factors ensures high effectiveness of the learning process based on the theoretical material and practical assignments presented in this book.

The proposed material has been actively used by the author in practical work teaching programming to students of the mathematics department at Francisk Skorina Gomel State University and to schoolchildren in the city of Gomel. The effectiveness of the implemented methodology is confirmed, in particular, by the following facts.

  • From 1997 to 2001, exactly half (10 out of 20) of the members of the Belarusian team at the International Olympiads in Informatics (IOI 1997-2001) were Gomel schoolchildren, who won a total of 5 silver and 2 bronze medals. Over the same period, the remaining representatives of the Republic of Belarus won 2 silver and 4 bronze medals.

  • Furthermore, over the same period, Gomel students won the Belarusian National Olympiad in Informatics in the individual standings 3 times (1997 — Artem Kuznetsov, 1999 — Konstantin Sudilovsky, 2000 — Evgeny Gonchar) and twice (1998, 2000) in the team standings.

The author hopes that the reader will be able to see for themselves the merits of this book and the proposed approach to learning and self-learning algorithms and programming.

Nevertheless, the author will be grateful for all feedback and comments (including and above all critical ones), sent to: dolinsky@gsu.unibel.by.

3. From the Publisher

Please send your comments, suggestions, and questions to the email address comp@piter.com (Piter Publishing, computer editorial office).

We will be glad to hear your opinion!

You can find detailed information about our books on the publisher’s website http://www.piter.com.

4. CHAPTER 1 Programming in Python

The goal of this study guide is to help you learn to write programs in the Python programming language. The guide requires active independent work on your part, without which this is impossible to achieve.

5. 1.1. Basic language constructs and simplest algorithms

This book is the result of the author’s many years of work teaching programming to schoolchildren of various ages and developing his own methodology for the initial stage of learning, which the author has called "rapid immersion" into program development in Python. In short, the essence of this methodology can be stated as follows: the learner needs to be given a minimally necessary amount of information, but in such a way that they can independently solve practically any problem involving numeric data. The author considers the following to be such information: integer and real variables, arithmetic and logical expressions, assignment (=), conditional statements (if …​ elif …​ else), and loops (for and while).

5.1. Introduction to programming

First of all, let us agree on some concepts. Let us try to do this in the form of answers to questions.

Why do we need a program?

To make the computer perform some useful work instead of a person. At the initial stage of your programming education — to perform some labor-intensive computations, for example, processing one-dimensional or two-dimensional arrays of numbers.

What is a one-dimensional array of numbers?

Suppose a clown in a circus has 10 barrels of different heights, from which he plans to build a "pyramid" by stacking one barrel on top of another. The question is: how tall will this pyramid be?

To answer this question correctly, we need to know the heights of these barrels, measured, for example, in centimeters:

25 10 30 40 54 12 60 90 40 20.

These numbers, separated by spaces, are those very heights: 25 cm is the height of the first barrel, 10 cm is the height of the second, and so on, 20 cm is the height of the tenth barrel. Then the height of the pyramid will be:

25 + 10 + 30 + 40 + 54 + 12 + 60 + 90 + 40 + 20 = 381 cm.

These 10 numbers, written in a single row (though they could also be written in a single column), are called a one-dimensional array of 10 numbers. In Python, we represent such arrays using lists.

Since all numbers in this array are integers, such a list is called an integer list.

If the clown measured the heights of his barrels not in centimeters but in decimeters, then the list of barrel heights would look like this:

2.5 1.0 3.0 4.0 5.4 1.2 6.0 9.0 4.0 2.0.

Now our list consists of real (floating-point) numbers and is therefore called a float list.

Note that historically, in programming, the fractional part is separated from the integer part by the symbol . (period), rather than the symbol , (comma), as is customary in mathematics.

But what if the clown’s barrels keep breaking or getting lost, and the height of the "pyramid" needs to be recalculated each time, or if we decided to help all clowns in the world who perform this trick at the same time?

Obviously, one can think of more serious examples where it is necessary to perform calculations on numbers in a list in one way or another. And the more numbers there are in these lists, the greater the chance that a person will make an error in the calculations. Therefore, a person’s salvation in such cases is the ability to write a program that can input the numbers needed for calculations (here the person will provide some help to the computer — entering the source data from the keyboard), then perform the necessary calculations, and output the finished result. It is clear that now we need to write a program that will do all of this. You might object: writing a program is also work! Especially since sometimes performing calculations with a pen, paper, and a calculator is much simpler and faster than writing a program. This is true, especially for the programs with which you will begin your learning, but:

1) a program is written once, and then it can be used an infinite number of times, and this is the main advantage of even the simplest programs;

2) a program can be written and tested for lists with a small number of elements, say 10, and then used for the same calculations but on 10 million elements.

There are, of course, other arguments in favor of programming over manual work. In this chapter, we will try to help you learn to write simple programs in the Python programming language that can input one-dimensional lists of integers or real numbers from the keyboard, perform some calculations, and then output the results to the screen.

6. Standard processing of one-dimensional arrays

The text of a program written in Python that solves the problem of calculating the height of the "pyramid" of barrels for our clown is shown in Listing 1.1.

Listing 1.1. Example of the simplest Python program
# Sum_1arr: Summing elements of a one-dimensional list

print('Enter 10 numbers')
a = [0] * 10  # list a with 10 elements
for i in range(10):  # Input
    a[i] = int(input())  # of one-dimensional list
s = 0  # Summing
for i in range(10):  # of one-dimensional
    s = s + a[i]  # list
print('source array')  # Output
for i in range(10):  # of one-dimensional
    print(a[i], end=' ')  # list
print()
print('answer')
print(s)  # Output of result

This same program also solves a large number of other problems where, in the end, 10 numbers need to be added together.

Ten friends decided to give a gift to their favorite singer Michael Jackson. Each one broke open their piggy bank and contributed their share to the common cause. How much money did the friends collect? Can you think of a problem whose solution requires adding 10 numbers?

It is clear that the simplest version of such a program should accumulate the sum, that is, first add the first 2 elements, then add the third to them, and so on for all elements of the list. The last element to be added to the sum in our problem is the tenth element of the list.

But let us look carefully at the text of the program. The first task is to figure out and try to remember the purpose of all keywords and punctuation used

in the program. After all, the program is intended for a computer, and for it every character matters. It will simply misunderstand the program if you make a mistake.

Python is case-sensitive: print and Print are different names! All Python keywords are written in lowercase. Variable names can use lowercase or uppercase letters, but by convention we use lowercase for variable names and UPPERCASE for constants.

So, a Python program does not need a special "program" declaration line — you simply start writing your code. In our example, we begin with a comment # Sum_1arr to give the program a name. Comments in Python start with the symbol — everything after on a line is ignored by the computer, but comments can be useful to the person who writes and reads the program, explaining what a particular line is for.

Unlike many other languages, Python does not use semicolons to end statements. Instead, each statement goes on its own line. Python uses indentation (spaces at the beginning of a line) to show which lines belong inside a loop or a conditional block. This is one of the most important rules in Python — incorrect indentation will cause an error.

The comments in our program state that the program sums the elements of a one-dimensional list. The list a is created with 10 elements initialized to zero. The variable i will be used to store and change the index of the element being processed. The variable s will be used to store the sum of the list elements that we have already added. Further: the one-dimensional list is input, its elements are summed, and then the list itself and the obtained result of summation are output.

A question arises: why output the source list? We entered it ourselves; we know what numbers are there. We only needed to output the result!

In this program, the source list was output for the following reasons.

  1. To show how to output a one-dimensional list, should the need arise.

  2. At the initial stage of learning programming, you may make some mistakes. Finding them will be easier if the numbers you entered and the numbers your program produced as an answer to the problem you posed are both right before your eyes. You might also make mistakes in the input statements themselves.

Let us try to analyze our program line by line:

# Sum_1arr: Summing elements of a one-dimensional list

This line is a comment that describes the purpose of the program — summing elements. In Python, the # character marks the start of a comment.

a = [0] * 10

This line creates a list named a containing 10 zeros. In Python, lists are dynamic — they can grow or shrink — but here we pre-create a list of a known size so we can fill it with values by index. Note that Python lists are 0-indexed: the elements are numbered from 0 to 9, not from 1 to 10.

for i in range(10):
    a[i] = int(input())

This block provides input of 10 numbers from the keyboard into the list a.

Literally, this reads as follows:

For i from 0 to 9 do read a[i].

The range(10) function generates numbers 0, 1, 2, …​, 9. Thus, the computer waits until we type a number on the keyboard and press Enter. It will store this number in list element a[0], that is, the first element of list a. Then the computer waits until we type the second number to store it in a[1], and so on, up to the 10th element, which it will store in a[9].

s = 0

Recall that we introduced the variable s to accumulate the sum of the entered numbers. It is clear that the initial value of this variable, that is, the value of variable s before we start adding, should be zero. This is what this statement ensures. Literally, it reads as follows:

Store the number 0 in variable s.

Or like this:

Assign the value 0 to variable s.

Next:

for i in range(10):
    s = s + a[i]

Literally, this reads as follows:

For i from 0 to 9 do
  compute  the  new  value  of variable s  as  the  result of adding
  the old value of variable s and the list element a[i].

Alternatively, it can be read as follows:

For i from 0 to 9 do
  take the old value of variable s,
  add the value of element a[i] to it and
  store the resulting sum back in variable s.

This is the only part of the program that directly solves the problem at hand — it sequentially sums the 10 entered numbers in variable s, adding to it:

  • first the value of a[0], that is, the first element of list a (the first number we entered from the keyboard);

  • then the value of a[1], that is, the second element of list a (the second number we entered from the keyboard), and so on;

  • finally, the value of a[9], that is, the tenth element of list a (the tenth number we entered from the keyboard).

After this loop finishes executing, the variable s contains the desired result — the required sum!

print('source array')

This line outputs the text source array to the screen, and it is needed so that the person who will run our program knows what

numbers are about to be displayed (the numbers of the source list in this case).

for i in range(10):
    print(a[i], end=' ')

Literally, this reads as follows:

For i from 0 to 9 do
  output a[i] to the screen, followed by a space character.

Note that print(a[i], end=' ') uses the end=' ' parameter to prevent moving to the next line after each number. By default, print() adds a newline character at the end. By specifying end=' ', the cursor is not moved to the beginning of the next line but a space is printed instead. Thus, the numbers of list a are displayed one after another on the same line, separated by spaces.

If we had used plain print(a[i]) without end=' ', the numbers would have been displayed in a column, which in our case is less clear.

print()

To move the cursor to the beginning of the next line after finishing the output of the entire list, a bare print() call is used. As a result, the word "answer" from the next statement is displayed at the beginning of the next line.

print('answer')

This statement is used to indicate that the results of our program’s work will follow.

print(s)

This statement outputs the value of variable s, which contains the result of the program’s work.

We have analyzed the first, simplest Python program. It can be used as a basis for writing other programs that process one-dimensional lists. When doing so, only the calculation part of the program needs to be changed. Creating lists, inputting source data, and outputting source data and results, using exactly the corresponding parts of the program we analyzed, must always be done.

For example, what needs to be changed in our program if we want to multiply, rather than add, the entered numbers?

Only 2 characters need to be changed: + to * and 0 to 1, resulting in the following calculation part:

s = 1  # Multiplying elements
for i in range(10):  # of a one-dimensional list
    s = s * a[i]

The text of this program is shown in Listing 1.2.

Listing 1.2. Program that multiplies the entered numbers
# Mul_1arr: Multiplying elements of a one-dimensional list

print('Enter 10 numbers')
a = [0] * 10  # list a with 10 elements
for i in range(10):  # Input
    a[i] = int(input())  # of one-dimensional list
s = 1  # Multiplying elements
for i in range(10):  # of one-dimensional list
    s = s * a[i]
print('source array')  # Output
for i in range(10):  # of one-dimensional list
    print(a[i], end=' ')
print()
print('answer')  # Output
print(s)  # of result

Thus, when developing programs, the main thing is to come up with the calculation part or, in other words, to design the algorithm of the program. For a large number of problems, such algorithms have long been devised, and instead of "reinventing the wheel," it is sufficient to study and memorize them, which is what you are invited to do.

7. Simplest algorithms on a one-dimensional list

Let us consider the most commonly used algorithms on a one-dimensional list:

  • counting elements that possess a given property;

  • finding the maximum and minimum elements;

  • searching for elements that possess a given property.

7.1. Counting elements that possess a given property

Suppose we have a list with computer science grades of 10 students. We need to count how many of them have a grade of 5. The algorithm for this problem is as follows:

s = 0  # Counting elements
for i in range(10):  # equal to a given value (5)
    if a[i] == 5:
        s = s + 1

Following our established tradition, let us examine the algorithm line by line.

s = 0

The initial count of students with top marks in computer science is set to 0.

for i in range(10):

This tells Python to repeat the indented block below for each value of i from 0 to 9. The colon : at the end of the for line is mandatory — it tells Python that an indented block follows.

Everything that is indented under the for line belongs to the loop body. This is how Python knows what to repeat — by looking at the indentation level.

    if a[i] == 5:
        s = s + 1

If a[i] equals 5, then increase the value of s by 1. In more detail, this can be read as follows: if the i-th element of list a equals 5, then the new value of variable s should be formed by adding the old value of s and 1. Note that Python uses == for comparison (is equal to?) and = for assignment (store a value).

Returning to the problem about top students: if the current student has a grade of 5, then they should be counted. But incrementing by 1 is precisely counting.

Question: how to count the number of students with C grades?

s = 0
for i in range(10):
    if a[i] == 3:
        s = s + 1

How to count the number of students with B and A grades?

s = 0
for i in range(10):
    if a[i] >= 4:
        s = s + 1

In general, in the if statement, the following comparison operators can be used:

  • == — equal to

  • > — greater than

  • < — less than

  • >= — greater than or equal to

  •  — less than or equal to

  • != — not equal to

For example, how many students have a grade other than 5?

s = 0
for i in range(10):
    if a[i] != 5:
        s = s + 1

In the if statement, more complex conditions can also be written using the keywords and and or and parentheses.

For example, suppose a[i] is a list of computer science grades and b[i] is a list of mathematics grades. How do we count the number of students who have a grade of 5 in both computer science and mathematics?

s = 0
for i in range(10):
    if a[i] == 5 and b[i] == 5:
        s = s + 1

How do we count the number of students who have a grade of 5 in at least one of these subjects?

s = 0
for i in range(10):
    if a[i] == 5 or b[i] == 5:
        s = s + 1

Now you can solve any problem that requires counting the number of elements in a list that possess some property. For example, problems 1 and 5 from the section "Exercises for independent practice."

Think of problems that are solved by counting elements possessing a given property.

7.2. Finding the maximum and minimum elements

Suppose the list a contains numbers representing the heights of 10 students in centimeters. We need to find the height of the tallest student.

...
s = a[0]  # Finding
for i in range(1, 10):  # the maximum element
    if a[i] > s:
        s = a[i]
...

As usual, let us examine the problem line by line.

s = a[0]

First, we store the height of the first student in variable s. Remember that in Python, the first element has index 0.

for i in range(1, 10):

For all remaining students from the 2nd to the 10th (indices 1 through 9), the following actions must be performed:

    if a[i] > s:
        s = a[i]

If the height of the current (i-th) student is greater than the one we remember in variable s, then s should be updated with this larger value.

Thus, when this procedure has been performed for all students, the variable s will contain the value corresponding to the maximum of all that were entered into list a.

And if we need to find not the maximum but the minimum element (that is, the height of the shortest student), what needs to be changed in this algorithm?

Correct — replace the > (greater than) sign with the < (less than) sign in the if statement. Then we get:

s = a[0]  # Finding
for i in range(1, 10):  # the minimum element
    if a[i] < s:
        s = a[i]

If, for example, the problem requires finding the difference between the largest and smallest elements, what should be done?

Introduce separate variables for the maximum and minimum elements:

mx = a[0]  # Finding
for i in range(1, 10):  # the maximum element
    if a[i] > mx:
        mx = a[i]
mn = a[0]  # Finding
for i in range(1, 10):  # the minimum element
    if a[i] < mn:
        mn = a[i]
s = mx - mn

In Python, you do not need to declare variables before using them — Python creates them automatically when you first assign a value. So there is no need for a separate declaration section.

Simultaneous search for the maximum and minimum elements in a list could be written somewhat more concisely:

mx = a[0]
mn = a[0]
for i in range(1, 10):
    if a[i] > mx:
        mx = a[i]
    if a[i] < mn:
        mn = a[i]
s = mx - mn

In this case, when we want to execute 2 (or more) if statements inside a for loop, we simply write them at the same indentation level under the for. In Python, there is no need for special "begin/end" brackets — indentation alone defines the block structure.

Pay attention to the indentation in the statements, reflecting the nesting of their execution.

7.3. Searching for elements that possess a given property

Suppose a contains the computer science grades of 10 students. We need to find out whether there is at least one student among them who has a grade of C.

i = 0  # Searching for elements
while i < 10 and a[i] != 3:  # equal to a given value (3)
    i += 1
if i >= 10:
    print('no 3s found')
else:
    print('the first 3 has index', i)

Of course, one could have used the algorithm for counting elements that possess a given property:

s = 0
for i in range(10):
    if a[i] == 3:
        s += 1
if s == 0:
    print('no C students')
else:
    print('there is a C student')

However, in this variant we are unable to find out who exactly the C student is, that is, what is the index of the element equal to 3.

The previous algorithm can be modified as follows:

s = 0
k = -1
for i in range(10):
    if a[i] == 3:
        s += 1
        k = i
if s == 0:
    print('no C students')
else:
    print('there is a C student, their index is', k)

But the main drawback of this approach is that we are forced to examine all elements of the list, even though the answer to our question could have been known after examining the first element if it turned out to be a C student. In the case of a list of 10 elements, this may not be a tragedy, but what if the list has 10 million elements?

For this case, Python provides the while loop:

i = 0  # Searching for elements
while i < 10 and a[i] != 3:  # equal to a given value (3)
    i += 1
if i >= 10:
    print('no 3s found')
else:
    print('the first 3 has index', i)

As usual, let us examine the algorithm line by line.

i = 0

i is assigned the value 0. i is a variable that stores the index of the current element of list a. Remember, Python lists start at index 0.

while i < 10 and a[i] != 3:
    i += 1

Literally, this reads as follows:

While (i < 10) and (a[i] != 3) do i = i + 1

As applied to our problem:

While (the list has not ended) and
  (the current element in the list is not what we are looking for)
  (that is, the element does not possess the property we need)
do - take the next element

The program can exit this while loop in one of two ways:

  1. in the process of adding 1 to i (taking subsequent elements), the desired element was never found;

  2. the desired element was found at some i.

if i >= 10:
    print('no 3s found')
else:
    print('the first 3 has index', i)

Precisely because there are two possible outcomes, the if statement follows the while, analyzing exactly how it ended and giving us the corresponding message.

Let us once again note that the main advantage of this method of list processing is that the search is stopped as soon as the desired element is found.

7.4. Listing 1.3. Sorting elements of a one-dimensional list

for j in range(9):
    s = a[j]
    k = j
    for i in range(j + 1, 10):  # Finding
        if a[i] < s:  # the minimum
            s = a[i]  # element
            k = i
    a[k] = a[j]  # Moving the minimum
    a[j] = s  # element "to the top"

Suppose list a contains the heights of 10 students in centimeters. We need to rearrange the numbers in list a so that each subsequent number is greater than the previous one (or equal to it).

If you have difficulty understanding this section on first reading, it can be skipped.

The main idea of the algorithm is as follows: 9 times, the smallest element is found among the remaining elements. If such an element is found, it is swapped the first time with the first element, the second time with the second element (the first smallest element is already in the first position), and so on. On the 9th pass, the minimum element is selected from the 9th and 10th elements and swapped with the 9th.

8. Methodological guidelines for solving problems 1-5, 7, 9, 12, 13 from the section "Exercises for independent practice"

Problem 1. Counting nonzero elements.

s = 0
for i in range(10):
    if a[i] != 0:
        s += 1
print(s)

Problem 2. Counting elements whose absolute value is greater than 7.

s = 0
for i in range(10):
    if abs(a[i]) > 7:
        s += 1
print(s)

Problem 3. Searching for 7.

i = 0
while i < 10 and a[i] != 7:
    i += 1
if i >= 10:
    print('no')
else:
    print('yes')

Problem 4. Difference between the maximum and minimum elements.

mx = a[0]
mn = a[0]
for i in range(1, 10):
    if a[i] > mx:
        mx = a[i]
    if a[i] < mn:
        mn = a[i]
s = mx - mn
print(s)

Problem 5. Pairwise comparison of two lists.

s1 = 0
s2 = 0
s3 = 0
for i in range(10):
    if a[i] > b[i]:
        s1 += 1
    if a[i] == b[i]:
        s2 += 1
    if a[i] < b[i]:
        s3 += 1
print(s1, s2, s3)

8.1. Problem 7. Summing and counting.

s = 0
for i in range(10):
    s += a[i]
r = s / 10
n = 0
for i in range(10):
    if a[i] > r:
        n += 1
print(n)

8.2. Problem 9. Finding the maximum element and counting.

mx = a[0]
for i in range(10):
    if a[i] > mx:
        mx = a[i]
s = 0
for i in range(10):
    if a[i] == mx:
        s += 1
print(s)

8.3. Problem 12. Searching for a zero element.

i = 0
while i < 10 and a[i] != 0:
    i += 1
if i >= 10:
    print('no')
else:
    print('the first 0 is at position', i)

8.4. Problem 13. Searching for a negative number from the end of the list.

i = 9
while i >= 0 and a[i] >= 0:
    i -= 1
if i < 0:
    print('no negative numbers')
else:
    print('the last number<0 is at position', i)

9. Standard processing of two-dimensional arrays

A significant number of practical problems require processing not one-dimensional but two-dimensional arrays (lists of lists in Python).

9.1. Two-dimensional array and its parts

For example, consider a two-dimensional array of 25 elements containing 5 rows and 5 columns:

5  -2  3  14  11
17  13  1   7   1
5  -2  3  14  20
8   0  9  10  -4
3  -6  3  14  16

Components of a two-dimensional array that may require special processing can be its rows, columns, and diagonals, for example:

  • the second row:

17 13 1 7 1
  • the first diagonal: 5 13 3 10 16

  • the second diagonal: 11 7 3 0 3

  • the third column: 3 1 3 9 3

  • and so on.

10. Indices of elements of a two-dimensional array

A two-dimensional array (list of lists) a of 25 elements (5 rows and 5 columns) has the following indices in Python (0-indexed):

5   -2   3   14   11   →   a[0][0] a[0][1] a[0][2] a[0][3] a[0][4]
17  13   1    7    1   →   a[1][0] a[1][1] a[1][2] a[1][3] a[1][4]
5   -2   3   14   20   →   a[2][0] a[2][1] a[2][2] a[2][3] a[2][4]
8    0   9   10   -4   →   a[3][0] a[3][1] a[3][2] a[3][3] a[3][4]
3   -6   3   14   16   →   a[4][0] a[4][1] a[4][2] a[4][3] a[4][4]

The first index is the row number, and the second is the corresponding column number. Both start from 0 in Python.

When processing all elements of a two-dimensional array in a program, nested for loops must be used:

for i in range(5):
    for j in range(5):
        ... a[i][j] ...

11. Row and column indices of a two-dimensional array

Let us consider the indices of the second row (index 1 in Python) as an example:

a[1][0] a[1][1] a[1][2] a[1][3] a[1][4].

It is easy to see that the first index — the row number — is fixed and equals 1 (for the 2nd row in 0-indexed Python), while the second index sequentially takes values from 0 to 4. Therefore, when the 2nd row of a two-dimensional array needs to be processed, it is sufficient to write:

for i in range(5):
    ... a[1][i] ...

The variable i can be replaced by any other as needed, for example m:

for m in range(5):
    ... a[1][m] ...

Now let us consider the indices of the third column (index 2 in Python) as an example:

a[0][2]
a[1][2]
a[2][2]
a[3][2]
a[4][2]

Obviously, now the second index — the column number — is fixed, and the first index — the row number — sequentially takes all values from 0 to 4. Therefore, the loop for processing elements of the third column should look like this:

for i in range(5):
    ... a[i][2] ...

12. Indices of diagonals of a two-dimensional array

The elements of the first diagonal of a two-dimensional array have the following indices:

a[0][0]
a[1][1]
a[2][2]
a[3][3]
a[4][4]

It is easy to see that the row index equals the column index for all elements of the first diagonal, and therefore the loop for processing its elements should look as follows:

for i in range(5):
    ... a[i][i] ...

The elements of the second diagonal of a two-dimensional array have the following indices:

a[0][4]
a[1][3]
a[2][2]
a[3][1]
a[4][0]

It is not easy, but one can notice that the sum of the row and column indices for all elements of the second diagonal is constant and equals 4 (for a 5x5 array with 0-based indexing; for an NxN array it would be N-1), and therefore the loop for processing the elements of the second diagonal should look as follows:

for i in range(5):
    ... a[i][4 - i] ...

13. Transferring simplest algorithms to two-dimensional arrays

Considering all of the above, one can agree with the methodology of transferring algorithms from one-dimensional lists to two-dimensional ones, illustrated here with the example of the summation algorithm:

...
s = 0  # Summing elements
for i in range(10):  # of a one-dimensional list
    s += a[i]
...
s = 0  # Summing elements
for i in range(5):  # of the second row of a two-dimensional array
    s += a[1][i]
...
s = 0  # Summing elements
for i in range(5):  # of the third column of a two-dimensional array
    s += a[i][2]
...
s = 0  # Summing elements
for i in range(5):  # of the first diagonal of a two-dimensional
    s += a[i][i]  # array
...
s = 0  # Summing elements
for i in range(5):  # of the second diagonal of a two-dimensional
    s += a[i][4 - i]  # array
...
s = 0  # Summing elements
for i in range(5):  # of a two-dimensional
    for j in range(5):  # array
        s += a[i][j]
...

What does this methodology consist of? In replacing the indices of a one-dimensional list with the indices of, respectively, a row, column, first or second diagonal, or the entire two-dimensional array. In the case of processing the entire two-dimensional array, nested for loops must also be used.

14. Declaration, input, and output of a two-dimensional array

In a program that processes a two-dimensional array, it is necessary to create, input, and output specifically a two-dimensional array (list of lists):

# Create a 5x5 array filled with zeros
a = [[0] * 5 for _ in range(5)]

print('Enter 25 numbers')  # Input
for i in range(5):  # of a two-dimensional
    for j in range(5):  # array
        a[i][j] = int(input())

print('source array')  # Output
for i in range(5):  # of a two-dimensional
    for j in range(5):  # array
        print(a[i][j], end=' ')
    print()

The changes compared to the creation, input, and output of a one-dimensional list are quite intuitive, and therefore no comments are needed here.

15. Methodological guidelines for solving problems 10, 11, 14, 19-21

Problem 10. In a two-dimensional array, change all signs to opposite. For example, suppose the input array is:

5  -2  3  14  11
17  13  1   7   1
5  -2  3  14  20
8   0  9  10  -4
3  -6  3  14  16

Then the output should become:

  -5    2  -3  -14  -11
 -17  -13  -1   -7   -1
  -5    2  -3  -14  -20
  -8    0  -9  -10    4
  -3    6  -3  -14  -16

Obviously, the calculation part of the program should look as follows:

for i in range(5):
    for j in range(5):
        a[i][j] = -a[i][j]

The text of the program itself is in Listing 1.4.

Listing 1.4. Program for changing the signs of array elements to their opposites
# N10

# Create a 5x5 array filled with zeros
a = [[0] * 5 for _ in range(5)]

print('Enter 25 numbers')  # Input
for i in range(5):  # of a two-dimensional
    for j in range(5):  # array
        a[i][j] = int(input())

print('source array')  # Output
for i in range(5):  # of the source
    for j in range(5):  # two-dimensional
        print(a[i][j], end=' ')  # array
    print()

for i in range(5):
    for j in range(5):
        a[i][j] = -a[i][j]

print('resulting array')  # Output
for i in range(5):  # of the obtained
    for j in range(5):  # two-dimensional
        print(a[i][j], end=' ')  # array
    print()

Problem 11. Find the maximum element in a two-dimensional array.

The calculation part of the problem can look like this:

mx = a[0][0]
for i in range(5):
    for j in range(5):
        if a[i][j] > mx:
            mx = a[i][j]

Problem 14. Determine whether a two-dimensional array (5x5) is a "magic square."

The simplest solution is to:

  • first introduce the variables: s1, s2, …​, s5 — sums of rows from the first to the fifth;

t1, t2, …​, t5 — sums of columns from the first to the fifth;

d1, d2 — sums of elements of the first and second diagonals;

  • and then write an if statement of the following type:

if (s1 == s2 == s3 == s4 == s5 ==
    t1 == t2 == t3 == t4 == t5 == d1):
    print('the square is magic')
else:
    print('the square is NOT magic')

Some of you may rightly note: if the array consists of 100 rows and 100 columns, this solution is very inconvenient. Agreed; in that case, one must devise an algorithm that solves this problem in the most convenient way. Inventing new algorithms is the most difficult task in programming. The next chapter is dedicated to it. I hope that once you manage to understand the material in the next section, you will come up with a better algorithm for solving problem 14 on your own.

Problem 19. Find the number of zeros in a given column.

The calculation part of the program can be as follows:

j = int(input())  # read column index (0-based)
k = 0
for i in range(5):
    if a[i][j] == 0:
        k += 1
print(k)

Problems 20 and 21. Despite their complex and at first glance different formulations, both require finding the minimum element among the row maximums. This can be done, for example, as follows:

row_max = [0] * 5
for i in range(5):
    row_max[i] = a[i][0]
    for j in range(1, 5):
        if a[i][j] > row_max[i]:
            row_max[i] = a[i][j]
minmax = row_max[0]
for i in range(1, 5):
    if row_max[i] < minmax:
        minmax = row_max[i]
print(minmax)

16. Non-standard algorithms and programs

In general, the development of algorithms and programs is not only a science but also, to a significant extent, an art. And therefore many believe that teaching algorithm and program development is like teaching a person to paint pictures or compose poetry and melodies. Nevertheless, there are some general facts that should be communicated to anyone wishing to learn how to develop algorithms. Below, a plan is presented for your attention; following it when developing algorithms may help you master this science (or art) more quickly. It is not at all necessary to memorize it. It is sufficient to try to follow it when developing algorithms for every new program.

17. Plan for developing algorithms and programs

The plan is as follows.

  1. Reformulate the problem statement.

  2. Determine what is the input and what is the output.

  3. Create test cases (not forgetting edge cases).

  4. Solve the test cases by hand.

  5. If the problem can be solved as a composition of known problems, go to step 9.

  6. Develop the algorithm for the program.

  7. Perform a manual trace.

  8. If there are errors, go to step 6.

  9. Write the program text.

17.1. NOTE

Explanations for this plan will be provided alongside its application to solving specific problems.

18. The long road to the algorithm

Let us begin with problem 9: given an integer list a with 10 elements, count how many times the maximum value appears in it.

To do this, we must sequentially go through all the steps of the plan.

  1. Reformulate the problem statement.

  2. Determine what is the input and what is the output.

This means that you should try to reformulate the statement so that it contains a minimum number of words while the problem remains the same, and it is unambiguously defined what needs to be input (a number, a one-dimensional list of numbers, a two-dimensional list of numbers, a string of characters) and what needs to be output.

For example, for problem 9 — new formulation: in a list, count the number of maximums; the input is a list, the output is a number.

+ 3. Create test cases (not forgetting edge cases).

+ 4. Solve the test cases by hand.

Before writing a program, you must clearly understand what exactly it should do. In addition, you must strictly define what criteria you will use to consider the program to be working correctly. It is precisely the creation of an adequate set of tests for the problem that allows you to solve this task.

What is a test? It is the data that you must input into your program and the answer that you consider correct, which your program should produce when this data is entered.

How many tests should there be? On the one hand, you must devise enough tests so that you can assert that your program works

correctly if it "passes" all your tests (that is, for all input data it produces the specified output). On the other hand, there should be as few tests as possible, because if you change even one character in your program, you will need to re-verify its correctness on all your tests.

Furthermore, when creating tests, one must take into account that many programs must handle various edge cases in a special way. For example, for problem 9 being solved, the following tests are sufficient.

5 -2 4 0 6 11 7 0 2 4,
  • If the list is:

then the maximum count is 1: 11.

5 11 4 0 6 11 7 0 2 11,
  • If the list is:

then there are 3 maximums. They are all the same and equal to 11.

5 5 5 5 5 5 5 5 5 5,
  • If the list is:

then all its elements are maximums.

  1. finding the maximum element in a list;

  2. counting the number of elements (in a list) equal to a given value (the found maximum). And then you can go directly to step 9 of the plan, that is, directly to writing the calculation part of the program (Listing 1.5).

Listing 1.5. Calculation part of the program for solving problem 9
mx = a[0]  # Finding
for i in range(1, 10):  # the maximum
                                                # element
    if a[i] > mx:
        mx = a[i]
n = 0  # Counting elements
for i in range(10):  # equal to a given value (mx)
    if a[i] == mx:
        n += 1
# And then the program itself:
# MaxNumb

a = [0] * 10  # list a with 10 elements
print('Enter 10 numbers')  # Input
for i in range(10):  # of one-dimensional list
    a[i] = int(input())
mx = a[0]  # Finding
for i in range(1, 10):  # the maximum
                                                   # element
    if a[i] > mx:
        mx = a[i]
n = 0  # Counting elements
for i in range(10):  # equal to a given value (mx)
    if a[i] == mx:
        n += 1
print('source array')  # Output
for i in range(10):  # of one-dimensional list
    print(a[i], end=' ')
print()
print('Count of maximum elements', end=' ')  # Output
print('of one-dimensional list =', n)  # of result

However, such an opportunity does not always present itself. As an example, let us consider problem 15: given an integer list a with 10 elements, count the greatest number of consecutive equal elements in it.

For practice, let us carry out the initial stages of algorithm development for this problem as well.

New formulation: in a list, count the greatest number of consecutive equal elements; the input is a list, the output is a number.

As for the complete set of tests, in this case it is more complex:

  1. a = [5, -2, 4, 0, 6, 11, 7, 0, 2, 4] — no consecutive equal elements;

  2. a = [5, 5, 5, 5, 5, 5, 5, 5, 5, 5] — all are consecutive equal elements;

  3. a = [5, 11, 4, 4, 4, 11, 7, 0, 2, 11] — one chain of consecutive equal elements;

  4. a = [5, 11, 4, 4, 4, 2, 2, 2, 2, 2] — first the min, then the max;

  5. a = [5, 4, 4, 4, 4, 4, 2, 2, 2] — first the max, then the min.

I draw your attention to the necessity of including the fourth and fifth tests in the test set. For example, if test 4 is absent from the test set, then having written a program that counts the first number of consecutive equal elements, one might, after getting correct answers on all remaining tests, erroneously believe that the program fully solves the problem.

Similarly, if the test set does not contain test 5, then having written a program that counts the last number of consecutive equal elements, one could again get correct answers on all remaining tests and, once again, erroneously believe that the program written fully solves the problem.

Suppose you have created a complete set of tests. How do you now construct an algorithm that solves the problem at hand?

First of all, note that since you have created the tests and manually calculated correct answers for all input data, you already know the correct algorithm for solving the problem. Your main challenge now is to write this algorithm in a way that the computer can correctly understand and execute it.

To help formalize an algorithm you already know, we suggest using the following mnemonic technique. Imagine that:

  • a million signs with numbers written on them are laid out side by side along a long highway;

  • you need to solve a problem for this million numbers;

  • you have to walk on foot (so you would like to walk as little as possible from number to number, ideally straight from the first to the last, without going back);

  • you can write some numbers on the palm of your hand, so you don’t have to memorize them;

  • you can only compare two numbers (both on your palm, both on the road, one on your palm and the other on the road);

  • you need to come up with a plan of action (an algorithm) so that the answer appears on your palm when you reach the end of the highway.

Another mnemonic technique for developing algorithms can be used when you are working together with a friend. In that case, you ask your friend to create a test with a large amount of input data, and then they tell you those input values one at a time. You are not allowed to write down the numbers they call out, but you can write down the results of computations on them. In this case you have two tasks:

  1. get the correct answer (checking it later against your friend’s answer);

  2. come up with a plan of action (an algorithm) that always produces the correct answer (regardless of the input data).

Let us now try to write the algorithm for the consecutive elements problem for human execution. In this case, you only need to write 2 numbers on your palm:

  • 1st — how many identical consecutive elements there are right now;

  • 2nd — the maximum number of identical consecutive elements encountered so far.

Then the algorithm can be written as follows:

Write the first number = 1 and the second number = 1 on your palm
Starting from the second number on the highway and up to the last, do the following
  if the current number on the highway equals the previous one
    then increase the first number on your palm by 1
    else if the first number on your palm is greater than the second number on your palm
      then write the first number in place of the second number on your palm
      (to preserve the maximum of consecutive elements)
      write 1 in place of the first number on your palm
      (to start counting from the beginning for the current consecutive elements)

The corresponding computerized algorithm may look like this:

  # Input a[0..9]  (laid out numbers on the highway)
mx = 1  # wrote 2 numbers on our palm
cur = 1
for i in range(1, 10):  # repeat for numbers from the second
                                                 # to the tenth
    if a[i] == a[i - 1]:  # if they are consecutive
        cur += 1  # then increment current
    else:
        if cur > mx:  # else if needed update mx
            mx = cur
        cur = 1  # and start counting from the beginning
if cur > mx:  # final check after loop ends
    mx = cur
print(mx)  # Output the result

We have finally obtained an algorithm, and we believe it is correct. What do we do now? "Write a program based on it," you might answer. You could, but I still suggest first doing a manual trace, so that, even before sitting down at the computer, you can find and fix as many errors as possible in the written algorithm.

What is a manual trace? It is the process of a human executing a program as if they were a computer. The main difficulty of a manual trace for the author of the developed algorithm is to forget the essence of the problem being solved, the idea of the algorithm, and "mechanically and strictly" execute what is written in the algorithm (because that is exactly what the computer will do).

Upon finding an error, you need to correct the algorithm and try performing the manual trace again, first on the same test, and then on others. And so on until the author is satisfied that the algorithm works correctly on all tests.

We have finally obtained an algorithm, and we believe it is correct. What do we do now? "Write a program based on it," you might answer. You could, but I still suggest first doing a manual trace, so that, even before sitting down at the computer, you can find and fix as many errors as possible in the written algorithm.

What is a manual trace? It is the process of a human executing a program as if they were a computer. The main difficulty of a manual trace for the author of the developed algorithm is to forget the essence of the problem being solved, the idea of the algorithm, and "mechanically and strictly" execute what is written in the algorithm (because that is exactly what the computer will do).

Upon finding an error, you need to correct the algorithm and try performing the manual trace again, first on the same test, and then on others. And so on until the author is satisfied that the algorithm works correctly on all tests.

How do you perform a manual trace?

First of all, you need to carefully write out the algorithm and the test example on which the manual trace will be performed. Then execute the algorithm line by line to the end.

As a test example (let us call it example 1), we choose the list A with elements:

5 11 4 4 4 2 2 2 2 2.

Let us perform a manual trace for the algorithm that finds the largest number of identical consecutive numbers. For convenience, we will build a manual trace table (Table 1.1). Each step in it will correspond to one pass through the loop in our algorithm.

Table 1.1. Result of the manual trace on test example 1

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max

1

1

3

5

cur

1

1

2

3

1

2

3

4

5

i

1

2

3

4

5

6

7

8

9

10

So, the manual trace:

Input A (10 elements, 0-indexed)
max = 1, cur = 1
for i in range(1, 10):
    if a[i] == a[i - 1]:
        cur = cur + 1
    else:
        if cur > max:
            max = cur
        cur = 1
print(max)
Answer: 3 (max) — ERROR!!!

Let us comment on this example line by line as it executes:

Input A (10 elements, 0-indexed)

After executing this line, the entered data will appear in the computer’s memory: the list a = [5, 11, 4, 4, 4, 2, 2, 2, 2, 2], that is, a[0] = 5, a[1] = 11, a[2] = 4, …​, a[9] = 2. We remember that the correct answer for this case is 5 (5 consecutive numbers 2; there were also 3 consecutive numbers 4, but 5 is greater than 3, so the correct answer is 5).

max_val = 1, cur = 1

The variables max_val and cur received the value 1; in the manual trace, this is recorded as follows:

A max_val cur

5 11 4 4 4 2 2 2 2 2

1

1

In principle, "documenting" the manual trace could also be done horizontally (Table 1.2).

18.1. Table 1.2. Initial step of the manual trace

Variable Value

A

5 11 4 4 4 2 2 2 2 2

max_val

1

cur

1

Table 1.2 shows the initial step of the manual trace, during which values are assigned to variables. The values of max_val and cur are shifted in the table relative to each other. With this shift, we depict the passage of time, i.e., that the variable cur received its value after the variable max_val.

for i in range(1, 10):

The variable i receives the value 1, this value is compared with the upper bound 10, and since 1 < 10, control is passed inside the loop:

if a[i] == a[i - 1]:

Since the value of i is 1, a[1] is compared with a[0], that is, 11 is compared with 5. Equal? No! Therefore, what comes after else will be executed:

else:
    if cur > max_val:
        max_val = cur

Cur is currently equal to 1, max_val is also equal to 1. 1 > 1? No. So the statement inside the if is not executed, and for now everything stays as it was.

cur = 1

In our table, cur is already equal to 1, but right now we are a PC and, like it, must replace the old value with the new one (even if it is the same).

The result of executing the first step of the manual trace is in Table 1.3.

Step zero in the table denotes the step of the manual trace during which values were assigned to variables (see Table 1.1).

19. Table 1.3. Results of the first step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

cur

1

1

i

1,

And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 1, becomes 2). \(2 < 10\), and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is now 2, therefore, a[2] is compared with a[1], that is, 4 is compared with 11. Equal? No! Therefore, what comes after else will be executed:

else:
    if cur > max_val:
        max_val = cur

Cur is currently equal to 1, max_val is also equal to 1, \(1 > 1?\) No. So the statement inside the if is not executed, and for now everything stays as it was.

cur = 1

In our table cur is already equal to 1, but right now we are a PC and, like it, must replace the old value with the new one (even if it is the same).

20. Table 1.4. Results of the second step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

cur

1

1

1

i

1

2

And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 2, becomes 3). \(3 < 10\), and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 3, therefore, a[3] is compared with a[2], that is, 4 is compared with 4. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 1, and will become 2.

20.1. Table 1.5. Results of the third step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

cur

1

1

1

2

i

1

2

3

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 3, becomes 4). \(4 < 10\), and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 4, therefore, a[4] is compared with a[3], that is, 4 is compared with 4. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 2, and will become 3.

20.2. Table 1.6. Results of the fourth step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

cur

1

1

1

2

3

i

1

2

3

4

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 4, becomes 5). \(5 < 10\), and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 5, therefore, a[5] is compared with a[4], that is, 2 is compared with 4. Equal? No! Therefore, what comes after else will be executed:

else:
    if cur > max_val:
        max_val = cur

Cur is currently equal to 3, max_val is equal to 1. \(3 > 1?\) Yes. So the statement inside the if is executed.

max_val = cur

And max_val receives the value 3.

Let us recall the placement of the statements:

else:
    if cur > max_val:
        max_val = cur
    cur = 1

The statement cur = 1 is inside the else block at the same indentation level as the if statement. This means that in the else case it must necessarily be executed:

cur = 1

Table 1.7. Results of the fifth step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

i

1

2

3

4

5

And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 5, becomes 6). 6 < 10, and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 6. Therefore, a[6] is compared with a[5], that is, 2 is compared with 2. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 1, will become 2.

Table 1.8. Results of the sixth step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

2

i

1

2

3

4

5

6

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 6, becomes 7). 7 < 10, and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 7. Therefore, a[7] is compared with a[6], that is, 2 is compared with 2. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 2, will become 3.

20.3. Table 1.9. Results of the seventh step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

2

3

i

1

2

3

4

5

6

7

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 7, becomes 8). 8 < 10, and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i is 8. Therefore, a[8] is compared with a[7], that is, 2 is compared with 2. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 3, will become 4.

20.4. Table 1.10. Results of the eighth step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

2

3

4

i

1

2

3

4

5

6

7

8

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 8, becomes 9). 9 < 10, and so control is once again passed inside the loop:

if a[i] == a[i - 1]:

The value of i = 9. Therefore, a[9] is compared with a[8], that is, 2 is compared with 2. Equal? Yes! Therefore, what comes after the if condition will be executed:

cur = cur + 1

Cur is currently equal to 4, will become 5.

20.5. Table 1.11. Results of the ninth step of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

2

3

4

5

i

1

2

3

4

5

6

7

8

9

At the same time, the statements in the else block are not executed (since the if condition was true). And since there are no more statements in the loop body, control is once again passed to the line at the beginning of the loop:

for i in range(1, 10):

i moves to the next value (was 9, becomes 10). 10 is not < 10, and so the loop is finished and control is passed to the statement following the loop:

print(max_val)

What is in our variable max_val? 3! The number 3 will be output as the answer (Table 1.12). This is an error, since the correct answer is 5 for this test.

20.6. Table 1.12. Final result of the manual trace

Variable Value

Step
0

Step
1

Step
2

Step
3

Step
4

Step
5

Step
6

Step
7

Step
8

Step
9

Step
10

A

5 11 4 4 4 2 2 2 2 2

max_val

1

1

3

cur

1

1

1

2

3

1

2

3

4

5

i

1

2

3

4

5

6

7

8

9

10

The error is that the algorithm does not handle the case when the largest number of consecutive elements results from the list ending, i.e., when the corresponding consecutive elements are at the end of the list. Fixing the algorithm is easy: it is sufficient to add an additional comparison of the variables max_val and cur after the loop ends.

Input A (10 elements, 0-indexed)
max_val = 1, cur = 1
for i in range(1, 10):
    if a[i] == a[i - 1]:
        cur = cur + 1
    else:
        if cur > max_val:
            max_val = cur
        cur = 1
if cur > max_val:
    max_val = cur
print(max_val)

Now that we are convinced the algorithm works correctly, it is easy to write the text of the corresponding program based on it. How this is done is described in the following subsection.

But first, a few remarks about the manual trace. Many probably found that the manual trace is a very labor-intensive process. And some will try to get by without it. This is a dangerous path and may lead to a significantly greater waste of time. Meanwhile, regular use of the manual trace leads to your brain performing this work subconsciously even at the moment when you are just composing the algorithm. This, accordingly, sharply reduces the number of errors you make when developing ever newer algorithms. Furthermore, performing a manual trace makes it easy to find errors during the debugging stage.

21. Translating an algorithm into a Python program

Let us consider the algorithm and its representation in the Python programming language:

# Algorithm  # Python
# max = 1, cur = 1                    max_val = 1; cur = 1
# for i from 1 to 9 (0-indexed)      for i in range(1, 10):
#   if a[i] == a[i-1]                     if a[i] == a[i - 1]:
#     then cur = cur + 1                      cur += 1
#     else if cur>max then max=cur        else:
#                                             if cur > max_val:
#       cur = 1                                   max_val = cur
#                                             cur = 1
# if cur>max then max = cur           if cur > max_val:
#                                         max_val = cur
# Output (max)                        print(max_val)

Obviously, when translating an algorithm into a Python program, one should follow these simple rules.

  1. Use = for assignment (no special assignment operator needed).

for   if     then   else    output
for   if     :      else:   print
  1. Replace keywords with their Python counterparts:

  2. Use indentation to define code blocks — no semicolons or special end markers needed.

  3. The most "convenient" rule: Python uses indentation to group statements into blocks. Where the algorithm implies grouping by nesting, simply indent the lines by the same amount. No "operator brackets" like begin…​end are needed.

  4. Python does not require variable or array declarations. Simply assign values as needed, and use lists for arrays. As a result, we obtain the program text.

Listing 1.6. Solution of the consecutive elements search problem
# MaxSucc
n = 10
print('Enter 10 numbers')  # Input
a = [int(input()) for _ in range(n)]  # of a list of n elements
max_val = 1
cur = 1
for i in range(1, n):  # Loop from index 1 to n-1
    if a[i] == a[i - 1]:  # If they are consecutive
        cur += 1  # then count them
    else:  # else
        if cur > max_val:  # remember the maximum
            max_val = cur
        cur = 1  # start counting from the beginning
if cur > max_val:
    max_val = cur
for i in range(n):  # Output the list
    print(a[i], end=' ')
print()
print('Answer =', max_val)  # Output the result

22. Basic techniques for working in the Python environment

This subsection provides, in reference format, the basic techniques for working in the Python environment for editing and debugging programs.

  1. Launch — python3 (or python) from the command line, or use an IDE such as IDLE, VS Code, or PyCharm.

    • Open your .py file in any text editor or IDE;

    • Use standard editor shortcuts for cut, copy, paste, undo;

    • Save the file with Ctrl+S.

    • Run the program: python3 my_program.py;

    • In IDLE, press F5 to run;

    • Use the built-in debugger: python3 -m pdb my_program.py;

    • Set breakpoints in your IDE for step-by-step execution.

  2. Exit — exit() or Ctrl+D in the interactive interpreter.

    • Use print() statements for quick inspection;

    • Use the debugger’s p variable_name command to inspect values;

    • In an IDE, hover over variables or use the watch window.

23. Methodological guidelines for solving problems 6, 8, 16-18, 22-24

Solving problems 6, 8, 16-18 will not require you to learn new theoretical material. You only need to consistently apply the plan for developing algorithms and programs proposed in this chapter,

starting from reformulating the problem statement and ending with the manual trace, and you should succeed!

The author’s many years of experience show that it is precisely at this stage that both the student and the teacher can determine whether the student is able and willing to pursue learning programming.

To solve problems 22-24, you need to know a few new facts about the Python programming language, which are presented below.

  1. An operation on integers — // — integer (floor) division:

k = m // n

+ As a result of this operation, the variable k receives a value equal to the quotient of dividing the integer m by the integer n (rounded down). For example, if m = 11 and n = 4, then the quotient of dividing 11 by 4 is 2, and therefore the variable k will receive the value 2.

+ 2. An operation on integers — % — obtaining the remainder from division:

k = m % n

As a result of this operation, the variable k receives a value equal to the remainder of dividing the integer m by the integer n. For example, if m = 11 and n = 4, then the remainder of dividing 11 by 4 is 3, and therefore the variable k will receive the value 3.

24. Problems for independent solving

The problem statements are taken from the first computer science textbook of 1985, compiled by Academician Yu. A. Ershov, who believed that schoolchildren should learn to solve such problems after two years of studying computer science.

The following notations are used below:

  • int list a[0:10] — a list of 10 integers;

  • float list a[0:10] — a list of 10 real (floating-point) numbers;

  • int 2D list a[0:5][0:5] — a two-dimensional list of 25 numbers (5 rows by 5 columns).

Problem statements:

  1. Find the number of non-zero elements in the list int list a of 10 elements.

  2. Find the number of elements in the list float list a of 10 elements whose absolute value is greater than 7.

  3. Compose an algorithm that gives the answer "yes" or "no" depending on whether the number 7 occurs in the list int list a of 10 elements.

  4. Given an integer list a of 10 elements. Find the difference between the largest and smallest numbers in this list.

    1. a[i] < b[i];

    2. a[i] == b[i];

    3. a[i] > b[i].

  1. Given an integer list a of 10 elements. Count the number of such i that a[i] is not less than all previous elements of the list (a[0], a[1], …​, a[i-1]).

  2. Given a float list a of 10 elements. Find the number of elements of this list that are greater than the arithmetic mean of all its elements.

  3. Given an integer list a of 10 elements. Fill a float list b of 10 elements, whose i-th element equals the arithmetic mean of the first i+1 elements of list a:

\(b[i\) = (a[0] + …​ + a[i]) / (i + 1)]. . Given an integer list a of 10 elements. Count how many times the maximum value appears in this list. . Given a rectangular integer 2D list a of size 5x5. Change all elements in this list to their opposite sign. . Given a rectangular integer 2D list a of size 5x5. Find the largest number occurring in this list. . Given an integer list a of 10 elements. Check whether it contains elements equal to 0. If yes, find the index of the first one, i.e., the smallest i for which a[i] == 0. If no, output the word "no". . Given an integer list a of 10 elements. Check whether it contains negative elements. If yes, find the largest i for which a[i] < 0. . Check whether a rectangular integer 2D list a of size 5x5 is a "magic square" (meaning that the sums of numbers in all its columns, all its rows, and both diagonals are equal). . Given an integer list a of 10 elements. Count the largest number of consecutive identical elements in it. . Count the number of distinct numbers occurring in int list a of 10 elements. Repeated numbers should be counted once. . Given an integer list a of 10 elements. Build int list b of 10 elements containing the same numbers as list a, but in which all negative elements precede all non-negative ones. . Given integer lists a and b, each of 10 elements, where: a[0] < a[1] < …​ < a[9],

b[0] < b[1] < …​ < b[9]. Build a list c of 20 elements containing all elements of lists a and b, in which

c[0] < c[1] < …​ < c[19]. . Given a rectangular integer 2D list a of size 5x5. Find the number of those row indices i from 0 to 4 for which a[i][j] == 0 for some column index j from 0 to 4. . Given a rectangular integer 2D list a of size 5x5. Find the smallest integer K having the following property: in at least one row of the list, all elements do not exceed K.

  1. Given a rectangular integer 2D list a of size 5x5. Find the largest K having the following property: in every row of the list, there is an element greater than or equal to K.

  2. Write a program for factoring natural numbers into prime factors.

  3. A natural number is called perfect if it equals the sum of all its divisors, not counting itself (for example, 6 = 1 + 2 + 3 is a perfect number). Write an algorithm that checks whether a given number is perfect.

  4. Print in ascending order the first 1000 numbers that have no prime divisors other than 2, 3, and 5. (Beginning of the list: 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 15 …​).

25. 1.2. Features of the Python programming language

The previous section was aimed at "quick immersion" of the thoughtful reader into program development in Python. This basic knowledge — integer and float variables, arithmetic and logical expressions, assignment, conditional, and loop statements — is sufficient to write a solution to almost any problem. Nevertheless, the Python programming language has many additional features and built-in functions that simplify program development and debugging. This section is devoted to an overview of these features.

25.1. Computer arithmetic

Theoretically, one can write programs without knowing in detail how exactly the computer is built and works, how the problem data is stored, how it is processed, and so on. But having such an understanding certainly enables one to write programs faster and more efficiently. The author finds it appropriate to draw a comparison with driving a car. Yes, you can drive a car knowing how to start it and steer it. However, it is obvious that the more the driver knows about the car’s design, the more likely they are to avoid an accident by pressing the gas or brake pedal at the wrong time, not to stop in the middle of the road because of running out of fuel, and so on.

25.2. Bit representation of information in a computer

A computer is designed in such a way that the minimum unit of information storage is a bit, which can hold only one digit — 0 or 1. Interestingly, the computer manufacturing technology may change (mechanical, electromechanical, electronic, optical, biochemical), but at the core there is always the ability to represent, using various physical, chemical, or biological processes, two numbers — 0 and 1. It has been theoretically proven that if it is possible to do this, and also to ensure the execution of two simple operations NOT and 2-AND, then an arbitrarily complex computer can be built on such a basis! This means that any computer can be constructed from elements of only these two types.

Let us explain how each of these elements works.

  • A signal \(X\) arrives at the input (let us remind you once more that it can take only one of two possible values — 0 or 1).

    1. if 1 was received, then 0 will appear at the output;

    2. if 0 was received, then 1 will appear at the output. In table form, these rules are written as follows:

X Y

0

1

1

0

  • If both input signals are equal to 1: \(X1 = 1\) and \(X2 = 1\), then the output signal is \(Y = 1\).

  • Otherwise \(Y = 0\). Below is the truth table for the 2-AND element:

X1 X2 Y

0

0

0

0

1

0

1

0

0

1

1

1

26. Byte

It is clear that a person wants to process numbers larger than 0 and 1. To represent any numbers, sequences of 0s and 1s are used, or, as they also say, sequences of bits.

A sequence of 8 bits is called a byte.

Question: how many distinct 8-bit sequences exist? Or the same question, phrased somewhat differently: how many distinct numbers can be represented in one byte?

Let us try to list all 8-bit sequences (Table 1.13). For greater clarity, we will split the byte into 2 parts of four bits each, or, as they also say, represent the byte as two nibbles.

Have we listed all possible 8-bit sequences? Clearly, not all, but only those whose first 2 bits are zeros. In other words, we have listed all 6-bit sequences (if we ignore (or erase) the first two zeros of each 8-bit sequence). In total, we obtained

64 distinct sequences, which can represent 64 distinct numbers — we numbered them from 0 to 63.

Table 1.13. Numbers (from 0 to 63) and the corresponding 8-bit sequences — bytes

Number Sequence Number Sequence

0

0000 0000

32

0010 0000

1

0000 0001

33

0010 0001

2

0000 0010

34

0010 0010

3

0000 0011

35

0010 0011

4

0000 0100

36

0010 0100

5

0000 0101

37

0010 0101

6

0000 0110

38

0010 0110

7

0000 0111

39

0010 0111

8

0000 1000

40

0010 1000

9

0000 1001

41

0010 1001

10

0000 1010

42

0010 1010

11

0000 1011

43

0010 1011

12

0000 1100

44

0010 1100

13

0000 1101

45

0010 1101

14

0000 1110

46

0010 1110

15

0000 1111

47

0010 1111

16

0001 0000

48

0011 0000

17

0001 0001

49

0011 0001

18

0001 0010

50

0011 0010

19

0001 0011

51

0011 0011

20

0001 0100

52

0011 0100

21

0001 0101

53

0011 0101

22

0001 0110

54

0011 0110

23

0001 0111

55

0011 0111

24

0001 1000

56

0011 1000

25

0001 1001

57

0011 1001

26

0001 1010

58

0011 1010

27

0001 1011

59

0011 1011

28

0001 1100

60

0011 1100

29

0001 1101

61

0011 1101

30

0001 1110

62

0011 1110

31

0001 1111

63

0011 1111

So how many 8-bit sequences are there after all? To find out, we need to write three more such tables: the numbers in the first table will have the first 2 bits as 0 and 1, in the second — 1 and 0, and in the third — 1 and 1. The total number of sequences of

eight bits is 256. Try as an exercise to list all these numbers yourself.

So, in one byte one can represent 256 distinct numbers (we numbered them from 0 to 255).

Let us think: could we have obtained this number — 256 — without listing all possible 8-bit sequences?

So, we have 8 positions in total, and each of them can contain one of two numbers — 0 or 1.

Thus, for 2-bit numbers there are \(2 * 2 = 4\) variants, for 3-bit numbers — \(2 * 2 * 2 = 8\) variants, for 4-bit numbers — \(2 * 2 * 2 * 2 = 16\) variants. And for a byte — \(2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 = 256\) variants.

Or using exponentiation, this can be written as

\(28 = 256\).

This is read as: two raised to the eighth power (i.e., 2 multiplied by itself 8 times) equals 256.

27. Hexadecimal number system

Already while listing Table 1.13, you may have noticed that representing numbers as sequences of 0s and 1s (or, as they also say, in the binary number system) is extremely tedious for a human, although this is exactly how they are stored in computer memory. Therefore, humans invented yet another way of representing numbers — the hexadecimal number system. This system has the following remarkable properties.

  • Hexadecimal numbers are much more understandable and easier to read for humans than binary ones.

  • There are simple methods for converting numbers from the decimal number system to hexadecimal and back.

  • There are extremely simple methods for converting numbers from the hexadecimal number system to binary and back.

Let us start with the simplest — converting numbers from binary to hexadecimal and back.

First of all, you need to memorize the hexadecimal number table (Table 1.14).

To convert numbers from binary to hexadecimal, it suffices to replace each nibble with the corresponding digit or letter. For example: 0011 1011 = 3B.

To convert numbers from hexadecimal to binary, it suffices to perform the reverse action — replace the letter or digit with the corresponding nibble (from the table below). For example: C7 = 1100 0111.

The space between nibbles is left for clarity.

28. Table 1.14. Number notation systems

Decimal Binary Hexadecimal

0

0000

0

1

0001

1

2

0010

2

3

0011

3

4

0100

4

5

0101

5

6

0110

6

7

0111

7

8

1000

8

9

1001

9

10

1010

A

11

1011

B

12

1100

C

13

1101

D

14

1110

E

15

1111

F

The rules for converting numbers from the decimal number system to hexadecimal and back are somewhat more complex. But since knowledge of them is not required for understanding the subsequent material, they are not provided here.

29. Numeric data types

In this subsection, we will look at the ways of representing numbers of various types in Python programs. Unlike many compiled languages, Python handles most numeric details automatically, but understanding how numbers are stored in a computer helps you write better programs.

29.1. Python’s numeric types

Python provides the following built-in numeric types:

  • int — integers of arbitrary precision (Python automatically handles big numbers, so there is no overflow!).

  • float — floating-point numbers (64-bit double precision, similar to C’s double).

  • complex — complex numbers (rarely needed in competitive programming).

Unlike languages such as C or Pascal, Python’s int type has no upper limit. You can work with numbers as large as you need without worrying about overflow. This is a huge advantage in competitive programming!

Python’s float type uses 8 bytes (64 bits) and provides about 15-16 significant decimal digits, with a range approximately from \(5.0x10-324\) to \(1.7x10308\).

Listing 1.7. Division of real numbers
# e1
l1 = int(input())
l2 = int(input())
r = l1 / l2
print(r)

By default, Python outputs floating-point numbers with full precision. For example, if in program e1 (Listing 1.7) you enter 5 and 3 as the input data for l1 and l2, then the answer will be displayed as: 1.6666666666666667.

To output a real number in a more familiar form with a specific number of decimal places, you can use formatted output:

print(f"{r:.3f}")

And the answer will be obtained in the following form: 1.667.

That is, we told the computer to output the real number rounded to three significant digits after the decimal point.

Python does not require any special compiler options to use different numeric types — they are all available by default.

For example:

# e2
l1 = int(input())
l2 = int(input())
d = l1 / l2
print(f"{d:.3f}")

To compute the integer remainder or quotient from dividing integers, you should use the % or // operators respectively. More details on this have already been written in the "Non-standard algorithms and programs" section.

In Python, the result of the / operator is always a float, even if both operands are integers. Use // for integer (floor) division.

Python’s int type has unlimited precision, so you never need to worry about integer overflow — unlike many other programming languages.

30. Range checking in Python

Python does not have built-in subrange types like some other languages. However, you can easily validate ranges yourself using assertions or if-statements:

# e3
day1 = int(input())
assert 1 <= day1 <= 31, "Day must be between 1 and 31"
day2 = day1 + 1
assert 1 <= day2 <= 31, "Day must be between 1 and 31"
print(day1, day2)

Now, upon attempting to enter a number for the variable day1 that is greater than 31 or less than 1 (for example, 32 or 0), the assertion will raise an error. Moreover, if 31 is entered for day1, an error will be raised on the assertion for day2, since when day1 = 31, day2 would have to receive the value 32, which is outside the valid range.

Python’s philosophy is "we are all consenting adults here" — the language trusts the programmer to validate input when needed, rather than enforcing rigid type constraints.

31. Boolean (logical) type bool

Most programs being developed have a nonlinear structure, meaning that depending on certain input data values, different parts of the program may be executed. Logical variables can be useful for controlling the selection of the needed parts of the program.

In Python, boolean variables are of type bool. They can take only 2 values: False and True.

The following operations are defined on boolean variables:

  1. and — "AND";

  2. or — "OR";

  3. ^ — "Exclusive OR";

  4. not — "NOT".

A boolean variable can also be formed as a result of arithmetic comparisons: "less than", "less than or equal to", "greater than", "greater than or equal to", "equal to", and "not equal to" (respectively <, , >, >=, ==, !=).

Examples of declaration and use:

is_empty_s: bool = True
decision_achieved: bool = False

Two boolean variables are declared — is_empty_s and decision_achieved; the first is assigned the value True, the second — the value False.

decision_achieved = (k == edge_number)

The variable decision_achieved will receive the value True if the value of variable k equals the value of variable edge_number, and the value False otherwise. That is, this is a shorter way of writing the following statement:

if k == edge_number:
    decision_achieved = True
else:
    decision_achieved = False

And here are statements for independent analysis:

is_current_decision = k > max_k
is_all_one_line = p != 0

Below is an example of organizing a loop using boolean variables:

while k > 0 and not decision_achieved:
    ...

that is:

while k > 0 and decision_achieved is False:
    ...

Again, a statement for independent analysis:

while not is_empty_s:
    ...

An example of using boolean variables in a conditional statement:

if is_current_may_continued and (not is_out):
    k += 1

If is_current_may_continued is True and is_out is False then k = k + 1

And again, a statement for independent analysis:

if is_current_decision:
    put_decision()

32. Character and string types

Until now, we have studied programs that can process numbers (integers and floats) and lists of numbers. But in real life, programs that process textual information are also needed. For example, a program for creating/editing a class register that needs to input, output, and edit student surnames.

32.1. Characters in Python

How does the computer manage to process letters if we have already agreed that there is nothing in computer memory except 0s and 1s? The thing is that there exist so-called encoding tables, in which each character (letters, digits, punctuation marks and arithmetic operation signs, etc.) is associated with its own number. In Python, there is no separate character type — a single character is simply a string of length 1. Python uses Unicode by default, which includes the older ASCII table (American Standard Code for Information Interchange) as a subset.

In Python, you can get the numeric code of a character and vice versa:

  • ord('A') — returns the Unicode code of the character (65 for 'A');

  • chr(65) — returns the character with the given code ('A');

  • hex(ord('A')) — returns the hexadecimal code as a string ('0x41').

Here is an example of working with characters:

ch = input("Press a key and Enter: ")
# In Python, input() reads a full line; we take the first character
if len(ch) > 0:
    ch = ch[0]
    print(f"You entered: '{ch}', code: {ord(ch)}")

Even more interesting is to write a program for reading characters and outputting their corresponding codes. By running it, you will be able to find out the code of any character. This program requires entering one character from the keyboard and outputs its code on the screen, until you type the letter z.

Listing 1.8. A program that outputs the code of the entered character on the screen
# ReadKeys
while True:
    line = input('Press any key and Enter: ')
    if not line:
        continue
    c = line[0]
    print(f'You pressed "{c}", Unicode code = {ord(c)}')
    if c in ('z', 'Z'):
        break

Python uses Unicode by default, which means it handles characters from virtually any language (including Russian, Chinese, Arabic, etc.) without any special effort. Unlike older systems where different encoding tables caused problems, Python 3 makes working with international text straightforward.

32.2. String type str

In Python, strings are built-in objects of type str. You can create them simply by assigning a value:

s = "Hello, World!"  # s is a string of 13 characters

Python strings have no fixed maximum length — they can grow as large as your computer’s memory allows. This is much more convenient than in languages where you must pre-declare a maximum string size.

Strings in Python are immutable — once created, individual characters cannot be changed. To modify a string, you create a new one.

33. Standard type conversion functions

It is often very useful to convert between types — for example, from float to int, or from string to number and back. Python has built-in functions for this. Brief information about them is provided below.

Table 1.17. Standard type conversion functions in Python

Function Argument type Result type Conversion

n % 2 != 0

int

bool

True if the number is odd, otherwise False

int()

float/str

int

Truncates the fractional part (or converts a string)

round()

float

int

Rounds to the nearest integer

ord()

str (1 char)

int

Returns the Unicode code of the character

chr()

int

str (1 char)

Returns the character with the given code

int(s) / float(s)

str

int / float

Converts a string to a number

str()

any

str

Converts a value to a string

34. Text files

In most olympiad problems, input from one text file and output to another text file are required.

A text file is a sequence of character strings of variable length, separated by newline characters; it ends at the end of the file.

In Python, you work with files using the built-in open() function:

f = open('input.txt', 'r')  # open for reading

The main operations with text files that interest us are:

  • opening for reading, reading lines;

  • opening for writing, writing lines;

  • checking for end of file;

  • closing.

35. Redirecting standard input/output at launch

Suppose we have a program p_cut_v9.py that reads from standard input and writes to standard output, and a file with prepared test data test7.dat is located in the same directory.

To ensure the program reads data from the test file and outputs to another file (in the example, test7.out), it suffices to use shell redirection:

  • python3 p_cut_v9.py <test7.dat — read from file test7.dat;

  • python3 p_cut_v9.py >test7.out — output to file test7.out;

  • python3 p_cut_v9.py <test7.dat >test7.out — read from file and output to file;

  • python3 p_cut_v9.py <test7.dat >/dev/null — read from file, output "to nowhere".

36. Redirecting standard input/output within a program

Below is a technique for redirecting standard input within a Python program. You can use the sys module to redirect stdin and stdout:

import sys

if len(sys.argv) < 2:
    print('No data file name')
    sys.exit(1)
else:
    print(f' file: {sys.argv[1]}')

sys.stdin = open(sys.argv[1], 'r')
# Now input() will read from the specified file

Here:

  • sys.argv — a list of command-line arguments;

  • len(sys.argv) — the number of arguments (including the program name);

  • sys.argv[1] — the first command-line argument (the file name);

  • sys.stdin — the standard input stream (used by input());

  • sys.stdout — the standard output stream (used by print()).

37. Running a program from the command line

To run a Python program with a test file passed as a command-line argument:

python3 p_cut_v9.py test7.dat

38. A simple example of working with text files

Listing 1.9 presents the text of a program that reads data from a file and outputs the result to another file. In Python, this is done using the open() function and optionally redirecting sys.stdin and sys.stdout.

Listing 1.9. Example of working with text files
import sys

# Open input file for reading
sys.stdin = open('test7.dat', 'r')

# Read variables n and m from file
n, m = map(int, input().split())

# ... process data here ...

# Close input
sys.stdin.close()

# Open output file for writing
sys.stdout = open('test7.out', 'w')

# Write variables n and m to file
print(n, m)

# Close output
sys.stdout.close()

39. 1.3. Program development methodology

This section is aimed at readers who have some programming skills in Python and is intended to help develop a proper programming style that ensures productive programming and effective support of the long lifecycle of the programs being developed. Furthermore, the program development method presented in this section will provide a natural transition for learners to object-oriented programming in the future.

39.1. General information

So, the main paradigms of program development in the proposed method are as follows.

  1. Create a complete set of tests for the problem being solved.

  2. Introduce data types that correspond to the essence of the problem.

  3. Introduce operations on the introduced data types that should also correspond to the essence of the problem. Implement these operations as functions.

  4. Develop the program "top-down", deferring the clarification of details that are insignificant at the current level of program development.

  5. When writing the actual program text, ensure maximum readability by using adequate names for variables, lists, and functions, as well as reasonably commenting the program text at the input stage (before debugging begins).

It should be noted that Python supports defining custom data types through classes and dataclasses. Therefore, introducing data types that correspond to the essence of the problem not only improves the clarity and readability of the program, reduces its text, and accordingly decreases the number of logical errors in the program, but also makes the code more maintainable and testable.

This development ideology assumes that the programmer defines new data types (using classes or dataclasses), creates instances of those types, develops functions for processing these instances (introduces allowed operations on the data), constructs the data processing algorithm based on the developed functions, and uses Python’s type hints to document the expected types.

The proposed program development method will be further illustrated by solving two problems.

40. Example of solving the line search problem

From a given set of points on a plane, select two distinct points such that the number of points lying on opposite sides of the line passing through these two points differs by the least amount.

  1. Create a complete set of tests for the problem being solved.

Table 1.18. Test examples

Input data Answer

1 point

Any line passing through it

2 identical points

Any line passing through them

2 distinct points

The line passing through them

A set of points, only 2 on the target line,
difference = 0

The target line (i.e., such a line that the numbers of points lying on opposite sides of it differ by the least amount)

A set of points, 5 points on the target line,
difference = 0

The target line

A set of points, only 2 on the target line,
difference = 3

The target line

A set of points, 5 points on the target line,
difference = 3

The target line

+ . Introduce data types that correspond to the essence of the problem.

Obviously, this problem will require the data types: point, line, and set of points. In Python, we can define these using dataclasses or named tuples:

from dataclasses import dataclass

@dataclass
class Point:
    x: float = 0.0  # Type POINT - coordinates X and Y
    y: float = 0.0

@dataclass
class Line:  # Type LINE
    a: float = 0.0  # coefficients in the equation
    b: float = 0.0  # Ax + By + C = 0
    c: float = 0.0

MAX_POINT_NUMBER = 100
points: list[Point] = []  # Set of points
  1. Introduce operations on the introduced data types. These operations should also correspond to the essence of the problem and be implemented as functions. To perform this work, one or more iterations of semantic algorithm development must first be carried out.

Algorithm:

  1. for all pairs of points, construct lines;

  2. for each such line, count the number of points on opposite sides of the line, simultaneously selecting the minimum differences;

  3. draw a picture of the solution.

Let us introduce the following operations (functions):

  • init_line(p1, p2) → Line — constructs a Line from 2 points p1 and p2;

  • difference(line, points) → tuple[int, int] — a function that computes the difference between the numbers of points lying on opposite sides of the line, simultaneously returning the number of points located directly on the line;

  • output_result(min_dif, point1, point2, points) — text output of the result;

  • draw_result() — graphical illustration.

    1. Develop the program "top-down", deferring the clarification of details that are insignificant at the current level of program development.

Now we can write the main part of the program directly in Python, using the introduced operations (Listing 1.10).

Listing 1.10. Main part of the line search program
found = False
for i in range(count_points - 1):  # For points from the first
    if min_dif == 0:  # If found, then exit
        break
    for j in range(i + 1, count_points):  # For points from the next
        if min_dif == 0:  # If found, then exit
            break
        line = init_line(s[i], s[j])  # Build the line
        dif, on_line_current = difference(line, s)  # Compute the difference
        if min_dif > dif:  # if it is less than
                                            # the minimum

40.1. Listing 1.10 (continued)

            min_dif = dif  # Minimum difference
            point1 = i  # Index of 1st point
            point2 = j  # Index of 2nd point
            on_line = on_line_current  # Points on this line

output_result(min_dif, point1, point2, s)  # Text output
if draw_flag:
    draw_result()  # Graphical illustration

Then we can move on to the next level of detail — implementing the functions introduced in the main body of the program.

The function for constructing a line:

def init_line(pa: Point, pb: Point) -> Line:
    line = Line()
    line.a = pb.y - pa.y
    line.b = pa.x - pb.x
    line.c = pa.y * (pb.x - pa.x) - pa.x * (pb.y - pa.y)
    return line

The theoretical basis of this function is the well-known equation of a line through two points:

\(Y - Y1 / Y2 - Y1 = X - X1 / X2 - X1\).

Let us transform it into the canonical equation of a line:

\(Ax + By + C = 0\).

We obtain: \(A = Y2 - Y1, B = X2 - X1, C = Y1 * (X2 - X1) - X1 * (Y2 - Y1)\). This is essentially what is implemented in the init_line function using Python’s dataclass attribute access.

The difference function:

  • determines the difference between the number of points on opposite sides of the line;

  • counts the number of points lying directly on the line.

def difference(line: Line, s: list[Point]) -> tuple[int, int]:
    d = 0
    on_line_current = 0
    for i in range(count_points):  # Loop over all points
        temp = sign_point(line, s[i])  # Compute the sign of the point
        d += temp  # Accumulate the difference
        if temp == 0:
            on_line_current += 1  # and the number of points on
                                              # the current line
    return abs(d), on_line_current  # We don't care which
                                              # side has more points

Note that when implementing this function, the author introduced yet another function — sign_point(line, p), which determines the sign of point p relative to line. Thus, the following level of detail appeared:

The function for computing the sign of a point relative to a line:

def sign_point(line: Line, p: Point) -> int:  # Compute the sign of the point
    r = line.a * p.x + line.b * p.y + line.c  # relative to the line
                                               # by substituting the point's
                                               # coordinates into the
    if abs(r) <= MISTAKE:  # line equation
        return 0  # 0 - on the line
    elif r < 0:
        return -1  # -1 - on one side
    else:
        return 1  # 1 - on the other side

When comparing real (floating-point) numbers, we always check them not for equality, but that the absolute value of their difference is less than some small value (MISTAKE), determined by the computational precision set by the programmer in the constants section, for example:

MISTAKE = 1e-6  # MISTAKE = 0.000001

When writing the actual program text, it is necessary to ensure maximum readability by using adequate names for variables, lists, and functions, as well as reasonably commenting the text at the stage of entering the program text (before debugging begins).

The attentive reader has noticed that practically all the program fragments presented are accompanied by informative comments. Furthermore, the names of variables, lists, and functions reflect the semantics of the corresponding concepts.

41. Example of solving the triangle set problem

A set of N arbitrarily intersecting line segments is given on a plane. Enumerate the set of all triangles formed by the indicated segments.

  1. Creating a complete set of tests for the problem being solved. We leave the reader the opportunity to resolve this question independently.

from dataclasses import dataclass

@dataclass
class Point:
    x: float = 0.0  # Type POINT - X and Y coordinates
    y: float = 0.0
  1. Introduce data types that correspond to the essence of the problem. Obviously, the following data types will be needed in this problem: point, segment, triangle, set of points, segments, and triangles. In Python, this can be written as follows:

@dataclass
class Segment:  # Type SEGMENT
    a: Point = None  # start point
    b: Point = None  # end point

    def __post_init__(self):
        if self.a is None: self.a = Point()
        if self.b is None: self.b = Point()

@dataclass
class Triangle:  # Type TRIANGLE
    a: Point = None  # vertices of the triangle
    b: Point = None
    c: Point = None

    def __post_init__(self):
        if self.a is None: self.a = Point()
        if self.b is None: self.b = Point()
        if self.c is None: self.c = Point()

MAX_POINT_NUMBER = 100
MAX_SEGMENTS_NUMBER = 100
MAX_TRIANGLE_NUMBER = 100

points: list[Point] = []
segments: list[Segment] = []
triangles: list[Triangle] = []

+ . Introduce operations on the defined data types that should also correspond to the nature of the problem. Implement these operations as functions.

In order to accomplish this work, it is first necessary to perform one or more iterations of semantic algorithm development.

Algorithm:

Input list of segments
draw the segments
count = 0
loop through all triples of segments
  IF all 3 pairs of segments intersect
    (forming 3 distinct points)
    and these points are not collinear
  THEN count += 1
    save the segment indices
    draw the triangle
print(count)

Let us introduce the following operations:

  • find_segment_intersect_point(seg1, seg2) → tuple[bool, Point]. Determines whether segments seg1 and seg2 intersect. If so, the function returns True and the intersection point.

  • triangle_init(p1, p2, p3) → Triangle. Initializes a Triangle from three given points p1, p2, p3.

  • triangle_area(triangle) → float. Computes the area of the triangle.

  • is_zero(r: float) → bool. Determines whether a floating-point variable equals zero. As mentioned earlier, it is not recommended to simply compare floating-point numbers for equality.

  • add_triangle(triangle_set, triangle). Adds a new triangle to the set of previously found triangles.

  • out(count_triangles). Outputs the result of the program.

count_triangles = 0
for i1 in range(len(segments) - 2):
    seg1 = segments[i1]
    for i2 in range(i1 + 1, len(segments) - 1):
        seg2 = segments[i2]
        point1 = find_s_intersect_point(seg1, seg2)
        if point1 is None:
            continue
        for i3 in range(i2 + 1, len(segments)):
            seg3 = segments[i3]
            point2 = find_s_intersect_point(seg1, seg3)
            point3 = find_s_intersect_point(seg2, seg3)
            if point2 is None or point3 is None:
                continue
            triangle = Triangle(point1, point2, point3)
            if not is_zero(triangle.area()):
                add_triangle(triangle_set, triangle)
print(count_triangles)
  1. Develop the program "top-down," deferring the refinement of details that are not essential at the current level of program development. Now we can write the main part of the program directly in the Python programming language, using the introduced operations:

The implementation of the introduced functions is left as an exercise for the thoughtful reader. . When writing the actual program text, ensure maximum readability by using adequate names for variables, lists, and functions, as well as reasonably commenting the text at the stage of entering the program text (before debugging begins).

The attentive reader has noticed that the names of variables, lists, and functions reflect the semantics of the corresponding concepts.

42. Questions and Answers

The presentation of problem solutions may seem overly concise to some, not revealing many details and not answering many questions that an inexperienced reader might have. This was done intentionally, in order to convey as clearly as possible the essence of the proposed approach to solving problems.

As for answers to the reader’s questions, three ways of resolving the issue are suggested.

  1. Anyone interested can join the project "Distance Learning in Belarus" (WWW:http://dl.gsu.unibel.by, E-mail:dl-service@gsu.unibel.by), receiving via Internet-online and/or by e-mail theory, problems, consultations, as well as the ability to submit solutions for automated testing, which is carried out daily and around the clock without breaks for weekends and holidays.

  2. Anyone interested can send their question regarding this material directly to the author at: dolinsky@gsu.unibel.by.

  3. Answers to questions that the author himself posed, anticipating their possible appearance among readers, are presented below.

Question 1. What is float in the following line:

TReal = float  # Redefined real type

and why was it necessary to redefine the real type at all: couldn’t we just use the familiar float type?

Answer to question 1.

In Python, the built-in float type corresponds to a 64-bit IEEE 754 double-precision floating-point number, giving about 15–17 significant decimal digits. This is the standard real type in Python and is sufficient for most tasks. If you need even higher precision, you can use the decimal.Decimal module from the standard library.

Defining a type alias like TReal = float means that our program will process real numbers with a consistent type. At the same time, if it becomes necessary to change the type of real numbers (for example, to Decimal for extra precision), it will be sufficient to replace just the one alias, and all our variables, points, segments, etc. will automatically switch to the new type as well.

Question 2. All the introduced types TReal, TPoint, TLine, etc. start with the letter T — is this mandatory?

Answer to question 2.

No.

But for many professional programmers, an important rule is the use of so-called "Hungarian notation," where one or more letters before the name define the functional purpose of that name (for example, T — type, then TPoint — type "point"). In Python, the convention is more commonly to use CamelCase for class names (e.g. Point, Line). This allows the programmer to navigate more easily in their own (and especially someone else’s) program, which is especially important for programs that operate and are maintained over several years.

Question 3. I haven’t worked with classes yet. Can you explain in more detail how to use them?

42.1. Answer to question 3.

It would be better, of course, to read some Python tutorial and then try it on the computer, but I’ll try to answer briefly as well.

So, first, we declare a new type — a class (using a dataclass for convenience):

from dataclasses import dataclass

@dataclass
class Point:
    x: float = 0.0  # Type POINT - X and Y coordinates
    y: float = 0.0

And variables of this type:

p1 = Point()
p2 = Point()

What does this lead to?

In the computer’s RAM, Python allocates memory for objects so that the values of the corresponding attributes can be stored and retrieved later.

In our example, float is a 64-bit floating-point number. A Point is defined as a combination of two real numbers (a point is defined by coordinates along the \(X\) and \(Y\) axes).

Then for points p1 and p2, Python allocates the necessary memory (for storing the coordinate along the \(X\) axis and for storing the coordinate along the \(Y\) axis).

To initialize a point, that is, to store the values corresponding to some actual point, for example (2, 4), you need to write the following in the program:

p1.x = 2
p1.y = 4

What makes the class type convenient?

The fact that you can refer to the entire object as a single entity. For example, to initialize the second point with the same values as the first, it suffices to write:

from copy import copy
p2 = copy(p1)

It is often necessary to access the attributes of variables individually. For example, if point p2 is 3 units to the right and 1 unit higher than point p1, then it can be initialized as follows:

p2.x = p1.x + 3
p2.y = p1.y + 1

To conclude this explanation, let us present the general form of accessing an attribute of an object: variable_name.attribute_name.

And let us remind you that object attributes can be accessed both for writing and for reading, that is, both on the left and on the right side of the assignment operator =.

Question 4. I haven’t worked with functions yet. What is a function? Where in the program are functions placed?

42.2. Answer to question 4.

First of all, again I would advise referring to Python tutorials. Nevertheless, I will provide a brief answer here as well.

43. Let us start with the definition of functions, which must appear before they are called:

# program ...

def init_line(p_a: Point, p_b: Point) -> Line:
    ...

# main program
init_line(...)

Let us examine the definition of function init_line in more detail:

def init_line(p_a: Point, p_b: Point) -> Line:
    a = p_b.y - p_a.y
    b = p_a.x - p_b.x
    c = p_a.y * (p_b.x - p_a.x) - p_a.x * (p_b.y - p_a.y)
    return Line(a, b, c)

where

  • init_line — is the name of the function;

  • p_a, p_b — are the parameters of the function.

A call to the function in the body of the program may look as follows:

l1 = init_line(p1, p2)

That is:

initialize line l1 from points p1 and p2

The advantages of using functions are that once defined, a function can be called many times. This is much simpler than directly coding the function’s content each time.

For example, to initialize line line from points point1 and point2, it suffices to write another function call:

line = init_line(point1, point2)

Note that when defining a function in Python, you can specify the types of the function’s parameters using type hints: Point — for variables p_a and p_b, and → Line for the return type. While Python does not enforce these at runtime, they serve as documentation and can be checked by tools like mypy.

In Python, unlike Pascal, there is no need for a special VAR keyword. If you pass a mutable object (like a list or a class instance), any modifications to it inside the function are visible outside. For immutable types (like numbers and strings), you simply return the new value. The init_line function is designed specifically to compute and return, based on the coordinate values of two points passed to it, the coefficients A, B, and C of the canonical equation of the line passing through the two given points.

Let us recall that the class Line is precisely defined as follows:

@dataclass
class Line:  # Type LINE
    a: float = 0.0  # coefficients in the equation
    b: float = 0.0  # Ax + By + C = 0
    c: float = 0.0

Thus, in Python, functions receive their inputs as parameters and return computed results using return statements. When you need to return multiple values, you can return a tuple.

What happens if you modify a mutable parameter (like a list or object) inside a function? The change will be visible in the calling code, because Python passes references to objects. But for immutable types (numbers, strings, tuples), modifications inside the function create new local values without affecting the caller.

Therefore, Python programmers always recommend precisely determining whether a function should modify its arguments in place or return new values, and design functions accordingly.

To clarify the concept of a function that returns a value, let us consider another example. Definition:

def difference(line: Line, points: list[Point]) -> tuple[int, int]:
    d = 0
    on_line_current = 0
    for i in range(len(points)):  # Loop through all points
        temp = sign_point(line, points[i])  # Compute the sign of the point
        d += temp  # Accumulate the difference
        if temp == 0:
            on_line_current += 1  # and the number of points on
                                            # the current line
    return abs(d), on_line_current  # We don't care which
                                            # side has more points

Call:

diff_val, on_line_current = difference(line, points)
if min_dif > diff_val:
    ...

First of all, let us note what makes Python functions powerful:

  • the ability to divide the task into logical subtasks;

  • functions can return multiple values using tuples:

# program ...

def difference(line: Line, points: list[Point]) -> tuple[int, int]:
    ...
    return abs(d), on_line_current

# main program
diff_val, on_line_current = difference(line, points)
if min_dif > diff_val:
    ...

The function signature with type hints:

def difference(line: Line, points: list[Point]) -> tuple[int, int]:

Here the function difference receives a line line and a list of points points, and returns a tuple containing the difference value and the number of points from the given set that are located exactly on the line.

Now let us consider how functions are used. In particular, calling functions:

l1 = init_line(p1, p2)
...
diff_val, on_line_current = difference(line, points)
if min_dif > diff_val:
    ...

The function is called and its return value is used directly in expressions. The name difference returns a tuple. In Python, the result type is specified using the annotation in the function definition — def difference(…​) → tuple[int, int]:, and in the body of the function, upon completion of computations, the return statement provides the result:

return abs(d), on_line_current

Using functions makes the program more compact and clearer.

Question 5. I am seeing the word break for the first time. What does it mean?

Answer to question 5.

We are talking about the following program text fragment:

for i in range(count_points - 1):  # For points from the first
                                           # to the second-to-last
    if min_dif == 0:
        break  # If found, then exit
    for j in range(i + 1, count_points):  # For points from the next
                                           # to the last
        if min_dif == 0:
            break  # If found, then exit
        ...

During the development of this program, it became clear: if a line is found such that the number of points on each side of it is equal, then the difference of these counts is 0 and it is minimal. A solution has been found, and there is no need to continue executing the program.

break exits the nearest enclosing loop (just one!). This is precisely why, to exit a double loop, two break statements were needed.

Question 6. I am seeing the word continue for the first time. What does it mean?

Answer to question 6.

We are talking about the following program text fragment:

for i1 in range(len(segments) - 2):
    seg1 = segments[i1]
    for i2 in range(i1 + 1, len(segments) - 1):
        seg2 = segments[i2]
        point1 = find_s_intersect_point(seg1, seg2)
        if point1 is None:
            continue
        for i3 in range(i2 + 1, len(segments)):
            seg3 = segments[i3]
            point2 = find_s_intersect_point(seg1, seg3)
            point3 = find_s_intersect_point(seg2, seg3)
            if point2 is None or point3 is None:
                continue
            ...

During the development of this program, it became clear that in the case when segments with indices i1 and i2 do not intersect, there is no need to examine all triples of segments i1, i2, and i3 in search of a triangle. That is, at this point we simply need to move on to the next segment after i2, which is what the first continue statement does. The second continue provides a transition to the next value of i3 in case at least one of the segments i1 or i2 does not intersect with the current segment i3.

Thus, continue skips the rest of the current loop iteration and jumps to continue the loop with the next value of the loop variable.

Question 7. And what do you have against goto statements? Why, instead of using just this one, are two (and perhaps there are more?) analogs invented?

Answer to question 7.

Indeed, since any program can be written without using goto statements, the answer is clear in Python — there is no goto statement at all! Python was designed from the start to use structured control flow: break, continue, return, and exceptions. Many professional programmers hold the following view.

  • Using goto statements complicates the program logic and, consequently, its comprehension. As a result, the time to debug a program depends significantly on the number of unstructured jumps in it. That is, the more unstructured jumps in a program, the harder it is to debug.

  • Python provides clean alternatives: break exits a loop, continue skips to the next iteration, return exits a function, and exceptions handle error conditions. These structured constructs make programs easier to read, understand, and debug.

  • When executing programs on modern processors, unstructured jumps can also slow down program execution, as they disrupt branch prediction and the processor’s instruction pipeline.

44. CHAPTER 2 Fundamentals of Algorithm Design

The first chapter provided the thoughtful reader with a "quick immersion" into the Python programming language, the tools for writing and debugging Python programs. It also provided information about basic introductory algorithms on one-dimensional and two-dimensional lists, for example: summing elements; counting and searching for elements that possess a given property; finding maximum and minimum elements. Several algorithm development methodologies were also presented. As was mentioned earlier, theoretically this is sufficient to write a program for solving any problem in the Python programming language. In practice, however, it is more reasonable to master not only the methods for developing new algorithms, but also the algorithms themselves that have already been developed for a wide class of problems. Such algorithms enrich the program developer’s "arsenal." Applying known algorithms, their modifications, and combinations significantly reduces the time for solving problems, developing, and debugging programs.

45. 2.1. Queue and Stack

In this section, the properties of two data structures will be examined in detail: the queue and the stack. The essence of these structures becomes clearer if we refer to their English designations — FIFO and LIFO respectively:

  • FIFO (First In First Out) — "first in — first out";

  • LIFO (Last In First Out) — "last in — first out."

45.1. Physical Examples of Stack and Queue

Solving a large number of both competition and practical programming problems requires the use of a queue or a stack. First, let us consider some physical analogies of the stack and queue.

Suppose heavy concrete slabs are delivered to a construction site, and workers stack them one on top of another. Then we can say that this stack of slabs represents a stack: the last slab placed on the pile will be the first one to be taken from it.

Another example of a stack is a magazine of cartridges, sealed at the back end. The last cartridge pushed into the magazine will be the first to come out when rounds are fired.

An illustrative example of a queue is the line at a store checkout, where the cashier serves people from the front of the line, and new customers join at the end of the line.

Another example of a queue can be imagined by assuming that the construction workers do not stack slabs one on top of another, but place them side by side. Then one crew takes slabs from the front of the queue, while another crew adds slabs to the end.

46. Representing the Stack in a Program

Both the queue and the stack can be represented and implemented in a program in various ways, but we will focus on the simplest — representing the queue and stack using a list of elements.

Obviously, for a stack, in Python the simplest approach is to use a regular list. At the beginning of the program, we create an empty list:

stack: list[int] = []

And this means that the stack is currently empty.

Push element x onto the stack — means performing one action:

stack.append(x)
  1. Append element x to the end of the list:

Pop an element from the stack into variable y — means performing one action:

y = stack.pop()
  1. Take and remove the last element from the list:

To understand how this works internally, let us also look at the manual array-based approach (as was done historically). We use a list and a top variable:

  • top += 1 — equivalent to incrementing the stack pointer;

  • top -= 1 — equivalent to decrementing the stack pointer.

Usually, for greater clarity and readability of programs, the stack operations are separated into special functions. Here is the manual approach for learning purposes, followed by the Pythonic approach:

Manual approach (for learning):

# Stack for up to 80 characters (manual approach)
stack: list[str] = [''] * 80
top: int = 0

def put(c: str) -> None:  # Push a character onto the stack
    global top
    top += 1  # Update the top of the stack
    stack[top - 1] = c  # Place the character in the stack
def get() -> tuple[str, bool]:  # Pop from the stack
    stack_empty = (top == 0)  # Is the stack empty?
    if not stack_empty:
        return stack[top - 1], False  # If not, take the character from the stack
    return '', True

def delete() -> None:  # Delete a character from the stack
    global top
    top -= 1  # Decrement the top of the stack by 1

Pythonic approach (recommended):

# Stack for characters (Pythonic approach)
stack: list[str] = []

def put(c: str) -> None:  # Push a character onto the stack
    stack.append(c)

def get() -> tuple[str, bool]:  # Pop from the stack
    if len(stack) == 0:  # Is the stack empty?
        return '', True
    return stack[-1], False  # Return top without removing

def delete() -> None:  # Delete a character from the stack
    stack.pop()

The size of the list for the stack is determined by the maximum number of elements expected to be placed in the stack and the type of elements stored in the stack. In Python, lists grow dynamically, so you do not need to pre-allocate a fixed size.

It is clear that the elements of a stack can be not only characters. You can create stacks of words, points, segments, triangles, etc. in a program, using Python’s facilities for defining classes. You can also have tuples of numbers as stack elements.

47. Representing the Queue in a Program

A queue, just like a stack, can be represented in a program by a list of elements. However, unlike a stack, working with a queue requires two additional variables — pointers to the beginning and end of the queue. Let us call them for definiteness:

  • que_begin — beginning of the queue;

  • que_end — end of the queue.

At the beginning of the program, the queue beginning is set to 0 and the end to -1 (using 0-based indexing), for example, with the following statements:

que_begin = 0
que_end = -1

Then, to enqueue a triple of numbers (x, y, z) into queue que (at its end), the following actions must be performed:

que_end += 1  # Increment the variable - end of queue
que[que_end] = (x, y, z)  # Enqueue the triple of numbers x y z

To dequeue a triple of numbers from the queue into variables x, y, and z (from the beginning of the queue, naturally), the following must be done:

x, y, z = que[que_begin]  # Take the triple of numbers
que_begin += 1  # Increment the beginning pointer

The attentive reader has noticed that in this case a list of tuples is used for working with the queue. Each tuple stores the numbers belonging to a sin-

gle element in the queue. In our case there are 3 such numbers.

Just as with stack operations, queue operations are usually placed in separate functions, for example:

def put(x: int, y: int, step_number: int) -> None:  # Enqueue
    global que_end
    que_end += 1  # Increment count
    que[que_end] = (x, y, step_number)  # x coordinate, y coordinate, move number

def get() -> tuple[int, int, int]:  # Dequeue
    global que_begin
    x, y, step_number = que[que_begin]  # x coordinate, y coordinate, move number
    que_begin += 1  # Update beginning
    return x, y, step_number

Pythonic approach: In Python, the best way to implement a queue is to use collections.deque, which provides O(1) append and popleft operations:

from collections import deque

que: deque[tuple[int, int, int]] = deque()

def put(x: int, y: int, step_number: int) -> None:
    que.append((x, y, step_number))

def get() -> tuple[int, int, int]:
    return que.popleft()

48. Problem-Solving Examples

In this section, examples of solving several problems using queues and stacks are provided. Those who are using this material for self-study of programming are recommended, after reading the problem statement, to first try to solve it on their own, without reading the material presented further in this section. If you succeed, then perhaps there is no need to spend time on further reading.

48.1. The Knight’s Move Problem

Given the designations of two squares on a chessboard (for example, A5 and C2). Find the minimum number of moves a chess knight needs to get from the first square to the second.

Solution idea: a queue of possible knight moves is introduced.

First, we enqueue all board squares reachable in one move. Then, using the elements already in the queue, we extend the queue with squares that can be reached in exactly 2 moves, and so on.

Obviously, each time we enqueue a square, it is useful to check whether it is the target square. If so, we already know how many moves it takes the knight to get there. The queue mechanism ensures that this will be the minimum number of moves.

A more rigorous algorithm for the solution is presented below:

Place the knight's initial square in the queue
While (not found)
  Dequeue the first element - square X,Y
  Squares reachable from X,Y by a knight's move,
    if not the target and not yet marked,
    are enqueued and marked
    (if the enqueued square is the target - STOP).

The main part of the program solving this problem looks as follows:

start_process()  # Start processing
step_number = 0  # Number of steps - 0
found = (sx == ex) and (sy == ey)  # Completion flag
while not found:  # While not found
    x, y, step_number = get()  # Get coordinate and step number
    step_number += 1  # Increment step number
    found = put_all(x, y, step_number) # Enqueue all possible moves
                                       # If the target square is among them,
                                       # found == True
print(step_number)  # Output step number

Analysis of the main program text shows that it uses three subprograms:

  • start_process — for inputting the initial data and converting them to numeric form;

  • get — for dequeuing elements from the queue; the queue stores the numeric coordinates of the chessboard square and the number of the minimum move by which it can be reached;

  • put_all — for enqueuing all squares reachable by a knight’s move from the current one. If one of these squares is reachable in the minimum number of moves, then the function returns True.

The full text of the self-documented program (knight.py) for solving this problem is given in Listing 2.1.

Listing 2.1. Program text for solving the Knight’s Move problem
# Program knight1
from collections import deque

que: deque[tuple[int, int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * 8 for _ in range(8)]  # Marks on the board
sx: int = 0; sy: int = 0  # Starting position (as numbers)
ex: int = 0; ey: int = 0  # Ending position (as numbers)

def convert(cell: str) -> tuple[int, int]:  # Convert position
                                            # of the knight from
                                            # string representation to numeric
    x = ord(cell[0]) - 96  # Instead of a-h -> 1-8
    y = int(cell[1])  # Instead of '1'-'8' -> 1-8
    return x, y

def put(x: int, y: int, step_number: int) -> None:  # Enqueue
    que.append((x, y, step_number))  # Enqueue coordinates and move number
    marked[x - 1][y - 1] = True  # Mark as used
def get() -> tuple[int, int, int]:  # Dequeue
    x, y, step_number = que.popleft()  # X, Y coordinate, move number
    return x, y, step_number

def start_process() -> None:
    global sx, sy, ex, ey, marked
    start_cell = input()  # Read starting position
    end_cell = input()  # Read ending position
    sx, sy = convert(start_cell)  # Convert to numbers
    ex, ey = convert(end_cell)  # Convert to numbers
    marked = [[False] * 8 for _ in range(8)]  # All squares are unmarked
    put(sx, sy, 0)  # Enqueue starting position

def put_all(x: int, y: int, step_number: int) -> bool:
                                            # Enqueue
                                            # all currently possible moves
    # Possible knight moves
    steps = [( 1,-2),( 1, 2),  # Array of constants
             (-1,-2),(-1, 2),
             ( 2,-1),( 2, 1),
             (-2,-1),(-2, 1)]
    found = False  # Found target square
    i = 0  # Possible move number
    while not found and i < 8:  # While not found and move exists
        dx, dy = steps[i]  # Make the next move
        current_x = x + dx  # X of current move
        current_y = y + dy  # Y of current move
        found = (ex == current_x) and (ey == current_y)  # Is this the target square?
        if (not found and  # If not and
            0 < current_x < 9 and  # X is on the board and
            0 < current_y < 9 and  # Y is on the board and
            not marked[current_x - 1][current_y - 1]):  # square(X,Y) is not marked
            put(current_x, current_y, step_number)  # enqueue and mark
        i += 1
    return found

continued …​

48.2. Listing 2.1 (continued)

start_process()  # Start processing
step_number = 0  # Number of steps - 0
found = (sx == ex) and (sy == ey)  # Completion flag
while not found:  # While not found
    x, y, step_number = get()  # Get coordinates and step number
    step_number += 1  # Increment step number
    found = put_all(x, y, step_number)  # Enqueue all possible moves
                                            # If the target square is
                                            # among them, found == True
print(step_number)  # Output step number

49. The Cutting Pieces Problem

From a sheet of graph paper of size 8 x 8 cells, some cells have been removed. Into how many pieces will the remaining part of the sheet fall apart?

If all cells of one color are removed from a chessboard, the remaining part will fall apart into 32 pieces.

Idea: a queue of cells adjacent to the current one is introduced. That is:

  1. find the first uncut cell and place it in the queue;

  2. as long as the queue is not empty, dequeue the first element and add to the queue all cells adjacent to the current one;

  3. when the queue becomes empty, this will mean that the first piece is finished; we must again find the first uncut cell (without using cells from the first piece) and continue the process until all cells are exhausted.

A more rigorous presentation of the algorithm looks as follows:

Piece number = 0
While there is an unmarked cell
  place it in the queue and mark it
  Inc(Piece number)
  While the queue is not empty
    Dequeue a cell
    All cells adjacent to it and unmarked
    are enqueued and marked.

Let us present the text of the main program solving this problem:

start_process()  # Start processing
step_number = 0  # Piece number = 0
result = find_unmarked()
while result is not None:  # While there is an unmarked x,y
    x, y = result
    put(x, y)  # Place it in the queue and mark
    step_number += 1  # Increment piece number by 1
    while len(que) > 0:  # While the queue is not empty
        x, y = get()  # Dequeue x,y
        put_all(x, y)  # Enqueue all possible moves
    result = find_unmarked()
print(step_number)  # Output step number

Analysis of the main program shows that it uses the following subprograms and functions:

  • start_process — for inputting the initial data and marking the cut-out cells;

  • find_unmarked() — for determining whether there are uncut and unmarked cells and, if there are, returning their coordinates;

  • get() — for dequeuing elements from the queue;

  • put_all — for enqueuing all cells adjacent by sides to the current one.

The full text of the self-documented program for solving this problem (cell.py) is given in Listing 2.2.

Listing 2.2.

Program text for solving the Cutting Pieces problem

# Program cell1
from collections import deque

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * 8 for _ in range(8)]  # Marks on the board

def put(x: int, y: int) -> None:  # Enqueue
    que.append((x, y))  # Enqueue coordinates
    marked[x][y] = True  # Mark as used

def get() -> tuple[int, int]:  # Dequeue
    x, y = que.popleft()  # X, Y coordinate
    return x, y

def start_process() -> None:
    global marked
    marked = [[False] * 8 for _ in range(8)]  # All squares are unmarked
    n = int(input())
    for _ in range(n):
        x, y = map(int, input().split())

continued …​

49.1. Listing 2.2 (continued)

        marked[x][y] = True
    # Queue is initially empty (deque starts empty)

def find_unmarked() -> tuple[int, int] | None:
    for i in range(8):
        for j in range(8):
            if not marked[i][j]:
                return i, j
    return None

def put_all(x: int, y: int) -> None:  # Enqueue
                                                  # all currently possible moves
    # Possible moves (up, down, left, right)
    steps = [(0, -1), (0, 1), (-1, 0), (1, 0)]  # Array of constants
    for dx, dy in steps:
        current_x = x + dx  # X of current move
        current_y = y + dy  # Y of current move
        if (0 <= current_x < 8 and  # X is on the board and
            0 <= current_y < 8 and  # Y is on the board and
            not marked[current_x][current_y]):  # square(X,Y) is not marked
            put(current_x, current_y)  # enqueue

start_process()  # Start processing
step_number = 0  # Piece number = 0
result = find_unmarked()
while result is not None:  # While there is an unmarked x,y
    x, y = result
    put(x, y)  # Place it in the queue and mark
    step_number += 1  # Increment piece number by 1
    while len(que) > 0:  # While the queue is not empty
        x, y = get()  # Dequeue x,y
        put_all(x, y)  # Enqueue all possible moves
    result = find_unmarked()
print(step_number)  # Output step number

50. The Brackets Problem

Given a finite sequence consisting of opening and closing brackets of various given types. Determine whether it is possible to add digits

and arithmetic operation signs to it so that a valid arithmetic expression is obtained.

(([()()]) — such a sequence will satisfy the problem condition, while this one — ([)] — will not.

Idea: a stack of opening brackets is introduced.

The input string, by the problem conditions, contains only opening and closing brackets, and it is processed character by character. The algorithm can be written as follows:

If the input character is an opening bracket, push it onto the stack
If the input character is a closing bracket, perform
    additional analysis:
    if the stack contains a matching opening bracket,
        the brackets are paired, remove the top bracket from the stack
        and continue the process
    if the stack has no elements at all or
        the bracket is not matching, then error - and stop
If we have reached the end of the string, analyze the stack
If it is not empty - error, otherwise - the string is correct.

A more rigorous representation of the proposed algorithm:

While (characters are not exhausted and no error)
    IF the bracket is opening
        THEN push it onto the stack
    ELSE
        IF the stack is not empty
            THEN
                IF the bracket is paired
                    THEN remove it from the stack
                    ELSE error
                ELSE error
        IF error or the stack is not empty
            THEN output ('Error')
            ELSE output ('Correct')

Let us look at the main program text for solving this problem:

start_process()
i = 0
while i < len(s) and not error:  # While the string is not exhausted and no
                                        # error
    c = s[i]  # take the next character
    if c in ('(', '{', '['):  # If it is an opening bracket
        put(c)  # then push it onto the stack
    else:  # otherwise
        x, stack_empty = get()  # try to take a character from the stack
        if not stack_empty:  # If successful - stack is not empty
                                        # then
            if ((x == '(' and c == ')') or  # If the bracket is paired
                (x == '{' and c == '}') or
                (x == '[' and c == ']')):
                delete()  # remove the closing bracket from the stack
            else:
                error = True  # Bracket is not paired - error
        else:
            error = True  # Stack is empty - error
    i += 1  # Take the next character

x, stack_empty = get()  # Check if the stack is empty
if error or not stack_empty:  # If error or the stack is not empty
    print('Error')
else:
    print('Correct')

The main program uses the following functions:

  • start_process — for inputting the string with brackets, determining its length, and initializing the stack;

  • put(c) — for pushing a character onto the stack;

  • get() — for peeking at the top character of the stack, returning a tuple of the character and a boolean indicating whether the stack is empty;

  • delete — for deleting a character from the stack.

The full program text solving this problem (stack.py) is given in Listing 2.3.

Listing 2.3. Program text solving the Brackets problem
# Program stek1

# Manual stack implementation (for learning)
stack: list[str] = []  # Stack for characters

def start_process() -> tuple[str, int]:
    s = input()  # Read the string
    return s, len(s)  # Return the string and its length

def put(c: str) -> None:  # Push a character onto the stack
    stack.append(c)  # Place the character in the stack

def get() -> tuple[str, bool]:  # Pop from the stack
    stack_empty = len(stack) == 0  # Is the stack empty?
    if not stack_empty:
        return stack[-1], False  # If not, take the character from
                                           # the stack
    return '', True

def delete() -> None:  # Delete a character from the stack
    stack.pop()  # Remove the top element
s, l = start_process()
error = False
i = 0
while i < l and not error:  # While the string is not exhausted and no error
    c = s[i]  # take the next character
    if c in ('[', '(', '{'):  # If it is an opening bracket
        put(c)  # then push it onto the stack
    else:  # otherwise
        x, stack_empty = get()  # try to take a character from the stack
        if not stack_empty:  # if successful - stack is not empty
                                          # then
            if ((x == '(' and c == ')') or  # if the bracket is paired
                (x == '[' and c == ']') or
                (x == '{' and c == '}')):
                delete()  # remove the closing bracket from the stack
            else:
                error = True  # Bracket is not paired - error
        else:
            error = True  # Stack is empty - error
    i += 1  # take the next character

x, stack_empty = get()  # Check if the stack is empty
if error or not stack_empty:  # If error or the stack is not empty
    print('Error')
else:
    print('Correct')

Note: In competitive programming, the Pythonic way to solve the brackets problem is even simpler using a plain list as a stack:

s = input()
stack: list[str] = []
matching = {')': '(', ']': '[', '}': '{'}
error = False
for c in s:
    if c in '([{':
        stack.append(c)
    elif c in ')]}':
        if not stack or stack[-1] != matching[c]:
            error = True
            break
        stack.pop()
if error or stack:
    print('Error')
else:
    print('Correct')

51. Additional Programming Techniques

Those who have read the full program texts may have questions about the techniques used in them. In addition, the author would like to draw the readers' attention to several additional programming techniques applied in solving the indicated problems. This section is devoted to examining these techniques.

51.1. Converting Characters to Numbers

Let us return to the chess knight problem.

def convert(cell: str) -> tuple[int, int]:  # Convert position
                                              # of the knight from string
                                              # representation to numeric
    x = ord(cell[0]) - 96  # Instead of a-h -> 1-8
    y = int(cell[1])  # Instead of '1'-'8' -> 1-8
    return x, y

So, in this problem, 2 characters are given as input designating the position of the knight’s initial square (and 2 characters for designating the knight’s target square). In the pro-

gram it is more convenient for us to convert these 2 characters into 2 numbers, replacing the letters from a to h with the digits from 1 to 8 and the characters from 1 to 8 with the corresponding numbers.

Let us start with the letters. It is known that each letter has its own ASCII code (a number from 0 to 255). At the same time, Latin letters have codes in ascending order. That is, the character b has a code 1 greater than the character a, the character c has a code 1 greater than the character b, and so on. The function ord(c) returns as its result the code corresponding to the character c. For example, ord('a') equals 97.

Thus, to obtain the corresponding number from 1 to 8 instead of a Latin character from the range a-h, it is sufficient to subtract 96 from the code of that character, which is exactly what is done in the program fragment shown above.

To replace a digit character with the corresponding number, we use the int() function, which converts a string to an integer.

52. Initialization of a Constant List

It is known that in general a knight has 8 different moves, defined by the corresponding combinations of pairs from the numbers 1, 2, -1, -2. The program reads better if we use in this case the initialization of a constant list defining the knight’s allowed moves.

def put_all(x: int, y: int, step_number: int) -> bool:
                                        # Enqueue
                                        # all currently possible moves
    # Possible knight moves
    steps = [( 1,-2), ( 1, 2),  # List of move offsets
             (-1,-2), (-1, 2),
             ( 2,-1), ( 2, 1),
             (-2,-1), (-2, 1)]
    found = False  # Found target square
    i = 0  # Possible move number
    while not found and i < 8:  # While not found and move exists
                                        # Make the next move
        dx, dy = steps[i]
        current_x = x + dx  # X of current move
        current_y = y + dy  # Y of current move
        found = (ex == current_x) and (ey == current_y)  # Is this the target square?
        if (not found and  # If not and
            0 < current_x < 9 and  # X is on the board and
            0 < current_y < 9 and  # Y is on the board and
            not marked[current_x - 1][current_y - 1]):  # square (X,Y) is not
                                        # marked
            put(current_x, current_y, step_number)  # enqueue
                                        # and mark
        i += 1
    return found

For the paper cutting problem, the allowed "moves" when enqueuing a cell are somewhat different, but this is actually reflected only in the constant list:

def put_all(x: int, y: int) -> None:  # Enqueue
                                                # all currently possible moves
    # Possible moves (up, down, left, right)
    steps = [(0, -1), (0, 1), (-1, 0), (1, 0)] # List of move offsets
    for dx, dy in steps:
        current_x = x + dx  # X of current move
        current_y = y + dy  # Y of current move
        if (0 <= current_x < 8 and  # X is on the board and
            0 <= current_y < 8 and  # Y is on the board and
            not marked[current_x][current_y]): # square(X,Y) is not marked
            put(current_x, current_y)  # enqueue

53. Global Variables, Local Variables, and Parameters

Incorrect distribution of variables in a program among these types leads to an increase in the number of potential errors in the program and, consequently, to a prolonged debugging process.

If a variable is declared inside a function — this means that it is local. That is, no changes to this variable inside the given function in any way affect variables with the same name in other functions and in the main program. For example, in the put_all function shown above, the variables dx, dy, current_x, and current_y are local.

If a variable is declared at the module level (outside the body of any function), then it is global, and any function that declares it with the global keyword can modify it. In Python, if you need to modify a global variable inside a function, you must use the global statement. Without it, assigning to a variable inside a function creates a new local variable.

And finally, parameters are variables that are declared directly in the function definition, such as x, y, and step_number. In Python, all parameters are passed by reference to the object. For immutable types (like integers and strings), any modifications create a new local value without affecting the caller. For mutable types (like lists and objects), modifications to the object itself are visible to the caller.

What considerations should guide the distribution of variables among these types?

54. Using Boolean Variables and Lists

Proper use of Boolean variables and lists reduces program size and improves its clarity, which ultimately shortens the debugging time.

Many beginning programmers often use variables and lists with values 0 and 1 instead of Boolean values False and True.

Let us give several examples of using Boolean variables and lists in the described programs:

  • knight.py (knight problem).

found = (sx == ex) and (sy == ey)  # Completion flag

Here the variable found receives the value True if both equalities hold simultaneously \((sx == ex)\) and \((sy == ey)\). That is, if the target square has been found during the search.

while not found:  # While not found
    # While not found - do
  • cell.py (the cutting into pieces problem, see Listing 2.2).

def find_unmarked() -> tuple[int, int] | None:
    for i in range(8):
        for j in range(8):
            if not marked[i][j]:
                return i, j
    return None

Here find_unmarked is a function that returns a tuple of coordinates if there are still unmarked squares on the board, or None if there are none. The coordinates of the first free square found are returned directly. Only if there are no unmarked squares left on the board does the function return None.

Here, the use of early return for non-standard exit from a function is also illustrative.

stack.py (the brackets problem, see Listing 2.3)

def get() -> tuple[str, bool]:  # Pop from the stack
    stack_empty = len(stack) == 0  # Is the stack empty?
    if not stack_empty:
        return stack[-1], False  # If not, take
                                           # character from the stack
    return '', True

The boolean value stack_empty is True if the stack is empty (length is 0), and False otherwise. This value is used for correct popping from the stack, and it is also returned to the calling program.

if error or not stack_empty:  # If error or the stack is not empty
    print('Error')
else:
    print('Correct')

This fragment requires no explanation, unlike the case where some numbers would be compared.

55. Small-Scale Test Automation

So, you have developed an algorithm, written a program, and it already produces correct answers on several tests you have prepared. If tests still appear on which it stops working correctly, then you will need to make changes to the program, achieving its correct operation on those tests as well. But after changing the code, you need to recheck everything again!

The following approach is suggested: immediately after you have written the program and begun testing it (or even better — before that), compose tests and place them in files 1.in, 2.in, 3.in, etc., 1.out, 2.out, 3.out, etc. The *.in files should contain what needs to be input to the program for the test with the corresponding number, and the *.out files should contain the data that the program should output in case of correct operation.

After that, you compose a test script. In Python, you can create a simple test runner:

  1. test_all.sh (or test_all.bat on Windows).

#!/bin/bash
for i in 1 2 3 4 5 6 7 8 9; do
    python3 program.py < "$i.in" > output.txt
    if diff -q output.txt "$i.out" > /dev/null 2>&1; then
        echo "Test $i: PASSED"
    else
        echo "Test $i: FAILED"
        diff output.txt "$i.out"
    fi
done

This script cyclically launches the program for all test numbers, feeding the prepared test as input and comparing the output with the reference answers.

Alternatively, you can use Python’s built-in testing tools. A simple approach using Python:

import subprocess
for i in range(1, 10):
    with open(f'{i}.in') as fin:
        result = subprocess.run(['python3', 'program.py'],
                                stdin=fin, capture_output=True, text=True)
    with open(f'{i}.out') as fout:
        expected = fout.read().strip()
    actual = result.stdout.strip()
    if actual == expected:
        print(f'Test {i}: PASSED')
    else:
        print(f'Test {i}: FAILED')
        print(f'  Expected: {expected}')
        print(f'  Got:      {actual}')

By running this script, you can at any time, after the next batch of changes, verify that the program works on all the tests you have prepared, or quickly discover those on which it does not yet or no longer works. If your program is supposed to read data from one file (for example, file.in) and write to another (for example, file.out), then you can slightly modify the test script accordingly.

56. Memory Management in Python

In Python, memory management is handled automatically. Unlike lower-level languages, you do not need to manually allocate and free memory. Python uses a system called garbage collection — when no variable references an object any longer, Python automatically frees the memory that object was using.

Every value in Python is an object, and every variable is a reference (essentially a pointer) to an object. When you write x = [1, 2, 3], Python allocates memory for a list object and stores a reference to it in x. When x goes out of scope or is reassigned, the list object becomes eligible for garbage collection if nothing else refers to it.

This means you can focus on your algorithm rather than worrying about memory allocation and deallocation. Lists, dictionaries, and other data structures in Python grow and shrink dynamically as needed.

Let us return to the problem statement from one of the previously solved problems. From a sheet of graph paper of size 8 x 8 cells, some cells have been removed. Into how many pieces will the remaining part of the sheet fall apart?

It was proposed to solve this problem using a queue. Two data structures were used to store data in this problem:

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * 8 for _ in range(8)]  # Marks on the board

The marked list has dimensions 8x8 because by the problem conditions the board size is 8x8. The que deque stores tuples of two numbers — x and y — coordinates of the cell we enqueued.

Now suppose the problem is made more complex as follows: from a sheet of graph paper of size \(N x M\) cells (\(N >= 1\), \(M <= 150\)), \(K\) cells have been arbitrarily removed (\(1 <= K <= 22,500\)). Into how many pieces will the remaining part of the sheet fall apart?

In Python, we simply adjust the list sizes dynamically — no special memory management needed:

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * m for _ in range(n)]  # Marks on the board

Python lists can grow as large as available memory allows. There is no artificial 64 KB limit as existed in older languages. If you need even larger data structures, Python handles them seamlessly.

Python’s memory model is based on objects and references. Every variable holds a reference to an object. A reference is essentially an internal pointer to the object in memory.

Python manages memory automatically using reference counting and a cyclic garbage collector. When no references point to an object, the memory is freed automatically. You never need to manually allocate or free memory.

Now let us learn to use Python’s data structures in our programs.

Let us begin with an example. Let us recall the problem statement from one of the previously solved problems. From a sheet of graph paper of size 8 x 8 cells, some cells have been removed. Into how many pieces will the remaining part of the sheet fall apart?

It was proposed to solve this problem using a queue. In Python, we can simply use a deque and a 2D list:

from collections import deque

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * 8 for _ in range(8)]  # Marks on the board

The marked list has dimensions 8x8 because by the problem conditions the board size is 8x8. The que deque stores tuples of (x, y) coordinates.

For the larger version of the problem (N x M grid), we simply create lists of the needed size:

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = [[False] * m for _ in range(n)]  # Marks on the board

The algorithm for solving the problem of cutting cells from a sheet of paper of size \(N x M\) can be written as follows.

Idea: a queue of cells adjacent to the current one.

Piece number = 0
While there is an unmarked cell
  place it in the queue and mark it
  Inc(Piece number)
  While the queue is not empty
    Dequeue a cell
    All cells adjacent to it and unmarked
    are enqueued and marked

The text of the corresponding program is given in Listing 2.4.

Listing 2.4. Program text solving the cell cutting problem
from collections import deque

que: deque[tuple[int, int]] = deque()  # Queue of moves
marked: list[list[bool]] = []  # Marks on the board
n: int = 0
m: int = 0

def put(x: int, y: int) -> None:  # Enqueue
    que.append((x, y))  # Enqueue coordinates
    marked[x][y] = True  # Mark as used

def get() -> tuple[int, int]:  # Dequeue
    x, y = que.popleft()  # X, Y coordinate
    return x, y

def start_process() -> None:
    global n, m, marked
    n, m = map(int, input().split())  # Read field dimensions
    marked = [[False] * m for _ in range(n)]  # All cells are unmarked
    k = int(input())  # Number of cut cells
    for _ in range(k):
        x, y = map(int, input().split())  # Read cut cell
        marked[x][y] = True  # Mark it

def find_unmarked() -> tuple[int, int] | None:
    for i in range(n):
        for j in range(m):
            if not marked[i][j]:
                return i, j
    return None

def put_all(x: int, y: int) -> None:  # Enqueue
                                                            # all currently possible moves
    # Possible moves (up, down, left, right)
    steps = [(0, -1), (0, 1), (-1, 0), (1, 0)]
    for dx, dy in steps:
        current_x = x + dx  # X of current move
        current_y = y + dy  # Y of current move
        if (0 <= current_x < n and  # X is on the board and
            0 <= current_y < m and  # Y is on the board and
            not marked[current_x][current_y]):  # square(X,Y) is not marked
            put(current_x, current_y)  # Enqueue and mark
start_process()  # Start processing
step_number = 0  # Piece number = 0
result = find_unmarked()
while result is not None:  # While there is an unmarked x,y
    x, y = result
    put(x, y)  # Place it in the queue and mark
    step_number += 1  # Increment piece number by 1
    while len(que) > 0:  # While the queue is not empty
        x, y = get()  # Dequeue x,y
        put_all(x, y)  # Enqueue all possible moves
    result = find_unmarked()
print(step_number)  # Output step number

In Python, there is no need to distinguish between "static" and "dynamic" arrays as in older languages. Python lists grow dynamically, and memory is managed automatically by the garbage collector. You can simply create lists of whatever size you need.

For competitive programming, Python provides simple and powerful tools for memory management. Unlike older languages where you needed to worry about memory allocation limits, Python lets you focus on the algorithm itself.

If you ever need to check memory usage of your program (for example, to stay within the memory limits of a competitive programming judge), you can use the sys.getsizeof() function or the tracemalloc module:

import sys
import tracemalloc

tracemalloc.start()

# ... your algorithm here ...

current, peak = tracemalloc.get_traced_memory()
print(f"Current memory: {current / 1024:.1f} KB")
print(f"Peak memory: {peak / 1024:.1f} KB")
tracemalloc.stop()

This helps the programmer track memory usage at different processing stages of the program.

In practice, Python programs rarely run into memory issues for typical competitive programming problems, as modern systems provide ample memory. The main concern is usually execution speed rather than memory.

For very large data, Python also provides specialized modules:

  • array module — provides compact arrays of numeric values (similar to C arrays), using less memory than regular lists;

  • numpy — provides efficient multidimensional arrays for numerical computing (though not always available in competitive programming judges).

For example, if you need a very large boolean grid:

from array import array

# Using array module for compact storage
# 'b' means signed byte - uses 1 byte per element
marked = array('b', [0] * (n * m))

# Access element at row i, column j:
# marked[i * m + j]

However, for most competitive programming problems, regular Python lists are perfectly adequate.

57. Working with Objects and Classes

Now let us give an example of using classes for storing structured data (in this example, for the coordinates of a point). We declare a new class that stores information about a point’s coordinates:

from dataclasses import dataclass

@dataclass
class Point:
    x: float = 0.0  # Type POINT - X and Y coordinates
    y: float = 0.0

We create a variable of type Point:

pp = Point()  # Create a Point object
                                 # Python automatically allocates memory

When accessing object attributes, we work as follows:

pp.x = 1.4  # Set the point's coordinates
pp.y = pp.x + 0.5

When we are done with an object, we simply stop referencing it — Python’s garbage collector handles the rest:

pp = None  # Remove reference; garbage collector
           # will free the memory when appropriate

It is also worth mentioning Python’s string handling. Python strings can be of arbitrary length (limited only by available memory). Unlike older languages with a 255-character limit, Python strings can hold millions of characters. Python strings are immutable — operations like concatenation create new string objects. For efficient string building, use str.join() or io.StringIO.

Python provides a rich set of string methods. For example:

  • string creation — s = "abcdef"

  • string concatenation — s = s1 + s2

  • string slicing — s[1:4]

  • string search — s.find("cd")

  • string comparison — s1 == s2

There are also functions such as string splitting, case conversion, replacement, and others. For complete information about the func-

58. 2.2. Recursive Functions

There is a significant class of programming problems whose simplest solutions are based on understanding the fundamentals of how recursive functions work and on the ability to construct recursive algorithms and debug recursive programs.

58.1. Examples of Problem Solutions

The author has chosen a teaching methodology based on examples, as it is the most effective for learning complex or unfamiliar concepts. Below, in order of increasing complexity, are examples illustrating how to solve problems using recursion.

58.2. Computing the Greatest Common Divisor

A program for recursively computing the greatest common divisor (GCD) of two numbers \(a\) and \(b\) is shown in Listing 2.5.

Listing 2.5. Recursive computation of the GCD
# euclid
import sys
sys.setrecursionlimit(10000)

def gcd(a: int, b: int) -> int:
    if a > b:
        return gcd(a - b, b)
    elif a < b:
        return gcd(a, b - a)
    else:
        return a

a, b = map(int, input().split())
print(gcd(a, b))

This program is based on the recursive Euclidean algorithm for computing the GCD:

  • find GCD(\(a - b, b\)), if \(a > b\);

  • GCD(\(a, b\)) = \(a\), if \(a = b\);

  • find GCD(\(a, b - a\)), if \(a < b\).

In words, this recursive algorithm can be expressed as follows: to find the GCD of two numbers \(a\) and \(b\), we need to compare these numbers. If they are equal, their GCD is \(a\). If one of the numbers is greater than the other, we subtract the smaller from the larger and reapply the above algorithm to the difference and the smaller number. The process terminates when the numbers become equal.

For example, computing the GCD of 12 and 18 using the Euclidean algorithm looks like this:

GCD(12, 18) = GCD(12, 18 - 12) = GCD(12, 6) = GCD(12 - 6, 6) = GCD(6, 6) = 6.

Now let us examine in more detail how the program euclid (see Listing 2.5) performs this work.

The body of the main program contains just two lines:

a, b = map(int, input().split())  # enter numbers for which we seek the GCD
print(gcd(a, b))  # output the result of computing the GCD function

In the example a = 18 and b = 12. Therefore, in the body of the main program, the function gcd(18, 12) is called. The main work is performed by this recursive function gcd:

def gcd(a: int, b: int) -> int:
    if a > b:
        return gcd(a - b, b)
    elif a < b:
        return gcd(a, b - a)
    else:
        return a

Since 18 > 12, the function gcd is called again, but now with the parameters (18 - 12, 12), that is, with the parameters (6, 12).

Note that the function gcd, without completing its work, calls itself, but with modified parameters. Such functions are called recursive. A necessary condition for the correct operation of recursive functions is a condition for non-recursive (i.e., without the function calling itself) computation of the result at certain argument values. For example, in the function gcd(a, b), such a condition is the equality of the parameters a and b.

In our example, the function gcd will first call itself with the parameters (6, 12), and then with the parameters (6, 6). This will be the last call, yielding the answer 6. After that, this answer will be sequentially passed back to the calling functions all the way up to the main program.

Note that Python has a default recursion limit of 1000 calls. For competitive programming, you should increase it using sys.setrecursionlimit() at the beginning of your program. For the subtraction-based Euclidean algorithm, large inputs (e.g., gcd(1, 1000000)) could exceed even an increased limit, so the modulo-based version is preferred in practice.

The program for computing the greatest common divisor of numbers a and b can also be written non-recursively, for example as proposed in Listing 2.6.

Listing 2.6.

Non-recursive GCD search

# euclid_n

a, b = map(int, input().split())
while a != b:
    if a > b:
        a = a - b
    else:
        b = b - a
print(a)

Some readers will impatiently object: this is shorter and even more understandable, so why do we need to know about recursive functions? I can offer the following answer.

  1. There is a huge number of problems where non-recursive solutions are much more complex than recursive ones.

  2. If the algorithm for solving a problem is inherently recursive, then writing a recursive program is easier, because writing a non-recursive one requires developing and debugging an alternative algorithm.

These claims can be verified by examining the following examples of problem solutions.

58.3. Number of combinations of \(N\) choose \(M\)

Let us start with Pascal’s triangle (row numbering starts from zero):

                    1
                  1   1
                1   2   1
              1   3   3   1
            1   4   6   4   1
          1   5  10  10   5   1
        1   6  15  20  15   6   1

Can you guess how the next row is computed from the previous one? Yes, that’s right: each number equals the sum of the two numbers located in the previous row directly above it. For example, in the 4th row (note that numbering of both rows and columns in our array starts from 0) 6 = 3 + 3, in the 5th row 10 = 6 + 4, in the 6th row 15 = 5 + 10, and so on.

Can you write out the next, 7th, row of Pascal’s triangle? Obviously, it will look like this:

                1  7  21  35  35  21  7  1

Now some interesting facts about Pascal’s triangle, intended to answer questions like: why do we need to know about it at all and how can it be useful to us? For example:

(a + b)0 = 1;

(a + b)1 = a + b;

(a + b)2 = a2 + 2ab + b2;

(a + b)3 = a3 + 3a2b + 3ab2 + b3;

(a + b)4 = a4 + 4a3b + 6a2b2 + 4ab3 + b4.

I hope attentive readers have already guessed that the \(N\)-th row of Pascal’s triangle gives us the coefficients in the expression obtained when computing \((a + b)N = (a + b) * (a + b) * ... * (a + b)\) (\(N\) factors).

Another example: let us consider Pascal’s triangle in the form of a more familiar two-dimensional array of dimensions \(N\) rows x \(M\) columns:

                1
              1   1
            1   2   1
          1   3   3   1
        1   4   6   4   1
      1   5  10  10   5   1
    1   6  15  20  15   6   1
  1   7  21  35  35  21   7   1
1   8  28  56  70  56  28   8   1

It turns out that these remarkable numbers also answer questions like: how many different combinations of \(N\) items taken \(M\) at a time exist? For example, suppose we have 8 different balls, numbered from 1 to 8 respectively. In how many different ways can we form groups of three balls? That is, we need to determine the number of combinations of 8 choose 3. In our array, we look for the number at the intersection of the 8th row and the 3rd column. We get the answer — 56. And from 4 balls choose 2? That’s right — 6 (4th row, 2nd column). Let us list all possible variants: ball 1 and ball 2, 1 and 3, 1 and 4, 2 and 3, 2 and 4, 3 and 4.

And what if we want to find out in how many ways we can form groups of three from 26 different balls? Continuing to build Pascal’s triangle by manual computation seems somewhat tedious. And therefore: isn’t that what we’re learning to program for?

Let us denote \(C(N, M)\) the number of combinations of \(N\) items taken \(M\) at a time. Briefly, this reads as: "C of \(N\) choose \(M\)." Let us recall the already computed values: \(C(8, 3) = 56\) (C of 8 choose 3 equals 56), \(C(4, 2) = 6\). We had difficulty computing \(C(26, 3)\).

Let us try to derive a relation that computes the next row from the previous one (more precisely, the element in the \(M\)-th column of the \(N\)-th row, from the needed elements of the row numbered \(N - 1\)):

\(C(N, M) = C(N - 1, M - 1) + C(N - 1, M).\)

To compute the number \(C(N, M)\), we must add the number located above it to the upper left \(C(N - 1, M - 1)\) and the number directly above it \(C(N - 1, M)\). This expression is valid for cases \(M ≤ N\) (i.e., all numbers are below the main diagonal of the table).

Furthermore, we know that \(C(N, 0) = 1\), i.e., all numbers in column 0 equal 1, and \(C(N, N) = 1\), i.e., all numbers on the main diagonal equal 1.

And finally, all remaining elements (empty positions in the table) above the main diagonal equal 0. Then the relation for \(C(N, M)\) will be correctly applicable to all elements of the table.

Thus, the final rules for computing elements in the table can look as follows:

  • C(N, M) = 1, if M = 0 and N > 0 or N = M ≥ 0;

  • C(N, M) = 0, if M > N ≥ 0;

  • C(N, M) = C(N - 1, M - 1) + C(N - 1, M) in all other cases.

And their notation in the Python programming language as a recursive function:

def c(n: int, m: int) -> int:
    if (m == 0 and n > 0) or (m == n and m > 0):
        return 1
    elif m > n and n >= 0:
        return 0
    else:
        return c(n - 1, m - 1) + c(n - 1, m)

The body of the main program looks like this:

n, m = map(int, input().split())
print(c(n, m))

The complete program is presented in Listing 2.7.

Listing 2.7. Program for computing elements of Pascal’s triangle
# CNM
import sys
sys.setrecursionlimit(10000)

def c(n: int, m: int) -> int:
    if (m == 0 and n > 0) or (m == n and m > 0):
        return 1
    elif m > n and n >= 0:
        return 0
    else:
        return c(n - 1, m - 1) + c(n - 1, m)

n, m = map(int, input().split())
print(f"C({n}.{m})={c(n, m)}")

To compute the values in the \(N\)-th row, one needs to know some values in the \(N - 1\)-th, \(N - 2\)-th rows and so on down to the 0th row. Let us build a list of values for the example \(C(4,2)\):

\(C(4,2) = C(3,1) + C(3,2), C(3,1) = C(2,0) + C(2,1), C(3,2) = C(2,1) + C(2,2),\)

\(C(2,0) = 1, C(2,1) = C(1,0) + C(1,1), C(2,1) = C(1,0) + C(1,1),\)

\(C(2,2) = 1, C(1,0) = 1, C(1,1) = 1, C(1,0) = 1, C(1,1) = 1.\)

It is much more convenient to represent recursive computations as a tree:

                    C(4,2)
          C(3,1)    +    C(3,2)
   C(2,0)  +  C(2,1)      C(2,1)  +  C(2,2)
1     C(1,0) + C(1,1)  C(1,0) + C(1,1)   1
         1       1        1        1

In this example, one could also write a non-recursive solution (I leave this as an optional exercise). Let us note: besides the fact that a non-recursive solution still needs to be devised (by filling a two-dimensional array from the 0th row to the \(N-th\)), most likely the non-recursive program will also have to perform many computations whose results (as the tree of recursive calls shows) we do not need at all.

The tree of recursive calls is a very important illustration of what recursive algorithms are and how they should be designed. If a problem requires investigating a set of variants that can be conveniently represented as a tree, then traversing this tree (and accordingly investigating all possible variants) is conveniently accomplished by a recursive algorithm.

Some readers may be indignant: four paragraphs have already been written about some tree, yet it is not visible. What tree?

First, I suggest the reader recall what trees look like in late autumn, when there are no more leaves on the trees and only the trunk and branches remain. And then look at this schematic drawing:

             O
            / \
           O   O
          /|\  /\
               ...

With a certain degree of imagination, one can agree that there is something in common with a tree. A strict mathematical definition of the concept of a "tree" is given in such an important branch of mathematics as graph theory, and we will still have a chance to become acquainted with it. For now, I suggest we stay with the above-mentioned intuitive notion of a tree.

59. Number of ways to compose a sum \(M\) from \(N\) numbers

Suppose we need to write a program that reads numbers \(M\) and \(N\), a sequence of \(N\) numbers — \(a(i)\) — and counts the number of ways in which the given sum \(M\) can be composed from the numbers \(a(i)\).

Variants in which the sum is composed of the same numbers but occupying different positions in the sequence will be considered different ways of composing the sum.

Example input data: \(M = 10, N = 6\), and the sequence of numbers is 1 10 7 4 2 3. That is, we need to compose the sum 10 from elements of this sequence of 6 numbers. We have the following variants:

  1. 10 = 10;

  2. 10 = 7 + 3;

3) \(10 = 7 + 2 + 1\);

4) \(10 = 4 + 3 + 2 + 1\).

Thus, the answer is 4.

How do we obtain it? Clearly, in the general case we need to enumerate all possible combinations of numbers, that is — all singletons, all pairs, all triples, all quadruples, all quintuples, and finally check the sum of all elements of the sequence. We will create a recursive algorithm that builds (traverses) such a tree, only it will perform the analysis from the last element to the first. But first, one more tricky test: \(M = 3\), \(N = 3\), and the sequence of numbers is 0 0 0. The question is: in how many ways can a sum be composed from three numbers, each of which equals 0? I believe that, according to the problem’s conditions, the answer is 8:

  • 3 times we take different zeros one at a time;

  • 3 times we take different pairs of zeros;

  • 1 time we take all zeros;

  • 1 time we take none of the numbers at all.

The recursive approach, as explained above, involves building a tree of enumeration. To illustrate it, let us take a simpler example: we need to compose the sum 3 from three numbers: 1, 2, and 3.

Obviously, the answer is: 2 (\(3 and 1 + 2\)). The enumeration tree can be depicted as follows:

                                    3
               !                                        -
               2                                        2
       !               -               !                -
       1               1               1                1
   !       -       !       -       !       -       !        -
3 + 2 + 1       3 + 2       3 + 1       3   2 + 1       2       1       took nothing
   6            5           4           3   3           2       1            0

"!" means that we take the number in the previous row; "-" means that we do not take the number in the previous row.

So, the function find (Listing 2.8) will have 2 parameters: n — the index of the element with which we are currently extending the tree, and m — the sum we want to obtain. The value of the function find is the number of ways in which the sum m can be composed from n elements of the list a.

Listing 2.8. Program for counting the ways to compose a sum M from N numbers
# Find3
import sys
sys.setrecursionlimit(100000)

a: list[int] = []  # continued below

59.1. Listing 2.8 (continued)

def find(n: int, m: int) -> int:
    if n == 0 and m == 0:
        return 1
    if n == 0 and m != 0:
        return 0
    if m - a[n - 1] >= 0:
        return find(n - 1, m) + find(n - 1, m - a[n - 1])
    else:
        return find(n - 1, m)

data = list(map(int, input().split()))
m_val = data[0]
n_val = data[1]
a = data[2:2 + n_val]
print(find(n_val, m_val))

As before, the main program reads the necessary data in the required order and calls the recursive function (directly in the output statement print). The main work of the recursive algorithm is contained in the lines:

if m - a[n - 1] >= 0:
    return find(n - 1, m) + find(n - 1, m - a[n - 1])
else:
    return find(n - 1, m)

m - a[n - 1] >= 0 means that the sum m can be composed both with the number a[n - 1] and without it. The number of ways to compose the sum m from n numbers (a[0], a[1], …​, a[n - 1]) will equal the number of ways to compose the sum m from n - 1 numbers (a[0], a[1], …​, a[n - 2]) (if we don’t take a[n - 1]) plus the number of ways to compose the sum m - a[n - 1] from n - 1 numbers (a[0], a[1], …​, a[n - 2]) (if we take a[n - 1]). If m - a[n - 1] < 0, then the number a[n - 1] cannot participate in composing the sum m. In this case, the number of ways to compose the sum m from n numbers will equal the number of ways to compose the sum m from n - 1 numbers. This is the main mechanism of recursion, which decreases the values of its parameters (arguments n and m) when calling the recursive function. For correct termination of the recursion, non-recursive answers at certain argument values of n and m are required:

if n == 0 and m == 0:
    return 1
if n == 0 and m != 0:
    return 0

If there are no more numbers (n == 0) and the sum has been composed correctly (m == 0), then we found 1 combination, so find returns 1 and we exit from this call of the recursive function find (via the return statement). The second if statement analyzes the situation when there are no more numbers (n == 0), but the sum was not composed correctly (m != 0), then find returns 0.

Perhaps this material may seem difficult to understand, but you must agree: solving such a non-trivial problem with a recursive function whose body consists of only 5 lines justifies our effort in mastering this effective method.

Now some advice on debugging recursive functions. First of all, it is useful to draw the tree of recursive function calls corresponding to a test example. For example, in the example just analyzed, it will look like this:

                        find(3,3)                                      taken/not
         find(2,0)                  find(2,3)                                3
find(1,0)         find(1,1)      +         find(1,3)                         2
find(0,0)   find(0,0) + find(0,1)   find(0,2) + find(0,3)                   1
    1           1         0              0             0

Let us explain in words the meaning of this tree.

  • find(3,3) — the number of ways in which the sum 3 can be composed from three numbers.

  • find(2,0) — the number of ways in which the sum 0 can be composed from two numbers. That is, the last number in the entered list — 3 — we took into the sum, subtracted it from the sum, and the remainder 0 must be composed from the remaining numbers.

  • find(2,3) — the number of ways in which the sum 3 can be composed from two numbers. That is, the last number in the entered list — 3 — we did not take into the sum, therefore did not subtract it from the sum, and the entire sum 3 must be composed from the remaining numbers. And so on.

Each of the next numbers we can either take, reducing the sum by its value, or not take, leaving the sum unchanged. It is clear that in this way we investigate all possible combinations of numbers from the original sequence.

During debugging, the following tips may be useful.

  1. print the call parameters n and m at the start of each function call;

  2. use indentation proportional to recursion depth to visualize the tree structure;

  3. you can use Python’s traceback module or simply a depth counter to see the current call stack;

  4. this way you will be able to actually monitor the recursive traversal of the tree you designed in accordance with the order you defined.

  5. store intermediate results in local variables;

  6. use them for intermediate computations, for example like this:

def find(n: int, m: int) -> int:
    if n == 0 and m == 0:
        return 1
    if n == 0 and m != 0:
        return 0
    if m - a[n - 1] >= 0:
        k1 = find(n - 1, m)
        k2 = find(n - 1, m - a[n - 1])
        k3 = k1 + k2
        return k3
    else:
        k4 = find(n - 1, m)
        return k4

Of course, the recursive function now looks less elegant and compact, but in return you can add print() statements to see the computed intermediate result values and, accordingly, compare them with the results obtained from manual trace-through for the test example.

60. Number of representations of a number as a sum of components

Consider a problem in which it is required to count the number of different representations of a given natural number \(N\) as a sum of at least two pairwise distinct positive addends.

For N = 7, the answer is 4, since 7 = 6 + 1, 5 + 2, 4 + 3, 4 + 2 + 1.

In fact, as in the problem considered earlier, we need to find numbers that add up to the given number.

The differences are as follows.

n = int(input())
a = list(range(1, n))
print(find(n - 1, n))
  1. We do not read the numbers but assign them: 1, 2, …​, \(N - 1\), and therefore the body of the main program looks like this:

where find(n - 1, n) finds the number of ways to represent the sum n from n - 1 numbers a[0], a[1], …​, a[n - 2]. . The sum must consist of at least two numbers (in the previous problem it was assumed that there could be one or even none).

if m - a[n - 1] > 0:
    return find(n - 1, m) + find(n - 1, m - a[n - 1])
else:
    return find(n - 1, m)
  1. We will reason similarly to how it was done in the previous problem: the main component of the recursive function find(n, m) (where again m is the sum to be composed, n is the number of elements that can be used) looks as follows:

If \(m > A[n\)], then we add find(n - 1, m) — the number of ways in which the sum m can be composed from \(n - 1\) elements (without element a[n - 1]) — and find(n - 1, m - a[n - 1]) — the num-

ber of ways in which the sum m - a[n - 1] can be composed (i.e., a[n - 1] participates in the total sum). And if m > a[n - 1], then obviously the sum m must be composed without element a[n - 1], and therefore return find(n - 1, m).

The case a[n - 1] == m is handled separately:

if m == a[n - 1]:
    if n >= 2:
        return 1 + find(n - 1, m)
    else:
        return 1

If m == a[n - 1], then in the case n >= 2 we have already found one variant and must continue searching for others, and in the case n == 1, this is the last variant.

And finally, the conditions for processing branches of the tree where the required sum is not achieved:

if n <= 1:
    return 0

Thus, the final program Find4 looks as shown in Listing 2.9.

Listing 2.9. Counting the number of representations of a number as a sum of components
# Find4
import sys
sys.setrecursionlimit(100000)

a: list[int] = []

def find(n: int, m: int) -> int:
    if m == a[n - 1]:
        if n >= 2:
            return 1 + find(n - 1, m)
        else:
            return 1
    if n <= 1:
        return 0
    if m - a[n - 1] > 0:
        return find(n - 1, m) + find(n - 1, m - a[n - 1])
    else:
        return find(n - 1, m)

n = int(input())
a = list(range(1, n))
print(find(n - 1, n))

61. Number of valid bracket expressions

Consider the following problem: we need to count the number of valid bracket expressions consisting of \(2 * N\) parentheses. An expression is called valid if it consists of \(2 * N\) characters and:

  1. contains exactly \(N\) opening parentheses and \(N\) closing parentheses;

  2. in any fragment (from the beginning of the bracket expression to any arbitrary position) there are always more opening parentheses than closing ones, or the same number.

EXAMPLE For \(N = 1\) there is one valid bracket expression: (), for \(N = 2\) there are two valid bracket expressions: ()() and )), and for \(N = 3\) there are five: (((), ()()(), ())), (((), )(.

For convenience, let us replace opening parentheses with 1 and closing parentheses with 0. Consequently, we need to traverse a tree of the following kind (example for \(N = 2\)):

                                1
                        1               0
                1               0               1               0
        1       0       1       0   1       0       1       0
      1111    1110   1101      1100 1011    1010   1001      1000
      ((((     (((    ()(       (()) ()(()   ()()   ())(      ()))

We need to count how many paths have the following properties:

  • the number of ones equals the number of zeros;

  • in any fragment of the path from the beginning, the number of ones is greater than the number of zeros, or equal.

We will write a recursive function:

def find(dn: int, d: int, zero: int, one: int) -> tuple[int, int, int]:

where dn is the number of characters in the chain of 1s and 0s representing the current state of the branch of the tree we are traversing, d (1 or 0) indicates which branch we will go down; zero is the number of zeros in the current chain; one is the number of ones in the current chain.

The main recursive call expression will look like this:

result = find(dn - 1, 0, zero, one)
zero, one = result[1], result[2]
result2 = find(dn - 1, 1, zero, one)
return (result[0] + result2[0], result2[1], result2[2])

That is, the number of valid expressions equals the sum of the counts of valid expressions in the branches selected for 0 and 1.

Upon entering the function and exiting from it, taking into account the parameter d, we need to correctly maintain the count of the current number of 1s and 0s. Furthermore, if at least one of the validity conditions is violated (zero > n or one > n or zero > one), then this branch is "pruned."

The complete program is presented in Listing 2.10. Note that in Python, since integers are immutable, we use mutable containers (lists) to simulate pass-by-reference behavior for the counters.

Listing 2.10. Counting the number of valid bracket expressions
# Find5
import sys
sys.setrecursionlimit(100000)

n = int(input())
zero = [0]
one = [0]

def find(dn: int, d: int) -> int:
    if d == 0:
        zero[0] += 1
    else:
        one[0] += 1
    if zero[0] > n or one[0] > n or zero[0] > one[0]:
        result = 0
        if d == 0:
            zero[0] -= 1
        else:
            one[0] -= 1
        return result
    if dn == 1:
        if zero[0] == one[0]:
            result = 1
        else:
            result = 0
    else:
        result = find(dn - 1, 0) + find(dn - 1, 1)
    if d == 0:
        zero[0] -= 1
    else:
        one[0] -= 1
    return result

print(find(2 * n, 1))

62. Outputting all possible ways

Often in problems it is necessary not only to count how many ways something can be done, but also to output all of these ways. Let us add this requirement to the problem about different representations of a given natural number \(N\) as a sum of at least two pairwise distinct positive addends:

For \(N = 7\) the answer is \(4: 7 = 6 + 1, 5 + 2, 4 + 3, 4 + 2 + 1\).

The solution presented earlier in the section "Number of representations of a number as a sum of components" will be supplemented as follows: to the recursive function find we will add one more parameter — add of type bool. It indicates whether or not we added the current element a[n]. To the main program we will also add a variable path of type list, which will store the indices of elements that were selected in the current branch of the tree.

In the function find itself, if the current element is added, then upon entry we will append to the list path, and upon exit we will remove the last element from it. Furthermore, when finding each next solution (m == a[n]), we will output it using the current contents of the list path.

The complete solution to the stated problem is given in Listing 2.11.

Listing 2.11. Number of representations of a number as a sum

with output of all possible ways

# Find6
import sys
sys.setrecursionlimit(100000)

a: list[int] = []
path: list[int] = []

def find(n: int, m: int, add: bool) -> int:
    if add:
        path.append(a[n])  # a[n] is the element just processed (n+1 in 1-indexed)
    if m == a[n - 1]:
        for val in path:  # continued below

62.1. Listing 2.11 (continued)

            print(val, end='+')
        print(a[n - 1])
        if n >= 2:
            result = 1 + find(n - 1, m, False)
        else:
            result = 1
        if add:
            path.pop()
        return result
    if n <= 1:
        if add:
            path.pop()
        return 0
    if m - a[n - 1] > 0:
        result = find(n - 1, m, False) + find(n - 1, m - a[n - 1], True)
    else:
        result = find(n - 1, m, False)
    if add:
        path.pop()
    return result

n = int(input())
a = list(range(1, n))
path = []
print(find(n - 1, n, False))

62.1.1. NOTE

In this program, we use a Python list to store the path. This is convenient since Python lists support efficient append() and pop() operations for adding and removing elements from the end. The entire path is visible in a single variable, and Python provides rich built-in list operations. In this example, len(), append(), and pop() were used.

Instead of the variable path of type list, one could use any other data structure and write the functions for inserting and removing elements yourself, with parallel tracking of the number of elements in it.

63. Outputting one of the possible ways

There are problems where it is sufficient to find just one of the possible ways. For example, suppose we need to write a program that reads numbers M, N and a sequence of N numbers \(a(i)\) and outputs one of the ways in which the given sum M can be composed from the numbers \(a(i)\).

To solve the problem, we modify the program (see Listing 2.8) as follows:

  1. when the first solution is found, we output it and terminate program execution with sys.exit();

  2. we read the elements of the list a;

  3. we convert the function find into a procedure (a function returning None).

The text of the modified program is in Listing 2.12.

Listing 2.12. Counting ways to compose a sum with output of one of the possible ways
# Find7
import sys
sys.setrecursionlimit(100000)

a: list[int] = []
path: list[int] = []

def find(n: int, m: int, add: bool) -> None:
    if add:
        path.append(a[n])  # a[n] is the element just processed
    if m == a[n - 1]:
        for val in path:
            print(val, end='+')
        print(a[n - 1])
        sys.exit()
    if n <= 1:
        if add:
            path.pop()
        return
    if m - a[n - 1] > 0:
        find(n - 1, m, False)
        find(n - 1, m - a[n - 1], True)
    else:
        find(n - 1, m, False)
    if add:
        path.pop()

data = list(map(int, input().split()))
m_val = data[0]
n_val = data[1]
a = data[2:2 + n_val]
path = []
find(n_val, m_val, False)

63.1. "Playing Dominoes"

Suppose we need to solve the following problem: we have \(N\) tiles from several sets of dominoes. Build a valid sequence of maximum length from them.

Suppose we have 6 tiles: 1 1, 1 2, 4 4, 1 1, 2 3, 2 1. Then the answer will be 5. One of the valid chains: 2 1, 1 1, 1 1, 1 2, 2 3.

A recursive solution can be built from the following considerations: we need to build such trees for the available domino tiles (for simplicity of the example, take 3 tiles and number them 1, 2, 3):

              1         2         3
          2     3     1   3     1   2
      3       2   3     1   2     1

Unlike the previous problems, here the order in which the elements are selected also matters. At the same time, we must remember that a domino tile can be flipped, meaning it can be attached to the end of the chain by either of its two ends.

To store the state of the tree, we will use the following variables:

  • path — a list storing the current chain (indices of the selected domino tiles);

  • free — a list storing all currently unused domino tiles;

  • orie — a list storing the orientation of the tile in the current chain ('D' — in the order of input, 'I' — in reverse order);

  • start — the value at the beginning of the current domino chain;

  • finish — the value at the end of the current domino chain (both variables are initialized with the value -1);

  • t — the current number of free domino tiles, initialized with the number of entered tiles;

  • max_l — the current maximum path length;

  • max_path — the current maximum path;

  • a[i][0], a[i][1] — the numbers on the \(i\)-th domino tile.

So, we build the recursive function find as follows:

Find(T,Start,Finish)
  For i from 1 to T
    Take a free tile
    If this is the start,
      then transfer it from free to the chain, orientation 0
        initialize Start and Finish from it
        Find(T-1, Start, Finish)
        return it from the chain to free
    else
      if it fits "as is",
        then transfer it from free to the chain, orientation 0
          update Finish from it
          Find(T-1, Start, Finish)
          return it from the chain to free
      else
        if it fits "flipped",
          then transfer it from free to the chain, orientation 1
            update Finish from it
            Find(T-1, Start, Finish)
            return it from the chain to free
  If Length(Path)>MaxL
    then maxL:=Length(Path), MaxPath:=Path
  If MaxL=N, then output Result (Path, MaxL), halt

The program text implementing this algorithm is given in Listing 2.13.

Listing 2.13. The "Dominoes" program
# Domino
import sys
sys.setrecursionlimit(100000)

a: list[list[int]] = []
n = 0
max_l = 0
max_path: list[int] = []
max_orie: list[str] = []
path: list[int] = []
orie: list[str] = []
free: list[int] = []

def put_r() -> None:
    for i in range(len(max_path)):
        k = max_path[i]
        if max_orie[i] == 'D':
            print(a[k][0], a[k][1])
        else:
            print(a[k][1], a[k][0])
    print(len(max_path))

def find(t: int, start: int, finish: int) -> None:
    global max_l, max_path, max_orie
    for i in range(t):
        k = free[i]
        if start == -1:
            path.append(k)
            free.pop(i)
            orie.append('D')
            find(t - 1, a[k][0], a[k][1])
            path.pop()
            free.insert(i, k)
            orie.pop()
        else:
            if a[k][0] == finish:
                path.append(k)
                free.pop(i)
                orie.append('D')
                find(t - 1, start, a[k][1])
                path.pop()
                free.insert(i, k)
                orie.pop()
            elif a[k][1] == finish:
                path.append(k)
                free.pop(i)
                orie.append('I')
                find(t - 1, start, a[k][0])
                path.pop()
                free.insert(i, k)
                orie.pop()
    if len(path) > max_l:
        max_l = len(path)
        max_path = path[:]
        max_orie = orie[:]
    if max_l == n:
        put_r()
        sys.exit()

n = int(input())
a = []
for i in range(n):
    x, y = map(int, input().split())
    a.append([x, y])

continued below

63.2. Listing 2.13 (continued)

max_l = 0
max_path = []
max_orie = []
path = []
orie = []
free = list(range(n))
find(n, -1, -1)
put_r()

63.2.1. NOTE

In this program, the convenience of storing the path in a Python list also lies in the fact that saving a new best path in place of the old one is accomplished with just one slice copy: max_path = path[:]. Another advantage of this approach (using a list to store collections of elements) is the ability to use Python’s built-in insert() method to return an element to the free list.

64. "Sources and Sinks"

Suppose we are given a map of one-way roads between cities. A city from which all other cities can be reached is called a source. A city that can be reached from all other cities is called a sink. We need to find all sources and sinks.

64.1. EXAMPLE

Suppose there are 6 cities (numbered from 1 to 6), and 10 roads: 2-1 (meaning this road connects city 2 to city 1), 2-4, 4-1, 4-2, 4-3, 1-3, 3-5, 5-3, 3-6, 6-3. Then the correct answer is: sources are 2 and 4, sinks are 3, 5, 6.

The solution to the problem can be as follows.

  1. From the input data we determine max_a — the maximum city number.

0 0 1 0 1 1
1 1 1 1 1 1
0 0 1 0 1 1
1 1 1 1 1 1
0 0 1 0 1 1
0 0 1 0 1 1
  1. Using the recursive function find, we build the reachability matrix md (from which city to which city one can get via the given road network; if its element md[i][j] == 1, then from city i one can reach city j). For the given example it will be:

  2. Rows with a count of ones greater than or equal to max_a - 1 correspond to source cities.

  3. Columns with a count of ones greater than or equal to max_a - 1 correspond to sink cities.

The recursive function find is very similar to the function from the domino problem considered earlier: after all, roads are chained together using the same domino principle.

The only difference is that in this problem the roads are one-way and therefore the function find is somewhat simplified:

Find(T,Start,Finish)
  For i from 1 to T
    Take a free road
    If this is the start,
      then transfer it from free to the chain
        initialize Start and Finish from it
        Find(T-1, Start, Finish)
        return it from the chain to free
    else
      if it fits,
        then transfer it from free to the chain
          update Finish from it
          Update reachability matrix MD
          Find(T-1, Start, Finish)
          return it from the chain to free

The program also uses the following functions:

  • init — for initializing the reachability matrix md from the given road map;

  • put_r — for analyzing the reachability matrix md and outputting the answers.

Listing 2.14 contains the full text of the program that solves this problem.

Listing 2.14. Text of the "Sources and Sinks" program
# Istoki_Stoki (Sources and Sinks)
import sys
sys.setrecursionlimit(100000)

a: list[list[int]] = []
md: list[list[int]] = []
n = 0
max_a = 0
path: list[int] = []
free: list[int] = []
s: list[int] = []
p: list[int] = []

def init() -> None:
    global max_a, md
    max_a = 0
    for i in range(n):
        if a[i][0] > max_a:
            max_a = a[i][0]
        if a[i][1] > max_a:
            max_a = a[i][1]
    md = [[0] * (max_a + 1) for _ in range(max_a + 1)]
    for i in range(n):
        md[a[i][0]][a[i][1]] = 1

def put_r() -> None:
    s = [0] * (max_a + 1)
    p = [0] * (max_a + 1)
    for i in range(1, max_a + 1):
        for j in range(1, max_a + 1):
            s[i] += md[i][j]
            p[i] += md[j][i]
    print('sources :', end='')
    for i in range(1, max_a + 1):
        if s[i] >= max_a - 1:
            print(f' {i}', end='')
    print()
    print('sinks  :', end='')
    for i in range(1, max_a + 1):
        if p[i] >= max_a - 1:
            print(f' {i}', end='')
    print()

continued below

64.2. Listing 2.14 (continued)

def find(t: int, start: int, finish: int) -> None:
    for i in range(t):
        k = free[i]
        if start == -1:
            path.append(k)
            free.pop(i)
            find(t - 1, a[k][0], a[k][1])
            path.pop()
            free.insert(i, k)
        else:
            if a[k][0] == finish:
                path.append(k)
                free.pop(i)
                md[start][a[k][1]] = 1
                find(t - 1, start, a[k][1])
                path.pop()
                free.insert(i, k)

n = int(input())
a = []
for i in range(n):
    x, y = map(int, input().split())
    a.append([x, y])
init()
path = []
free = list(range(n))
find(n, -1, -1)
put_r()

64.2.1. NOTE

It is important to understand that the larger the total size of local variables in a recursive function, the smaller the tree of the solution space that can be investigated.

Python has a default recursion limit of 1000 calls. You can change this limit using sys.setrecursionlimit(). For competitive programming, it is common to set it to a large value such as sys.setrecursionlimit(10**6). However, be aware that very deep recursion in Python can cause a stack overflow (segmentation fault), so for extremely deep recursion you may need to convert to an iterative approach.

If a problem requires traversing the tree of possible solutions not "depth-first" but "breadth-first" (i.e., it is necessary to investigate tree vertices "level by level" — first the entire top row, then the entire next lower row, etc.), then one can use

the "queue" mechanism (for example, using Python’s collections.deque). However, in this case the programmer must take on the full responsibility of saving the necessary data in the queue and timely removing them from the queue.

The main attractive property of programs using recursive functions lies precisely in the fact that all the work of managing the stack with local data of all instances of recursive function calls is taken on by the Python runtime system. As a result, the user’s program is freed from the corresponding code, becoming more compact and clear. When solving a sufficient number of problems to build stable skills in correct recursive programming, this method becomes extremely effective, especially under the limited time allocated for solving a problem.

For example, consider the initial fragment of the program Find7 (see Listing 2.12):

# Find7
import sys
sys.setrecursionlimit(100000)

a: list[int] = []
path: list[int] = []

def find(n: int, m: int, add: bool) -> None:
    ...

Here, in the recursive function find:

  • n, m, add are local variables (function parameters); their values are relevant only in the current instance of the function find;

  • a is a global list, accessible to all instances of the recursive function.

Another case: when a function modifies mutable objects like lists that are passed as arguments, those changes are visible to all callers. In Python, integers and other immutable types are always passed by value, while lists and other mutable types are passed by reference.

The main disadvantages of the recursive approach are the limited depth of the call stack and, possibly, unsatisfactory execution time of the algorithm. The first problem, as already mentioned, can be partially addressed by increasing the recursion limit with sys.setrecursionlimit(), as well as by carefully minimizing the number of local variables in the recursive function. Execution time can be reduced by introducing so-called pruning into the recursive bodies — special conditions that reduce the investigation tree by cutting off subtrees that are guaranteed not to contain the sought solutions. For example, in the program Find5 (see Listing 2.10), such a pruning condition is:

if zero[0] > n or one[0] > n or zero[0] > one[0]:
    result = 0
    ...
    return result

If the bracket expression is already invalid, there is no point in considering variants of appending new brackets to it.

65. 2.3. Recurrence Relations

There is a number of problems in which, due to the impressive ranges of input data, the recursive approach is fundamentally unable to provide a complete solution. In these cases, a related approach called recurrence comes to the rescue.

65.1. General information about recurrence relations

It should be noted that devising recurrence relations is a process that requires a creative approach. This means that, understanding the meaning of the term recurrence relation and having already solved several problems using it, you may still fail to devise a correct recurrence relation for the next problem. And this is because recurrence relations are constructed with regard to the specifics of the problems, and often very deep specifics. Nevertheless, the more problems you have solved using the recurrence relation method, the higher the probability that you will master this art.

you will be able to devise a correct recurrence relation for solving a newly encountered problem.

An important advantage of recurrence relations is the simplicity of implementation. This means that even under limited time (for example, during an olympiad), you can spend an hour or more devising, verifying, and proving the correctness of a recurrence relation, and then 10-15 minutes on implementing, debugging, and testing the corresponding program.

The subsequent material is structured as follows: the main idea of the recurrence relation method is explained and illustrated, and then solutions to a large number of problems from Belarusian national and international (IOI — International Olympiad in Informatics) informatics olympiads for schoolchildren are presented. Not only the recurrence relation itself is given, but also attempts are made to illustrate the process of investigating the specifics of the problem to construct a correct recurrence relation.

After reviewing the general information, it is recommended to analyze each new problem in the following order: after reading the problem statement, set the material aside and try to solve the problem independently, first by any method that comes to mind (even under much smaller constraints than those actually given in the problem statement, for example, by some simple brute force), then still try to compose a recurrence relation. It is important to note that your simple solution will be useful for verifying the correctness of the composed recurrence relation. The procedure can be as follows: for small values of the input data, you calculate the result manually. Then you solve the problem under significantly smaller constraints than those specified in the problem using some method you know (brute force, recursion, queue, etc.), and you test this solution on manually created tests. Then the recurrence solution can be verified against the "partial solution." If it seems to you that you have reached a dead end and further reflection cannot lead to a useful result, you should return to the material and read the author’s analysis of the problem’s specifics.

After that, set the material aside again and try once more to compose the recurrence relation independently. Then, if you still cannot solve the problem, read the complete solution and implement it on the computer. The more thorough and intensive the work you have done on the current problem, the greater the chances that you will solve the next problem without resorting to help. Moreover, such work on each of these problems will lead to a significant increase in the chances that you will solve new problems of this class that you will inevitably encounter. Conversely, superficial reading may at best lead to you simply understanding and possibly memorizing the solution of a dozen problems on recurrence relations. This, of course, is also not a small amount, since you may encounter these or similar problems. But the author sets a different goal — to help you learn to solve new problems in this way.

66. The Fibonacci Numbers Problem

Let us consider the classic Fibonacci numbers problem, a staple of educational materials on recurrence relations.

In some sources, this problem is formulated as the "rabbit breeding problem": suppose we had one rabbit in the "zeroth" generation and one rabbit in the first generation. It is also known that the number of rabbits in the next generation equals the sum of the numbers of rabbits in the two previous generations. It is required to compute the number of rabbits in generation \(N\) (for example, \(N ≤ 10,000\)).

The sequence of numbers built for generations from the 0th to the 7th is as follows:

1 1 2 3 5 8 13 21 34.

That is, in the 2nd generation (\(N=2\)) there will be \(2 (1 + 1)\) rabbits, in the 3rd — \(3 (1 + 2)\) rabbits, in the 4th — 5 rabbits, and so on.

This sequence of numbers is called the Fibonacci sequence, and the numbers themselves, respectively, Fibonacci numbers.

How do we find the number of rabbits in a given generation \(N\)?

We introduce a list k of size n + 1. By the problem’s conditions, k[0] = 1, k[1] = 1.

To solve the problem, we fill the list elements as follows:

for i in range(2, n + 1):
    k[i] = k[i - 1] + k[i - 2]

And output the result as follows:

print(k[n])

The complete program text for solving the problem is given in Listing 2.15. Note that Python has arbitrary-precision integers, so unlike some other languages, there is no overflow issue even for very large N.

Listing 2.15. The Fibonacci Numbers Problem
# Fib

n = int(input())
k = [0] * (n + 1)
k[0] = 1
k[1] = 1
for i in range(2, n + 1):
    k[i] = k[i - 1] + k[i - 2]
print(k[n])

Now let us return to the general terminology.

The relation k[i] = k[i - 1] + k[i - 2] is called a recurrence relation for the quantity k. The quantity k itself is called recurrent. The word "recurrent" here conveys the meaning that the next value of the recurrent quantity is computed from one or more of its previous values.

The quantity i is called the parameter of the recurrent quantity. For example, k[i] is a recurrent quantity of one parameter i. For storing recurrent quantities of one parameter, a one-dimensional list is typically used.

However, for example, in the Fibonacci numbers problem, to compute the next number we need to know and, accordingly, store only two previous numbers. Therefore, we could have simply used two variables to store them. In that case, the program could look as shown in Listing 2.16.

Listing 2.16. The Fibonacci Numbers Problem (second variant)
# Fib2

n = int(input())
k0 = 1
k1 = 1
k2 = 1
for i in range(2, n + 1):
    k2 = k1 + k0
    k0 = k1
    k1 = k2
print(k2)

Let us note in passing that although this solution looks less understandable and slightly more complex, it can compute Fibonacci numbers for N much larger than 10,000. For example, for N = 100,000. In Python, since integers have arbitrary precision, there is no overflow — the numbers simply grow as large as needed.

Recurrent quantities can have more than one parameter. For example, the recurrent quantity R[i][j] = R[i-1][j] + R[i-2][j] + …​ + R[i-j][j] has two parameters, denoted i and j. In the general case, a two-dimensional list is used in the program to store the values of recurrent quantities of two parameters. To compute the elements of the next row of the list R, one needs to know the computed values of j rows (with numbers i-1, i-2, …​, i-j) above the current one.

The recurrent quantity R[p][k][t] = max(R[p-1][k][t-L[p]] + 1, R[p-1][k][t]) has three parameters p, k, t. And its value for given values of these parameters is computed, as we can see, somewhat more complexly. For storing the values of a recurrent quantity of three parameters, a three-dimensional list is typically used. But there are cases when the maximum values of the recurrent quantities established in the problem constraints are too large to simultaneously store all the values of the recurrent quantity for all possible values of its parameters in memory. Then not all values are stored, but only those that are actually needed for computing new values. Obviously, the program becomes more complex in this case, sometimes insignificantly, and sometimes substantially.

So, the essence of the method of solving problems using recurrence relations is:

  • establish the fact that the problem can be solved by this method, i.e., make sure that a recurrence relation exists and it makes sense to spend time searching for and deriving it;

  • introduce a recurrent quantity of one, two, three, or more parameters;

  • determine how to find the solution result given the computed values of the recurrent quantity;

  • establish the initial values of the recurrent quantity;

  • based on the specifics of the problem, derive a correct recurrence relation (from the author’s point of view, such ability is more of an art than a science; and thoughtful solving of a larger number of problems on recurrence relations increases the probability that you will master this art);

  • verify the correctness of the recurrence relation for values computed manually or by auxiliary programs; at this point, you can implement the program itself that computes the recurrent quantity;

  • determine whether the representation of the derived recurrent quantity as a list of the appropriate dimensionality fits in memory; if not, modify the recurrence relation for the program to function correctly within the given constraints, and adjust the program accordingly.

Python does not have the same strict static memory limitations as older environments. However, memory is still finite, and for very large problem sizes you may need to optimize by only keeping the rows of a 2D list that are actually needed for computation, rather than storing the entire table.

67. Recurrence Relations with One Parameter

This section presents problems for which it is necessary to compose a recurrence relation with one parameter. This means that the introduced recurrently-defined quantity depends on one argument.

67.1. The "Sums" Problem

Belarusian national olympiad in informatics, 2000. Grades 8-9. Day 1. Problem 2.

Any integer \(N > 0\) can be represented as a sum of natural addends, each of which is a power of 2. Sums that differ only in the order of addends are considered identical. Find \(K\) — the number of ways in which the number \(N\) can be represented as a sum of natural addends, each of which is a power of 2. Constraints: \(N ≤ 1000\).

For example, for \(N = 7\):

  • \(7 = 4 + 2 + 1\);

  • \(7 = 4 + 1 + 1 + 1\);

  • \(7 = 2 + 2 + 2 + 1\);

  • \(7 = 2 + 2 + 1 + 1 + 1\);

  • \(7 = 2 + 1 + 1 + 1 + 1 + 1\);

  • \(7 = 1 + 1 + 1 + 1 + 1 + 1 + 1\);

and, consequently, \(K = 6\).

In the program that solves the problem, we will read N from the text file SUM.IN, and write the result (K) to the text file SUM.OUT. For example:

  • SUM.IN

    132 * SUM.OUT

    31196

Let us try to systematically compose several consecutive decompositions:

N = 1 N = 2 N = 3 N = 4 N = 5 N = 6 N = 7

1

2

2+1

4

4+1

4+2

4+2+1

1+1

1+1+1

2+2

2+2+1

4+1+1

4+1+1+1

2+1+1

2+1+1+1

2+2+2

2+2+2+1

1+1+1+1

1+1+1+1+1

2+2+1+1

2+2+1+1+1

2+1+1+1+1

2+1+1+1+1+1

1+1+1+1+1+1

1+1+1+1+1+1+1

Let us introduce the notation: \(K(i)\) — the number of decompositions of the number i according to the problem’s conditions. We immediately notice that \(K(1) = 1\), \(K(2) = 2\).

\(K(i) = K(i-1)\), if i is odd (in each row, +1 is added to the corresponding previous decomposition, and the number of rows does not change). It remains to find out how to compute \(K(i)\) for even i.

It is natural to assume that \(K(i)\) depends on \(K(i-1)\) as follows: \(K(i) = K(i-1) + X\), meaning the number of decompositions of the i-th number equals the number of decompositions of the number i-1 plus some more. How many more?

As an example, let us examine in more detail the decompositions for \(N = 6\) and mark (with the symbol "!") those rows that were carried over from the decomposition of \(N = 5\) by adding +1:

N = 5 N = 6

4+1

4+2

2+2+1

(!) 4+1+1

2+1+1+1

2+2+2

1+1+1+1+1

(!) 2+2+1+1

(!) 2+1+1+1+1

(!) 1+1+1+1+1+1

Now let us write out the rows that remained unmarked, i.e., those that were added by a different rule (which is precisely what determines X):

4+2

2+2+2

Interestingly, all of them are composed of even numbers. Let us divide all these numbers by 2, obtaining:

2+1
1+1+1

Now notice that this is the decomposition of the number 3, which can be obtained from the number 6 also by dividing by 2. In total we get: X = K(i // 2).

The final recurrence relation has the form:

  • K(i) = K(i - 1), if i is odd;

  • K(i) = K(i - 1) + K(i // 2), if i is even.

Let us verify our reasoning on one more test example:

N = 7 N = 8

4+2+1

8

4+1+1+1

4+4

2+2+2+1

4+2+2

2+2+1+1+1

(!) 4+2+1+1

2+1+1+1+1+1

(!) 4+1+1+1+1

1+1+1+1+1+1+1

2+2+2+2

(!) 2+2+2+1+1

(!) 2+2+1+1+1+1

(!) 2+1+1+1+1+1+1

(!) 1+1+1+1+1+1+1+1

The unmarked rows are:

8
4+4
4+2+2
2+2+2+2

After dividing all numbers by 2, we get:

4
2+2
2+1+1
1+1+1+1

This is precisely the decomposition of the number 4, which can be obtained from the original N = 8 by dividing by 2. And then the complete solution of the problem can consist of recurrently filling the list k[1..n] and outputting the value k[n].

One can also notice that k[2] = k[1] + k[2 // 2] = k[1] + k[1] = 2. That is, k[2] can be computed by the general rule. It is sufficient to set only the initial value k[1].

The complete solution to the problem is in Listing 2.17.

67.2. Listing 2.17. Solution to the "Sums" problem

# by00m1t2

with open('sum.in', 'r') as fin:
    n = int(fin.readline())

k = [0] * (n + 1)
k[1] = 1
for i in range(2, n + 1):
    if i % 2 == 1:
        k[i] = k[i - 1]
    else:
        k[i] = k[i - 1] + k[i // 2]

with open('sum.out', 'w') as fout:
    fout.write(str(k[n]) + '\n')

NOTE

Here i % 2 == 1 checks whether the number i is odd, equivalent to the odd(i) function. In Python, you can also use i % 2 != 0 or i & 1 for the same purpose.

68. The "Scouting Selection" Problem

Quarter-final of the ACM World Programming Championship for students, Central Region of Russia. October 5-6, 1999.

From \(N\) soldiers lined up in a row, several must be selected for a scouting mission. To do this, the following operation is performed: if there are more than 3 soldiers in the row, then all soldiers standing at even positions, or all soldiers standing at odd positions, are removed. This procedure is repeated until 3 or fewer soldiers remain in the row. They are sent on the scouting mission. Calculate how many ways scouting groups of exactly three people can be formed in this manner. Constraints: 0 < \(N\) ≤ 10,000,000.

Input file — INPUT.TXT (contains the number N), output file — OUTPUT.TXT (contains the solution — the number of variants). Time per test — 5 seconds.

EXAMPLE

For \(N\) = 10, the number of ways to form a scouting group will be 2; for \(N\) = 4, none, i.e., 0.

Let us denote K(N) — the number of ways in which scouting groups can be formed from N people in the row. Then, by the problem’s conditions, \(K(1) = 0, K(2) = 0, K(3) = 1\). That is, from three people only one group can be formed, and from one or two — none.

Consider the case of an arbitrary number of soldiers in the row. If N is even, then by applying the deletion operation defined in the problem, we will be left with either N // 2 soldiers standing at even positions, or N // 2 soldiers standing at odd positions. The total number of ways can be found by the formula K(N) = 2 * K(N // 2) (if N is even). That is, the number of ways

form a reconnaissance group from the soldiers remaining at even positions, plus the number of ways to form a reconnaissance group from the soldiers remaining at odd positions.

If N is odd, then we are left with either N // 2 soldiers who stood at even positions, or (N // 2) + 1 soldiers who stood at odd positions. The total number of ways in this case is K(N) = K(N // 2) + K((N // 2) + 1).

Thus, the final recurrence relation has the form:

  • K(N) = 0, if N == 1 or N == 2;

  • K(N) = 1, if N == 3;

  • K(N) = 2 * K(N // 2), if N is even;

  • K(N) = K(N // 2) + K((N // 2) + 1), if N is odd.

It only remains to note the following facts.

  1. N can reach 10,000,000, therefore we cannot allocate a list to store all values of the recurrence K(N);

  2. In fact, to compute the answer for a specific N, we need far fewer than all values of K(N). Therefore, it is convenient to use recursive computation of the recurrence K(N):

import sys
sys.setrecursionlimit(100000)

def k(n: int) -> int:
    if n < 3:
        return 0
    elif n == 3:
        return 1
    elif n % 2 == 1:
        return k(n // 2) + k(n // 2 + 1)
    else:
        return 2 * k(n // 2)

The complete solution to this problem is shown in Listing 2.18.

Listing 2.18. Solution to the "Reconnaissance Selection" problem
# nee4c99g
import sys
sys.setrecursionlimit(100000)

def k(n: int) -> int:
    if n < 3:
        return 0
    elif n == 3:
        return 1
    elif n % 2 == 1:
        return k(n // 2) + k(n // 2 + 1)
    else:
        return 2 * k(n // 2)

with open('input.txt', 'r') as fin:
    n = int(fin.readline())

with open('output.txt', 'w') as fout:
    fout.write(str(k(n)) + '\n')

69. Problem "Video Salon"

Belarusian National Olympiad, 1996. Day 2. Problem 3.

The owner of a video salon decided to maximize the income from his business. The video salon has an unlimited number of blank videotapes. The duration of each videotape is \(L\). The owner has the opportunity to record \(N\) movies of duration \(A(1), ..., A(N), A(i) < L\) onto videotapes. He cannot record the same movie twice on one videotape and cannot leave empty space on a tape that would fit entirely one of the movies not recorded on that tape. Movies are recorded in their entirety. The video salon owner aims to record movies onto tapes in such a way that to watch all the movies, a customer would be forced to rent the maximum number of tapes. Determine this number \(K\). Constraints: \(A[i\), L, N] are natural numbers, \(L ≤ 1000, N ≤ 100\).

Input — from file Input3.txt:

L
N
A[1]
…
A[N]

Output — to file output3.txt (\(K\)).

Consider the following case: let \(L = 100, N = 8\), and \(A[i\) = [2 2 3 3 97 97 97 97]] (durations of eight movies).

Since some movies may have the same duration (as in our example), to distinguish these movies when recording, we introduce notation: \(2(1)\) means a movie of duration 2, the first in the original list, or \(97(6)\) means a movie of duration 97, the sixth in the original list. If the customer were to determine the order of recording movies onto tapes, then in our example, obviously, they would choose the most advantageous option with the minimum number of tapes:

97(5) + 2(1),
97(6) + 2(2),
97(7) + 3(1),
97(8) + 3(2).

With this arrangement, their payment for renting all movies would be minimal. But the cunning video salon owner records the tapes himself. How will he record them? It would be good if you, before reading further, came up with a "clever" arrangement on your own. And even better — also a method for a "clever" algorithm for recording movies onto videotapes.

Now let us try together to compose this "clever" arrangement. The first tape will remain the same: \(97(5) + 2(1)\). But on the second tape, the video salon owner will prefer to record only one new movie — \(2(2)\), that is, \(97(5) + 2(2)\). He will do the same with the 3rd and 4th tapes:

97(5) + 3(1),
97(5) + 3(2).

So, the customer is already forced to buy 4 tapes, and yet they haven’t seen three of the best movies — 97(6), 97(7), and 97(8) — for recording which, in the video salon owner’s "clever" arrangement, they would need to buy three more tapes:

97(5) + 2(1),
97(6) + 2(1),
97(7) + 2(1).

What to use to fill the remaining space on these three tapes from the available "short" movies is essentially irrelevant. For example, movie 2(1).

We seem to have figured out this particular arrangement. But what about others? And what is the algorithm for "clever" recording of movies? And finally, how do we determine the required number of videotapes k from the input data L, N, A[i]?

If we reformulate the problem, we are required to compose all possible sums from combinations of the original numbers, and from these sums select the maximum number such that the sequence of numbers in each sum differs from the rest by at least one number (identical numbers at different positions in the input data are considered different).

Since longer movies are more significant for the recording process, we reorder the input data so that movies go in decreasing order of their durations.

Let us examine a single videotape "under a microscope." It has a recording duration of L (let’s say L minutes for definiteness). Then we can view a single tape as a one-dimensional list s of length L+1. And then each of the aforementioned sums corresponds to exactly one position in this list. We will discard sums exceeding L (since they don’t interest us as combinations of movies that don’t fit on a single tape). We will store a one in s[j] if the recording of the previous movie ended at position j.

Then each new movie we should try to append to all possible positions roughly like this:

if s[j] != 0 and j + a[i] <= L: s[j + a[i]] = 1

If we found a place where we can "squeeze in" a movie and it fits until the end of the tape (a[i] is the duration of the current movie), then we mark the position where this movie will end.

At the same time, before the first movie, s[j] = 0 for all j from 1 to L, and s[0] = 1, meaning we can write movies from the beginning of the tape.

Clearly, the above operation needs to be done for all movies. How do we simultaneously keep track of the number of variants?

Let us introduce another list ns of length L+1. We will modify it in parallel with list s. But while in s[j] we only note the fact that the recording of the previous movie ended at this position, in ns[j] we will keep track of the number of ways we have recorded movies up to this point:

if s[j] != 0 and j + a[i] <= L:
    ...
    ns[j + a[i]] = ns[j + a[i]] + max(1, ns[j])
    ns[j] = 0

If we found a place to record a new movie, then the count of ways "migrates" to the end of the recording of that movie, and we must take into account that:

  1. the counts need to be added — the old one (ns[j + a[i]]) and the new one (ns[j]);

  2. the very first time we simply add 1 (max(1, ns[j]));

  3. "migration" of the count ns[j] means that after we have added it "forward," we need to zero it out at the current position to avoid "inflating" the number of variants.

Good, now for all sums s[j] we will count how many different ways ns[j] they can be obtained using N movies. And which of these sums ms[j] should be added to get the answer K?

To answer this question, let us introduce one more list: ms of length L+1. Initially, all its elements will equal 0. Then we will set ms[j] to one in the case where we formed subsequent sums from this sum, meaning this sum is intermediate and should not be counted:

    if s[j] != 0 and j + a[i] <= L:
        ...
        ms[j] = 1

And to obtain the answer K, we need to sum elements of list ns[j] from the end, as long as ms[j] == 0:

    k = 0
    j = L
    while ms[j] == 0:
        k += ns[j]
        j -= 1
    print(k)

And finally, one last remark: generally speaking, we need two lists s: one for the current value of s, and another for the next one, so as not to append a movie to itself on the same tape. But we can achieve the same effect by working with the same list s but processing it for each i from the last element s[L - a[i]] to the first s[0]:

    ...
    for i in range(n):
        for j in range(L - a[i], -1, -1):
            if s[j] != 0 and j + a[i] <= L:
    ...

Clearly, processing s[j] for j > L - a[i] is pointless: the movie a[i] certainly won’t fit on the tape if we write it starting from this position j.

The complete solution to this problem is given in Listing 2.19.

Listing 2.19. Solution to the "Video Salon" problem
# by96s2t3 - Video Salon

import sys


def solve() -> None:
    with open('input3.txt', 'r') as fin:
        data = fin.read().split()

    idx = 0
    L = int(data[idx]); idx += 1
    n = int(data[idx]); idx += 1
    a: list[int] = []
    for i in range(n):
        a.append(int(data[idx])); idx += 1

    # Sort movies in decreasing order of duration
    a.sort(reverse=True)

    s: list[int] = [0] * (L + 1)
    ns: list[int] = [0] * (L + 1)
    ms: list[int] = [0] * (L + 1)

    s[0] = 1
    ns[0] = 0

    for i in range(n):
        for j in range(L - a[i], -1, -1):
            if s[j] != 0 and j + a[i] <= L:
                s[j + a[i]] = 1
                ns[j + a[i]] = ns[j + a[i]] + max(1, ns[j])
                ms[j] = 1

    k = 0
    j = L
    while ms[j] == 0:
        k += ns[j]
        j -= 1

    with open('output3.txt', 'w') as fout:
        fout.write(f"{k}\n")


solve()

70. Recurrence Relations with Two Parameters

This section presents problems whose solutions require constructing a recurrence relation with two parameters. This means that the recursively defined quantity depends on two arguments.

70.1. Problem "Triangle"

IOI'94. Day 1. Problem 1.

                    5
                    7
                  3 8
                8 1 0
              2 7 4 4
            4 5 2 6 5

A triangle of numbers is shown. Write a program that computes the largest sum of numbers along a path starting at the top of the triangle and ending at the base of the triangle. Constraints:

  • each step along the path may go diagonally down-left or diagonally down-right;

  • the number of rows in the triangle is greater than 1 and at most 100;

  • the triangle is composed of integers from 0 to 99.

The first number in the input file named INPUT.TXT is the number of rows in the triangle. The output file named OUTPUT.TXT should contain only the largest sum as an integer. For example, for the triangle shown above:

  • INPUT.TXT

5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
  • OUTPUT.TXT

30

Let us consider step by step the algorithm for finding the sum from the input data:

7                7                   7                   7
3 8              3+7 8+7 =>  10 15                   10 15
8 1 0                        8+10 1+max(10,15)   15+0   18 16 15
2 7 4 4
4 5 2 6 5
7                7                                   7
3 8              10  15                              10 15
8 1 0                18        16                15               => 18 16 15
2 7 4 4              2+18   7+max(18,16)   4+max(16,15)        4+15 20 25 20 19
4 5 2 6 5
7                7                                               7
3 8              10   15                                     10 15
8 1 0            18   16       15                            18 16 15
2 7 4 4          20   25       20       19                   20 25 20 19
4 5 2 6 5    4+20  5+max(20,25) 2+max(25,20) 6+max(20,19) 5+19   24 30 27 26 24

So, following the summation rules defined in the problem, we obtained from the original matrix the following:

7
10  15
18  16  15
20  25  20  19
24  30  27  26  24

To output the result, it suffices to find the maximum element in the last row.

Now let us formally write down the recurrence relations that we used when computing the matrix:

  • a[i][j] + a[i-1][j], if j == 0 (first column);

  • a[i][j] + a[i-1][j-1], if j == i (last element in row);

  • a[i][j] + max(a[i-1][j-1], a[i-1][j]) — in all other cases, for all rows starting from the second.

To obtain the answer, one then needs to find the maximum element in the last row. The program text for the solution is in Listing 2.20.

Listing 2.20. Solution to the "Triangle" problem
# l004d1t1 - Triangle (IOI'94)

def solve() -> None:
    with open('input.txt', 'r') as fin:
        n = int(fin.readline())
        a: list[list[int]] = []
        for i in range(n):
            row = list(map(int, fin.readline().split()))
            a.append(row)

    for i in range(1, n):
        for j in range(i + 1):
            if j == 0:
                a[i][j] = a[i][j] + a[i - 1][j]
            elif j == i:
                a[i][j] = a[i][j] + a[i - 1][j - 1]
            else:
                a[i][j] = a[i][j] + max(a[i - 1][j - 1], a[i - 1][j])

    m = max(a[n - 1])

    with open('output.txt', 'w') as fout:
        fout.write(f"{m}\n")


solve()

For better readability of the main loop, we use Python’s built-in max() function to find the maximum of two numbers.

Pay attention to the input. According to the problem statement, not the entire two-dimensional list is read, but only its part below and including the main diagonal:

for i in range(n):
    row = list(map(int, fin.readline().split()))
    a.append(row)

Only these elements are modified.

71. Problem "Binary Numbers"

Belarusian National Olympiad, 1996. Day 2. Problem 1.

Among all \(N\)-bit binary numbers, determine the count of those whose binary representation does not contain \(K\) consecutive ones. The numbers themselves need not be output. \(N\) and \(K\) are natural numbers, \(K ≤ N ≤ 30\).

Input of \(N\) and \(K\) — from file Input1.txt, output of \(M\) (the desired count) — to file Output1.txt.

Let \(N = 3\), \(K = 2\). Then \(M = 5\). Indeed, among all 3-bit binary numbers (000, 001, 010, 011, 100, 101, 110, 111), there are 5 numbers (000, 001, 010, 100, 101) whose representation does not have 2 consecutive ones.

Let us introduce the recurrence quantity r[i][j] — the number of \(i\)-bit binary numbers whose binary representation does not contain \(j\) consecutive ones. Then the answer to the problem is r[n][k].

First, let us try to analyze the patterns in filling the matrix r[i][j]. If \(N = 1\), there are only 2 one-bit numbers — 0 and 1. Accordingly, r[1][1] = 1, meaning there is one number whose representation does not contain a single one — 0.

r[1][j] = 2 for all \(j ≥ 2\), meaning there are two 1-bit numbers whose representation does not contain two (or more) consecutive ones — 0 and 1. Similarly, r[i][j] = 2^j for all cases \(i < j\), meaning the number of \(i\)-bit numbers whose representation does not contain \(j\) consecutive ones equals the total count of all such numbers.

Obviously, r[i][1] = 1 for all \(i ≥ 1\), meaning there is only one \(i\)-bit number whose representation contains no ones at all — the number with zeros in all its digits.

And finally, r[i][i] = 2 * i - 1, meaning the number of \(i\)-bit numbers whose representation does not contain \(i\) consecutive ones equals the total count of all \(i\)-bit numbers minus one (the one that contains ones in all digits).

Thus, the initial filling of the matrix r[i][j] is as follows:

1  2   2   2   2  2
1  3   4   4   4  4
1  7   8   8   8
1  15  16  16
1  31  32
1  63

It remains to learn how to fill the table below the main diagonal, that is, to compute r[i][j] for the case i > j.

Let us consider r[i][3] as an example. We need to compute the number of i-bit numbers (i > 4) whose representation does not contain 3 consecutive ones. All i-bit numbers are the union of the following groups:

  1. all (i-1)-bit numbers and 0 at the i-th position;

  2. all (i-2)-bit numbers and 01 at the last two positions;

  3. all (i-3)-bit numbers and 011 at the last three positions;

  4. all (i-3)-bit numbers and 111 at the last three positions;

Obviously, among the last group there is not a single number whose representation does not contain three consecutive ones.

All these groups, by definition, share no common numbers, and therefore the required count of desired numbers equals the sum of the counts of desired numbers in each group:

r[i][3] = r[i-1][3] + r[i-2][3] + r[i-3][3].

And in the general case:

r[i][j] = r[i-1][j] + r[i-2][j] + …​ + r[i-j][j].

The complete solution program is presented in Listing 2.21.

Listing 2.21. Program text for the "Binary Numbers" problem solution
# by96s2t1 - Binary Numbers

def solve() -> None:
    with open('input1.txt', 'r') as fin:
        n, k = map(int, fin.read().split())

    # r is 1-indexed: r[i][j] for i in 1..n, j in 1..n
    r: list[list[int]] = [[0] * (n + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        r[i][1] = 1

    for i in range(2, n + 1):
        r[i][i] = 2 * r[i - 1][i - 1] + 1
        for j in range(i + 1, n + 1):
            r[i][j] = r[i][i] + 1

    for i in range(3, n + 1):
        for j in range(2, i):
            s = 0
            for m in range(1, j + 1):
                s += r[i - m][j]
            r[i][j] = s

    with open('output1.txt', 'w') as fout:
        fout.write(f"{r[n][k]}\n")


solve()

An attentive reader who has tried to delve deeper into the essence of the proposed computations may note: to fill a two-dimensional matrix we use a triple loop. Moreover, in this innermost loop we are essentially adding up nearly the same numbers each time. Let us try to improve this solution based on more refined reasoning.

Suppose we want to compute first:

\(R[i,j\) = R[i-1,j] + R[i-2,j] + …​ + R[i-j,j].]

And before that we computed:

\(R[i-1,j\) = R[i-2,j] + R[i-3,j] + .. + R[i-j,j] + R[i-1-j,j].]

One can notice that

\(R[i,j\) = R[i-1,j] + (R[i-1,j] – R[i-1-j,j]) =]

\(= 2 * R[i-1,j\) – R[i-1-j,j].]

And then the program can look as shown in Listing 2.22.

Listing 2.22. Another variant of the "Binary Numbers" problem solution
# by96s2t1 - Binary Numbers (optimized)

def solve() -> None:
    with open('input1.txt', 'r') as fin:
        n, k = map(int, fin.read().split())

    # r is indexed from 0..n, 1..n
    r: list[list[int]] = [[0] * (n + 1) for _ in range(n + 1)]

    for i in range(0, n + 1):
        r[i][1] = 1

    for i in range(2, n + 1):
        r[0][i] = 1
        r[1][i] = 2
        r[i][i] = 2 * r[i - 1][i - 1] + 1
        for j in range(i + 1, n + 1):
            r[i][j] = r[i][i] + 1

    for i in range(3, n + 1):
        for j in range(2, i):
            r[i][j] = 2 * r[i - 1][j] - r[i - j - 1][j]

    with open('output1.txt', 'w') as fout:
        fout.write(f"{r[n][k]}\n")


solve()

71.1. NOTE

When solving this problem, schoolchildren from Gomel noticed that one can increase \(N\) and \(K\) up to 60 simply by using Python’s built-in arbitrary-precision integers, which have no size limit — unlike Pascal’s comp type.

72. Problem "Flower Shop"

IOI'99. Day 1. Problem 2.

You want to arrange the window of your flower shop in the most aesthetically pleasing way. You have \(F\) bouquets (each bouquet consists of flowers of one kind, and all flowers of one kind are collected in one bouquet) and no fewer vases arranged in a row. The vases are glued to the shelf and numbered from left to right consecutively from 1 to \(V\), where \(V\) is the number of vases. Vase 1 is the leftmost in the row, and vase \(V\) is the rightmost. Bouquets can be placed in different vases, and each bouquet has a unique number in the range from 1 to \(F\). The placement order of bouquets in vases is as follows: bouquet number \(i\) is always placed in a vase that stands to the left of the vase containing bouquet \(j\), if \(i < j\). For example, suppose you have a bouquet of azaleas numbered 1, a bouquet of begonias numbered 2, and a bouquet of carnations numbered 3. All bouquets must be placed in vases. The azalea bouquet must be placed in a vase to the left of the begonias, and the begonia bouquet must be to the left of the carnations. If there are more vases than bouquets, some vases will remain empty. Each vase can contain at most one bouquet.

Each vase has its own characteristics, as do the flowers. Therefore, a bouquet placed in a vase has a certain aesthetic value expressed as an integer. The aesthetic values are given in Table 2.1. An empty vase has an aesthetic value of zero.

Table 2.1. Aesthetic values of bouquets in various vases

Bouquet Vase

1st

2nd

3rd

4th

5th

1st (azaleas)

7

23

-5

-24

16

2nd (begonias)

5

21

-4

10

23

3rd (carnations)

-21

5

-4

-20

20

According to Table 2.1, azaleas look wonderful in the second vase and terrible in the fourth. To achieve the best aesthetic arrangement of the display, you must maximize the sum of aesthetic values of flower placements in vases while maintaining the required ordering of bouquets. If there are multiple placements having the maximum sum, you should output only one of them.

Constraints: \(1 ≤ F ≤ 100\) and \(F ≤ V ≤ 100\), and also \(-50 ≤ Aij ≤ 50\), where \(Aij\) is the aesthetic value obtained by placing bouquet \(i\) in vase \(j\).

The input text file is called flower.inp, and its first line contains two numbers: \(F\), \(V\). Each of the following \(F\) lines contains \(V\) integers: \(A[i,j\)] is the \(j\)-th number in the \((i+1)\)-th line of the input file.

The output file — flower.out — consists of two lines:

  • the first line contains the sum of the aesthetic values of your flower placement in vases;

  • the second line contains a sequence of F numbers, where the k-th number specifies the vase number in which bouquet k is placed.

For example:

1) flower.inp

3 5
7  23 -5 -24 16
5  21 -4  10 23
-21 5  -4 -20 20

2) flower.out

53
2 4 5

Verification: your program must execute in no more than two seconds. Partial solutions for each test will not be scored.

So, we are trying to devise a recurrence relation for the example given above. We have two parameters: the number of vases and the number of bouquets. Let r[i][j] be the score of the best placement of i bouquets in j vases (i from 1 to F, j from 1 to V):

7  23  -5  -24  16  _x  x  x  x  x_
5  21  -4   10  23  _x  x  x  x  x_
-21  5  -4  -20  20  _x  x  x  x  x_

Let us start with the case when there is exactly 1 bouquet (azalea). Then for the given number of vases we simply need to choose the best one: if there is only one vase (the first) — then that’s the one (aesthetic value equals 7), and for all other numbers of vases (2, 3, 4, 5) — it is best to place this bouquet in the 2nd.

Thus, we have:

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  _x  x  x  x  x_
-21  5  -4  -20  20  _x  x  x  x  x_

Furthermore, it is obvious that if the number of bouquets is exactly equal to the number of vases, then the only possible option is to place each bouquet in the vase with the same number and then add the corresponding values:

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  _x_  28  _x  x  x_
-21  5  -4  -20  20  _x  x_  24  _x  x_

Elements below the main diagonal are obviously zero (if there are more bouquets than vases). But the problem conditions always ensure that the number of vases is greater than the number of bouquets. Therefore, this part of the list need not be filled.

The remaining elements (above the main diagonal) are logically constructed as follows: r[2][3] is the optimal placement of 2 bouquets in 3 vases.

If we place the 2nd bouquet in the 3rd vase, then the total score will be:

a[2][3] + r[1][2] = -4 + 23 = 19.

The remaining one bouquet can be optimally placed in the first two vases.

If we do not place the 2nd bouquet in the 3rd vase, then the optimal placement remains the same, as does the optimal score r[2][2] = 28 (placement of 2 bouquets in 2 vases).

According to the problem conditions, we must choose the better option — 28.

We get:

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  x  28  28   x   x
-21  5  -4  -20  20  x   x  24   x   x

Similarly

r[2][4] = max(r[2][3], a[2][4] + r[1][3]) = max(28, 10 + 23) = 33.

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  x  28  28  33   x
-21  5  -4  -20  20  x   x  24   x   x

Next:

r[2][5] = max(r[2][4], a[2][5] + r[1][4]) = max(33, 23 + 23) = 46.

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  x  28  28  33  46
-21  5  -4  -20  20  x   x  24   x   x

r[3][4] = max(r[3][3], a[3][4] + r[2][3]) = max(24, -20 + 28) = 24.

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  x  28  28  33  46
-21  5  -4  -20  20  x   x  24  24   x

Finally:

r[3][5] = max(r[3][4], a[3][5] + r[2][4]) = max(24, 20 + 33) = 53.

7  23  -5  -24  16  7  23  23  23  23
5  21  -4   10  23  x  28  28  33  46
-21  5  -4  -20  20  x   x  24  24  53

r[3][5] = 53 — this is the desired answer.

Now let us write the corresponding formal relations in general form. We introduce the input data:

  • f — number of bouquets;

  • v — number of vases;

  • a[i][j] — aesthetic value of bouquet i in vase j.

Then:

with open('flower.inp', 'r') as fin:
    f, v = map(int, fin.readline().split())
    a: list[list[int]] = [[0] * (v + 1) for _ in range(f + 1)]
    for i in range(1, f + 1):
        row = list(map(int, fin.readline().split()))
        for j in range(1, v + 1):
            a[i][j] = row[j - 1]

We know that r[i][j] is the score of the best placement of i bouquets in j vases (i from 1 to f, j from 1 to v). Then:

  • r[1][1] = a[1][1] — one bouquet in one vase — the only placement;

  • r[i][i] = r[i-1][i-1] + a[i][i]i bouquets in i vases — the only placement (i-th bouquet into the i-th vase);

  • r[1][i] = max of a[1][1], a[1][2], …​, a[1][i] — if there is one bouquet and several vases, we place it in the best vase for it.

And finally, the general case for all other i, j:

r[i][j] = max(r[i][j - 1], a[i][j] + r[i - 1][j - 1])

That is, the optimal placement of i bouquets in j vases equals the better of the following placements:

  1. the optimal old placement (i bouquets in j - 1 vases);

  2. the last bouquet is placed in the new vase (number j) and the remaining i - 1 bouquets are optimally placed in the remaining j - 1 vases.

In the program, this takes the form:

r[1][1] = a[1][1]  # bouquets
for i in range(2, f + 1):  # as many as vases
    r[i][i] = r[i - 1][i - 1] + a[i][i]

rmax = a[1][1]  # One bouquet
for i in range(1, v + 1):
    if a[1][i] > rmax:
        rmax = a[1][i]
    r[1][i] = rmax

for i in range(2, f + 1):  # General case
    for j in range(i + 1, v + 1):
        r[i][j] = max(r[i][j - 1], a[i][j] + r[i - 1][j - 1])

Now r[f][v] contains the score of the best placement of f bouquets in v vases. However, the problem also requires outputting the vase numbers in which we placed the bouquets. Let us refer again to the constructed table r[i][j]:

7  23  23  23  23
x  28  28  39  46
x   x  24  24  53

Let us recover from it the vase numbers in which we placed bouquets for the optimal arrangement. Consider the last row: the largest number in it is 53, and it appears only in the 5th column. Therefore, the last (3rd) bouquet was placed in the 5th vase.

To find out which vase the previous bouquet (2nd) went into, consider the previous (2nd) row. The 5th vase already contains a bouquet (the 3rd). Therefore, we will consider the table starting from the 4th column (corresponding to the 4th vase). The largest of the remaining numbers — 33 — is in the 4th column. Therefore, the 2nd bouquet was placed in the 4th vase.

To find out which vase the previous (1st) bouquet was placed in, we again go up one row. Consider the first row, starting from the 3rd column (the 4th and 5th vases are already occupied). The maximum number in the 1st row is 23. But it appears in both the 3rd and 2nd columns. We must choose the column with the smallest number (that is, the 2nd). So, the bouquets were placed in vases numbered: 2, 4, and 5.

Thus, to recover the numbers of the used vases, it suffices to find in each row, starting from the last, the column number where the maximum number first appeared — jr[i]. This will be the vase number in which bouquet i was placed in the optimal arrangement.

jr = [0] * (f + 2)
jr[f + 1] = v + 1
for i in range(f, 0, -1):
    k = jr[i + 1] - 1
    rmax = r[i][k]
    for j in range(v, 0, -1):
        if r[i][j] == rmax:
            k = j
    jr[i] = k

And finally, outputting the result according to the problem requirements:

with open('flower.out', 'w') as fout:
    fout.write(f"{r[f][v]}\n")
    fout.write(' '.join(str(jr[i]) for i in range(1, f + 1)) + '\n')

The full program text is in Listing 2.23.

Listing 2.23. Solution to the "Flower Shop" problem
# io99d1t2 - Flower Shop (IOI'99)

def solve() -> None:
    with open('flower.inp', 'r') as fin:
        f, v = map(int, fin.readline().split())
        a: list[list[int]] = [[0] * (v + 1) for _ in range(f + 1)]
        for i in range(1, f + 1):
            row = list(map(int, fin.readline().split()))
            for j in range(1, v + 1):
                a[i][j] = row[j - 1]

    r: list[list[int]] = [[0] * (v + 1) for _ in range(f + 1)]

    r[1][1] = a[1][1]
    for i in range(2, f + 1):
        r[i][i] = r[i - 1][i - 1] + a[i][i]

    rmax = a[1][1]
    for i in range(1, v + 1):
        if a[1][i] > rmax:
            rmax = a[1][i]
        r[1][i] = rmax

    for i in range(2, f + 1):
        for j in range(i + 1, v + 1):
            r[i][j] = max(r[i][j - 1], a[i][j] + r[i - 1][j - 1])

    jr = [0] * (f + 2)
    jr[f + 1] = v + 1
    for i in range(f, 0, -1):
        k = jr[i + 1] - 1
        rmax = r[i][k]
        for j in range(v, 0, -1):
            if r[i][j] == rmax:
                k = j
        jr[i] = k

    with open('flower.out', 'w') as fout:
        fout.write(f"{r[f][v]}\n")
        fout.write(' '.join(str(jr[i]) for i in range(1, f + 1)) + '\n')


solve()

73. Problem "Palindrome"

Belarusian National Olympiad of Schoolchildren in Informatics, 1997. Day 1. Problem 2.

A string S is given. It is necessary to remove the minimum number of characters from it so that the result is a palindrome (that is, a string that reads the same from left to right and from right to left).

The string S is non-empty, has a length of at most 100 characters, and consists only of uppercase Latin letters. The string is read from a file named INPUT.TXT. The length of the resulting palindrome and the palindrome itself must be written to a file named OUTPUT.TXT (1st line — length of the palindrome, 2nd line — the palindrome itself). If there are multiple palindromes, only one of them should be output.

Example:

ASDDFSA 6
  • INPUT.TXT

6
ASFDFSA
  • OUTPUT.TXT

74. Consider 2 strings: the original ASDDFSA and the reversed ASFDDSA. We will fill a two-dimensional list K[i][j].

K[i][j] is the maximum length of the common substring of the initial part of i characters from the original string and the initial part of j characters from the reversed string.

For this example, the list filled with the values of the recurrence K[i][j] looks as follows:

0  0  0  0  0  0  0  0
0  1  1  1  1  1  1  1
0  1  2  2  2  2  2  2
0  1  2  2  2  3  3  3
0  1  2  3  3  4  4  4
0  1  2  3  3  4  4  4
0  1  2  3  3  4  5  5
0  1  2  3  3  4  5  6

Clearly, K[0][i] and K[i][0] equal 0 for all i. That is, no string has common characters with an empty string.

The first row has all ones, since the first characters of the strings (A) matched, and obviously a string of length 1 cannot have a longer common substring. And so on — for example, K[5][6] = 4 means that the substring of length 5 (ASDDF) has with the string of length 6 (ASFDDSA) a maximum common substring of length 4 (ASDD).

In the general case, K[i][j] is computed recursively as follows:

if s1[i] == s2[j]:
    k[i][j] = k[i - 1][j - 1] + 1
else:
    k[i][j] = max(k[i - 1][j], k[i][j - 1])

If the i-th character of the first string matches the j-th character of the second string, then we increase by 1 the number of matched characters in the substrings of i-1 characters from the first string and j-1 characters from the second string. Otherwise, we choose the maximum value from the previously matched substrings.

The length of the maximum matched substring (the length of the palindrome) is determined by the value K[L][L], where L is the length of the original string.

To output the longest common substring, we also use the list K[i][j], recovering it by backtracking.

The full program text is in Listing 2.24.

Listing 2.24. Solution to the "Palindrome" problem
# by97s1t2 - Palindrome

def solve() -> None:
    with open('input.txt', 'r') as fin:
        s1 = fin.readline().strip()

    L = len(s1)
    s2 = s1[::-1]

    # Use 1-based indexing for the DP table
    k: list[list[int]] = [[0] * (L + 1) for _ in range(L + 1)]

    for i in range(1, L + 1):
        for j in range(1, L + 1):
            if s1[i - 1] == s2[j - 1]:
                k[i][j] = k[i - 1][j - 1] + 1
            else:
                k[i][j] = max(k[i - 1][j], k[i][j - 1])

    # Recover the palindrome by backtracking
    i = L
    j = L
    p = ""
    while k[i][j] > 0:
        while k[i][j - 1] == k[i][j]:
            j -= 1
        if s1[i - 1] == s2[j - 1]:
            p = s1[i - 1] + p
            j -= 1
        i -= 1

    with open('output.txt', 'w') as fout:
        fout.write(f"{k[L][L]}\n")
        fout.write(f"{p}\n")


solve()

75. Recurrence Relations with Three or More Parameters

This section presents problems whose solutions require constructing a recurrence relation with three or even more parameters. This means that the recursively defined quantity depends on three or more arguments.

75.1. Problem "Audio Salon"

Belarusian National Olympiad of Schoolchildren in Informatics, 1997. Round 2. Problem 2.

During the broadcast of the concert "Old Songs About the Most Important — 3," entrepreneur K decided to make a business out of producing cassettes. He has M cassettes with a playing duration of D each and wants to record the maximum number of songs on them. These songs (their total count is N) are broadcast in the order 1, 2, …​, N and have known durations \(L(1), L(2), ..., L(N)\). The entrepreneur can perform one of the following actions.

  1. Record the next song onto a cassette (if it fits) or skip it.

  2. If the song does not fit on the cassette, he can skip the song or start recording it on a new cassette. In this case, the old cassette is set aside, and nothing more can be recorded on it.

Determine the maximum number of songs the entrepreneur can record on the cassettes.

The input data is written in the file KASS.DAT as follows: the first line of the file contains the numbers N, M, and D. Starting from the second line are the numbers L(1), L(2), …​, L(N) (one per line). All numbers are natural.

The answer is output to a file named KASS.OUT.

For example:

6 2 7
5
2
2
4
2
3
  • KASS.DAT

5
  • KASS.OUT

In the example, there are 6 songs with durations 5, 2, 2, 4, 2, 3. It is required to record the maximum number of them on 2 cassettes with a playing duration of 7 each.

We introduce and will calculate a recurrence quantity of three parameters: the song number, the cassette number, and the unit of playing duration (minutes — for definiteness) R[p][k][t]. R[p][k][t] is the maximum number of the first p songs that can be recorded on k cassettes, up to and including minute t on the k-th cassette.

Obviously, the answer will be contained in R[N][M][D]. It is also clear that the initial values R[0][i][j] are 0 for all i and j.

Let us see how this list R is filled when transitioning from song P-1 to song number P.

  • P = 0    0 0 0 0 0 0 0
                0 0 0 0 0 0 0

  • P = 1    0 0 0 0 1 1 1

  • L[1]=5  1 1 1 1 1 1 1

The 1st song is recorded on a cassette starting from minute 1. This song has a duration of 5. Therefore, its recording will end at minute 5. And this means that starting from minute 5 of the 1st cassette, we can record 1 song out of the 1 song presented onto this set of cassettes.

Now we record the next song:

  • P = 2    0 1 1 1 1 1 2

  • L[2] = 2  2 2 2 2 2 2 2

Starting from minute 2 of the 1st cassette, one can record 1 song out of those presented (if we skip the first song and immediately record the second song from the beginning). And start-

ing from minute 7 of the 1st cassette, one can record 2 songs out of the 2 presented (from minute 1 to 5, the 1st song will be recorded, and from minute 6 to 7 — the 2nd song).

P = 3 0 1 1 2 2 2 2

L[3] = 2 2 3 3 3 3 3 3

Starting from minute 4 of the 1st cassette, we can record 2 songs (the 2nd and 3rd, each lasting 2 minutes), and starting from minute 2 of the 2nd cassette, we can record 3 songs (5 + 2 on the 1st cassette and 2 on the 2nd), and so on.

Obviously, when trying to process the next song, we either do not change the value of the list element or add 1 to it. We add one in the case when the song fits on the cassette from the beginning to the current minute (inclusive).

Taking into account all the nuances, the recurrence relation looks as follows:

if t - L[p] >= 0:
    R[p][k][t] = max(R[p-1][k][t - L[p]] + 1, R[p-1][k][t])
else:
    R[p][k][t] = max(R[p][k-1][D], R[p-1][k][t])
If the current song fits on the current cassette up to minute T
  (including minute T),
  then the maximum number of songs from the presented P songs
  that can be recorded on K cassettes up to minute T (inclusive)
  equals the maximum of the number of songs that were
  successfully recorded up to this point when recording P-1 songs, and
  the count of songs that were successfully recorded up to
  minute T-L[P] inclusive, increased by 1
otherwise (if the current song does not fit on the current cassette
  up to minute T inclusive)
  the maximum number of songs equals the maximum of
  the number of songs that were successfully recorded up to this point
  when recording P-1 songs, and the number of songs that were
  successfully recorded up to the last minute of the previous cassette.

The full program text for the problem solution is in Listing 2.25.

Listing 2.25. Solution to the "Audio Salon" problem
# by97s2t2 - Audio Salon

def solve() -> None:
    with open('kass.dat', 'r') as fin:
        data = fin.read().split()

    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1
    d = int(data[idx]); idx += 1
    L: list[int] = [0]  # 1-indexed
    for i in range(1, n + 1):
        L.append(int(data[idx])); idx += 1

    # R[p][k][t] - 3D DP table
    R: list[list[list[int]]] = [
        [[0] * (d + 1) for _ in range(m + 1)]
        for _ in range(n + 1)
    ]

    for k in range(1, m + 1):
        for t in range(0, d + 1):
            R[0][k][t] = 0

    for p in range(1, n + 1):
        for k in range(1, m + 1):
            for t in range(0, d + 1):
                if t - L[p] >= 0:
                    R[p][k][t] = max(R[p-1][k][t - L[p]] + 1, R[p-1][k][t])
                else:
                    R[p][k][t] = max(R[p][k-1][d], R[p-1][k][t])

    with open('kass.out', 'w') as fout:
        fout.write(f"{R[n][m][d]}\n")


solve()

continued ⇨

An attentive reader has obviously noticed that in fact we do not need to store the entire three-dimensional list in memory: it suffices to store only two of its components — the "layer" (previous and new song). Therefore, the list can be declared as follows:

# R has only 2 layers: R[0] and R[1]
R: list[list[list[int]]] = [
    [[0] * (d + 1) for _ in range(m + 1)]
    for _ in range(2)
]

And to switch between "layers," two variables are used: p0 (indexes the previous layer) and p1 (indexes the new layer).

Then the computational part of the program changes as follows:

p0 = 0
p1 = 1
for p in range(1, n + 1):
    for k in range(1, m + 1):
        for t in range(0, d + 1):
            if t - L[p] >= 0:
                R[p1][k][t] = max(R[p0][k][t - L[p]] + 1, R[p0][k][t])
            else:
                R[p1][k][t] = max(R[p1][k - 1][d], R[p0][k][t])
    p0 = 1 - p0
    p1 = 1 - p1

print(max(R[0][m][d], R[1][m][d]))

And the entire program will look as shown in Listing 2.26.

75.2. Listing 2.26. Solution to the "Audio Salon" problem (another variant)

# by97s2t2 - Audio Salon (space-optimized)

def solve() -> None:
    with open('kass.dat', 'r') as fin:
        data = fin.read().split()

    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1
    d = int(data[idx]); idx += 1
    L: list[int] = [0]  # 1-indexed
    for i in range(1, n + 1):
        L.append(int(data[idx])); idx += 1

    # Only 2 layers needed
    R: list[list[list[int]]] = [
        [[0] * (d + 1) for _ in range(m + 1)]
        for _ in range(2)
    ]

    for k in range(1, m + 1):
        for t in range(0, d + 1):
            R[0][k][t] = 0

    p0 = 0
    p1 = 1
    for p in range(1, n + 1):
        for k in range(1, m + 1):
            for t in range(0, d + 1):
                if t - L[p] >= 0:
                    R[p1][k][t] = max(R[p0][k][t - L[p]] + 1, R[p0][k][t])
                else:
                    R[p1][k][t] = max(R[p1][k - 1][d], R[p0][k][t])
        p0 = 1 - p0
        p1 = 1 - p1

    with open('kass.out', 'w') as fout:
        fout.write(f"{max(R[0][m][d], R[1][m][0])}\n")


solve()

76. Problem "Labyrinth"

Quarterfinal of the ACM International Collegiate Programming Contest, Central Region of Russia. October 5-6, 1999.

A labyrinth map is a square field of size \(N × N\). Some squares of this field are forbidden for passage. A step in the labyrinth is a move from one allowed cell to another allowed cell adjacent to the first by a side. A path is some sequence of such steps. It is required to count the number of different paths from cell \((1, 1)\) to cell \((N, N)\) in exactly \(K\) steps (that is, to be in cell \((N, N)\) after the \(K\)-th step). Each cell, including the starting and ending ones, can be visited multiple times. The starting and ending cells are always allowed for passage.

Constraints: \(1 < N ≤ 20\), \(0 < K ≤ 50\).

The first line of the input file INPUT.TXT consists of just two numbers: N and K, separated by one or more spaces. The following N lines, each containing N characters, contain the labyrinth map starting from cell \((1, 1)\). The character '0' denotes a cell that is not forbidden for passage, and the character '1' denotes a forbidden one.

The output file OUTPUT.TXT contains the result — the number of possible different paths of length \(K\).

Time per test — 15 seconds.

Consider two examples:

  1. INPUT.TXT

    3 6
    000
    101
    100

    OUTPUT.TXT

    5
  2. INPUT.TXT

    3 6
    000
    111
    000

    OUTPUT.TXT

    0

We will construct a recurrence relation for the quantity A[k][i][j] — the number of ways to reach cell (i, j) in exactly k steps. Clearly, in 0 steps one can only be in cell (1, 1). Therefore, A[0][1][1] = 1 and A[0][i][j] = 0 for all i and j. On step k, cell (i, j) can be reached (if it is not forbidden for passage) only from neighboring cells according to the rules established in the problem: (i-1, j), (i+1, j), (i, j-1), (i, j+1).

The total number of ways to reach a given cell after step k will equal the sum of the numbers of ways to reach the neighboring cells on the previous (k-1)-th step, or 0 if the cell is forbidden for passage.

It is easy to calculate that storing all values of the recurrence A[k][i][j] requires \(50 * 20 * 20 = 20,000\) elements. In Python, memory is not as tightly constrained as in old Turbo Pascal, but we can still optimize by storing only the two actually needed last layers.

The complete solution to the problem is in Listing 2.27.

Listing 2.27. Program "Labyrinth"
# need4c99g - Labyrinth

def solve() -> None:
    with open('input.txt', 'r') as fin:
        first_line = fin.readline().split()
        n = int(first_line[0])
        K = int(first_line[1])

        # Read the labyrinth (1-indexed)
        C: list[list[str]] = [[''] * (n + 1)]
        for i in range(1, n + 1):
            line = fin.readline().strip()
            row = [''] + list(line)
            C.append(row)

    # A has 2 layers, indices 0..n+1 for boundary handling
    # We use n+2 to avoid index-out-of-range when accessing i+1 or j+1
    def make_layer() -> list[list[int]]:
        return [[0] * (n + 2) for _ in range(n + 2)]

    A: list[list[list[int]]] = [make_layer(), make_layer()]

    A[0][1][1] = 1
    k0 = 0
    k1 = 1

    for step in range(1, K + 1):
        # Clear k1 layer
        for i in range(0, n + 2):
            for j in range(0, n + 2):
                A[k1][i][j] = 0

        for i in range(1, n + 1):
            for j in range(1, n + 1):
                if C[i][j] == '0':
                    A[k1][i][j] = (A[k0][i][j - 1] + A[k0][i - 1][j] +
                                   A[k0][i][j + 1] + A[k0][i + 1][j])
                else:
                    A[k1][i][j] = 0

        k0 = 1 - k0
        k1 = 1 - k1

    # After all steps, result is in layer k0 (the last written layer)
    # Since we swapped at the end, the result is in k0
    # But let's just check: after the loop, we swapped k0/k1,
    # so the latest result is in A[k0] (which was k1 before swap)
    # Actually, the result is in A[1 - k1], i.e., A[k0]
    result = A[k0][n][n]

    with open('output.txt', 'w') as fout:
        fout.write(f"{result}\n")


solve()

Unlike Pascal’s comp type which has limited precision, Python integers have arbitrary precision and can handle numbers of any size. Therefore, no "long" addition implementation is needed — Python handles it natively.

77. Problem "Anniversary"

Belarusian National Olympiad of Schoolchildren in Informatics, 1998. Day 1. Problem 2.

In connection with the opening of the 1998 informatics olympiad in Mogilev, \(N\) people (\(N ≤ 10\)) decided to throw a party. To hold the party, it suffices to buy MF bottles of Fanta, MB bananas, and MC cakes. It is required to determine the minimum contribution per party participant. Note that when purchasing certain sets of goods, wholesale trade rules apply: the cost of a set of goods may differ from the total cost of individual items.

Write a program that determines the minimum contribution per party participant from the input data. The input data is in a text file named PART.IN and has the following format:

  • the first line contains the numbers N (number of people, \(N ≤ 10\)) and M (number of possible sets, \(≤ 100,000\));

  • each of the following M lines contains 4 numbers: F, B, C (number of Fanta bottles, bananas, and cakes in the set, \(0 ≤ F, B, C ≤ 1000\)) and S (cost of the set, \(S ≤ 100,000\)).

  • the last line contains the numbers MF, MB, and MC (MF, MB, MF \(≤ 9\)).

The output data should be in a text file named PART.OUT and contain the number V — the minimum contribution per participant.

So, our task is to form, taking into account all discounts, the list o[f][b][c] (dimensions 10 × 10 × 10), where o[f][b][c] is the minimum cost of purchasing f bottles of Fanta, b bananas, and c cakes.

First, let us deal with the input data: the conditions state that the number of discounts on input can be \(≤ 100,000\). But we know that for the party we need no more than 9 items of each type, so all discounts can be stored in sk[f][b][c] — a 3D list of minimum discounts for purchasing f bottles of Fanta, b bananas, and c cakes. The discount input can be done by first filling the lists o and sk with a very large value (acting as infinity):

INF = float('inf')
o = [[[INF] * 10 for _ in range(10)] for _ in range(10)]
sk = [[[INF] * 10 for _ in range(10)] for _ in range(10)]

If you don’t buy anything, you don’t spend anything, so we zero out the element o[0][0][0], that is:

o[0][0][0] = 0

Now we read the next of the 100,000 discounts and use it to form the next element in the sk list. If a discount contains a number (of Fanta bottles, bananas, or cakes) greater than 9, we replace it with 9. And we enter the corresponding cost into the sk list only if it is less than the one already stored in the list at that position (that is, only if the new discount is better than the old one).

with open('part.in', 'r') as fin:
    n, m = map(int, fin.readline().split())
    for i in range(m):
        f, b, c, s = map(int, fin.readline().split())
        if f > 9: f = 9
        if b > 9: b = 9
        if c > 9: c = 9
        if sk[f][b][c] > s:
            sk[f][b][c] = s
    mf, mb, mc = map(int, fin.readline().split())

Now we need to use the discount list sk to recursively form the list o, taking into account the following considerations.

  1. Suppose we have the optimal cost — o[i1][i2][i3] — of purchasing exactly i1 bottles of Fanta, i2 bananas, i3 cakes, and we have a discount s for f bottles of Fanta, b bananas, c cakes. Then we can construct the optimal cost for purchasing i1 + f bottles of Fanta, i2 + b bananas, and i3 + c cakes as the minimum of o[i1+f][i2+b][i3+c] and s + o[i1][i2][i3].

  2. Each of the numbers i1 + f, i2 + b, i3 + c may turn out to be greater than 9, so in that case we replace them with 9 (the problem statement says that we can buy no fewer goods than we need — the main thing is at the lowest cost).

  3. We must apply every real (not equal to infinity) discount from the list of original discounts sk to every real (not equal to infinity) cost from the list o.

for f in range(10):
    for b in range(10):
        for c in range(10):
            s = sk[f][b][c]
            if s == INF:
                continue
            for i1 in range(10):
                for i2 in range(10):
                    for i3 in range(10):
                        i1f = min(i1 + f, 9)
                        i2b = min(i2 + b, 9)
                        i3c = min(i3 + c, 9)
                        if o[i1][i2][i3] != INF and \
                           o[i1f][i2b][i3c] > s + o[i1][i2][i3]:
                            o[i1f][i2b][i3c] = s + o[i1][i2][i3]

77.1. NOTE

In Python, the continue statement skips to the next iteration of the loop, just like in other languages.

Now we need to find the minimum element of the part of the optimal discounts list o[mf..9][mb..9][mc..9], where mf, mb, mc are the original values denoting the minimum quantity of the corresponding products needed for the party, and divide it by the number of participants.

min_cost = INF
for i1 in range(mf, 10):
    for i2 in range(mb, 10):
        for i3 in range(mc, 10):
            if o[i1][i2][i3] < min_cost:
                min_cost = o[i1][i2][i3]
res = min_cost // n

And the final touch: the problem author V. M. Kotov tested the attentiveness of olympiad participants by specifying the party location (Mogilev) in the problem. He was hinting that Belarusian money should be used, and at that time Belarusian currency had no denominations smaller than hundreds in circulation. This means the result needs to be rounded to hundreds:

res = (min_cost // (n * 100)) * 100  # rounding
if min_cost % (n * 100) > 0:  # to hundreds
    res += 100

The full program text is given in Listing 2.28.

Listing 2.28. Program "Anniversary"
# by98d1t2 - Anniversary

def solve() -> None:
    INF = float('inf')

    o = [[[INF] * 10 for _ in range(10)] for _ in range(10)]
    sk = [[[INF] * 10 for _ in range(10)] for _ in range(10)]
    o[0][0][0] = 0

    with open('part.in', 'r') as fin:
        n, m = map(int, fin.readline().split())
        for i in range(m):
            f, b, c, s = map(int, fin.readline().split())
            f = min(f, 9)
            b = min(b, 9)
            c = min(c, 9)
            if sk[f][b][c] > s:
                sk[f][b][c] = s

        for f in range(10):
            for b in range(10):
                for c in range(10):
                    s = sk[f][b][c]
                    if s == INF:
                        continue
                    for i1 in range(10):
                        for i2 in range(10):
                            for i3 in range(10):
                                i1f = min(i1 + f, 9)
                                i2b = min(i2 + b, 9)
                                i3c = min(i3 + c, 9)
                                if o[i1][i2][i3] != INF and \
                                   o[i1f][i2b][i3c] > s + o[i1][i2][i3]:
                                    o[i1f][i2b][i3c] = s + o[i1][i2][i3]

        mf, mb, mc = map(int, fin.readline().split())

    min_cost = INF
    for i1 in range(mf, 10):
        for i2 in range(mb, 10):
            for i3 in range(mc, 10):
                if o[i1][i2][i3] < min_cost:
                    min_cost = o[i1][i2][i3]

    res = (int(min_cost) // (n * 100)) * 100  # rounding
    if int(min_cost) % (n * 100) > 0:  # to hundreds
        res += 100

    with open('part.out', 'w') as fout:
        fout.write(f"{res}\n")


solve()

78. Problem "Trade Discounts"

IOI'95 — 7th International Olympiad in Informatics (The Netherlands). Day 1. Problem 2.

In a store, each product has a price. For example, the price of one flower is 2 ICU (Informatics Currency Units), and the price of one vase is 5 ICU. To attract more customers, the store introduced discounts that consist of selling a set of identical or different products at a reduced price. For example: three flowers for 5 ICU instead of 6, or two vases together with one flower for 10 ICU instead of 12.

Write a program that computes the lowest price a customer must pay for the given purchases. The optimal solution must be obtained by taking discounts into account. The set of goods to be purchased cannot be supplemented with anything, even if it would reduce the total cost. For the prices and discounts described above, the lowest price for 3 flowers and 2 vases is 14 ICU: 2 vases and 1 flower are sold at the discounted price of 10 ICU, and 2 flowers at the regular price of 4 ICU.

The input data is contained in two files: INPUT.TXT and OFFER.TXT. The first file describes the purchases ("shopping basket"). The second file describes the discounts. Both files contain only integers. The first line of INPUT.TXT contains the number b of different product types in the basket (0 ≤ b ≤ 5). Each of the following b lines contains values c, k, and p. The value c is the unique product code (1 ≤ c ≤ 999). The value k specifies how many units of the product are in the basket

149

(1 ≤ k ≤ 5). The value p specifies the regular (undiscounted) price per unit (1 ≤ P ≤ 999). Note that the total number of products in the basket can be at most 5*5 = 25 units.

The first line of OFFER.TXT contains the number s of possible discounts (0 ≤ s ≤ 99). Each of the following s lines describes one discount, specifying a set of products and the total cost of the set. The first number n in such a line specifies the number of different product types in the set (1 ≤ n ≤ 5). The following n pairs of numbers (c,k) indicate that k units of product with code c are included in the discount set (1 ≤ k ≤ 5, 1 ≤ c ≤ 999). The last number in the line p specifies the discounted cost of the set (1 ≤ p ≤ 9999). The cost of a set is less than the total cost of individual product units in the set.

The output file OUTPUT.TXT contains one line with the lowest possible total cost of purchases specified in the input file.

Input and output example:

2
7 3 2
8 2 5
2
1 7 3 5
2 7 1 8 2 10
14

INPUT.TXT

OFFER.TXT

OUTPUT.TXT

So, we are told that there can be no more than five types of products and no more than 5 units of each product. Then the input data can be transformed to the form:

  • o[i][j] — sets of possible purchases;

  • i — number of the possible purchase set (discount);

  • j — product number;

  • o[i][j] — number of units of product j that must be bought when using set i. i ≤ number of products + number of discounts, j ≤ 5;

  • p[i] — cost of the i-th set from table o[i][j].

For example, for the original example, o[i][j] and p[i] look as follows:

o[i][j]    p[i]
1 0 0 0 0  2  (corresponds to line) 7 3 2
0 1 0 0 0  5                          8 2 5
3 0 0 0 0  5                          1 7 3 5
1 2 0 0 0 10                          2 7 1 8 2 10

It is required to find the minimum cost of the purchase defined by list k[0..4], where k[i] is the number of products of type i that need to be purchased.

For the given example, the list k has the form:

3 2 0 0 0

An attentive reader may notice that we renumbered the products from 1 to 5, while in the problem conditions products can have any numbers. This is true, but no more than 5 different numbers. And since these numbers are not used anywhere else, the products can be renumbered practically right after input.

We read the products to be purchased c[i], their quantity k[i], and their base price p[i]:

with open('input.txt', 'r') as fin:
    b = int(fin.readline())
    c: list[int] = [0] * (b + 1)
    k: list[int] = [0] * (b + 1)
    p: list[int] = [0] * 105
    o: list[list[int]] = [[0] * 6 for _ in range(105)]
    for i in range(1, b + 1):
        ci, ki, pi = map(int, fin.readline().split())
        c[i] = ci
        k[i] = ki
        p[i] = pi
        for j in range(1, 6):
            o[i][j] = 0
        o[i][i] = 1

Note that we immediately form b rows in the table o. Purchases at the base price can participate just like discounts!

The next step is reading the discounts. For each discount read, we populate a new row numbered kp in the purchase set list o and add the corresponding element to the set cost list p.

def find_product(cs: int) -> int:
    """Find the product index (1..b) by its code."""
    i = 1
    while c[i] != cs:
        i += 1
    if i <= b:
        return i
    else:
        print('error')
        raise SystemExit(1)

kp = b
with open('offer.txt', 'r') as fin:
    s = int(fin.readline())
    for i in range(s):
        parts = list(map(int, fin.readline().split()))
        n_items = parts[0]
        kp += 1
        for j in range(1, 6):
            o[kp][j] = 0
        idx = 1
        for j in range(n_items):
            cs = parts[idx]; idx += 1
            ks = parts[idx]; idx += 1
            o[kp][find_product(cs)] = ks
        p[kp] = parts[idx]

The trick with the numbers, which can be arbitrary on input (but we need to convert them to corresponding numbers from 1 to 5), is "hidden" in the function find_product(cs). cs is the number we read, and find_product(cs) is the corresponding number in the range from 1 to 5.

The logic of this function is simple: by the problem conditions, we know that all different product numbers must have been entered when specifying the products to buy. Therefore, the list c[i] contains all possible product codes. So each of them corresponds to its own number from 1 to 5 in the list c. The function, given a code as a parameter, simply scans all elements in the list c and finds its number. If there is no such code, it means an error in the input data.

So, we have formed the lists:

  • o[i][j] — with five columns and kp = b + s rows, where b is the number of products that can be purchased, and s is the number of discounts established by the store;

  • p[i] — with kp elements;

  • k[i] — with 5 elements.

The problem conditions emphasize that there can be at most 5 products and no more than 5 units of each can be purchased.

We introduce the function r[i1][i2][i3][i4][i5], which determines the optimal cost of purchasing i1 products of type 1, i2 of type 2, i3 of type 3, i4 of type 4, and i5 products of type 5. Here \(0 ≤ i1, i2, i3, i4, i5 ≤ 5\). Obviously, r[0][0][0][0][0] = 0, meaning if you don’t buy anything, you don’t have to pay anything.

INF = float('inf')
# 5D list: r[i1][i2][i3][i4][i5]
r = [[[[[INF for _ in range(6)]
         for _ in range(6)]
         for _ in range(6)]
         for _ in range(6)]
         for _ in range(6)]
r[0][0][0][0][0] = 0
for i in range(1, kp + 1):
    r[o[i][1]][o[i][2]][o[i][3]][o[i][4]][o[i][5]] = p[i]

First, we set the maximum possible price for all variants and zeroed out the cost of an "empty purchase." Then we entered the costs of purchasing items both at their nominal price and with available discounts. Next, we recalculate the cost of all possible purchase variants using all kp available discount variants.

for i in range(1, kp + 1):
    for i1 in range(6):
        for i2 in range(6):
            for i3 in range(6):
                for i4 in range(6):
                    for i5 in range(6):
                        if r[i1][i2][i3][i4][i5] != INF:
                            j1 = min(i1 + o[i][1], 5)
                            j2 = min(i2 + o[i][2], 5)
                            j3 = min(i3 + o[i][3], 5)
                            j4 = min(i4 + o[i][4], 5)
                            j5 = min(i5 + o[i][5], 5)
                            cost = p[i] + r[i1][i2][i3][i4][i5]
                            if r[j1][j2][j3][j4][j5] > cost:
                                r[j1][j2][j3][j4][j5] = cost

If we have a set of goods that can be purchased at the optimal price, then based on it we build the optimal costs of all purchases that can be made using the current discount: o[i][1], o[i][2], o[i][3], o[i][4], o[i][5]. The number of units of goods should be from 0 to 5, and a new variant is better if the resulting cost is less than the one calculated earlier.

And finally, as the answer we output the computed value r[k[1]][k[2]][k[3]][k[4]][k[5]]:

with open('output.txt', 'w') as fout:
    fout.write(f"{int(r[k[1]][k[2]][k[3]][k[4]][k[5]])}\n")
Listing 2.29. Program "Trade Discounts"
# io95dit2 - Trade Discounts (IOI'95)

def solve() -> None:
    INF = float('inf')

    c: list[int] = [0] * 105
    k: list[int] = [0] * 105
    p: list[int] = [0] * 105
    o: list[list[int]] = [[0] * 6 for _ in range(105)]

    def find_product(cs: int) -> int:
        """Find product index (1..b) by its code."""
        i = 1
        while c[i] != cs:
            i += 1
        if i <= b:
            return i
        else:
            print('error')
            raise SystemExit(1)

    # Read purchases
    with open('input.txt', 'r') as fin:
        b = int(fin.readline())
        for i in range(1, b + 1):
            ci, ki, pi = map(int, fin.readline().split())
            c[i] = ci
            k[i] = ki
            p[i] = pi
            for j in range(1, 6):
                o[i][j] = 0
            o[i][i] = 1

    # Read discounts
    kp = b
    with open('offer.txt', 'r') as fin:
        s = int(fin.readline())
        for i in range(s):
            parts = list(map(int, fin.readline().split()))
            n_items = parts[0]
            kp += 1
            for j in range(1, 6):
                o[kp][j] = 0
            idx = 1
            for j in range(n_items):
                cs = parts[idx]; idx += 1
                ks = parts[idx]; idx += 1
                o[kp][find_product(cs)] = ks
            p[kp] = parts[idx]

    # Initialize 5D DP table
    r = [[[[[INF for _ in range(6)]
             for _ in range(6)]
             for _ in range(6)]
             for _ in range(6)]
             for _ in range(6)]
    r[0][0][0][0][0] = 0

    for i in range(1, kp + 1):
        r[o[i][1]][o[i][2]][o[i][3]][o[i][4]][o[i][5]] = p[i]

    # Fill DP table
    for i in range(1, kp + 1):
        for i1 in range(6):
            for i2 in range(6):
                for i3 in range(6):
                    for i4 in range(6):
                        for i5 in range(6):
                            if r[i1][i2][i3][i4][i5] != INF:
                                j1 = min(i1 + o[i][1], 5)
                                j2 = min(i2 + o[i][2], 5)
                                j3 = min(i3 + o[i][3], 5)
                                j4 = min(i4 + o[i][4], 5)
                                j5 = min(i5 + o[i][5], 5)
                                cost = p[i] + r[i1][i2][i3][i4][i5]
                                if r[j1][j2][j3][j4][j5] > cost:
                                    r[j1][j2][j3][j4][j5] = cost

    # Output result
    with open('output.txt', 'w') as fout:
        fout.write(f"{int(r[k[1]][k[2]][k[3]][k[4]][k[5]])}\n")


solve()
    read_data()
    for i1 in range(6):
        for i2 in range(6):
            for i3 in range(6):
                for i4 in range(6):
                    for i5 in range(6):
                        r[i1][i2][i3][i4][i5] = float('inf')
    r[0][0][0][0][0] = 0
    for i in range(1, kp + 1):
        r[o[i][1]][o[i][2]][o[i][3]][o[i][4]][o[i][5]] = p[i]
    for i in range(1, kp + 1):
        for i1 in range(6):
            for i2 in range(6):
                for i3 in range(6):
                    for i4 in range(6):
                        for i5 in range(6):
                            if r[i1][i2][i3][i4][i5] != float('inf'):
                                ni1 = i1 + o[i][1]
                                ni2 = i2 + o[i][2]
                                ni3 = i3 + o[i][3]
                                ni4 = i4 + o[i][4]
                                ni5 = i5 + o[i][5]
                                if (0 <= ni1 <= 5 and
                                    0 <= ni2 <= 5 and
                                    0 <= ni3 <= 5 and
                                    0 <= ni4 <= 5 and
                                    0 <= ni5 <= 5 and
                                    r[ni1][ni2][ni3][ni4][ni5] >
                                    p[i] + r[i1][i2][i3][i4][i5]):
                                    r[ni1][ni2][ni3][ni4][ni5] = (
                                        p[i] + r[i1][i2][i3][i4][i5]
                                    )
    with open('output.txt', 'w') as f_out:
        print(r[k[1]][k[2]][k[3]][k[4]][k[5]], file=f_out)

79. General Techniques for Solving Recurrence Relation Problems

Earlier in this chapter, we examined many problems whose solutions involved devising a recurrence relation. Some readers may have gotten the impression that solving each such problem is a special art. In reality, the art lies in devising such a recurrence relation for a new class of problems. Below are the classes of problems covered in this chapter. Once you recognize a given problem as belonging to one of these standard types, you can simply use the corresponding standard recurrence relation.

79.1. Forming All Possible Sums

Suppose we have a set of integers (let there be N of them with values L[i]) and we need to construct all possible variants of their sums. We proceed as follows: we create a one-dimensional list (say, s[j]) with a maximum size equal to the maximum possible sum (M, for definiteness). Then, iterating over all our numbers L[i], we sequentially modify all elements of the list s[j]:

s = [0] * (M + 1)
s[0] = 1
for i in range(n):
    for j in range(M - L[i], -1, -1):
        if s[j] == 1:
            s[j + L[i]] = 1

As a result, the list s will have ones at positions corresponding to sum values that can be formed from the given numbers L[i].

Can you recall which of the problems solved above used this technique in one modification or another? Yes, that’s right — the "Video Salon" and "Audio Salon" problems. I would also add to this list all the discount problems: "Flower Shop," "Anniversary," and "Trade Discounts." Although formally, summation was performed not on one-dimensional lists but on three-dimensional and even five-dimensional lists (for the last problem).

80. Restoring the Optimal Path

If a problem requires outputting not only the computed optimal value of the recurrence quantity but also the list of elements that produce this optimal value, then it is necessary to restore the optimal path. This was done in the "Flower Shop" problem (when restoring the specific optimal assignment of bouquets to vases) and in the "Palindrome" problem (when finding the specific characters from the original string that form the longest palindrome). In both problems, it was necessary to process the constructed two-dimensional lists of recurrence values "from optimal values back to initial ones" ("bottom-up" and "right-to-left"), that is, in the reverse order of list filling.

81. Reducing Memory Usage

As mentioned at the beginning, sometimes it is necessary to reduce the memory occupied by the list storing the values of the recurrence quantity. For example, when computing Fibonacci numbers, instead of a one-dimensional list we used three variables. When solving the "Audio Salon" problem, instead of storing a three-dimensional list of all songs r[101][101][101] (which would use a huge amount of memory), we stored only two "layers": r[2][101][101], which required 50 times less memory! And finally, for the "Video Salon" problem, we processed a one-dimensional list s[0:100] "from the end" in order to avoid storing two copies (that is, to avoid turning it into a two-dimensional list s[2][100]).

82. Recursive Computation of Recurrence Relations

The problem of insufficient memory for computing recurrence quantities can in some cases be solved by using recursive functions for computing recurrence values, as was done in the "Reconnaissance Selection" problem.

83. Recurrence Relations on Strings

If we need to solve problems involving strings, we can, as in the "Palindrome" problem, construct a recurrence relation r[i][j] — with the goal of finding

the optimal solution for i characters of the first string and j characters of the second string. If, for example, we need to process three strings, then we would consider a recurrence relation with three parameters: r[i][j][k], where k is the first \(k\) characters of the third string.

84. 2.4. Graph Algorithms

There is an entire class of programming problems that can be reformulated in terms of graph theory to simplify their solution. However, this requires a certain set of knowledge, skills, and abilities in the area of graph algorithms.

Graph theory contains a huge number of definitions, theorems, and algorithms. Therefore, the material presented below cannot claim, and does not claim, to be comprehensive. However, in the author’s opinion, the information offered represents a good compromise between the volume of material and its "efficiency factor" in practical programming and solving competition problems.

It should be noted that this material relies substantially on the student having certain skills in using recursive functions and recurrence relations.

The further presentation of the material is structured as follows: first, a basic amount of theory is provided, followed by solutions to a large number of problems from Belarusian national and international (IOI — International Olympiad in Informatics) computer science olympiads for school students.

The author recommends that after familiarizing yourself with the general information, you approach each new problem in the following order: after reading the problem statement, set the material aside and try to solve the problem on your own. If you feel that you have reached a dead end and further thinking cannot lead to a useful result, return to the material, read the complete solution, and implement it on your computer independently. The more thorough and intensive your work on the current problem, the greater your chances of solving the next problem on your own.

84.1. General Information about Graph Algorithms

First, a few words about how the concept of a graph arises from natural problem conditions. Here are several examples.

  • Suppose we have a road map in which, for each city, the distance to all its neighboring cities is given. Here two cities are called neighbors if there is a road directly connecting them.

  • Similarly, one can consider streets and intersections within a single city. Note that there may be one-way streets.

  • A network of computers connected by wired communication lines.

  • A set of words, each of which starts with a certain letter and ends with the same or another letter.

  • A set of dominoes. Each domino has two numbers — the left and right halves of the tile.

  • A device consisting of microchips connected to each other by sets of conductors.

  • Genealogical trees illustrating family relationships between people.

  • And finally, graphs themselves, indicating relationships between some abstract concepts, for example, numbers.

Thus, informally a graph can be defined as a set of vertices (cities, intersections, computers, letters, digits on a domino tile, microchips, people, etc.) and connections between them (roads between cities; streets between intersections; wired communication lines between two computers; words starting with one letter and ending with another or the same letter; conductors connecting microchips; family relationships, for example, Alexei is the son of Peter).

Bidirectional connections (for example, two-way roads) are commonly called edges of the graph; while unidirectional connections (one-way roads) are called arcs of the graph.

A graph in which vertices are connected by edges is called an undirected graph, while a graph in which at least some vertices are connected by arcs is called a directed graph.

85. Shortest Distances in Graphs

As an example of a graph, consider the following undirected graph with six vertices:

(1)--(3) (5)
 |    |   |
 |    |   |
(2)--(4) (6)

Graphs can be specified using an adjacency matrix. For example, for the graph shown above, the adjacency matrix \(G\) looks as follows:

0  1  1  0  0  0
1  0  0  1  0  0
1  0  0  1  0  0
0  1  1  0  0  0
0  0  0  0  0  1
0  0  0  0  1  0

\(Gij\) = 1 if there is an arc from vertex \(i\) to vertex \(j\), and \(Gij\) = 0 otherwise.

Often of interest is also the reachability matrix of a graph, in which \(Gij = 1\) if there exists a path along edges (arcs) of the original graph from vertex i to vertex j. For example, for the graph in our example, the reachability matrix would be:

1  1  1  1  0  0
1  1  1  1  0  0
1  1  1  1  0  0
1  1  1  1  0  0
0  0  0  0  1  1
0  0  0  0  1  1

A graph is called weighted if weights are assigned to its arcs (for example, road lengths between cities).

In a weighted graph, \(Gij\) is the weight of the arc from vertex i to vertex j.

On weighted graphs, one can solve the problem of finding the shortest distance between vertices.

The simplest method to implement for finding the shortest distances from every vertex to every other is Floyd’s method:

for k in range(n):
    for i in range(n):
        for j in range(n):
            g[i][j] = min(g[i][j], g[i][k] + g[k][j])

As a result of executing this algorithm, g[i][j] will contain the shortest distance from vertex i to vertex j for all pairs of vertices in the graph. Note that:

GREAT = 10**9
g = [[GREAT] * n for _ in range(n)]
  1. the initial values of g[i][j] for vertices i and j between which there is no arc in the original graph must be initialized with a deliberately extremely large value GREAT, for example:

here GREAT is a constant appropriate for the context; it is not recommended to use float('inf') without care, since unexpected behavior may occur during comparisons; . a side benefit of Floyd’s algorithm is the ability to build the reachability matrix for the original graph, since if g[i][j] remains equal to GREAT, this means that vertex j is unreachable from vertex i; . Floyd’s algorithm also works in the case when some edges of the original graph have negative weights, but it is guaranteed that the graph has no negative-weight cycles.

In cases where it is necessary to find the distance between two specific vertices or the distance from a specific vertex to all others, one can use the somewhat more complex Dijkstra’s method for faster execution and reduced memory usage (as in the "Pyramid of Cheops" and "Oh, the Roads" problems).

86. Problem "Maneuvers"

National Olympiad in Informatics, 2000.

On the territory of a certain state with heavily rugged mountainous terrain, military maneuvers are underway between two opposing sides: the "Blues" and the "Greens." The features of the landscape and complex climatic conditions force units of both sides to be stationed only in certain populated areas. The total number of populated areas in this state is \(N\).

The "Blues'" tactics for conducting military operations are based on delivering fast and sudden strikes against the enemy, which is possible only if motorized units are used in operations, and their movement occurs only along roads. The diversity of military equipment used means that the travel time of different combat units from one point to another turns out to be different and is determined by the value \(Vi\) — the speed of movement of the combat unit stationed at the \(j\)-th populated area.

Using their overwhelming superiority in equipment, the "Blues" plan to organize a night raid on the enemy’s bases (code-named "Greens") and completely destroy them. All combat units of the "Blues" begin the operation simultaneously. If a "Blues" combat unit storms into a populated area occupied by the "Greens," then, taking advantage of the element of surprise, they manage to completely destroy that group.

Unfortunately, the brilliant execution of this operation was hindered by the fact that time \(T\) after the start of the operation, the "Greens" intercepted a radio message about the ongoing operation. After the radio intercept, the "Greens'" groups instantly disperse into the surrounding mountains and remain unharmed.

Determine how many enemy groups and in which populated areas the "Blues" will still manage to defeat the "Greens."

It is assumed that at the initial moment the "Greens'" and "Blues'" groups cannot be in the same populated area. If the alarm is received at the moment when a "Blues" combat unit is just storming into a populated area occupied by the "Greens," then, using their superior knowledge of the terrain, the "Greens" still manage to escape into the mountains. The overwhelming superiority in equipment and personnel allows the "Blues'" combat units to organize any number of expeditions from each unit to defeat the "Greens." Nothing prevents one expedition from destroying several groups during the operation.

The initial data for the problem is contained in files MAP.IN and TROOPS.IN. The structure of the file MAP.IN describes the terrain map. The first line of this file contains two integers: N — the number of populated areas (\(0 < N < 256\)) and K — the number of roads connecting these populated areas (\(0 ≤ K ≤ 1000\)). Roads do not intersect anywhere. In the subsequent K lines of the file, the road network is described. Each line contains a pair of two natural numbers i and j and one positive real number Lij, meaning that between populated areas i and j there exists a road of length Lij kilometers (Lij < 1000).

The contents of the file TROOPS.IN reflect the deployment of combat units of the warring sides. The first line of the file contains the number MF — the number of "Blues" combat units. Each of the following MF lines contains two numbers. The first is an integer j — the number of the populated area where the unit is stationed; the second is a non-negative real number Vj — the speed of movement of this unit’s combat columns in kilometers per hour (Vj < 110). Next, on a separate line of the file, the number MB — the number of "Greens" combat groups — is written, followed by MB numbers — the numbers of populated areas where these groups are located. And finally, the last line of the file contains a positive real number T, measured in hours (T < 24). All numbers in the file lines are separated by spaces.

The result of solving the problem must be written to a text file VICTORY.OUT. The first line of the file contains the number of defeated groups, and the second line contains the numbers of populated areas (in ascending order) where these groups were stationed.

Consider the following example:

8 7
1 2 80
2 4 25
4 5 10
6 2 5
2 3 40
7 6 10
8 7 15
2
1 50
6 20
4 4 5 3 8
2.0
2
4 8

MAP.IN

TROOPS.IN

VICTORY.OUT

Briefly, the solution algorithm can be described as follows: using Floyd’s method, we compute the shortest distances from every vertex to every other. Then, iterating over all "Greens" vertices and all "Blues" vertices, we check: if the "Blues" arrive in time, then the corresponding "Greens" group is defeated.

Let us examine the solution in more detail. First, the input data:

data = open('map.in').read().split()
idx = 0
n = int(data[idx]); idx += 1  # Read the number of areas
k = int(data[idx]); idx += 1  # and roads

GREAT = 1e12
a = [[GREAT] * (n + 1) for _ in range(n + 1)]  # Initialize for Floyd's method

for _ in range(k):
    x = int(data[idx]); idx += 1
    y = int(data[idx]); idx += 1
    z = float(data[idx]); idx += 1
    a[x][y] = z  # Update the adjacency matrix
    a[y][x] = z
troops = open('troops.in').read().split()
tidx = 0
num_blue = int(troops[tidx]); tidx += 1  # Read positions and speeds
                                              # of the "Blues"
blue = [0] * (num_blue + 1)
v_blue = [0.0] * (num_blue + 1)
for i in range(1, num_blue + 1):
    blue[i] = int(troops[tidx]); tidx += 1
    v_blue[i] = float(troops[tidx]); tidx += 1

num_green = int(troops[tidx]); tidx += 1  # Read positions of the "Greens"
green = [0] * (num_green + 1)
for i in range(1, num_green + 1):
    green[i] = int(troops[tidx]); tidx += 1

T = float(troops[tidx]); tidx += 1  # Read operation duration

Then, computing distances between all populated areas using Floyd’s method:

for i in range(1, n + 1):
    for x in range(1, n + 1):
        for y in range(1, n + 1):
            a[x][y] = min(a[x][y], a[x][i] + a[i][y])

Computing the number of defeated groups:

deleted = 0
num_list: list[int] = []
for i in range(1, num_green + 1):  # For all "Greens"
    for j in range(1, num_blue + 1):  # For all "Blues"
        if a[green[i]][blue[j]] < v_blue[j] * T:  # If the "Blues" arrive in time
            deleted += 1  # Increment defeated count
            num_list.append(green[i])  # Store the number
            break  # Move to the next "Greens"

Finally, in accordance with the problem requirements, we sort the numbers of the defeated groups and output them:

num_list.sort()
with open('victory.out', 'w') as f_out:
    print(deleted, file=f_out)
    print(' '.join(str(x) for x in num_list), file=f_out)

In Python there is no static memory limitation like in old compilers, so the full 256x256 matrix can be used without issues.

Listing 2.30. Program "Maneuvers"
# Maneuvers
import sys

def solve() -> None:
    # Read map
    with open('map.in') as f:
        data = f.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    k = int(data[idx]); idx += 1

    GREAT = 1e12
    a = [[GREAT] * (n + 1) for _ in range(n + 1)]

    for _ in range(k):
        x = int(data[idx]); idx += 1
        y = int(data[idx]); idx += 1
        z = float(data[idx]); idx += 1
        a[x][y] = z
        a[y][x] = z
  # Read troops
    with open('troops.in') as f:
        tdata = f.read().split()
    tidx = 0
    num_blue = int(tdata[tidx]); tidx += 1

    blue: list[int] = [0] * (num_blue + 1)
    v_blue: list[float] = [0.0] * (num_blue + 1)
    for i in range(1, num_blue + 1):
        blue[i] = int(tdata[tidx]); tidx += 1
        v_blue[i] = float(tdata[tidx]); tidx += 1

    num_green = int(tdata[tidx]); tidx += 1
    green: list[int] = [0] * (num_green + 1)
    for i in range(1, num_green + 1):
        green[i] = int(tdata[tidx]); tidx += 1
    T = float(tdata[tidx]); tidx += 1

    # Floyd's algorithm
    for i in range(1, n + 1):
        for x in range(1, n + 1):
            for y in range(1, n + 1):
                a[x][y] = min(a[x][y], a[x][i] + a[i][y])

    # Determine defeated groups
    deleted = 0
    num_list: list[int] = []

    for i in range(1, num_green + 1):
        for j in range(1, num_blue + 1):
            if a[green[i]][blue[j]] <= v_blue[j] * T + 0.00001:
                deleted += 1
                num_list.append(green[i])
                break

    num_list.sort()

    with open('victory.out', 'w') as f_out:
        print(deleted, file=f_out)
        print(' '.join(str(x) for x in num_list), file=f_out)

solve()

87. Problem "Pyramid of Cheops"

National Olympiad in Informatics, 1996.

Inside the Pyramid of Cheops, there are \(N\) rooms containing \(2M\) modules that make up \(M\) devices, each consisting of two modules located in different rooms, and designed for teleportation between the pair of rooms in which these modules are installed. Teleportation takes 0.5 time units. At the initial moment, the modules of all devices enter "preparation mode." Each module has its own integer time period during which it remains in "preparation mode."

After this time elapses, the module instantly "activates," after which it returns to "preparation mode" again. A device can only be used at the moment when both of its modules "activate" simultaneously.

Indiana Jones managed to penetrate the pharaoh’s tomb (room 1). After examining it, he turned on the devices and was about to leave, but at that moment the tomb guardian woke up. Now Jones needs to reach room \(N\) as quickly as possible, which contains the exit from the pyramid. He can only move from room to room using the devices, since the awakened guardian has locked all doors in the pyramid’s rooms.

It is necessary to write a program that takes as input descriptions of the devices' locations and their characteristics, and outputs the optimal time and the sequence of devices that must be used to get from room 1 to room \(N\) in that time.

Input file — input2.txt, output file — output2.txt.

Input format (line by line):

  • N

  • M

  • R11 T11 R12 T12

  • …​

  • RM1 TM1 RM2 TM2

That is, the following data is provided:

  • \(N (2 < N < 100)\) — the number of rooms;

  • \(M (M < 100)\) — the number of devices;

  • Ri1 and Ri2 — the numbers of the rooms in which the modules of device i are located;

  • Ti1, Ti2 (Ti1, Ti2 < 1000) — the time periods after which the corresponding modules activate.

The output file should contain the optimal time and the sequence of devices that need to be used to achieve the desired result.

All numbers are natural.

As an example, consider the following:

4
5
1 5 3 2
1 1 2 1
2 5 3 5
4 4 3 2
3 5 4 5
  • input2.txt

8.5
2 3 4
  • output2.txt

By representing rooms as vertices and pairs of devices (with activation times) as weighted arcs, we can apply Dijkstra’s algorithm to find the shortest path from the first vertex to the last. More precisely, Dijkstra’s algorithm will build the shortest paths from the first to all other vertices, but in this problem we are only interested in the shortest path from the first to the last. It must be taken into account that arcs exist only at moments that are multiples of the activation times.

Let us examine the solution in more detail. We begin with reading the input data:

from math import gcd

data = open('input2.txt').read().split()
idx = 0
n = int(data[idx]); idx += 1  # Read the number of rooms
m = int(data[idx]); idx += 1  # Read the number of devices
kg: list[int] = [0] * (n + 1)

# Adjacency list: g[v] = [(neighbor, lcm_period), ...]
g: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]

for i in range(m):
    r1 = int(data[idx]); idx += 1
    t1 = int(data[idx]); idx += 1
    r2 = int(data[idx]); idx += 1
    t2 = int(data[idx]); idx += 1
    lcm_val = (t1 * t2) // gcd(t1, t2)  # lcm - least common multiple
    g[r1].append((r2, lcm_val))
    g[r2].append((r1, lcm_val))

The original graph (by the problem conditions) is undirected. Therefore, we add both arcs. The graph is represented as a list of arcs adjacent to the current one:

  • g[i] — the list of arcs from vertex i, each as a (neighbor, weight) tuple;

  • g[i][j][0] — the vertex to which arc j goes from vertex i;

  • g[i][j][1] — the weight of the arc from vertex i to vertex g[i][j][0].

To compute the GCD, Python’s built-in math.gcd function is used (which implements the Euclidean algorithm internally):

from math import gcd
# gcd(a, b) returns the greatest common divisor of a and b
# LCM can be computed as: (a * b) // gcd(a, b)

Then preparation for executing Dijkstra’s algorithm is performed:

FREE = 1
DONE = 2

INF = float('inf')
label: list[int] = [FREE] * (n + 1)  # Mark all as free
pred: list[int] = [1] * (n + 1)  # Predecessor is the first
d: list[float] = [INF] * (n + 1)  # Distance to the first is max

label[1] = DONE  # First vertex is processed
pred[1] = 0  # Predecessor of the first is 0
d[1] = 0.0  # Distance from first to first is 0
for neighbor, weight in g[1]:  # For all arcs from the first vertex
    d[neighbor] = weight + 0.5  # Distance to the first vertex
                                       # equals edge weight + 0.5

Note that in the last line, 0.5 is added to the edge weights, because, according to the problem conditions, teleportation using devices from one room to another takes 0.5 time units.

Next comes the main loop of Dijkstra’s algorithm:

for i in range(n - 1):
    # Find the nearest unprocessed vertex to the first

Recalculate shortest distances through the nearest vertex to vertices adjacent to the nearest

The first step — finding the nearest unprocessed vertex to the first:

    min_d = INF  # min_d - max
    bliz = -1
    for j in range(1, n + 1):  # For all vertices
        if label[j] == FREE and d[j] < min_d:  # if the vertex is free
                                         # and the distance from it to the first
                                         # vertex is less than min_d
            min_d = d[j]  # Store this distance in min_d
            bliz = j  # Remember the vertex as the nearest
    label[bliz] = DONE  # Mark the found nearest as
                                         # processed

The second step — recalculating shortest distances through the nearest vertex to vertices adjacent to the nearest:

    for neighbor, weight in g[bliz]:  # For all vertices adjacent to
                                         # the nearest
        if label[neighbor] == FREE:  # If the vertex has not yet been
                                         # processed
            new_time = calculat(d[bliz], weight)  # Compute
                                         # the distance to it
                                         # in problem terms - the travel
                                         # time to it
            if d[neighbor] > new_time + 0.5:  # If the new time is better
                d[neighbor] = new_time + 0.5  # store it in list d
                pred[neighbor] = bliz  # set the nearest as
                                         # predecessor for the current

When computing the shortest distance to the current vertex through the nearest (in Dijkstra’s algorithm terms) or the travel time from the first room through the nearest to the current (in this problem’s terms), the function calculat(t, t_lcm) is used:

def calculat(t: float, t_lcm: int) -> int:
    t_res = 0
    while t > t_res:
        t_res += t_lcm
    return t_res

This function computes the time t_res — the nearest time greater than the current time t (t equals the minimum time required to optimally travel from the first vertex to the nearest) and a multiple of t_lcm — the activation period of the pair of devices connecting rooms bliz (nearest) and the current neighbor.

After executing Dijkstra’s algorithm, the following lists are formed:

  • d[j] — the shortest distance from the starting vertex to vertex j;

  • pred[j] — the predecessor of vertex j on the optimal route.

Taking this information into account, the result output is performed as follows:

tg = n
path: list[int] = []
while tg != 0:  # while there are predecessors
    path.append(tg)  # Store current in the path list
    tg = pred[tg]  # Reset the predecessor

path.reverse()

with open('output2.txt', 'w') as f_out:
    print(f'{d[n]:.1f}', file=f_out)
    print('required sequence:', ' '.join(str(v) for v in path[1:]), file=f_out)

The full program text is in Listing 2.31.

Listing 2.31. Program "Pyramid of Cheops"
# Pyramid of Cheops
from math import gcd

FREE = 1
DONE = 2

def calculat(t: float, t_lcm: int) -> int:
    t_res = 0
    while t > t_res:
        t_res += t_lcm
    return t_res

def solve() -> None:
    with open('input2.txt') as f:
        data = f.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1

    # Adjacency list: list of (neighbor, lcm_period)
    g: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]

    for _ in range(m):
        r1 = int(data[idx]); idx += 1
        t1 = int(data[idx]); idx += 1
        r2 = int(data[idx]); idx += 1
        t2 = int(data[idx]); idx += 1
        lcm_val = (t1 * t2) // gcd(t1, t2)
        g[r1].append((r2, lcm_val))
        g[r2].append((r1, lcm_val))

87.1. Listing 2.31 (continued)

    INF = float('inf')
    label: list[int] = [FREE] * (n + 1)
    pred: list[int] = [0] * (n + 1)
    d: list[float] = [INF] * (n + 1)

    label[1] = DONE
    pred[1] = 0
    d[1] = 0.0
    for neighbor, weight in g[1]:
        d[neighbor] = weight + 0.5

    for _ in range(n - 1):
        # Find nearest unprocessed vertex
        min_d = INF
        bliz = -1
        for j in range(1, n + 1):
            if label[j] == FREE and d[j] < min_d:
                min_d = d[j]
                bliz = j
        if bliz == -1:
            break
        label[bliz] = DONE

        # Relax neighbors
        for neighbor, weight in g[bliz]:
            if label[neighbor] == FREE:
                new_time = calculat(d[bliz], weight)
                if d[neighbor] > new_time + 0.5:
                    d[neighbor] = new_time + 0.5
                    pred[neighbor] = bliz

    # Reconstruct path
    tg = n
    path: list[int] = []
    while tg != 0:
        path.append(tg)
        tg = pred[tg]
    path.reverse()

    with open('output2.txt', 'w') as f_out:
        print(f'{d[n]:.1f}', file=f_out)
        print(' '.join(str(v) for v in path[1:]), file=f_out)

solve()

88. Problem "Oh, the Roads"

National Olympiad in Informatics, 1998.

There are \(N\) cities numbered from 1 to \(N\) (where \(N\) is a natural number, \(1 ≤ N ≤ 100\)). Some of them are connected by two-way roads that intersect only at cities. There are two types of roads — highways and railways. For each road, the base fare for traveling on it is known.

You need to travel from city \(A\) to city \(B\), paying the minimum total fare. The cost of travel depends on the set of roads traveled and the mode of travel. If you have arrived at city \(C\) via a highway (railway) road \(X→C\) and want to continue along road \(C→Y\) of the same type, then you only need to pay the base fare for road \(C→Y\). If the type of road \(C→Y\) differs from the type of road \(X→C\), then you must pay the base fare for road \(C→Y\) plus 10% of the base fare for that road (insurance fee). When

departing from city A, the insurance fee is always charged. Write a program that finds the cheapest travel route as a sequence of cities and computes the travel cost along this route.

The input data is in a text file named TOUR.IN with the following format: the first line contains the number N. The second line contains the number M (the number of roads, M is a natural number and M ≤ 1000); each of the following M lines contains four numbers: x, y, t, and p, separated by spaces, where x and y are the numbers of the cities connected by the road, t is the road type (0 — highway, 1 — railway), and p is the base fare for traveling on it (p is real, 0 < p ≤ 1000). The last line contains the numbers of the starting and ending cities A and B.

The output data must be written to a text file named TOUR.OUT with the following format: the first line contains the number S — the travel cost along the cheapest route with 2 decimal places of precision. Each of the subsequent lines, except the last, contains two numbers — the number of the next city in the route (starting from city A) and the type of road used to depart from that city (0 or 1), separated by a space. The last line contains a single number — the number of city B.

Consider the following example:

3
2
1 2 0 10.00
2 3 1 10.00
1 3
  • TOUR.IN:

22.0
1 0
2 1
3
  • TOUR.OUT:

Dijkstra’s method cannot be applied directly (with cities as vertices), since the optimal route may require visiting the same city twice in order to pay less for the insurance fee when departing from that city.

We introduce 2 * N vertices (where N is the number of cities). That is, for each city we create two vertices: the first if we entered the city via a road of type 0, the second via a road of type 1. Now Dijkstra’s method can be directly applied to compute the shortest distances from the given city A to all introduced vertices. For the destination city B, we choose the better of the options — entering B via a road of type 0 or via a road of type 1.

Dijkstra’s method allows sequentially saving all intermediate vertices in the optimal route, which is necessary for outputting the complete answer in the required format.

Let us examine the solution in more detail, based on the text of the solution program. Input data is read as follows:

data = open('tour.in').read().split()
idx = 0
n = int(data[idx]); idx += 1  # N - number of cities
m = int(data[idx]); idx += 1  # M - number of roads
MAXR = 1e4
# p[x][y][t] - travel cost from x to y via road of type t
p = [[[MAXR] * 2 for _ in range(n + 1)] for _ in range(n + 1)]

for _ in range(m):  # x - city number "from"
    x = int(data[idx]); idx += 1  # y - city number "to"
    y = int(data[idx]); idx += 1  # t - road type
    t = int(data[idx]); idx += 1  # p[x][y][t] - travel cost
    cost = float(data[idx]); idx += 1  # from x to y via road of type t
    p[x][y][t] = cost
    p[y][x][t] = cost

A = int(data[idx]); idx += 1  # Start and end points of the route
B = int(data[idx]); idx += 1

Preparation for executing Dijkstra’s algorithm:

FREE = 0
DONE = 1

for i in range(1, n + 1):  # When departing from city A
    for t in range(2):  # the insurance fee
        p[A][i][t] = 1.1 * p[A][i][t]  # is always charged

# d[i][t] - shortest cost to city i entering via road type t
d = [[MAXR] * 2 for _ in range(n + 1)]
for i in range(1, n + 1):  # For all cities
    d[i][0] = p[A][i][0]  # shortest costs to the first
    d[i][1] = p[A][i][1]  # via highway / via railway

label = [[FREE] * 2 for _ in range(n + 1)]  # For all cities
pred = [[A] * 2 for _ in range(n + 1)]  # Previous city equals A
                                                  # for both road types
label[A][0] = DONE  # Starting city is processed
label[A][1] = DONE  # for both road types
pred[A][0] = 0  # Predecessor of the starting city is 0
pred[A][1] = 0  # for both road types

The main part of Dijkstra’s algorithm is a loop over the number of remaining unprocessed vertices:

def cost_factor(t1: int, t2: int) -> float:
    """Insurance fee multiplier when switching road types."""
    return 1.0 if t1 == t2 else 1.1

for _ in range(2 * n - 2):
    # Find the nearest unprocessed vertex to the first
    # Recalculate minimum costs through the nearest vertex
    # to vertices adjacent to the nearest

The first step — finding the nearest unprocessed vertex to the first:

    min_d = MAXR  # min_d - maximum
    bliz = -1
    bt = -1
    for j in range(1, n + 1):  # For all cities
        for t in range(2):  # For roads of both types
            if (label[j][t] == FREE  # If entry into city j via road of type t
                    and  # has not been processed yet and
                    min_d > d[j][t]):  # cost from city A to city j
                                                  # via road of type t is less than min_d
                bliz = j  # Nearest is j
                bt = t  # its type is bt
                min_d = d[j][t]  # minimum cost into min_d
    label[bliz][bt] = DONE  # Mark as processed the vertex
                                                  # with minimum cost from city A
                                                  # among still unprocessed vertices

The second step — recalculating minimum costs through the nearest vertex to vertices adjacent to the nearest:

    typp = [[0] * 2 for _ in range(n + 1)]  # (initialized before main loop)

    for j in range(1, n + 1):  # For all cities
        for t1 in range(2):  # For roads of both types
            if (label[j][t1] == FREE  # If the road to city j of type t is unprocessed
                    and d[j][t1]  # and the cost to it from vertex A
                    >  # is greater than
                    d[bliz][bt] + cost_factor(t1, bt) * p[bliz][j][t1]):  # cost through nearest
                d[j][t1] = d[bliz][bt] + cost_factor(t1, bt) * p[bliz][j][t1]  # replace cost
                pred[j][t1] = bliz  # remember
                typp[j][t1] = bt  # the previous city and road type

When computing the travel cost through the nearest vertex, we account for the insurance fee when switching from one road type to another using the function cost_factor(t1, t2):

def cost_factor(t1: int, t2: int) -> float:
    if t1 == t2:
        return 1.0
    else:
        return 1.1

Output of results is done as follows:

g_city = B
is_count = 0
t = 0 if d[B][0] < d[B][1] else 1  # Current city is the final city
                                        # Entry type is the better of two options
stg: list[int] = [0] * (n + 2)
stt: list[int] = [0] * (n + 2)
stt[1] = t  # Current type is the chosen best

while g_city != 0:  # While the current city is not 0
    is_count += 1  # Increment the number of cities
    stg[is_count] = pred[g_city][t]  # save the current city to the path
    stt[is_count + 1] = typp[g_city][t]  # and its type
    ng = pred[g_city][t]  # Reset the current city
    nt = typp[g_city][t]  # and type to the previous city
    g_city = ng  # and type
    t = nt

with open('tour.out', 'w') as f_out:
    print(f'{min(d[B][0], d[B][1]):.2f}', file=f_out)  # Output the minimum cost
    for i in range(is_count - 1, 0, -1):  # and the path
        print(stg[i], stt[i], file=f_out)
    print(B, file=f_out)  # add the last city

The full program text is in Listing 2.32.

Listing 2.32. Program "Oh, the Roads"
# Oh, the Roads
FREE = 0
DONE = 1
MAXR = 1e4

def cost_factor(t1: int, t2: int) -> float:
    if t1 == t2:
        return 1.0
    else:
        return 1.1

def solve() -> None:
    with open('tour.in') as f:
        data = f.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1

    p = [[[MAXR] * 2 for _ in range(n + 1)] for _ in range(n + 1)]
    for _ in range(m):
        x = int(data[idx]); idx += 1
        y = int(data[idx]); idx += 1
        t = int(data[idx]); idx += 1
        cost = float(data[idx]); idx += 1
        p[x][y][t] = cost
        p[y][x][t] = cost
    A = int(data[idx]); idx += 1
    B = int(data[idx]); idx += 1
  # Apply insurance fee for departures from city A
    for i in range(1, n + 1):
        for t in range(2):
            p[A][i][t] = 1.1 * p[A][i][t]

    d = [[MAXR] * 2 for _ in range(n + 1)]
    for i in range(1, n + 1):
        d[i][0] = p[A][i][0]
        d[i][1] = p[A][i][1]

    label = [[FREE] * 2 for _ in range(n + 1)]
    pred = [[A] * 2 for _ in range(n + 1)]
    typp = [[0] * 2 for _ in range(n + 1)]

    label[A][0] = DONE
    label[A][1] = DONE
    pred[A][0] = 0
    pred[A][1] = 0

    for _ in range(2 * n - 2):
        min_d = MAXR
        bliz = -1
        bt = -1
        for j in range(1, n + 1):
            for t in range(2):
                if label[j][t] == FREE and min_d > d[j][t]:
                    bliz = j
                    bt = t
                    min_d = d[j][t]
        if bliz == -1:
            break
        label[bliz][bt] = DONE

        for j in range(1, n + 1):
            for t1 in range(2):
                if (label[j][t1] == FREE and
                        d[j][t1] > d[bliz][bt] + cost_factor(t1, bt) * p[bliz][j][t1]):
                    d[j][t1] = d[bliz][bt] + cost_factor(t1, bt) * p[bliz][j][t1]
                    pred[j][t1] = bliz
                    typp[j][t1] = bt
  # Reconstruct the path
    g_city = B
    is_count = 0
    t = 0 if d[B][0] < d[B][1] else 1
    stg: list[int] = [0] * (2 * n + 2)
    stt: list[int] = [0] * (2 * n + 2)
    stt[1] = t
    while g_city != 0:
        is_count += 1
        stg[is_count] = pred[g_city][t]
        stt[is_count + 1] = typp[g_city][t]
        ng = pred[g_city][t]
        nt = typp[g_city][t]
        g_city = ng
        t = nt

    with open('tour.out', 'w') as f_out:
        print(f'{min(d[B][0], d[B][1]):.2f}', file=f_out)
        for i in range(is_count - 1, 0, -1):
            print(stg[i], stt[i], file=f_out)
        print(B, file=f_out)

solve()

Consider an example of an undirected graph with six vertices:

            (1)--(3) (5)
             I \/ I   I
             I /\ I   I
            (2)--(4) (6)

In computer processing, a graph can be specified by a list of edges (arcs) for each vertex. For example, for the graph in our example, this list looks as follows:

  • 1: 2, 3, 4 (that is, vertex 1 is connected to vertices 2, 3, and 4);

  • 2: 1, 3, 4;

  • 3: 2, 3, 4;

  • 4: 1, 2, 3;

  • 5: 6;

  • 6: 5.

To store this information in computer memory, one can use a list of lists g[i], where each g[i] contains the neighbors of vertex i. In Python, this is naturally represented as g: list[list[int]] — an adjacency list.

Then to process the connection list of the current vertex u, one can write:

for v in g[u]:
    ...

Thus we obtain processing of the arc connecting vertices u and v for all vertices directly connected to u.

To process all connections of all vertices, one can use depth-first search (DFS — Depth-First Search):

import sys
sys.setrecursionlimit(10000)  # Increase for large graphs

WHITE = 1
GRAY = 2

color: list[int] = [WHITE] * (n + 1)

def dfs(u: int) -> None:
    color[u] = GRAY
    for v in g[u]:
        dfs(v)

for u in range(1, n + 1):
    if color[u] == WHITE:
        dfs(u)

The introduced notations:

  • WHITE (constant=1) — white, if we have not yet visited this vertex;

  • GRAY (constant=2) — gray, if we have visited this vertex;

    1. dfs(u) — a recursive function that calls itself for all descendant vertices of the given vertex.

That is, to perform depth-first search on a given graph g represented as adjacency lists, we first set all vertices to color WHITE, and then for all vertices that have not yet been visited (color[v] == WHITE), we call the recursive function dfs.

In this way, all possible routes in the graph are formed. There are no restrictions on how many times arcs and vertices can be used. If, however, the problem conditions require visiting each vertex no more than once, in order to form routes from non-repeating vertices, this can be ensured in the dfs function by a conditional call:

def dfs(u: int) -> None:
    color[u] = GRAY
    for v in g[u]:
        if color[v] == WHITE:
            dfs(v)

If each arc must be used no more than once, then arc coloring can be introduced — for example, a set of visited edges or a 2D list edge_color[u][j] — with values FREE or DONE.

The dfs function for forming such routes where each arc is used in the route no more than once would look as follows:

FREE = 1
DONE = 2

def dfs(v: int) -> None:
    for j in range(len(g[v])):
        if edge_color[v][j] == FREE:
            ...
            edge_color[v][j] = DONE
            dfs(g[v][j])
            edge_color[v][j] = FREE
            ...

Here arc labels are introduced — edge_color[v][j]. edge_color[v][j] == FREE if the arc has not yet been processed, and DONE if it is included in the current path.

If the problem requires outputting the found path, then a special list path is introduced for storing it. The vertices of the current path are added to this list and removed from it during graph traversal:

path: list[int] = []

def dfs(v: int) -> None:
    for j in range(len(g[v])):
        ...
        path.append(g[v][j])
        dfs(g[v][j])
        path.pop()
        ...

Now it is time to move on to applying the presented theoretical information to solving specific problems.

89.1. Problem "Roads"

National Olympiad in Informatics, 1997.

Given a system of one-way roads defined by a set of city pairs. Each such pair (i, j) indicates that one can travel from city i to city j, but this does not mean that one can travel in the reverse direction.

It is necessary to determine whether one can travel from a given city A to a given city B in such a way as to visit city C without traveling along any road more than once.

The input data is specified in a file named PATH.IN as follows: the first line contains the natural number N (N ≤ 50) — the number of cities (cities are numbered from 1 to N). The second line contains the natural number M (M ≤ 100) — the number of roads. Each of the following lines contains pairs of city numbers connected by a road. The last, (M + 3)-rd, line contains the city numbers A, B, and C.

The answer is a sequence of cities starting with city A and ending with city B that satisfies the problem conditions. The answer must be written to the file PATH.OUT as a sequence of city numbers (one number per line). The first line of the file must contain the number of cities in the sequence. If no path exists, write the number -1 to the first line of the file.

90. PATH.IN

3
2
1 2
2 3
1 3 2

91. PATH.OUT

3
1
2
3

The solution idea can be stated as follows:

Depth-first search
If we encounter vertex B, set the corresponding flag
If we encounter vertex C and flag B is set - output the result and
terminate
After the search completes (required route not found) output -1

Let us present the solution in more detail. Input data is read as follows:

data = open('path.in').read().split()
idx = 0
n = int(data[idx]); idx += 1
m = int(data[idx]); idx += 1

FREE = 1
DONE = 2

# Adjacency list with edge colors
g: list[list[int]] = [[] for _ in range(n + 1)]
edge_color: list[list[int]] = [[] for _ in range(n + 1)]

for _ in range(m):
    x = int(data[idx]); idx += 1
    y = int(data[idx]); idx += 1
    g[x].append(y)
    edge_color[x].append(FREE)

A = int(data[idx]); idx += 1
B = int(data[idx]); idx += 1
C = int(data[idx]); idx += 1

Here, as before, g[i] is the list of vertices connected from vertex i by arcs. In addition, arc color is introduced: FREE (free) and DONE (occupied). FREE and DONE are constants.

The main program essentially includes only the following statements:

lab_c = 0  # Set the label - vertex C has not been visited yet
path: list[int] = []
dfs(A)  # Depth-first search from vertex A
print(-1)  # Output the no-path indicator

The recursive depth-first search function from vertex v looks as follows:

import sys

def dfs(v: int) -> None:
    global lab_c
    for j in range(len(g[v])):
        if edge_color[v][j] == FREE:
            if g[v][j] == B and lab_c == 1:
                out_res()
                sys.exit()
            if g[v][j] == C:
                lab_c = 1
            edge_color[v][j] = DONE
            path.append(g[v][j])
            dfs(g[v][j])
            edge_color[v][j] = FREE
            path.pop()
            if g[v][j] == C:
                lab_c = 0

For all not yet processed (edge_color[v][j] == FREE) arcs from the current vertex, we determine whether it leads to the destination, and if city C has already been visited, the result output function is called.

If the current arc leads to city C, we set the corresponding label (lab_c = 1). We mark the arc as processed, add it to the path list, which contains the current path being processed.

Then we call the dfs function from the vertex (g[v][j]) to which the current arc leads. Before exiting the dfs function, we restore the state that was "original" before its call: we remove the processing mark from the arc (edge_color[v][j] = FREE) and remove it from the path list (path.pop()).

And finally, the result output function:

def out_res() -> None:
    with open('path.out', 'w') as f_out:
        print(len(path) + 2, file=f_out)
        print(A, file=f_out)
        for v in path:
            print(v, file=f_out)
        print(B, file=f_out)

Since, by the construction algorithm, the starting (A) and ending (B) cities were not added to the path list, they are output separately, and the number of cities in the path (len(path)) is increased by 2 before output.

Listing 2.33. Program "Roads"
# Roads
import sys
sys.setrecursionlimit(10000)

FREE = 1
DONE = 2

def solve() -> None:
    with open('path.in') as f:
        data = f.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1

    g: list[list[int]] = [[] for _ in range(n + 1)]
    edge_color: list[list[int]] = [[] for _ in range(n + 1)]

    for _ in range(m):
        x = int(data[idx]); idx += 1
        y = int(data[idx]); idx += 1
        g[x].append(y)
        edge_color[x].append(FREE)

    A = int(data[idx]); idx += 1
    B = int(data[idx]); idx += 1
    C = int(data[idx]); idx += 1

    path: list[int] = []
    lab_c = 0

    def out_res() -> None:
        with open('path.out', 'w') as f_out:
            print(len(path) + 2, file=f_out)
            print(A, file=f_out)
            for v in path:
                print(v, file=f_out)
            print(B, file=f_out)

    def dfs(v: int) -> None:
        nonlocal lab_c
        for j in range(len(g[v])):
            if edge_color[v][j] == FREE:
                if g[v][j] == B and lab_c == 1:
                    out_res()
                    sys.exit()
                if g[v][j] == C:
                    lab_c = 1

continued

92. Listing 2.33 (continued)

                edge_color[v][j] = DONE
                path.append(g[v][j])
                dfs(g[v][j])
                edge_color[v][j] = FREE
                path.pop()
                if g[v][j] == C:
                    lab_c = 0

    dfs(A)
    with open('path.out', 'w') as f_out:
        print(-1, file=f_out)

solve()

93. Problem "Intersections"

National Olympiad in Informatics, 1998.

Given are the Cartesian coordinates of \(N\) city intersections, numbered from 1 to \(N\). Each intersection has a traffic light. Some of the intersections are connected by roads with two-way (right-hand) traffic that cross only at intersections. For each road, the time required to travel along it from one intersection to another is known.

It is necessary to travel from intersection number \(A\) to intersection number \(B\) in the minimum time.

The travel time depends on the set of roads traveled and the waiting time at intersections. If you have arrived from intersection \(X\) at intersection \(C\) via road \(X→C\) and want to continue along road \(C→Y\), then the waiting time at intersection \(C\) depends on whether you turn left or not. If you turn left, the waiting time equals \(D * K\), where \(D\) is the number of roads intersecting at intersection \(C\), and \(K\) is some constant. If you do not turn left, the waiting time is zero.

Write a program that determines the fastest route.

The input data is in a text file named PER.IN with the following structure:

  1. the first line contains the number \(N\) (natural, ≤ 1000);

  2. the second line contains the number of roads \(M\) (natural, ≤ 1000);

  3. the third line contains the constant \(K\) (natural number, ≤ 1000);

  4. each of the \(N\) following lines contains a pair of numbers \(x\) and \(y\), separated by a space, which are the coordinates of the intersection (integers not exceeding 1000 in absolute value);

5) each of the M following lines contains 3 numbers p, r, t, separated by a space, where p and r are the numbers of the intersections connected by the road, and t (natural, ≤ 1000) is the travel time along it;

6) the last line contains the numbers of the starting intersection A and the ending intersection B.

The output data must be written to a text file named PER.OUT with the following format:

  1. the first line contains a natural number T — the travel time along the fastest route;

  2. each of the following lines contains one number — the number of the next intersection in the route (starting from intersection A and ending with B).

In the solution, Dijkstra’s algorithm is not directly applicable, since the optimal solution at the next vertex is not the sum of the optimal solution for the previous vertex and the weight of the current edge. Moreover, the weight of the current edge is not a constant, depending on which turn was made leaving the vertex.

Therefore, it is proposed to solve the problem using depth-first search. Each vertex is allowed to be used as many times as it has edges. Solutions that are worse than the current optimum are pruned.

The solution can be further accelerated if the search starts with right turns (pruning would work more effectively).

This is not explicitly stated in the problem text, but the authors of the original test cases for the problem assumed that a 180-degree turn is a left turn (as in the military: a 180-degree turn is over the left shoulder).

Let us examine the solution in more detail. We begin with reading the input data:

with open('PER.in') as f:
    data = f.read().split()
idx = 0
n = int(data[idx]); idx += 1
m = int(data[idx]); idx += 1
K = int(data[idx]); idx += 1

cx: list[int] = [0] * (n + 1)  # x-coordinates of intersections
cy: list[int] = [0] * (n + 1)  # y-coordinates of intersections
for i in range(1, n + 1):
    cx[i] = int(data[idx]); idx += 1
    cy[i] = int(data[idx]); idx += 1

# g[i] = list of (neighbor, travel_time)
g: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]
D: list[int] = [0] * (n + 1)  # number of roads at each intersection

for _ in range(m):
    p = int(data[idx]); idx += 1
    r = int(data[idx]); idx += 1
    t = int(data[idx]); idx += 1
    g[p].append((r, t)); D[p] += 1
    g[r].append((p, t)); D[r] += 1

vA = int(data[idx]); idx += 1
vB = int(data[idx]); idx += 1

Here we introduced:

  • g[i] — list of (neighbor, travel_time) tuples for vertex i;

  • g[i][j][0] — the vertex connected to vertex i by arc number j;

  • g[i][j][1] — the travel time from vertex i to the vertex connected to it by arc j;

  • D[i] — the number of roads intersecting at intersection i (that is, the total number of arcs emanating from and entering vertex i);

  • vA and vB — indicate the origin and destination.

Since the roads are two-way by the problem conditions, for each introduced road we add two arcs to the graph.

The main algorithm looks as follows:

FREE = 1
DONE = 2
INF = 10**18

edge_color: list[list[int]] = [[] for _ in range(n + 1)]
time_arr: list[int] = [INF] * (n + 1)

for i in range(1, n + 1):
    edge_color[i] = [FREE] * len(g[i])  # All arcs are free
    time_arr[i] = INF  # Route time to i is maximum

opt_t = INF  # Optimal time is maximum
path: list[int] = [0, vA]  # Add vertex vA to the route
kv = 1  # Number of vertices in the route = 1
time_arr[vA] = 0  # Optimal time to vertex vA = 0

dfs(vA)  # Depth-first search from vertex vA
# ... output the answer ...

The recursive function dfs(i) performs the following work:

def dfs(i: int) -> None:
    global opt_t, kv, opt_kv, opt_path
    for j in range(len(g[i])):
        neighbor, travel = g[i][j][0], g[i][j][1]
        if neighbor != vB:
            if edge_color[i][j] == FREE:
                # ... continue the path with a DFS call
                pass
        else:
            # ... compare the path with the current optimum
            pass

If the current vertex is the destination (vB), we compare the path with the current optimum. Otherwise, if the current arc has not yet been used (edge_color[i][j] == FREE), we continue the path with a DFS call. Before entering dfs, we mark the arc as used (edge_color[i][j] = DONE), and after exiting — as free (edge_color[i][j] = FREE).

Continuing the path with a DFS call includes the following statements:

                kv += 1
                path.append(neighbor)  # Add the new vertex to the path
                new_time = time_arr[i] + travel + count_time()  # Compute the new time
                if new_time < opt_t:  # If the new time is less than optimal
                                               # then continue, otherwise prune
                    edge_color[i][j] = DONE  # Mark the edge as used
                    ret_time = time_arr[neighbor]  # Save the old time
                    time_arr[neighbor] = new_time  # Set the new time
                    dfs(neighbor)  # Call search from neighbor
                    time_arr[neighbor] = ret_time  # Restore the old time
                    edge_color[i][j] = FREE  # Mark the edge as free
                path.pop()  # Remove the vertex from the path
                kv -= 1

To compute the new time, the function count_time() using the current path length kv is used here. This function recovers the vertex numbers of the passage through intersection i2 (from i1 through i2 to i3):

def count_time() -> int:
    if kv == 2:
        return 0
    i1 = path[kv - 2]
    i2 = path[kv - 1]
    i3 = path[kv]
    if i3 == i1:
        return K * D[i2]

Along the way, it is determined that:

  1. if there are only 2 vertices in the path, meaning there was no turn, then this is a step from the starting vertex and count_time returns 0.

  2. if i1 == i3, then this is a 180-degree turn, and the problem authors consider this also a left turn, count_time returns K * D[i2], where K is the input coefficient, i2 is the intersection, and D[i2] is the number of roads entering this intersection.

Then from the intersection coordinate lists, the coordinates of the current intersections are selected: (x1, y1) (from), (x2, y2) (through), and (x3, y3) (to).

    x1, y1 = cx[i1], cy[i1]
    x2, y2 = cx[i2], cy[i2]
    x3, y3 = cx[i3], cy[i3]

Computing the equation of the line through points (x1, y1) and (x2, y2):

    a_coef = y2 - y1
    b_coef = x1 - x2
    c_coef = y1 * (x2 - x1) - x1 * (y2 - y1)

By substituting the coordinates (x3, y3) into the line equation \(Ax + By + C = 0\), constructed from the first two points (x1, y1) and (x2, y2), taking into account edge cases where the line is parallel to the coordinate axes, we determine whether the turn was left or right, and accordingly set the value of the function count_time.

    val = a_coef * x3 + b_coef * y3 + c_coef
    left = (((x2 > x1) and (val < 0)) or
            ((x2 < x1) and (val > 0)) or
            ((y2 == y1) and (x1 > x2) and (y3 < y1)) or
            ((y2 == y1) and (x1 < x2) and (y3 > y1)) or
            ((x2 == x1) and (y1 > y2) and (x3 > x1)) or
            ((x2 == x1) and (y1 < y2) and (x3 < x1)))
    if left:
        return K * D[i2]
    else:
        return 0

Comparing the path with the optimum is done as follows:

            kv += 1
            path.append(neighbor)
            t_val = time_arr[i] + travel + count_time()
            if t_val < opt_t:
                opt_t = t_val
                opt_kv = kv
                opt_path = path[:]
            path.pop()
            kv -= 1

Thus, the optimal time is stored in the variable opt_t, and the optimal path is in the list opt_path with opt_kv elements. Therefore, the result output looks like this:

with open('PER.out', 'w') as f_out:
    print(opt_t, file=f_out)
    for i in range(1, opt_kv + 1):
        print(opt_path[i], file=f_out)

The full program text is given in Listing 2.34.

Listing 2.34. Program "Intersections"
# Intersections
import sys
sys.setrecursionlimit(100000)

FREE = 1
DONE = 2

def solve() -> None:
    with open('PER.in') as f:
        data = f.read().split()
    idx = 0
    n = int(data[idx]); idx += 1
    m = int(data[idx]); idx += 1
    K = int(data[idx]); idx += 1

    cx: list[int] = [0] * (n + 1)
    cy: list[int] = [0] * (n + 1)
    for i in range(1, n + 1):
        cx[i] = int(data[idx]); idx += 1
        cy[i] = int(data[idx]); idx += 1

    # g[i] = list of (neighbor, travel_time)
    g: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]
    D: list[int] = [0] * (n + 1)

    for _ in range(m):
        p = int(data[idx]); idx += 1
        r = int(data[idx]); idx += 1
        t = int(data[idx]); idx += 1
        g[p].append((r, t)); D[p] += 1
        g[r].append((p, t)); D[r] += 1

    vA = int(data[idx]); idx += 1
    vB = int(data[idx]); idx += 1

    edge_color: list[list[int]] = [[] for _ in range(n + 1)]
    INF = 10**18
    time_arr: list[int] = [INF] * (n + 1)

    for i in range(1, n + 1):
        edge_color[i] = [FREE] * len(g[i])

    opt_t = INF
    opt_kv = 0
    opt_path: list[int] = []
    path: list[int] = [0, vA]  # 1-indexed: path[1] = vA
    kv = 1
    time_arr[vA] = 0

    def count_time() -> int:
        if kv == 2:
            return 0
        i1 = path[kv - 2]
        i2 = path[kv - 1]
        i3 = path[kv]
        if i3 == i1:
            return K * D[i2]
        x1, y1 = cx[i1], cy[i1]
        x2, y2 = cx[i2], cy[i2]
        x3, y3 = cx[i3], cy[i3]
        a_coef = y2 - y1
        b_coef = x1 - x2
        c_coef = y1 * (x2 - x1) - x1 * (y2 - y1)
        val = a_coef * x3 + b_coef * y3 + c_coef
        left = (((x2 > x1) and (val < 0)) or
                ((x2 < x1) and (val > 0)) or
                ((y2 == y1) and (x1 > x2) and (y3 < y1)) or
                ((y2 == y1) and (x1 < x2) and (y3 > y1)) or
                ((x2 == x1) and (y1 > y2) and (x3 > x1)) or
                ((x2 == x1) and (y1 < y2) and (x3 < x1)))
        if left:
            return K * D[i2]
        else:
            return 0
    def dfs(i: int) -> None:
        nonlocal opt_t, opt_kv, opt_path, kv
        for j in range(len(g[i])):
            neighbor, travel = g[i][j]
            if neighbor != vB:
                if edge_color[i][j] == FREE:
                    kv += 1
                    path.append(neighbor)
                    new_time = time_arr[i] + travel + count_time()
                    if new_time < opt_t:
                        edge_color[i][j] = DONE
                        ret_time = time_arr[neighbor]
                        time_arr[neighbor] = new_time
                        dfs(neighbor)
                        time_arr[neighbor] = ret_time
                        edge_color[i][j] = FREE
                    path.pop()
                    kv -= 1
            else:
                kv += 1
                path.append(neighbor)
                t_val = time_arr[i] + travel + count_time()
                if t_val < opt_t:
                    opt_t = t_val
                    opt_kv = kv
                    opt_path = path[:]
                path.pop()
                kv -= 1

    dfs(vA)

    with open('PER.out', 'w') as f_out:
        print(opt_t, file=f_out)
        for i in range(1, opt_kv + 1):
            print(opt_path[i], file=f_out)

solve()

94. Problem "Scrooge McDuck"

National programming olympiad, 1995.

Scrooge McDuck decided to build a device for controlling an airplane. As is well known, the position of the control yoke depends on the state of the input sensors, but this dependency is quite complex. His mechanic Gyro G. built a device that computes this function in several stages using intermediate memory and auxiliary functions. To compute each function, it is required that the computed parameters (which are values of previously computed functions) needed for its computation are already stored in memory cells. A function with no parameters can be computed at any time. After a function is computed, the memory cells can be reused (at least for storing the result of the computed function). The function call structure is such that each function is computed no more than once and each parameter (function name) is used no more than once.

Since Scrooge does not want to spend extra money on microchips, he set the task of minimizing the device’s memory. Given the function call structure, it is necessary to determine the minimum possible memory size for the device and specify the order in which the functions should be computed.

  • Line 1: contains the number N — total number of functions;

  • Line 2: contains the name of the function to be computed;

  • Line 3: function name, number of parameters [list of parameter names];

  • …​

  • Line (N + 2): function name, number of parameters [list of parameter names].

  • memory size (in cells);

  • name of the 1st function to be computed;

  • name of the 2nd function to be computed;

  • …​.

  • name of the function to be computed.

NOTE

A function name is a natural number from 1 to N.

For example:

5
1
1 2 2 3
2 0
3 2 4 5
4 0
5 0
  • INPUT.TXT

OUTPUT.TXT
4
5
3
2
1

In short, the following needs to be done in this problem.

  1. Find S — the maximum degree among the vertices that need to be traversed (depth-first search DFS1).

  2. The required memory is calculated using the formula MaxS = S + k − 1, where S was found in the previous step (DFS1), k is the maximum number of vertices at the same nesting level with the same degree S. To compute k, a second depth-first search DFS2 is performed.

  3. A third depth-first search DFS3 finds and places into the queue V all vertices whose degree equals S.

  4. The fourth and final depth-first search traverses the given tree in the order of vertices in the queue V, that is, functions requiring the maximum memory are computed first.

Let us consider the implementation of the algorithm in more detail. We start with reading the input data:

n, fc = map(int, input().split())
kg: list[int] = [0] * (n + 1)
g: list[list[int]] = [[] for _ in range(n + 1)]
for _ in range(n):
    parts = list(map(int, input().split()))
    f = parts[0]
    kg[f] = parts[1]
    for j in range(kg[f]):
        g[f].append(parts[2 + j])

The notation used:

  • n — the initial number of vertices;

  • fc — the number of the function to be computed;

  • kg[i] — the number of parameters for computing function i;

  • g[i][j] — the j-th parameter required for computing function i.

The main program body looks like this:

color = [WHITE] * (n + 1)  # Mark all vertices as free
dfs1(fc)  # Find S - the maximum degree of vertices
                                   # used for computing function fc
max_s = s
dfs2(fc)  # Compute K - the number of vertices with degree S
                                   # in the current layer and max_s - the maximum
                                   # value of S+K-1 across layers
kv = 0
dfs3(fc)  # Place into list v all vertices whose degree
                                   # equals S; the count of such
                                   # vertices is in the variable kv
kr = 0
for i in range(kv):  # Traverse the graph using depth-first search starting
    dfs4(v[i])  # from vertices with the maximum degree from
  # list v. Found vertices are added
                                   # to list r
print(max_s)  # Output the number of memory cells
for i in range(kr):  # Output the order of function computation
    print(r[i])

The full program text is given in Listing 2.35.

Listing 2.35. Program "Scrooge McDuck"
import sys
sys.setrecursionlimit(10000)

WHITE = 1
GRAY = 2
BLACK = 3

g: list[list[int]] = [[] for _ in range(101)]
kg: list[int] = [0] * 101
v: list[int] = [0] * 101
color: list[int] = [0] * 101
r: list[int] = [0] * 101

s = 0
max_s = 0
kv = 0
kr = 0


def dfs1(u: int) -> None:
    global s
    if s < kg[u]:
        s = kg[u]
    for i in range(kg[u]):
        dfs1(g[u][i])


def dfs2(u: int) -> None:
    global max_s
    k = 0
    for i in range(kg[u]):
        if kg[g[u][i]] > s:
            k += 1
    if max_s < s + k - 1:
        max_s = s + k - 1
    for i in range(kg[u]):
        dfs2(g[u][i])


def dfs3(u: int) -> None:
    global kv
    if kg[u] == s:
        for i in range(kg[u]):
            dfs3(g[u][i])
        kv += 1
        v[kv] = u


def dfs4(u: int) -> None:
    global kr
    color[u] = GRAY
    if kg[u] != 0:
        for i in range(kg[u]):
            if color[g[u][i]] != GRAY:
                dfs4(g[u][i])
    kr += 1
    r[kr] = u


def main() -> None:
    global s, max_s, kv, kr

    with open('input.txt', 'r') as fin:
        data = fin.read().split()

    idx = 0
    n = int(data[idx]); idx += 1
    fc = int(data[idx]); idx += 1
    for _ in range(n):
        f = int(data[idx]); idx += 1
        kg[f] = int(data[idx]); idx += 1
        for j in range(kg[f]):
            g[f].append(int(data[idx])); idx += 1

    for i in range(1, n + 1):
        color[i] = WHITE
    dfs1(fc)
    max_s = s
    dfs2(fc)
    kv = 0
    dfs3(fc)
    kr = 0
    for i in range(1, kv + 1):
        dfs4(v[i])

    with open('output.txt', 'w') as fout:
        fout.write(f"{max_s}\n")
        for i in range(1, kr + 1):
            fout.write(f"{r[i]}\n")


main()

95. Strongly Connected Components and Dominant Sets

A subgraph of a graph is called its strongly connected component if every vertex of the subgraph is reachable from any other vertex of the subgraph.

A dominant set is a minimal set of vertices of a graph from which all vertices of the graph are reachable.

Algorithms for constructing strongly connected components and dominant sets of a graph are considered in the examples that follow.

96. Problem "Cards"

National programming olympiad, 1994.

There are \(N\) cards. On each of them, its unique number — a number from 1 to \(N\) — is written in black ink. Also, on each card, another integer from 1 to \(N\) is written in red ink (several cards may be marked with the same "red" number).

It is necessary to select from the given \(N\) cards the maximum number of cards such that the sets of "red" and "black" numbers on them coincide.

Suppose \(N\) = 5, and these 5 cards are marked with "black" numbers 1, 2, 3, 4, 5 and, respectively, "red" numbers 3, 3, 2, 4, 2. Then the "correct" cards are those with "black" numbers 2, 3, 4, since the set of "red" numbers in this example is exactly 2, 3, 4.

  • N (N ≤ 50);

  • "black"_number_1 "red"_number_1;

  • …​;

  • "black"_number_N "red"_number_N.

  • S (number of elements in the selected subset);

  • a1 ("black" numbers of the selected cards);

  • …​;

  • aS.

6
1 2
2 3
3 4
4 5
5 1
6 6
  • Sample input:

6
1
2
3
4
5
6
  • Sample output:

In fact, the problem requires finding all strongly connected components of the directed graph represented by the cards and adding to them the cards on which the same number is written in both red and black.

Let us describe the solution in more detail. We start with the body of the main program:

n = int(input())  # Read the number of cards
m = 0  # Number of cards in the result
for i in range(1, n + 1):  # For all cards
    u, v = map(int, input().split())  # Read the card
    kg[u] += 1  # Update the graph with it
    g[u].append(v)
    if u == v:  # If the red number equals the black one
        m += 1  # add the card to the result
        t[m] = u
build_dominators()  # Build the set of dominators
reverse_graph()  # Transpose the graph
build_dominators2()  # Build the set of dominators
                                       # for the transposed graph, simultaneously
                                       # obtaining all strongly connected components

Let us consider the build_dominators function:

def build_dominators() -> None:
    global time_counter
    time_counter = 0  # Vertex processing time = 0
    for i in range(1, n + 1):  # For each vertex i, mark
        color[i] = WHITE  # Vertex is free
        dom[i] = WHITE  # No dominators
        cur_c[i] = WHITE  # Free at the current step
        f[i] = 0  # Processing completion time is 0
    for v in range(1, n + 1):  # For all vertices
        if color[v] == WHITE:  # If vertex v is free
            dfs(v)  # Depth-first search from vertex v
            add_dominator(v)  # Add the found dominator

Thus, at this stage of processing graph vertices, we have three sets:

  • dom — dominators of the graph found so far;

  • color — all processed vertices;

  • cur_c — vertices processed during the depth-first search from the current base vertex v.

The recursive depth-first search function for this problem looks like this:

def dfs(i: int) -> None:
    global time_counter
    color[i] = GRAY  # Vertex i is processed overall
    cur_c[i] = GRAY  # Vertex i is processed at the current step
    time_counter += 1  # Increment vertex processing time
    for j in range(kg[i]):  # While the vertex has successors
        if cur_c[g[i][j]] == WHITE:  # If the successor has not been processed
                                              # at the current step
            dfs(g[i][j])  # Launch depth-first search from
                                              # the successor
    time_counter += 1  # Increment vertex processing time
    if f[i] == 0:  # If vertex i has not been assigned a time
        f[i] = time_counter  # of completion — assign it

The add_dominator(v) function, also called from build_dominators, adds vertex v to the list of dominators as follows: all vertices reached from the current one (cur_c[i] != WHITE) are removed from the dominators, and the current vertex is added to the list of dominators (dom[dom_v] = GRAY).

def add_dominator(dom_v: int) -> None:
    for i in range(1, n + 1):  # For all vertices
        if cur_c[i] != WHITE:  # If it is reached from the current one
            dom[i] = WHITE  # remove it from the dominators
    dom[dom_v] = GRAY  # Add the current one to the dominator list
    for i in range(1, n + 1):  # For all vertices, set the mark
        cur_c[i] = WHITE  # not reached from the current one

The next step after building the dominators of the original graph is transposing the graph (in other words — reversing the direction of arcs). This is done using the reverse_graph function. The reverse_graph function builds graph b[1..n], kb[1..n] from the original graph g[1..n], kg[1..n]:

def reverse_graph() -> None:
    for i in range(1, n + 1):
        kb[i] = 0  # Zero out the arc counts from vertex i
    for i in range(1, n + 1):
        for j in range(kg[i]):
            kb[g[i][j]] += 1  # Increment the number of
                                      # arcs from vertex i
            b[g[i][j]].append(i)  # Add the reverse arc to the graph

The build_dominators2 function builds the set of dominators on the transposed graph, simultaneously forming the strongly connected components (SCCs) of the original graph:

def build_dominators2() -> None:
    global time_counter, m
    time_counter = 0
    for i in range(1, n + 1):  # For all vertices
        color[i] = WHITE  # Free overall
        cur_c[i] = WHITE  # Free in the current cycle
        kart[i] = WHITE  # Not part of an SCC
    sort_f()  # Sort in descending order of completion time
                                      # of vertex processing during
                                      # construction of the dominator set of the original
                                      # graph — result q[1..n]
    for v in range(1, n + 1):  # In descending order of processing times
        if color[q[v]] == WHITE:  # If the vertex is free
            dfs2(q[v])  # Build the dominator
            add_dominator2(q[v])  # Add the dominator
    for i in range(1, n + 1):  # For all vertices
        if kart[i] != WHITE:  # If it is part of an SCC
            m += 1  # add it to the result
            t[m] = i
    sort_t()  # Sort the result set
    print(m)  # output it
    for i in range(1, m + 1):
        print(t[i])

The dfs2 depth-first search function on the transposed graph looks as follows:

def dfs2(i: int) -> None:
    color[i] = GRAY
    cur_c[i] = GRAY
    for j in range(kb[i]):
        if color[b[i][j]] == WHITE:
            dfs2(b[i][j])

The add_dominator2 function, which ensures correct construction of dominators of the transposed graph and strongly connected components of the original (and transposed) graph, is as follows:

def add_dominator2(dom1: int) -> None:
    ks = 0  # Number of vertices in the current SCC
    for i in range(1, n + 1):  # For all vertices
        if cur_c[i] != WHITE:  # If the vertex is reached
            ks += 1  # increment the vertex count in the current SCC
    if ks > 1:  # If the current SCC has more
                                      # than one vertex
        for i in range(1, n + 1):  # add them all to kart
            if cur_c[i] != WHITE:
                kart[i] = GRAY
    for i in range(1, n + 1):  # Make all vertices
        cur_c[i] = WHITE  # free for the new step

The full program text solving the given problem is provided in Listing 2.36.

Listing 2.36. Program "Cards"
import sys
sys.setrecursionlimit(10000)

WHITE = 1
GRAY = 2

g: list[list[int]] = [[] for _ in range(101)]
b: list[list[int]] = [[] for _ in range(101)]
color: list[int] = [0] * 101
kg: list[int] = [0] * 101
kb: list[int] = [0] * 101
dom: list[int] = [0] * 101
cur_c: list[int] = [0] * 101
kart: list[int] = [0] * 101
d: list[int] = [0] * 101
f: list[int] = [0] * 101
t: list[int] = [0] * 101
q: list[int] = [0] * 101
n = 0
time_counter = 0
m = 0


def max2(a: int, b: int) -> int:
    return a if a > b else b


def reverse_graph() -> None:
    for i in range(1, n + 1):
        kb[i] = 0
    for i in range(1, n + 1):
        for j in range(kg[i]):
            kb[g[i][j]] += 1
            b[g[i][j]].append(i)


def add_dominator(dom1: int) -> None:
    for i in range(1, n + 1):
        if cur_c[i] != WHITE:
            dom[i] = WHITE
    dom[dom1] = GRAY

continued ⇨

    for i in range(1, n + 1):
        cur_c[i] = WHITE


def dfs(i: int) -> None:
    global time_counter
    color[i] = GRAY
    cur_c[i] = GRAY
    time_counter += 1
    for j in range(kg[i]):
        if cur_c[g[i][j]] == WHITE:
            dfs(g[i][j])
    time_counter += 1
    if f[i] == 0:
        f[i] = time_counter


def build_dominators() -> None:
    global time_counter
    time_counter = 0
    for i in range(1, n + 1):
        color[i] = WHITE
        dom[i] = WHITE
        cur_c[i] = WHITE
        f[i] = 0
    for v in range(1, n + 1):
        if color[v] == WHITE:
            dfs(v)
            add_dominator(v)


def sort_f() -> None:
    for i in range(1, n + 1):
        q[i] = i
    for i in range(1, n):
        max_d = f[i]
        k = i
        for j in range(i + 1, n + 1):
            if f[j] > max_d:
                max_d = f[j]
                k = j
        f[k] = f[i]
        f[i] = max_d
        q[k], q[i] = q[i], q[k]


def sort_t() -> None:
    for i in range(1, m):
        max_d = t[i]
        k = i
        for j in range(i + 1, m + 1):
            if t[j] > max_d:
                max_d = t[j]
                k = j
        t[k] = t[i]
        t[i] = max_d


def dfs2(i: int) -> None:
    color[i] = GRAY
    cur_c[i] = GRAY
    for j in range(kb[i]):
        if color[b[i][j]] == WHITE:
            dfs2(b[i][j])


def add_dominator2(domi: int) -> None:
    ks = 0
    for i in range(1, n + 1):
        if cur_c[i] != WHITE:
            ks += 1
    if ks > 1:
        for i in range(1, n + 1):
            if cur_c[i] != WHITE:
                kart[i] = GRAY
    for i in range(1, n + 1):
        cur_c[i] = WHITE


def build_dominators2() -> None:
    global time_counter, m
    time_counter = 0
    for i in range(1, n + 1):
        color[i] = WHITE
        cur_c[i] = WHITE
        kart[i] = WHITE
    sort_f()
    for v in range(1, n + 1):
        if color[q[v]] == WHITE:
            dfs2(q[v])
            add_dominator2(q[v])
    for i in range(1, n + 1):
        if kart[i] != WHITE:
            m += 1
            t[m] = i
    sort_t()
    print(m)
    for i in range(1, m + 1):
        print(t[i])


def main() -> None:
    global n, m

    with open('input.txt', 'r') as fin:
        lines = fin.read().split('\n')

    n = int(lines[0])
    m = 0
    for i in range(1, n + 1):
        u, v = map(int, lines[i].split())
        kg[u] += 1
        g[u].append(v)
        if u == v:
            m += 1
            t[m] = u

    build_dominators()
    reverse_graph()

    with open('output.txt', 'w') as fout:
        import sys
        sys.stdout = fout
        build_dominators2()
        sys.stdout = sys.__stdout__


main()

97. Problem "School Network"

Hungary, IOI'96 — International Olympiad in Informatics.

Some schools are connected by a computer network. Agreements have been made between the schools: each school has a list of recipient schools to which it sends software whenever it receives new free software

(from outside the network or from another school). Note that if school B is on the recipient list of school A, then school A may or may not be on the recipient list of school B.

Write a program that determines the minimum number of schools to which a copy of new software must be delivered so that it spreads to all schools in the network according to the agreements (subtask A).

In addition, it is necessary to ensure the ability to distribute new software from any school to all other schools. To do this, extend the recipient lists of some schools by adding new schools to them. Find the minimum total number of list extensions at which software from any school would reach all other schools (subtask B). One extension means adding one new recipient school to the recipient list of one school.

The first line of the input file INPUT.TXT contains an integer N — the number of schools in the network (2 ≤ N ≤ 100). Each of the following N lines specifies a recipient list. Line number 1 + i contains the recipient numbers of the i-th school. Each such list ends with a zero. An empty list contains only 0.

The program must write 2 lines to the output file OUTPUT.TXT: the first line contains the solution to subtask A (a positive integer), and the second line contains the solution to subtask B.

The following can be given as examples of input and output files:

□ INPUT.TXT

5
2 4 3 0
4 5 0
0
0
1 0

□ OUTPUT.TXT

1
2

Subtask A requires finding the dominant set, that is, the minimum set of vertices of the original graph from which there are "chains" to all other vertices of the graph.

Using depth-first search, while there are free (not recolored from white to gray) vertices, we find all vertices reachable from the given free vertex. From the set of dominants, we remove all vertices reached at the current step and add the original free vertex to the set of dominants.

Subtask B requires finding the number of edges that need to be added so that the original directed graph becomes strongly connected. The transposed graph is built from the original graph. For the new (transposed) graph, the dominant set is built again.

If both the first and the second dominant sets consist of the same single element — vertex 1, then the graph was already strongly connected and no edges need to be added. In any other case, the maximum of the sizes of the two constructed dominant sets needs to be added.

97.1. NOTE

Since an almost identical problem is fully considered in the previous section, a complete description of the solution is not provided here.

An alternative solution to this problem can use the construction of the reachability matrix using Floyd’s method. Then all vertices are divided into groups:

  1. sources (they have no incoming arcs);

  2. sinks (they have no outgoing arcs);

  3. intermediate (they have both incoming and outgoing arcs).

The answer to subtask A is the number of sources. In subtask B, if there are no sources or sinks, then the graph is an SCC and no edges need to be added. Otherwise, we select the maximum of the number of sources and sinks — this will be the number of edges that need to be added.

97.2. NOTE

In subtask B, we add connections between sources and sinks, as a result of which the graph becomes a strongly connected component.

97.3. Problem "Winnie-the-Pooh and Piglet"

National school olympiad, 1995.

Winnie-the-Pooh and Piglet were hired to protect a computer network from hackers who were extracting secret information from the computers. Winnie-the-Pooh and Piglet’s computer network consisted of interconnected mainframe computers, each of which had several terminals connected to it. Connecting to one of the mainframes allowed access to the information stored in that computer’s memory, as well as all the information accessible to other computers to which that one could send requests. Hackers had attacked similar computer networks before, and their tactics were known. Therefore, Winnie-the-Pooh and Piglet developed a special program that helped take measures against the planned attack.

The hackers' tactics are as follows: during attacks, they always gain access to information from all computers in the network. They achieve this by capturing certain computers in the network so that they can request information from the remaining ones. There are many possible capture strategies. For example, capturing all computers. But hackers always choose the option in which the total number of terminals of the captured computers is minimized.

97.4. NOTE

In Winnie-the-Pooh and Piglet’s network, no two computers have the same number of terminals.

194

You need to write a program whose input is a description of the network and whose output is a list of computer numbers that could be chosen by the hackers for capturing the network according to their tactics. Input is from a file named INPUT.TXT. Input from the keyboard is also possible.

  • N — number of computers in the network;

  • T[1] — number of terminals of the first computer;

  • T[2] — number of terminals of the second computer;

  • …​;

  • T[N] — number of terminals of the N-th computer;

  • A[1] B[1] — request rights;

  • A[2] B[2];

  • …​;

  • A[K] B[K];

  • 0 0. A[i] and B[i] are computer numbers; each pair A[i] B[i] means that computer number A[i] has the right to request information from computer number B[i] (A[i] is not equal to B[i]). The last line (0 0) indicates the end of the request rights list.

The numbers N and T[i] are natural; T[i] ≤ 1000, N ≤ 50, K ≤ 2450.

  • C[1] C[2] …​ C[M] — numbers of captured computers;

  • M — number of captured computers.

For example:

5
100
2
1
3
10
1 3
3 2
4 3
4 5
5 4
0 0
  • INPUT.TXT

1 4
2
  • OUTPUT.TXT

Using depth-first search, we find the set of dominators of the network (graph sources). However, for the optimal selection of machines by the number of terminals, it is necessary to choose the machine with the fewest terminals in each strongly connected component. To do this, we invert the graph (reverse the direction of all arcs of the gra-

For many graph problems whose solutions involve enumerating all possible paths along edges (vertices), a method called "breadth-first search" can be applied. Breadth-first search implies an order of enumeration in which first all paths of two vertices (starting from the given current one) are considered, then all paths of three vertices, four, and so on. As a rule, a queue is used to organize breadth-first search.

99. Problem "Street Race"

The Netherlands, IOI'95 — International Olympiad in Informatics.

        1       4       7
       / \     / \     / \
      /   \   /   \   /   \
     /     \ /     \ /     \
    0       3       6       9
     \     / \     / \     /
      \   /   \   /   \   /
       \ /     \ /     \ /
        2       5   ← 8

The figure shows an example of a street plan for a race. You see points labeled with numbers from 0 to \(N\) (where \(N = 9\)), as well as arrows connecting them. Point 0 is the start, and point \(N\) is the finish. The arrows represent one-way streets. Race participants move from point to point along the streets only in the direction of the arrows. At each point, a participant can choose any of the outgoing arrows.

We call a street plan good if it has the following properties:

  1. every point in the plan can be reached from the start;

  2. the finish can be reached from any point in the plan;

  3. the finish has no outgoing arrows.

To reach the finish, a participant does not have to pass through all points. However, some points cannot be bypassed. We call them unavoidable. In the example,

196

such points are 0, 3, 6, 9. For a given good plan, your program must determine the set of unavoidable points (excluding start and finish) that all participants must visit (subtask A).

Suppose the race is to be held over two consecutive days. For this purpose, the plan must be split into two good plans: one for each day. On the first day, the start is point 0 and the finish is some splitting point. On the second day, the start is at this splitting point and the finish is at point N. For a given good plan, your program must determine the set of all possible splitting points (subtask B).

Point S is a splitting point for a good plan C if S differs from the start and finish of C, and the plan is split by it into two good plans with no common arrows and with a single common point — S. In the example, the splitting point is point 3.

The input file INPUT.TXT describes a good plan containing no more than 50 points and no more than 100 arrows. The file has N + 1 lines. The first N lines contain the endpoints of arrows originating from points 0 through N − 1, respectively. Each of these lines ends with the number −2. The last line contains the number −1.

Your program must write two lines to the output file OUTPUT.TXT. The first line must contain the number of unavoidable points in the given plan and the numbers of these points in any order (subtask A). The second line must contain the number of splitting points in the given plan and the numbers of these points in any order (subtask B).

The input and output data can be, for example, as follows:

□ INPUT.TXT
  1 2 -2
  3 -2
  3 -2
  5 4 -2
  6 4 -2
  6 -2
  7 8 -2
  9 -2
  5 9 -2
  -1

□ OUTPUT.TXT
  2 3 6
  1 3

To solve subtask A, we need to find all unavoidable points. This is done using breadth-first search: if after taking a vertex from the queue it becomes empty, then that vertex is unavoidable.

To solve subtask B, we need to check each unavoidable point to determine whether it is a splitting point. To do this, we remove this point from the graph and use breadth-first search to build two sets: reachable points from the start and unreachable points from the start. If there exists at least one edge from an unreachable point to a reachable one, then the current unavoidable point is not a possible splitting point (by the definition of a splitting point).

Let us consider the algorithm for solving the problem in more detail. We start with the main program body:

input_data()  # Read input data
bfs()  # Breadth-first search — find unavoidable points
out_sub_a()  # Output the answer for subtask A
for k in range(1, sub_a + 1):  # For each unavoidable point
    del_arcs(duty[k])  # remove the unavoidable point
    bfs()  # Breadth-first search —
                                        # Build sets of reachable color[i]==1
                                        # and unreachable color[i]==0 vertices
    ret_arcs(duty[k])  # Return the unavoidable point to the graph
    for i in range(n):  # for all vertices
        j = 0  # j — number of arc originating from the vertex
        while a[i][j] >= 0:  # while the arc exists
            if (color[i] == WHITE and  # If the source vertex is reachable
                color[a[i][j]] != WHITE):  # and the arc endpoint is unreachable
                duty[k] = 0  # This is not a splitting point
                break  # Proceed to check the next
                                        # unavoidable point
            j += 1  # Take the next arc
out_sub_b()  # Output the answer for subtask B

The input data reading function:

def input_data() -> None:
    global n
    i = 0
    j = 0
    a[0][0] = int(tokens[0])  # Start reading from vertex 0
    pos = 1
    while a[i][j] != -1:  # While vertex processing is not finished
        while a[i][j] != -2:  # While arc processing is not finished
            j += 1  # Increment the arc number
            a[i][j] = int(tokens[pos])  # Read the arc
            pos += 1
        if j > 1:  # If there is more than one arc,
            sort_arcs(i, j)  # sort them in ascending order
                                        # of the endpoint number
        i += 1  # Proceed to reading the next line
        j = 0
        a[i][j] = int(tokens[pos])  # Read the first element of the line
        pos += 1
    n = i  # Store the number of the last vertex in n

Breadth-first search is performed using the bfs (Breadth-First Search) function.

def bfs() -> None:
    global sub_a, beg_q, end_q
    for i in range(n + 1):
        color[i] = WHITE  # All vertices are free
    color[0] = GRAY  # The starting vertex is processed
    sub_a = 0  # Number of unavoidable vertices = 0
    beg_q = 0  # Beginning of the queue
    end_q = -1  # Queue is empty
    put(0)  # Place the starting vertex in the queue
    while beg_q <= end_q:  # While the queue is not empty
        i = get()  # take vertex i from the queue
        if beg_q > end_q:  # If the queue remained empty
            sub_a += 1  # increment the count of
                                        # unavoidable vertices
            color[i] = COL_SUB_A  # Mark the unavoidable vertex
        j = 0  # arc number from vertex i
        while a[i][j] >= 0:  # while arcs are not exhausted
            if color[a[i][j]] == WHITE: # If vertex a[i][j] is free
                put(a[i][j])  # put it in the queue
                color[a[i][j]] = GRAY  # mark vertex a[i][j]
                                        # as used
            j += 1  # take the next arc

Output of the answer for subtask A and accumulation of unavoidable points in the duty list is done as follows:

def out_sub_a() -> None:
    global sub_a
    sub_a -= 2  # The start and end points are NOT
    print(sub_a, end='')  # considered unavoidable points
    j = 0  # j — number of unavoidable points
    for i in range(1, n):  # for all vertices
        if color[i] == COL_SUB_A:  # If it is unavoidable
            print(f' {i}', end='')  # Output it
            j += 1  # Increment the count of unavoidable vertices
            duty[j] = i  # Remember the unavoidable vertex number
    print()

The full program text is in Listing 2.37.

Listing 2.37. Program "Street Race"
import sys

WHITE = 1
GRAY = 2
COL_SUB_A = 4
MAXINT = 10**9

a: list[list[int]] = [[0] * 102 for _ in range(102)]
color: list[int] = [0] * 101
queue: list[int] = [0] * 101
duty: list[int] = [0] * 101
n = 0
sub_a = 0
sub_b = 0
beg_q = 0
end_q = 0


def put(x: int) -> None:
    global end_q
    end_q += 1
    queue[end_q] = x


def get() -> int:
    global beg_q
    x = queue[beg_q]
    beg_q += 1
    return x


def sort_arcs(row: int, count: int) -> None:
    for m in range(count - 1):
        min_val = a[row][m]
        rmin = m
        for k in range(m + 1, count):
            if a[row][k] < min_val:
                min_val = a[row][k]
                rmin = k
        a[row][rmin] = a[row][m]
        a[row][m] = min_val


def input_data() -> None:
    global n
    with open('input.txt', 'r') as fin:
        tokens = fin.read().split()
    pos = 0
    i = 0
    j = 0
    a[0][0] = int(tokens[pos]); pos += 1
    while a[i][j] != -1:
        while a[i][j] != -2:
            j += 1
            a[i][j] = int(tokens[pos]); pos += 1
        if j > 1:
            sort_arcs(i, j)
        i += 1
        j = 0
        a[i][j] = int(tokens[pos]); pos += 1
    n = i


def out_sub_a() -> None:
    global sub_a
    sub_a -= 2
    print(sub_a, end='')
    duty[0] = 0
    j = 0
    for i in range(1, n):
        if color[i] == COL_SUB_A:
            print(f' {i}', end='')
            j += 1
            duty[j] = i
    print()


def bfs() -> None:
    global sub_a, beg_q, end_q
    for i in range(n + 1):
        color[i] = WHITE
        queue[i] = 0
    color[0] = GRAY
    sub_a = 0
    beg_q = 0
    end_q = -1
    put(0)
    while beg_q <= end_q:
        i = get()
        if beg_q > end_q:
            sub_a += 1
            color[i] = COL_SUB_A
        j = 0

continued ⇨

Listing 2.37 (continued)
        while a[i][j] >= 0:
            if color[a[i][j]] == WHITE:
                put(a[i][j])
                color[a[i][j]] = GRAY
            j += 1


def out_sub_b() -> None:
    sub_b = 0
    for i in range(1, sub_a + 1):
        if duty[i] >= 0:
            sub_b += 1
    print(sub_b, end='')
    for i in range(1, sub_a + 1):
        if duty[i] >= 0:
            print(f' {duty[i]}', end='')
    print()


def del_arcs(k: int) -> None:
    for j in range(n, 0, -1):
        a[k][j + 1] = a[k][j]
    a[k][0] = -2
    for i in range(n):
        j = 0
        while a[i][j] >= 0:
            if a[i][j] == k:
                a[i][j] = MAXINT
            j += 1


def ret_arcs(k: int) -> None:
    for i in range(n):
        j = 0
        while a[i][j] >= 0:
            if a[i][j] == MAXINT:
                a[i][j] = k
            j += 1
    for j in range(n):
        a[k][j] = a[k][j + 1]


def main() -> None:
    global sub_a

    input_data()
    bfs()  # Breadth-first search — find unavoidable points
    out_sub_a()
    for k in range(1, sub_a + 1):
        del_arcs(duty[k])
        bfs()
        ret_arcs(duty[k])
        for i in range(n):
            j = 0
            while a[i][j] != 0:
                if (color[j] == WHITE and
                        color[a[i][j]] != WHITE):
                    duty[k] = 0
                    break
                j += 1
    out_sub_b()


main()

100. On the Dimensions of Lists Used in Problems

An attentive reader has noticed that in some cases, lists are declared with smaller dimensions than actually required by the problem conditions. This is done intentionally for the following reasons.

  1. In modern competitive programming, Python 3 is widely used, and its dynamic lists do not have fixed size limits like statically declared arrays. However, for clarity we often pre-allocate lists of a specific size to mirror the original algorithm structure.

  2. The main purpose of the material is to demonstrate the proposed basic algorithms for solving graph problems, and we did not want to complicate the material, thereby obscuring the essence of the basic algorithms.

101. Overview of the Presented Theoretical Material

The purpose of this section is to briefly list the theoretical knowledge for solving graph problems. The reader can use this list for self-assessment of how successfully the material has been mastered. If you understand what lies behind the given name and know how to perform the named task — then the goals set by the author (and by you) have been achieved.

Here is the list of self-assessment questions.

  1. Reducing problems to graphs.

  2. Floyd’s method. Used for finding the shortest distances between all pairs of graph vertices (at the same time, the shortest paths themselves from each vertex to every other one can also be constructed).

  3. Constructing the reachability matrix of a graph.

  4. Constructing the set of sources and the set of sinks (a source is a vertex with no incoming arcs, a sink is a vertex with no outgoing arcs).

  1. Dijkstra’s method. Used for finding the shortest distances from one vertex to all others and constructing optimal routes from one vertex to all others.

  2. Depth-first search. Used for solving arbitrary graph problems that require the corresponding order of traversal ("in depth") of the graph.

  3. By coloring traversed vertices and (or) arcs, one can control the order and methods of traversal (for example, single or multiple use of arcs and vertices).

  4. Constructing the set of sources and sinks (as sources on the transposed graph).

  5. Constructing the strongly connected components of a graph. A strongly connected component of a graph is a set of vertices, each of which is reachable from all other vertices.

  6. Breadth-first search. Used for solving arbitrary graph problems that require the corresponding order of traversal ("in breadth") of the graph. By coloring traversed vertices and (or) arcs, one can control the order and methods of traversal (for example, single or multiple use of arcs and vertices).

102. 2.5. Generating Combinatorial Objects

There is a set of problems whose solution consists of generating all elements of such combinatorial objects as the power set (set of all subsets), permutations, combinations, arrangements, permutations with repetitions, combinations with repetitions. For each generated element, certain properties are checked for the specific problem.

The following order of presentation is proposed for each combinatorial object: example, algorithm, program, comments on the program. For greater clarity, all programs operate with a list of characters from A to Z. Obviously, the proposed algorithms and programs change very little when working with lists of elements of any type required by the problem conditions (for example, lists of numbers, words, geometric figures, etc.).

102.1. The Power Set (Set of All Subsets)

Suppose we have a set of four components, denoted by Latin letters A, B, C, D. And suppose, according to the problem conditions, we need to select a subset consisting of several components that possesses a certain property. The following approach to solving the problem is proposed: we generate all possible subsets of the given set and for each generated subset check whether it satisfies the given property.

An alternative version of the problem is to count all subsets of the given set that possess the given property.

For example: for a set of four characters A, B, C, D, the power set includes the following sets:

  • the empty set;

  • one-element sets: {A}, {B}, {C}, {D};

  • two-element sets: {A, B}, {A, C}, {A, D}, {B, C}, {B, D}, {C, D};

  • three-element sets: {A, B, C}, {A, B, D}, {A, C, D}, {B, C, D};

  • four-element set: {A, B, C, D}.

When the order of subset generation does not matter (when we need to count all subsets possessing a given property, this is indeed the case), one of the simplest algorithms to code for generating the power set is as follows: we create a list b consisting of four numbers, each of which can take the value 0 or 1. We consider that the value 1 indicates the inclusion of the corresponding component of the original set in the desired subset. The value 0 indicates that the element is not included.

Now consider the sequence of binary numbers from 0 to 15 and the corresponding subsets:

  1. {0000} — the empty set;

  2. {0001} — A, {0010} — B, {0100} — C, {1000} — D;

  3. {0011} — AB, {0101} — AC, {0110} — BC, {1001} — AD, {1010} — BD, {1100} — CD;

  4. {0111} — ABC, {1011} — ABD, {1101} — ACD, {1110} — BCD;

  5. {1111} — ABCD.

Thus, there are 16 different subsets of a set of 4 elements in total. In the general case, the power set of a set of N elements contains 2_N_ elements.

The algorithm that generates the power set of N elements can be informally described as follows: we form a list consisting of N zeros and consider it as the current set. Thus, the initial value of the current subset is the empty set.

To obtain the next subset from the current one, we process the current list of 0s and 1s as follows: from right to left (from the first element of the list to the last), we look for the first number equal to 0. If no such number is found, then the current subset is the last one, that is, the set consisting of all elements. At this point, the algorithm finishes its work. If an element equal to 0 is found, it is replaced by 1, and all numbers to its right (if any) are replaced by zeros.

In a more formalized way, this algorithm can be written as follows:

Input (N)
Zero out list b of N+1 elements
Output (Empty set)
While b[N]==0
  i=0
    while b[i] == 1:
        b[i] = 0
        i += 1
    b[i] = 1
    # Output the set defined by list b

Now it is easy to write the program text that reads the number of elements in the set (\(N\)) and outputs to the screen the power set, denoting elements by the corresponding Latin letters in order (Listing 2.38).

102.2. Program for generating the power set

Listing 2.38.
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


def main() -> None:
    n = int(input())
    b: list[int] = [0] * (n + 1)
    print('Empty set')
    while b[n] == 0:
        i = 0
        while b[i] == 1:
            b[i] = 0
            i += 1
        b[i] = 1
        result = ''
        for i in range(n):
            if b[i] == 1:
                result += ALPHABET[i]
        print(result)


main()

102.2.1. NOTE

If it is necessary to process (analyze) the constructed subsets, one can add calls to processing functions that receive list b as a parameter, whose elements equal to one indicate the indices of the elements of the set included in the current subset.

103. Permutations

Suppose we have 4 components denoted by the letters \(A, B, C, D\). Then the set of all permutations of these components will include the following elements:

ABCD, BACD, CABD, DABC, ABDC, BADC, CADB, DACB, ACBD, BCAD, CBAD, DBAC, ACDB, BCDA, CBDA, DBCA, ADBC, BDAC, CDAB, DCAB, ADCB, BDCA, CDBA, DCBA.

Here is the algorithm for constructing the next permutation of 9 components denoted by digits from 1 to 9. The first such permutation is:

1 2 3 4 5 6 7 8 9.

Let the current permutation of 9 components be:

1 9 5 8 4 7 6 3 2.

What will be the next permutation if we are building permutations in lexicographic order (that is, in ascending order of the number formed by these digits)? The correct answer is:

1 9 5 8 6 2 3 4 7.

How is it obtained? First, we scan the original list from end to beginning to find the first number that is less than the previous one; in our case, it is 4 (7 < 6 < 3 < 2, but 4 < 7). Next, among the scanned numbers to the right of the found 4, we look for the last number that is greater than 4. This number is 6 (7 > 4, 6 > 4, 3 < 4, 2 < 4). Then we swap these 2 found numbers (4 and 6) and get:

1 9 5 8 6 7 4 3 2.

And now the numbers (to the right of 6), which form a decreasing sequence (7 4 3 2), are pairwise swapped so that they form an increasing sequence (2 3 4 7):

1 9 5 8 6 2 3 4 7.

This is the next permutation.

And which permutation will be the last for this example? I hope the thoughtful reader has figured it out on their own:

9 8 7 6 5 4 3 2 1.

Informally, the algorithm for constructing the next permutation from the current one can be written as follows:

  • From end to beginning of the permutation, find the first element b[i] such that b[i] < b[i + 1].

  • Remember its index — i.

  • From element i + 1 to the end, find the last element greater than b[i] and remember its index — k.

  • Swap these elements with indices i and k.

  • The entire group of elements from the (i + 1)-th to the last are pairwise swapped: the (i + 1)-th element with the last, the (i + 2)-th element with the second-to-last, and so on.

The formalized algorithm for generating all permutations of N elements can look as follows:

Input N
Fill list b sequentially with numbers from 1 to N
This is the first — initial — permutation, output it
While (true)
  i=N-1
  While (i>=0) and (b[i]>=b[i+1]), i=i-1
    If i<0 then end of work
  For j from i+1 to N-1
    if b[j]>b[i] then k=j
  Swap values of b[i] and b[k]
Reverse b[i+1 .. N-1]
Output the current permutation b

It is clear that the loop for reversing the "tail" of list b cannot run from the \((i + 1)\)-th to the last element, otherwise the elements would be swapped twice and nothing would change. The loop must be executed for half of this "tail". In Python, reversing a slice is most naturally done with slice assignment: b[i+1:] = b[i+1:][::-1].

Listing 2.39. Program generating permutations of N components
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


def swap_b(b: list[int], i: int, k: int) -> None:
    b[i], b[k] = b[k], b[i]


def write_b(b: list[int], n: int) -> None:
    print(''.join(ALPHABET[b[i]] for i in range(n)))


def main() -> None:
    n = int(input())
    b: list[int] = list(range(n))
    write_b(b, n)
    while True:
        i = n - 2
        while i >= 0 and b[i] >= b[i + 1]:
            i -= 1
        if i < 0:
            break
        k = i + 1
        for j in range(i + 1, n):
            if b[j] > b[i]:
                k = j
        swap_b(b, i, k)
        # Reverse the tail from i+1 to end
        left = i + 1
        right = n - 1
        while left < right:
            swap_b(b, left, right)
            left += 1
            right -= 1
        write_b(b, n)


main()

In the program that generates all permutations of N components denoted by uppercase Latin letters (Listing 2.39), two functions are introduced: write_b and swap_b.

The write_b function is called every time a new permutation is constructed. In this program, the write_b function simply outputs the corresponding sequence of Latin letters. The swap_b(b, i, k) function is introduced to simplify understanding of the main program. swap_b simply exchanges the values of two elements of list b whose indices correspond to the values of the function’s parameters i and k.

The swap_b function is used in the program text twice:

  1. when exchanging the values of the two found elements with indices i and k;

  2. when performing pairwise exchange of "tail" elements, in which the current element with index j is swapped with its "partner" located at position n + i + 1 - j (using 1-based indexing). In 0-based Python indexing, we reverse the sublist from i+1 to the end. Thus, the (i + 1)-th element will be swapped with the last element, the (i + 2)-th element with the second-to-last, and so on.

The total number of permutations of N elements is N! (read — N factorial). Recall that N! = 1 * 2 * 3 * …​ * N.

104. Combinations

Suppose we have 5 components denoted by Latin letters A, B, C, D, E. Then all combinations of these 5 components taken 3 at a time, listed in lexicographic order, are as follows:

  • for letters — ABC, ABD, ABE, ACD, ACE, ADE, BCD, BCE, BDE, CDE;

  • for digits — 123, 124, 125, 134, 135, 145, 234, 235, 245, 345.

Informally, the algorithm for generating a sequence of numbers in lexicographic order can be written as follows: we choose the smallest M numbers out of the available N and write them in ascending order (in our example — 1 2 3) — this will be the initial combination. Obviously, the largest M numbers out of the available ones (3 4 5), written in ascending order, will constitute the last combination. To obtain the next combination from the current one, we can proceed as follows: we find the position in the current combination where the last possible value does not stand, and then increase it by 1. All subsequent elements of the combination are obtained by adding 1 to the previous element of the combination.

For example, let the current combination be:

1 3 5.

The analysis starts from the last position of the combination. 5 is the last possible value, so we move to the previous position. 3 is not the last possible value for this position (which is 4 in this case). Therefore, we increase it by 1 and get 4. The number in the next position is obtained by adding 1 to this 4, giving 5. Thus, the next combination will be:

1 4 5.

More rigorously, this algorithm can be written as follows:

Input N, M (from how many, taken how many at a time)
Fill list b with numbers from 1 to M
This is the first combination, output it
While (true)
  i=M-1
  While (i>=0) and (b[i]==N-M+i+1), i=i-1
If i<0 then work is done
b[i]=b[i]+1
For j from i+1 to M-1: b[j]=b[j-1]+1
Output b — the next combination
Listing 2.40. Program that outputs in lexicographic order

all combinations of stem:[N

ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


def write_b(b: list[int], m: int) -> None:
    print(''.join(ALPHABET[b[i]] for i in range(m)))


def main() -> None:
    n, m = map(int, input().split())
    b: list[int] = list(range(1, m + 1))
    write_b(b, m)
    while True:
        i = m - 1
        while i >= 0 and b[i] == n - m + i + 1:
            i -= 1
        if i < 0:
            break
        b[i] += 1
        for j in range(i + 1, m):
            b[j] = b[j - 1] + 1
        write_b(b, m)


main()

Recall that the total number of combinations of \(N\) elements taken \(M\) at a time can be calculated using the formula:

\(C(N,M) = N! / (M!(N - M)!)\)

105. Arrangements

To generate all arrangements of \(N\) elements taken \(M\) at a time, one can use a composition of the algorithms described above. That is, generate all combinations of \(N\) taken \(M\) at a time, and then for each obtained combination generate all permutations of \(M\) elements.

For example, when generating all arrangements of 5 elements taken 3 at a time, where the elements themselves are denoted by Latin letters A, B, C, D, E, the following sequence needs to be obtained, presented for compactness as 10 rows, each of which represents all possible permutations of the 3 letters of the first element of the row (the first elements of the rows represent all possible combinations of 5 letters taken 3 at a time):

ABC ACB BAC BCA CAB CBA

ABD …​

ABE …​

ACD …​

ACE …​

ADE …​

BCD …​

BCE …​

BDE …​

CDE CED DCE DEC ECD EDC

The total number of arrangements of \(N\) elements taken \(M\) at a time can be found using the formula:

\(N!\)

\((N − M)!\)

Listing 2.41. Program generating all possible arrangements of N

\( letters taken stem:[M\) at a time]

ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

z = 0


def write_b(b: list[int], m: int) -> None:
    global z
    z += 1
    print(f'{z:3d} - ', end='')
    print(''.join(ALPHABET[b[i]] for i in range(m)))


def swap_b(b: list[int], i: int, k: int) -> None:
    b[i], b[k] = b[k], b[i]


def permute_all(b: list[int], n: int, m: int) -> None:
    b = b[:]  # work on a copy
    write_b(b, m)
    while True:
        i = n - 2
        while i >= 0 and b[i] >= b[i + 1]:
            i -= 1
        if i < 0:
            break
        k = i + 1
        for j in range(i + 1, n):

105.1. (continued)

Listing 2.41
            if b[j] > b[i]:
                k = j
        swap_b(b, i, k)
        # Reverse tail from i+1 to end
        left = i + 1
        right = n - 1
        while left < right:
            swap_b(b, left, right)
            left += 1
            right -= 1
        write_b(b, m)


def main() -> None:
    n, m = map(int, input().split())
    b: list[int] = list(range(1, m + 1))
    permute_all(b, m, m)
    while True:
        i = m - 1
        while i >= 0 and b[i] == n - m + i + 1:
            i -= 1
        if i < 0:
            break
        b[i] += 1
        for j in range(i + 1, m):
            b[j] = b[j - 1] + 1
        permute_all(b, m, m)


main()

Here are some explanations for the program that reads the numbers N, M from the keyboard and generates all possible arrangements of N letters taken M at a time (Listing 2.41).

  1. The main program reads the numbers N, M and generates all combinations of N taken M at a time using the algorithm described above. For each obtained combination in list b, the permute_all function is called, receiving the current combination b and the number of elements M as parameters. The permute_all function generates all possible permutations for the received combination.

  2. List b is copied inside the permute_all function (using b = b[:]), and therefore changes to list b in permute_all do not affect the contents of list b in the main program. This is analogous to pass-by-value in Python.

  3. The swap_b function swaps values in b in place. Since Python lists are mutable, passing a list to a function allows the function to modify it directly — similar to pass-by-reference.

  4. In Python, no special type declaration is needed for passing lists as parameters, unlike some other languages where a special type must be created.

  5. The variable z in the write_b function is used for counting all generated arrangements.

106. Permutations with Repetitions

Permutations with repetitions allow repeated use of elements. Suppose we have a set consisting of two symbols A and two symbols B. Then all permutations with repetitions of these symbols are: AABB, ABBA, BABA, ABAB, BAAB, BBAA.

In the general case, if there are \(N1\) items of the 1st kind, \(N2\) items of the 2nd kind, and so on, \(Nk\) items of the \(k\)-th kind, then the total number of permutations can be calculated using the formula:

            N!
       N1!N2!...Nk!

The algorithm is similar to generating permutations without repetitions, except for the formation of the initial permutation:

i = 0
for j in range(k):
    for m in range(nn[j]):
        b[i] = j + 1
        i += 1

In the program for generating permutations with repetitions (Listing 2.42), the number K of different types of items, denoted by Latin letters, is entered from the keyboard. The quantities nn[j] of items of each type are also entered from the keyboard. The sum of the entered nn[j] determines the total number of elements in each of the generated permutations.

Listing 2.42. Program for generating permutations with repetitions
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


def swap_b(b: list[int], i: int, k: int) -> None:
    b[i], b[k] = b[k], b[i]


def write_b(b: list[int], n: int) -> None:
    print(''.join(ALPHABET[b[i] - 1] for i in range(n)))


def main() -> None:
    k = int(input())
    nn: list[int] = list(map(int, input().split()))
    n = sum(nn)
    # Build initial permutation
    b: list[int] = []
    for j in range(k):
        for m in range(nn[j]):
            b.append(j + 1)
    write_b(b, n)
    while True:
        i = n - 2
        while i >= 0 and b[i] >= b[i + 1]:
            i -= 1
        if i < 0:
            break
        kk = i + 1
        for j in range(i + 1, n):
            if b[j] > b[i]:
                kk = j
        swap_b(b, i, kk)
        # Reverse tail from i+1 to end
        left = i + 1
        right = n - 1
        while left < right:
            swap_b(b, left, right)
            left += 1
            right -= 1
        write_b(b, n)


main()

107. Combinations with Repetitions

For a set of symbols from A to C and size \(M = 3\), the combinations with repetitions will be as follows: CCC, BCC, BBC, BBB, ACC, ABC, ABB, AAC, AAB, AAA.

The total number of combinations:

\((N + M − 1)! / M!(N − 1)!\),

where \(N\) is the number of symbols, \(M\) is the number of symbols in a combination.

The main idea behind generating such combinations with repetitions is as follows: we write combinations as \((N - 1)\) zeros and \(M\) ones, where ones replace symbols and zeros act as separators.

For example:

ABB — 1 0 1 1, AAC — 1 1 0 0 1, CCC — 0 0 1 1 1.

Let us explain these examples.

  • The first digit 1 means that symbol A is present in the string of symbols.

  • The next digit 0 means that the A symbols have ended.

  • The next digit 1 means that symbol B follows.

  • The next digit 1 means that another symbol B follows.

  • The digits have ended. Thus, the string of symbols ABB was represented by the binary string 1011.

  • The first digit 1 means that symbol A is present in the string of symbols.

  • The next digit 1 means that symbol A appears again.

  • The next digit 0 means that the A symbols have ended.

  • The next digit 0 means that the B symbols have ended (were absent).

  • The next digit 1 means that symbol C follows.

  • The digits have ended. Thus, the string of symbols AAC was represented by the binary string 11001.

  • The first digit 0 means that symbol A is absent from the string of symbols.

  • The next digit 0 means that symbol B is absent from the string of symbols.

  • The next digit 1 means that symbol C follows (first symbol).

  • The next digit 1 means that symbol C appears again.

  • The next digit 1 means that symbol C appears again.

  • The digits have ended. Thus, the string of symbols CCC was represented by the binary string 00111.

With this approach, to solve the problem it is sufficient to generate all permutations of N ones and (N − 1) zeros.

Listing 2.43. Program that generates combinations with repetitions
import string

ALPHABET = string.ascii_uppercase  # 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


def swap_b(b: list[int], i: int, k: int) -> None:
    b[i], b[k] = b[k], b[i]


def write_b(b: list[int], n: int) -> None:
    j = 0
    result = []
    for i in range(n):
        if b[i] == 0:
            j += 1
        else:
            result.append(ALPHABET[j])
    print("".join(result))


def main() -> None:
    n1, m = map(int, input().split())
    n = n1 - 1 + m
    b = [0] * n
    for i in range(n1 - 1):
        b[i] = 0
    for i in range(n1 - 1, n1 - 1 + m):
        b[i] = 1
    write_b(b, n)
    while True:
        i = n - 1
        while i > 0 and b[i - 1] >= b[i]:
            i -= 1
        if i == 0:
            break
        i -= 1  # convert to 0-based position
        k = i
        for j in range(i + 1, n):
            if b[j] > b[i]:
                k = j
        swap_b(b, i, k)
        # reverse the suffix after position i
        left = i + 1
        right = n - 1
        while left < right:
            swap_b(b, left, right)
            left += 1
            right -= 1
        write_b(b, n)


main()

Explanations for the program.

  1. The initial permutation is formed sequentially from N1-1 zeros and M ones.

  2. In the permutation output function write_b, modifications were made according to the design (zeros are separators, ones are symbols). If the current element of list B equals 0, then the next symbol "becomes active." If the current element of list B equals 1, then the current active symbol is printed to the screen.

108. CHAPTER 3 Additional Information

The previous chapter was devoted to examining fundamental algorithms that cover a wide range of practical needs and competition problems, including: queue and stack, recursion, recurrence relations and dynamic programming, graphs, and generation of combinatorial objects. This chapter collects less significant facts and algorithms when considered from the perspective of the facts and algorithms of the previous chapter. Nevertheless, practice shows that the information gathered in this chapter is also very useful when solving various problems on the corresponding topics: "analytic geometry on the plane" and "some facts from number theory." In addition, problems are often "combined," meaning that solving such problems requires knowledge from not only the previous chapter but also this one.

109. 3.1. Analytic Geometry on the Plane

A huge number of programming problems belong to the class of "geometry on the plane" problems. To solve these problems, it is very useful to know certain facts and formulas from the course of analytic geometry, which are presented in this section.

109.1. Point, Line, Area

A point A on the plane in the Cartesian coordinate system is defined by its coordinates along the OX and OY axes: A(x1, y1).

The distance D between two points A(x1,y1) and B(x2, y2) is determined by the formula:

\(D = √((x1 - x2)² + (y1 - y2)²)\).

In Python, this is written as follows:

import math

d = math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)

In all problems (unless stated otherwise), the coordinates of points are real numbers (type float). Accordingly, the distance between points is always a real number.

110. Midpoint Coordinates of a Segment

The midpoint coordinates \((x, y)\) of a segment with endpoints at \((x1, y1)\) and \((x2, y2)\) are determined by the following formulas:

x = (x1 + x2) / 2,

y = (y1 + y2) / 2.

111. Straight Line on the Plane. Equation of a Line

A straight line on the plane is uniquely determined by any two points lying on that line. However, such a representation of a line (effectively as a segment) is inconvenient for solving problems.

There is an alternative way to define a line on the plane — using the equation of a line. That is, for any line there exists an equation with two variables \(x\) and \(y\) of the following form:

Ax + By + C = 0.

This equation becomes an identity when the coordinates of any point belonging to the given line are substituted for \(x\) and \(y\) (\(A\), \(B\), and \(C\) are integer or real number constants). This method of defining a line is the one used when solving problems.

112. Equation of a Line Passing Through Two Given Points

As mentioned earlier, a line is uniquely determined by any two points lying on it. Therefore, knowing the coordinates of these points, we can always construct the equation of the line.

Let the line pass through points with coordinates \((x1, y1)\) and \((x2, y2)\). Then the coefficients in the equation of the line are determined by the following formulas:

A = y2 − y1,

B = x1 − x2,

C = y1 * (x2 − x1) − x1 * (y2 − y1).

113. Distance from a Point to a Line

The distance \(d\) from a point \((x1, y1)\) to a line \(Ax + By + C = 0\) is determined by the formula:

d = |Ax1 + By1 + C| / √(A² + B²).

In Python, this is written as follows:

import math

d = abs(a * x1 + b * y1 + c) / math.sqrt(a ** 2 + b ** 2)

114. Finding the Intersection Point of Two Lines

Very often when solving problems, it is necessary to find the intersection point of two lines.

Let two lines be given by their equations:

\(A1x + B1y + C1 = 0, A2x + B2y + C2 = 0.\)

Then to find the intersection point of these lines, one must solve the system consisting of these two equations. For this, one can use Cramer’s theorem. We will limit ourselves to the conclusions from this theorem for our system of equations.

Let us introduce auxiliary quantities \(d, dx, dy\). Then the solution of the system is found by the formulas:

\(d = A1 * B2 – B1 * A2,\)

\(dx = – C1 * B2 + B1 * C2,\)

\(dy = – A1 * C2 + C1 * A2.\)

If \(d = 0\), then the lines are parallel (if \(C1 * A2 ≠ C2 * A1\)) or coincide (if \(C1 * A2 = C2 * A1\)). Otherwise (\(d ≠ 0\)), the lines intersect and the coordinates of the intersection point \((x, y)\) are:

\(x = dx / d,\)

\(y = dy / d.\)

115. Perpendicular to a Line Passing Through a Given Point

Let a line \(Ax + By + C = 0\) be given. The coefficients in the equation of the line perpendicular to the given one and passing through point \((x1, y1)\) are determined by the following formulas:

\(A1 = – B,\)

\(B1 = A,\)

\(C1 = Bx – Ay.\)

116. Constructing the Circumscribed Circle of a Triangle

The center of the circumscribed circle of a triangle lies at the intersection of the perpendicular bisectors of its sides. Therefore, the sequence of steps for constructing the circumscribed circle should be as follows.

  1. Find the midpoints of any two sides of the triangle.

  2. Construct the equations of the lines on which these sides lie.

  3. Construct the equations of the lines perpendicular to these sides and passing through their midpoints.

117. Computing Areas of Plane Figures

The area S of a triangle with side lengths \(a, b\), and \(c\) is conveniently determined using Heron’s formula:

\(S = √[p(p-a)(p-b)(p-c)\),]

where \(p = (a + b + c) / 2\) is the semi-perimeter.

117.1. Area of a Circle with Radius R

The area S of a circle with radius \(R\) is computed by the formula:

\(S = πR².\)

Since Python’s math module provides the constant math.pi which returns the value of the number \(π = 3.141592653589793238\)5, the computation of the area of a circle is written as follows:

import math

s = math.pi * r ** 2

The area of an arbitrary plane polygon (not necessarily convex) with vertices at points \((x1, y1), ..., (xn, yn)\) is determined by the formula:

\(S = |Σ (xᵢ - xᵢ₊₁)(yᵢ₊₁ + yᵢ) / 2|\) from \(i=1\) to \(n\).

In this case, \(x_{n+1} = x1, y_{n+1} = y1\). In Python, this can be written as follows:

s = 0.0
for i in range(n):
    next_i = (i + 1) % n
    s += (x[i] - x[next_i]) * (y[i] + y[next_i]) / 2
s = abs(s)

118. Point Belonging to a Figure

This section is devoted to algorithms that answer the question of whether a point, given by its coordinates, is inside or on the boundary of a figure defined by the coordinates of the points that determine the given figure.

118.1. Point Belonging to a Triangle

Let a triangle ABC and a point M be given. Point M belongs to the region bounded by triangle ABC if the following condition holds:

\(S_{ABC} = S_{ABM} + S_{ACM} + S_{BCM}.\)

That is, the area of triangle ABC equals the sum of the areas of three triangles with bases being the sides of the given triangle and the vertex at the given point M.

Here and everywhere when comparing real numbers (floats), one must account for computational error.

Taking error into account, the condition is rewritten as:

|SABC − SABM − SACM − SBCM| < Pogr,

where Pogr is the maximum error value (for example, 0.001).

119. Point Belonging to a Segment

Point C belongs to segment AB if for the lengths of segments AB, AC, and CB the following equality holds:

AB = AC + CB

or taking error into account:

|AB − AC − CB| < Pogr.

120. Point Belonging to a Rectangle

Let a rectangle with sides parallel to the coordinate axes be given by the coordinates of its lower-left (x1, y1) and upper-right (x2, y2) points. Then the condition for a point with coordinates (x, y) belonging to this rectangle is written as follows:

x1 ≤ x ≤ x2 and y1 ≤ y ≤ y2.

The condition for a point being inside the rectangle is written the same way, only the inequalities in this case are strict.

121. Angle at Which a Segment is Seen from a Point

The algorithm for finding the angle at which segment BC is seen from point A is left for the reader to devise independently.

122. Point Belonging to the Interior of a Polygon

To check whether point M belongs to the interior of an arbitrary (not necessarily convex) polygon P with vertex coordinates (x1, y1), …​, (xN, yN), the following algorithm can be used: the sum of angles at which the edges of the polygon are seen from point M is computed.

IF none of the angles in absolute value equals Pi and
     point M does not coincide with any vertex of polygon P.
THEN IF | SUM OF ANGLES | > Pogr

122.1. Minimum Convex Hull

This section is devoted to introducing the concept of the minimum convex hull and analyzing the algorithm for constructing it.

122.2. Non-Self-Intersecting Polyline

A polyline is called non-self-intersecting if no two of its edges intersect (except for intersections of adjacent edges at endpoints, including the first and last).

Two segments intersect if the lines on which they lie intersect and the intersection point belongs to both segments, or one of the endpoints of one segment belongs to the other segment.

The latter condition is necessary here for the case when the segments lie on the same line and overlap. In this case, the lines do not intersect, but the polyline should be considered self-intersecting.

122.3. Convexity of a Polygon

A polygon is convex if all its vertices lie on the same side of any of its sides.

To determine whether two points lie on the same side of a line \(Ax + By + C = 0\), the following rule is used: two points lie on opposite sides of a given line if and only if substituting the coordinates of these points into the equation of the line yields values of opposite signs.

122.4. Constructing the Minimum Convex Hull of a Set of Points

One of the algorithms for constructing the minimum convex hull of a set of points on the plane is as follows.

  1. Find the leftmost point of the set and take it as the first vertex of the hull.

  2. As the next vertex of the hull, choose the point of the set for which the segment formed by this point and the previous vertex of the hull is seen from the current last vertex of the hull at the maximum angle. (For the first vertex of the hull, the point with coordinates \((x + 1, y)\), where \((x, y)\) are the coordinates of the first vertex, should be taken as the previous one.)

  3. Step 2 is repeated until the next found vertex turns out to be the first vertex of the hull.

123. Basic Relations in a Triangle

This section presents formulas relating the side lengths and angle values of a triangle. The solution of many practical and competition problems is based on these formulas.

123.1. Basic Relations in a Right Triangle

Let a right triangle be given with legs (sides adjacent to the right angle) \(a\) and \(b\) and hypotenuse (side opposite the right angle) \(c\). Let us introduce the notation:

  • \(A1\) — the angle opposite leg \(a\);

  • \(B1\) — the angle opposite leg \(b\).

Then the following relations hold:

\(c² = a² + b²\) (Pythagorean theorem);

\(sin(A1) = a/c, cos(A1) = b/c;\)

\(sin(B1) = b/c, cos(B1) = a/c;\)

\(tg(A1) = a/b, tg(B1) = b/a.\)

Note that Python’s math module provides math.sin(x) and math.cos(x) functions, and for the tangent you can use math.tan(x). You can also compute it as:

\(tan(x) = sin(x)/cos(x).\)

Using the above relations, it is easy to:

  1. find the third side, knowing any two sides of a right triangle;

  2. find the other two sides and an angle, knowing one side and an angle.

How to find an acute angle knowing two sides of a right triangle? To do this, one must first find the tangent of the desired angle (variable t), and then use the math.atan function:

import math

a1 = math.atan(t)

Note that both the argument \(x\) in the functions math.sin(x), math.cos(x), and the result \(y\) in the function y = math.atan(x) are angles measured in radians, not degrees.

123.2. Basic Relations in an Arbitrary Triangle

Let an arbitrary triangle with sides \(a, b, c\) be given.

Let us introduce the notation:

  • \(A1\) — the angle opposite side \(a\);

  • \(B1\) — the angle opposite side \(b\);

  • \(C1\) — the angle opposite side \(c\).

Then the following relations hold:

\(a / sin(A1) = b / sin(B1) = c / sin(C1)\) (law of sines),

\(c² = a² + b² − 2ab · cos(C1)\) (law of cosines).

Obviously, knowing three sides, or two sides and the angle between them, or one side and two adjacent angles in a triangle, and using these formulas, it is easy to find the unknown components (sides and angles) of the triangle.

124. Problems for Independent Solution

These problems for beginners are adapted from [8] for the distance learning system http://dl.gsu.unibel.by.

Conventions for outputting computation results: all output data that are real numbers are displayed with a precision of two decimal places. Problem statements:

  • Input: side lengths of the triangle a, b, c.

  • Output: answer as "YES/NO."

  • Input: side lengths of the quadrilateral a, b, c, d.

  • Output: answer as "YES/NO."

  • Input: coordinates of point A, coordinates of the center of the circle and its radius.

  • Output: position of the point (on the circle/inside the circle/outside the circle).

  • Input from file (line by line):

    1. coordinates of the center of the first circle and its radius;

    2. coordinates of the center of the second circle and its radius.

222

  • Output: relative position of the circles (inside/internally tangent/externally tangent/outside/intersecting/coinciding).

    • Input from file (line by line):

      1. \(R\);

      2. \(X0 Y0\);

      3. \(X1 Y1\);

      4. \(X2 Y2\).

    • Output: relative position of the circle and line (intersecting/not intersecting/tangent).

    • Input: \(X0, Y0, R\).

    • Output: number of points.

    • Input from file (line by line):

      1. \(X1 Y1 R1\);

      2. \(X2 Y2 R2\).

    • Output: coordinates of the intersection points in ascending order of \(X\) (if \(X\) values are equal, then in ascending order of \(Y\)). Each point on a separate line.

    • Input from file (line by line):

      1. \(X1 Y1\);

      2. \(A B C\).

    • Output: coordinates of the point.

    • Input from file (line by line):

      1. coordinates of the first point;

      2. coordinates of the second point;

      3. coefficients of the equation of the given line.

    • Output: coordinates of the desired point.

      1. Given three points with coordinates \((x1, y1)\), \((x2, y2)\), and \((x3, y3)\), which are vertices of a certain rectangle with sides parallel to the coordinate axes. Find the coordinates of the fourth point.

  • Input: coordinates of the points in traversal order.

  • Output: answer as "convex/non-convex."

  • Input: coordinates of the points.

  • Output: as "YES/NO" for each of the figures.

  • Input from file (line by line): 1) \(x1 y1\);

2) \(x2 y2\).

  • Output: points in ascending order of \(X\), each point on a separate line. If \(X\) values are equal, then sort by \(Y\).

  • Input from file (line by line): 1) \(x1 y1\);

2) \(x2 y2\);

3) \(x3 y3\).

  • Output: as "YES/NO."

  • Input: coordinates of the triangle’s vertices in traversal order.

  • Output: coordinates of the intersection point of the triangle’s medians.

  • Input from file (line by line): 1) \(x1 y1\);

2) \(x2 y2\);

3) \(x3 y3\).

  • Output: lengths of the altitudes in ascending order (each altitude on a separate line).

  1. Determine the coefficients of the equation of a line parallel to the given line defined by the equation \(Ax + Bx + C = 0\) and passing through the point with coordinates \((x0, y0)\).

  • Input: coordinates of the point through which the line parallel to the given one passes.

  • Output: coefficients of the equation of the line parallel to the given one.

    1. Determine the coefficients of the equation of a line perpendicular to the given line defined by the equation \(Ax + Bx + C = 0\) and passing through the point with coordinates (\(x0, y0\)).

  • Input from file (line by line):

    1. coordinates of the point;

    2. coefficients of the equation of the line.

  • Output: coefficients of the equation of the line perpendicular to the given one.

125. 3.2. Some Facts from Number Theory

There is a significant class of programming problems whose straightforward solution assumes the student’s knowledge of the following topics from number theory:

  • properties of the remainder of dividing number \(X\) by number \(Y\) (X % Y);

  • positional number systems and fast polynomial evaluation;

  • the exponent with which a given prime \(P\) enters the product \(N!\);

  • properties of GCD (greatest common divisor).

This section is devoted to presenting the corresponding facts and examples of their application to solving specific problems.

125.1. Properties of X % Y

Let \(X\) and \(Y\) be integers. Dividing \(X\) by \(Y\) with integer division, we obtain two numbers — the quotient (denoted q) and the remainder (r). In Python, the corresponding operations for finding q and r are:

q = x // y
r = x % y

Let us consider some properties of the remainder of division.

  1. \((A + B) mod Y = (A mod Y + B mod Y) mod Y\).

That is, to find the remainder of dividing the sum of two numbers \(A\) and \(B\) by number \(Y\), it suffices to find the remainders of dividing numbers \(A\) and \(B\) by \(Y\), add the resulting numbers, and then take the remainder of dividing the computed sum by \(Y\).

Suppose we need to compute the remainder of dividing the number 197 by 5. The traditional method involves long division of 197 by 5:

  197 | 5
− 15  | 39
  ——
   47
−  45
  ——
    2

It turns out that the quotient is 39 and the remainder is 2.

But no one asked us for the quotient. This means we most likely performed unnecessary work. Let us count how many operations were performed in total:

  1. 3 * 5 = 15;

  2. 19 – 15 = 4;

  3. 9 * 5 = 45;

  4. 47 – 45 = 2.

In fact, we performed even more operations. After all, to find the digits 3 and 9 of the quotient, we had to search by trial for a digit whose product with 5 would be the maximum but less than 19 — in the first case (when we were selecting the digit 3) and less than 47 — in the second case (when we were selecting the digit 9).

Now let us try to solve the same problem using property 1.

197 mod 5 = (190 + 7) mod 5 =

190 mod 5) + (7 mod 5 mod 5 =

(0 + 2) mod 5 = 2.

Obviously, this method is much simpler (especially in the more general case — with an arbitrary divisor).

  1. (A * B) mod Y = A mod Y) * (B mod Y mod Y.

That is, to find the remainder of dividing the product of two numbers A and B by number Y, it suffices to find the remainders of dividing numbers A and B by Y, multiply the resulting numbers, and then take the remainder of dividing the computed product by Y.

Suppose we need to compute the remainder of dividing the number 100 by 7. The traditional method involves long division of 100 by 7:

   100│ 7
     7 │14
    30
    28
     2

It turns out that the quotient is 14 and the remainder is 2.

Now let us try to find the same result using property 2:

100 mod 7 = (10 * 10) mod 7 =

10 mod 7) * (10 mod 7 mod 7 = (3 * 3) mod 7 =

9 mod 7 = 2.

Obviously, this method is significantly simpler.

126. Positional Number Systems and Fast Polynomial Evaluation

Consider a polynomial:

a₀ + a₁ * x + …​ + aₙ₋₁ * xn-1 + aₙ * xn.

Suppose we are given a list of polynomial coefficients a[0], a[1], …​, a[n-1], a[n] and some number x for which we need to evaluate this polynomial. Then the program can be written, for example, as follows:

s = 0
y = 1
for k in range(n + 1):
    s = s + a[k] * y
    y = y * x

Python does have the ** exponentiation operator, but computing powers incrementally by multiplication (as in y = y * x) is a common technique in competitive programming.

In mathematics, the summation sign is used to abbreviate polynomial notation:

n
expression.
k=0

This reads as follows: the sum of the expressions following the summation sign for k from 0 to n.

Using this notation, the polynomial takes the form:

n
∑ A[k] · xk.
k=0

And the rules for computing the modulus of a polynomial, following from properties 1 and 2, can be written as follows:

  1. n
    ∑ A[k] · xk mod M = (n
    ∑ A[k](xk mod M)) mod M.
    k=0 k=0

  2. xk mod M = (x(xk-1 mod M)) mod M (for x < M).

  3. (P + Q) mod M = (P mod M + Q) mod M (for 0 < M).

Now let us return to the polynomial evaluation program:

s = 0
y = 1
for k in range(n + 1):
    s = s + a[k] * y
    y = y * x

Some readers have probably already noticed that one could also write it as:

s = a[0]
y = x
for k in range(1, n + 1):
    s = s + a[k] * y
    y = y * x

Then there would be one fewer addition and multiplication operation each — n and 2n respectively.

But can we write this program so as to further reduce the number of operations (additions and/or multiplications)?

So, suppose we have the polynomial:

\(A = an + a1x + a2x2 + ... + aNx2 (0 ≤ ak < N)\),

which represents the number A in a positional number system, where \(ak\) are the digits and \(x\) is the base of the number system.

Denoting \(ak\) as a[k] and reversing the order of terms in the polynomial, we get:

\(A = a[N\) * xn + …​ + a[k] * x2 + …​ + a[0]].

Let us introduce the notation:

  • s[n] = a[n]

  • s[n-1] = s[n] * x + a[n-1] = a[n] * x + a[n-1]

  • s[n-2] = s[n-1] * x + a[n-2] = a[n] * x**2 + a[n-1] * x + a[n-2]

  • …​

  • s[0] = s[1] * x + a[0] = a[n] * xn + a[n-1] * x(n-1) + …​ + a[0]

Now the polynomial evaluation algorithm looks as follows:

s[n] = a[n]                                    ! fast polynomial
For i from n to 1 s[i-1] = s[i]*x + a[i-1]    ! evaluation

And the corresponding program fragment:

s = [0] * (n + 1)
s[n] = a[n]
for i in range(n, 0, -1):
    s[i - 1] = s[i] * x + a[i - 1]

Since each time we only need the one previous value of s to compute the next one, this program fragment can be simplified:

s = a[n]
for i in range(n, 0, -1):
    s = s * x + a[i - 1]

Now we can finally write the complete program text (Listing 3.1), which, given the argument \(x\) and polynomial coefficients a[i], evaluates the polynomial itself.

Listing 3.1. Polynomial evaluation program
# poly3
def main() -> None:
    x = int(input())
    n = int(input())
    a: list[int] = []
    for i in range(n + 1):
        a.append(int(input()))
    s = a[n]
    for i in range(n, 0, -1):
        s = s * x + a[i - 1]
    print(s)


main()
Listing 3.1 (continued)

What makes this program better than the one written earlier, which had n additions and 2n multiplications? It has exactly n additions and n multiplications, meaning the number of multiplications has been halved. This means the program will require half the execution time. There are problems for which this is very important.

127. The Polynomial Remainder Problem

A number is given in the K-ary number system:

\(anan-1...a0 (K ≤ 36)\).

Find the remainder of dividing it by \(m\). The numbers \(K\), \(n\), and \(M\) (as well as the remainder of division by \(m\)) are represented in the decimal number system.

The idea of the solution is to use the properties of the % operator and the fast polynomial evaluation algorithm.

If K is the base of the number system, then the number \(anan-1...a0\) should be evaluated as follows:

\(S = a[n\)Kn + a[n-1]Kn-1 + …​ + a[0]].

And the corresponding algorithm looks like this:

Input N, A[0..N], K, M
S = a[n] % M                       Quickly compute the remainder
For i from n to 1                   of dividing the polynomial by M,
  S = (S*K+ a[i-1]) % M            using % properties along the way
Output S
Listing 3.2. Solution to the polynomial remainder problem
# poly4
def main() -> None:
    n = int(input())
    a: list[int] = []
    for i in range(n + 1):
        a.append(int(input()))
    k, m = map(int, input().split())
    s = a[n] % m
    for i in range(n, 0, -1):
        s = (s * k + a[i - 1]) % m
    print(s)


main()

128. The Divisibility by 15 Problem

A number is entered in its binary representation (the length of the number does not exceed 10,000 binary digits). Determine whether the number is divisible by 15.

We will reason in the hexadecimal number system. It is easy to convert to it by replacing tetrads in the entered binary number with the corresponding hexadecimal digits (from right to left).

Similarly to the divisibility rule for 9 in the decimal number system, if the sum of the hexadecimal digits is divisible by 15, then the number is also divisible by 15.

Here is the proof:

∑k=0n a[k]16k = a[0] + ∑k=1n 16a[k]16k-1 = ∑k=0n a[k] + ∑k=1n 15a[k]16k-1.

As a result of the transformations, we obtained two summands. The second one is divisible by 15, since each element of the sum has a factor of 15. Thus, the original number is divisible by 15 if the sum of the digits of number A is divisible by 15.

The algorithm for solving the problem can be written as follows:

Input n, a[0..n]                    a[i] - coefficient at 2i
K = (n % 4)+1                      Number of hexadecimal digits in the original number
Convert A to H[0..K]               Build the list of hex digits (numbers 0 - 15)
S = H[0]                           Store the least significant digit in S
For i from 1 to K                  For all remaining digits
  S = ( S + H[i] ) % 15            add the digit to S and take the remainder
If S==0                            If the sum of digits is divisible by 15,
  then divisible                   then the number is divisible
  else not divisible               otherwise it is not

CONVERTING A -> H
For i from n+1 to 4*K-1 A[i]=0     Pad the most significant tetrad with zeros
For i from 0 to K-1                Form a hex digit H[i] from each tetrad
  m = 4*i
  H[i] = A[m] + A[m+1]*2 + A[m+2]*4 + A[m+3]*8
Listing 3.3. Solution to the divisibility by 15 problem
# poly5
def main() -> None:
    n = int(input())
    a: list[int] = []
    for i in range(n + 1):
        a.append(int(input()))
    k = (n % 4) + 1
    # Pad with zeros to fill the last tetrad
    for i in range(n + 1, 4 * k):
        a.append(0)
    # Build hex digits from tetrads
    h: list[int] = []
    for i in range(k):
        m = 4 * i
        h.append(a[m] + a[m + 1] * 2 + a[m + 2] * 4 + a[m + 3] * 8)
    s = h[0] % 15
    for i in range(1, k):
        s = (s + h[i]) % 15
    if s == 0:
        print("Yes")
    else:
        print("No")


main()

129. Formula for the Occurrence of a Prime Factor in N-Factorial

Consider the number \(N!\). Obviously, it can be represented as:

\(N! = 2k2 * 3k3 * 5k5 * 7k7 * ... * PkP\),

where the exponent \(kP\) indicates the count of numbers \(P\) (prime) in the product \(N!\). This exponent equals:

\(kP = [N/P\) + [N/P2] + …​ + [N/Pk]] (as long as \(N > Pk\)),

where \([Y\)] denotes the integer part of the number \(Y\).

Explanation of the formula’s meaning:

  • every \(P\)-th number is divisible by \(P\);

  • every \(P2\)-th number is divisible by \(P2\);

  • every \(P3\)-th number is divisible by \(P3\);

  • and so on, for all \(k\) as long as \(N > Pk\).

Consider the following problem.

A number N is entered. Find how many trailing zeros are in the number \(N! = 1 * 2 * 3 * ... * N\).

Solution idea: write N! as \(N! = 2k2 * 3k3 * 5k5 * 7k7 * ...\) and count k2 and k5 (since a number represented as a product of primes can end in zero(s) only if 2 and 5 are among those primes). Obviously, K (the number of zeros) = min(k2, k5) = k5.

Algorithm:

Input N
K = N // 5; D = K
While (D >= 5)                  While there is something to add,
  D = D // 5, K = K + D        compute and add
Listing 3.4. Solution to the problem of prime factor occurrence in N!
# pfact
def main() -> None:
    n = int(input())
    k = n // 5
    d = k
    while d >= 5:
        d = d // 5
        k = k + d
    print(k)


main()

130. Properties of the Greatest Common Divisor

By definition, the greatest common divisor (GCD) of two numbers \(a\) and \(b\) is the largest of the numbers that are divisors of both number \(a\) and number \(b\) simultaneously.

For the numbers 12 and 18, the greatest common divisor is 6. Indeed:

  • divisors of 12: 1, 2, 3, 4, 6, 12;

  • divisors of 18: 1, 2, 3, 4, 6, 9, 18.

In the school mathematics course, the following algorithm was proposed for finding the greatest common divisor: find all divisors of each number and select the largest of the common divisors.

In computer science, various accelerated methods for finding the GCD of two numbers \(a\) and \(b\) are used, based on the following properties:

  1. GCD = {GCD(\(a − b,b\)), if \(a > b\),

\(a\),]

GCD(\(a,b − a\)), if \(b > a\).]

+ 2. GCD = {2·GCD(\(a/2, b/2\)), if \(a\) and \(b\) are even;

GCD(\(a/2, b\)), if \(a\) is even, \(b\) is odd;]

GCD(\(a, b/2\)), if \(a\) is odd, \(b\) is even;]

GCD(\(a, |b−a|/2\)), if \(a\) and \(b\) are odd.]

+ 3. GCD(\(a,b\)) = GCD(\(a − b,b\)) = GCD(\(a + b,b\)), if \(a > b\).

Listing 3.5. Finding the GCD
import math


def gcd(a: int, b: int) -> int:
    """Find GCD using subtraction-based Euclidean algorithm."""
    if a > b:
        return gcd(a - b, b)
    elif a < b:
        return gcd(a, b - a)
    else:
        return a


def main() -> None:
    a, b = map(int, input().split())
    # Manual implementation:
    print(gcd(a, b))
    # Python also provides a built-in: math.gcd(a, b)


main()

For example, the program above (see Listing 3.5) finds the GCD using the recursive function gcd(a, b). Python also provides a built-in math.gcd() function that you can use directly.

131. Bibliography

  1. Valvachev A. N., Krisevich V. S. Programming in the PASCAL Language for ES Personal Computers. — Minsk: Vyssh. shk., 1989. 223 pp. (Note: examples in this edition have been adapted to Python.)

  2. Emelichev V. A., Melnikov O. I., Sarvanov V. I., Tyshkevich R. I. Lectures on Graph Theory. — Moscow: Nauka, 1990. 384 pp.

  3. Zuev E. A. Programming in TURBO PASCAL 6.0, 7.0. — Moscow: Radio i svyaz, 1993. 384 pp. (Note: examples in this edition have been adapted to Python.)

  4. Kasyanov V. N., Sabelfeld V. K. Collection of Problems for Computer Practicum. — Moscow: Nauka, 1986. 272 pp.

  5. Kiryukhin V. M., Lapunov A. V., Okulov S. M. Problems in Informatics: International Olympiads 1989–1996. — Moscow: AVF, 1996. 272 pp.

  6. Knuth D. The Art of Computer Programming. Vol. 1: Fundamental Algorithms. — Moscow: Mir, 1976. 735 pp.

  7. Knuth D. The Art of Computer Programming. Vol. 2: Seminumerical Algorithms. 3rd ed. — Moscow: Williams Publishing House, 2000. 828 pp.

  8. Knuth D. The Art of Computer Programming. Vol. 3: Sorting and Searching. — Moscow: Mir, 1978. 844 pp.

  9. Cormen T., Leiserson C., Rivest R. Introduction to Algorithms. — Moscow: MTsNMO, 1999. 960 pp.

  10. Kotov V. M., Volkov I. A., Kharitonovich A. I. Methods of Algorithmization: Textbook for 8th Grade of Secondary School with Advanced Study of Informatics. — Minsk: Narodnaya asveta, 1996. 127 pp.

  11. Kotov V. M., Volkov I. A., Lapo A. I. Methods of Algorithmization: Textbook for 9th Grade of Secondary School with Advanced Study of Informatics. — Minsk: Narodnaya asveta, 1997. 160 pp.

  12. Kotov V. M., Melnikov O. I. Methods of Algorithmization: Textbook for 10th–11th Grades of Secondary School with Advanced Study of Informatics. — Minsk: Narodnaya asveta, 2000. 221 pp.

  13. Christofides N. Graph Theory: An Algorithmic Approach. — Moscow: Mir, 1988. 250 pp.

132. Bibliography

  1. Lipsky V. Combinatorics for Programmers. – Moscow: Mir, 1988. 368 pp.

  2. Novikov F. A. Discrete Mathematics for Programmers. – St. Petersburg: Piter, 2000. 304 pp.

  3. Ovsyannikov A. P., Ovsyannikova T. V., Marchenko A. P., Prokhorov R. V. Selected Problems from Informatics Olympiads. – Moscow: Trovant, 1997. 95 pp.

  4. Okulov S. M. Lecture Notes on Informatics (Algorithms on Graphs): Textbook. – Kirov: Vyatka State Pedagogical University, 1996. 117 pp.

  5. Shen A. Programming: Theorems and Problems. – Moscow: MTsNMO, 1995. 264 pp.

133. Alphabetical Index

134. A

  • Dijkstra, 157

  • Euclid’s, 93

  • on graphs, 155

  • depth-first search, 171

  • breadth-first search, 195

    • minimum convex hull, 219

    • circumscribed circle of a triangle, 216

  • Floyd’s, 157

135. B

  • byte, 45

  • bit, 44

  • boolean data type (bool in Python), 51

136. C

  • convex polygon, 219

  • context-sensitive help invocation, 54

    • of segment midpoint, 215

      • on the plane, 214

      • of intersection of two lines, 216

137. D

  • two-dimensional list (2D array in Python), 23

    • on the plane, 214

  • dynamic memory, 86

  • dominant set, 185

  • graph arc, 156

138. E

  • equation of a line,

passing through two points, 215 * equation of a line

on the plane, 215

139. F

  • factorial, 207

    • for prime factor occurrence in N!, 230

    • Heron’s, 217

  • functions in Python, 54

  • type conversion functions, 54

    • slicing (replaces Copy), 54

    • len() (replaces Length), 54

    • str.find() (replaces Pos), 54

140. G

  • set of all subsets, 202

  • permutations, 204

  • arrangements, 208

  • combinations, 207

    • global variables, 84

    • greatest common divisor (math.gcd()), 230

    • graph edge, 156

141. I

  • of diagonals of a two-dimensional list, 25

  • of column of a two-dimensional list, 24

  • of row of a two-dimensional list, 24

    • initialization of a constant list, 82

    • input of a two-dimensional list, 26

    • integer data type (int in Python), 48

142. L

  • logical variables and lists (bool), 51

  • local variables, 83

143. M

  • minor automation of testing, 85

  • adjacency matrix, 156

  • set of all subsets, 203

  • maximum number of identical consecutive

  elements, 31

144. N

  • number of combinations, 208

145. O

  • one-dimensional list (array in Python), 10, 11

  • output of a two-dimensional list, 26

146. P

  • Pascal’s triangle, 95

  • perpendicular to a line passing

  through a point, 216 * plan for developing algorithms

  and programs, 29 * point belonging to

  a segment, 218

  a rectangle, 218

  a triangle, 217

  a polygon, 218 * positional number systems, 47 (hexadecimal)

  • del / list slicing (replaces Delete), 54

  • list insert() (replaces Insert), 54

    • functions in Python, 54

  • fast polynomial evaluation, 227

  • replacing list elements

         with their opposites, 27
    ** greatest common divisor, 231
    ** polynomial remainder, 228
    ** summing elements of a one-dimensional +
        list, 25

147. Q

  • queue (collections.deque), 70

148. R

  • float data type (float in Python), 49

  • dataclass / dictionary (replaces record), 65

    • restoring the optimal path, 154

    • on strings, 154

    • with two parameters, 126

    • with one parameter, 118

    • with three or more parameters, 139

  • reduction of memory usage, 154

  • redirection of standard

  input-output

  in a program, 56

  at launch, 55 * type hints in Python, 51

149. S

  • searching for elements with a given

  property, 17, 20 * searching for the maximum element, 17, 19 * searching for the minimum element, 17, 19 * shortest distances on graphs, 157 * simplest algorithms on a one-dimensional

  list, 17 * simplest techniques for working in the

  Python environment, 41 * sorting elements of a one-dimensional

  list, 21 * source (in a network), 110 * sink (in a network), 110 * stack, 70 * string data type (str in Python), 53 * strongly connected components, 185 * character data type (str of length 1), 52 * character-to-number conversion (ord()), 82

150. T

  • test, 29

  • text files, 55

  • transferring algorithms to two-dimensional

  lists, 25 * translating an algorithm

  into a Python program, 40 * technology of program development, 57

151. W

  • weighted graph, 157

152. Index (continued)

area

  of a circle, 217

  of an arbitrary plane

    polygon, 217

  of a triangle, 217

combinations with repetitions, 212

counting elements with a given

  property, 17

debugging recursive functions, 112

distance

  from a point to a line, 215

forming all possible

  sums, 153

manual trace, 33

data type

  boolean (bool), 51

  large integer (Python int is arbitrary precision), 50

  dataclass / dictionary (replaces record), 65

  string (str), 53

permutations

  with repetitions, 210

problem

  outputting all possible ways, 105

  outputting one of the possible ways, 106

  number of valid bracket expressions, 103

  number of representations of a number as a sum of components, 102

  number of ways to form sum M from N numbers, 98

  about cutting pieces, 76

  about a set of triangles, 61

  about finding a line, 58

  about brackets, 78

  about the knight’s tour, 73

  about Fibonacci numbers, 116

  number of combinations of \(N\) choose \(M\), 96

  • text files, 55

  • test, 29

  • technology of program development, 57

    • boolean (bool), 51

    • large integer (Python int is arbitrary precision), 50

    • dataclass / dictionary (replaces record), 65

    • string (str), 53

  • Pascal’s triangle, 95

153. U

  • equation of a line,

passing through two points, 215 * equation of a line

on the plane, 215

154. V

  • factorial, 207

    • for prime factor occurrence in N!, 230

    • Heron’s, 217

  • functions in Python, 54

  • type conversion functions, 54

    • slicing (replaces Copy), 54

    • len() (replaces Length), 54

    • str.find() (replaces Pos), 54

155. W

  • integer data type (int in Python), 48

156. X

  • number of combinations, 208

157. Y

  • hexadecimal number

system, 47

Dolinsky Mikhail Semenovich

Algorithmization and Programming in Python: From Simple to Olympiad Problems

Textbook

Editor-in-Chief E. Stroganova

Head of Editorial Department A. Krivtsov

Project Manager A. Adamenko

Literary Editor M. Vdovina

Artist E. Dyachenko

Proofreaders A. Kelle-Pelle, D. Stuzhalin

Layout A. Kelle-Pelle

License ID No. 05784 dated 07.09.01.

Signed for printing 26.08.04. Format 70×100/16. Conventional printed sheets 19.35.

Print run 4000. Order 877

OOO "Piter Print," 194044, St. Petersburg, Bolshoy Sampsonievsky pr., 29a.

Tax benefit — All-Russian Product Classifier

OK 005-93, vol. 2; 95 3005 — educational literature.

Printed from camera-ready copy at OAO "Tekhnicheskaya kniga"

190005, St. Petersburg, Izmaylovsky pr., 29