Как прошел IT’s Tinkoff Solution Cup
Привет! Провели финал IT«s Tinkoff Solution Cup 22 апреля. Шесть треков, нестандартные задачи и призы. У нас получилось собрать офлайн много крутых разработчиков и близких по духу людей, порешать задачи и пообщаться с коллегами.
Рассказываем, как все прошло, и разбираем задачи разных треков.
Как готовили чемпионат
Идея чемпионата возникла, когда мы задумались над мероприятием для объединения сообщества. Хотелось чего-то фанового, практико-ориентированного и непохожего на классические олимпиады и контесты. Задумывали такой формат, чтобы участвовали мидлы и сеньоры без каких-то специфических навыков.
Соревнование готовили 60 человек, еще 150 подключали по разным задачам. Нам хотелось классного мероприятия — заявить о себе и воплотить креативные задумки. Многие организаторы до этого были участниками других чемпионатов и хотели провести что-то свое в новой роли.
Еще мотивировало общение с комьюнити: узнать людей из других компаний, обменяться новостями — кто какие продукты делает и какие технологии запускает. А финал проводили в Москве, и для кого-то мотивацией стало именно это — приехать и интересно провести время.
На отборочном туре запускали разные задания, в том числе немного алгоритмических задач — потому что их проще проверять автоматически, когда много участников. А в финале уделили внимание задачам, приближенным к реальности, — чтобы взглянуть на кейсы под интересным углом и применить результаты в работе.
Участникам было интересно проверить знания, логические способности и получить удовольствие от решения сложных задач. Мы организовали очные площадки с настольными играми, ИТ-квизами, подарками и общением.
Особенности Solution Cup:
Первый в истории Тинькофф чемпионат такого формата — восемь тысяч регистраций, четыре тысячи решенных задач и 700 человек на очных площадках в 14 городах.
Призовой фонд — 3,6 млн рублей на все треки.
Шесть треков — Mobile, Backend, Frontend, SRE, системный анализ и Data Engineering. Добавили редкие треки, потому что у нас в компании специалисты этих направлений активно растут.
Для системных аналитиков не так много всего проводится и сложно найти соревнования. Расскажем о том, что получилось, в отдельной статье.
В трек Backend включили пять языков: Java, Python, C#, Scala и Golang. Java — один из самых популярных в Тинькофф, а Scala и Go более редкие, но у нас есть для них задачи и активные команды разработчиков, которые развивают свои направления.
В SRE была командная работа: ребята создавали код под открытые лицензии Apache 2.0 — это значит, что решение можно использовать для своей работы дальше.
Рассказываем, чего ждали от участников и какие задачи решали на каждом треке.
Mobile
Для подготовки соревнований мы собрали команду из Android- и iOS-разработчиков с опытом в контестах и хакатонах, а также тех, кто хотел себя попробовать в новой роли. Мы брейнштормили и собирали идеи — от реализации хитрых анимаций до мини-игр. Выбирали те, что относятся к нашей экспертизе финтеха.
Так на отборочном этапе оказались задачи про валидацию номера карты с применением алгоритма Луна и расчет выдачи банкомата. Делали упор на то, что мы мобильные разработчики: на каждую платформу придумали список вопросов с разными уровнями сложности и задачу на поиск view.
Задача про банкомат. Есть банкомат, в котором хранятся купюры пяти номиналов: 20, 50, 100, 200 и 500 $. Изначально банкомат пуст. Клиент может использовать банкомат для внесения или снятия любой суммы.
При снятии банкомат отдает приоритет использованию банкнот большего номинала.
Например, если вы хотите снять 300 $ и есть две банкноты по 50 $, одна банкнота 100 $ и одна банкнота 200 $, то банкомат будет использовать банкноты по 100 и 200 $.
Но если вы попытаетесь снять 600 $, а есть три банкноты по 200 $ и одна банкнота 500 $, запрос на снятие средств будет отклонен, поскольку банкомат сначала попытается использовать 500 $, а затем не сможет использовать банкноты для выдачи оставшихся 100 $. Обратите внимание, что банкомату не разрешено использовать банкноты 200 $ вместо банкноты 500 $.
Для хранения состояния банкомата необходимо добавить поля, отвечающие за номиналы и количество банкнот заданного номинала.
private val denominations = intArrayOf(20, 50, 100, 200, 500)
private val banknotesVariants = denominations.size
private val currentAmount = LongArray(banknotesVariants)
private let denominations = [20, 50, 100, 200, 500]
private var balance = [0, 0, 0, 0, 0]
Функция пополнения банкомата — это цикл с заполнением количества банкнот соответствующего номинала:
fun deposit(banknotesCount: IntArray) {
for (i in 0..currentAmount.lastIndex) {
currentAmount[i] = currentAmount[i] + banknotesCount[i]
}
}
func deposit(_ banknotes: [Int]) {
for i in 0 ..< banknotes.count {
balance[i] = balance[i] + banknotes[i]
}
}
Функция списания более интересная. Поскольку банкомат обязан отдавать предпочтение купюрам наибольшего номинала, вот что нужно сделать для списания:
Пройтись от самых крупных до самых мелких купюр, поэтапно вычитая набранную сумму из требуемой к выдаче.
Если после прохода по номиналам не удалось собрать заказанную сумму, мы возвращаем массив, содержащий −1.
В случае выдачи нужно обновить состояние банкомата, так как количество купюр в нем изменилось.
Функция для списания средств будет выглядеть так:
fun withdraw(amount: Int): IntArray {
val toWithdraw = IntArray(banknotesVariants)
var remainingAmountToWithdraw = amount
for (i in toWithdraw.lastIndex downTo 0) {
val banknotesQuantity = min((remainingAmountToWithdraw / denominations[i]).toLong(), currentAmount[i]).toInt()
toWithdraw[i] = banknotesQuantity
remainingAmountToWithdraw -= banknotesQuantity * denominations[i]
if (remainingAmountToWithdraw <= 0) {
break
}
}
if (remainingAmountToWithdraw != 0) {
return intArrayOf(-1)
}
for (i in 0..currentAmount.lastIndex) {
currentAmount[i] = currentAmount[i] - toWithdraw[i]
}
return toWithdraw
}
func withdraw(_ amount: Int) -> [Int] {
var result = [0, 0, 0, 0, 0]
var remainingAmount = amount
for i in result.indices.reversed() {
let banknotesQuantity = min((remainingAmount / denominations[i]), balance[i])
result[i] = banknotesQuantity
remainingAmount -= banknotesQuantity * denominations[i]
if remainingAmount <= .zero {
break
}
}
if remainingAmount != .zero {
return [-1]
}
for i in 0 ..< balance.count {
balance[i] = balance[i] - result[i]
}
return result
}
Соберем все вместе, тогда итоговая реализация банкомата будет такой:
import java.util.Arrays
import kotlin.math.min
class ATM {
private val denominations = intArrayOf(20, 50, 100, 200, 500)
private val banknotesVariants = denominations.size
private val currentAmount = LongArray(banknotesVariants)
fun deposit(banknotesCount: IntArray) {
for (i in 0..currentAmount.lastIndex) {
currentAmount[i] = currentAmount[i] + banknotesCount[i]
}
}
fun withdraw(amount: Int): IntArray {
val toWithdraw = IntArray(banknotesVariants)
var remainingAmountToWithdraw = amount
for (i in toWithdraw.lastIndex downTo 0) {
val banknotesQuantity = min((remainingAmountToWithdraw / denominations[i]).toLong(), currentAmount[i]).toInt()
toWithdraw[i] = banknotesQuantity
remainingAmountToWithdraw -= banknotesQuantity * denominations[i]
if (remainingAmountToWithdraw <= 0) {
break
}
}
if (remainingAmountToWithdraw != 0) {
return intArrayOf(-1)
}
for (i in 0..currentAmount.lastIndex) {
currentAmount[i] = currentAmount[i] - toWithdraw[i]
}
return toWithdraw
}
}
class ATM {
private let denominations = [20, 50, 100, 200, 500]
private var balance = [0, 0, 0, 0, 0]
func deposit(_ banknotes: [Int]) {
for i in 0 ..< banknotes.count {
balance[i] = balance[i] + banknotes[i]
}
}
func withdraw(_ amount: Int) -> [Int] {
var result = [0, 0, 0, 0, 0]
var remainingAmount = amount
for i in result.indices.reversed() {
let banknotesQuantity = min((remainingAmount / denominations[i]), balance[i])
result[i] = banknotesQuantity
remainingAmount -= banknotesQuantity * denominations[i]
if remainingAmount <= .zero {
break
}
}
if remainingAmount != .zero {
return [-1]
}
for i in 0 ..< balance.count {
balance[i] = balance[i] - result[i]
}
return result
}
}
Backend
Во время соревнований большинство участников назвали интересной упрощенную задачу про нашу легендарную игру »5 букв». О ней и расскажем.
Нужно было реализовать метод для проверки решений, который принимает на вход две строки строгой длины в пять символов: загаданное слово и предположение игрока.
Мы упростили постановку: участникам не нужно проверять, существует ли слово, которое загадал игрок, осталось сравнить слова и вернуть ответ в виде массива из чисел.
Каждый элемент — это число, отвечающее за совпадения:
−1 — буква отсутствует;
0 — буква есть, но в другом месте;
1 — буква на месте.
Основная сложность крылась в пограничных кейсах. Например, при проверке повторяющихся букв нужно учитывать их количество: если в ответе «ТЕКСТ» есть только одна буква Е, а пользователь отправил слово «ЕГЕРЬ», то только одна буква Е должна быть помечена как 0, остальные — как −1.
Один из интересных тестов затрагивал три одинаковые буквы: АГАВА и ПАЛКА. Соответственно, ожидался результат 0, −1, −1, −1, 1:
0 (попали, но буква не там);
−1 (не попали);
−1 (букв А всего две, поэтому помечаем, что такой больше нет);
−1 (не попали);
1 (попали).
Разберем решение. Сначала собираем словарь, в котором отмечаем, сколько раз повторяются одинаковые буквы:
var result = new int[5];
var letterToRemainingCount = answer
.GroupBy(letter => letter)
.ToDictionary(letter => letter.Key, letter => letter.Count());
После помечаем совпавшие и отсутствующие буквы:
for (var letterIndex = 0; letterIndex < 5; letterIndex++) {
var letter = suggestion[letterIndex];
if (!letterToRemainingCount.ContainsKey(letter)) {
result[letterIndex] = -1;
}
else if (answer[letterIndex] == letter) {
letterToRemainingCount[letter]--;
result[letterIndex] = 1;
}
}
В последнюю очередь отмечаем буквы, которые стоят не на своем месте:
for (var letterIndex = 0; letterIndex < 5; letterIndex++) {
if (result[letterIndex] != 0) {
continue;
}
var letter = suggestion[letterIndex];
if (!letterToRemainingCount.ContainsKey(letter)) {
throw new Exception();
}
if (letterToRemainingCount[letter] > 0) {
letterToRemainingCount[letter]--;
result[letterIndex] = 0;
}
else {
result[letterIndex] = -1;
}
}
Полный код:
public static string Solve(string suggestion, string answer) {
var result = new int[5];
var letterToRemainingCount = answer
.GroupBy(letter => letter)
.ToDictionary(letter => letter.Key, letter => letter.Count());
for (var letterIndex = 0; letterIndex < 5; letterIndex++) {
var letter = suggestion[letterIndex];
if (!letterToRemainingCount.ContainsKey(letter)) {
result[letterIndex] = -1;
}
else if (answer[letterIndex] == letter) {
letterToRemainingCount[letter]--;
result[letterIndex] = 1;
}
}
for (var letterIndex = 0; letterIndex < 5; letterIndex++) {
if (result[letterIndex] != 0) {
continue;
}
var letter = suggestion[letterIndex];
if (!letterToRemainingCount.ContainsKey(letter)) {
throw new Exception();
}
if (letterToRemainingCount[letter] > 0) {
letterToRemainingCount[letter]--;
result[letterIndex] = 0;
}
else {
result[letterIndex] = -1;
}
}
return string.Join(", ", result);
}
Frontend
Готовить задания для фронтенд-секции мы начали еще в январе. Так как фронтенд — это не только JavaScript, очень важно было декомпозировать составление задач по областям, чтобы каждый участник оргкомитета взял на себя определенную часть работы. Еще в начале договорились уйти от алгоритмических задач, чтобы соревнования получились максимально интересными, а задачи были связаны с реальными проектами. Именно так появились задания на теорию (викторина) и практику.
С начала подготовки до финала мы собирались каждую неделю и обсуждали, придумывали и выбирали задачи, а перед турниром попросили коллег, которые не участвовали в чемпионате, порешать их. Мы старались подойти к соревнованиям ответственно и найти слабые или непонятные места. А теперь подробнее пример задачи.
Часто в работе фронтенд-приложения нужно загрузить файл на сервер. Иногда файл очень большой или есть ограничения на максимальный размер запроса. В таких случаях приходится разбивать файл на куски, которые отправляются на сервер последовательно. Иногда мы можем отправлять по несколько чанков параллельно. Так и появилась задача — загрузка файла в несколько потоков.
Сначала определим контракт:
/** Настройки загрузки */
type Options = {
readonly maxChunks: number;
readonly chunkSize: number;
}
/** Интерфейс источника данных */
interface Source {
readonly size: number;
read(start: number, end: number): Blob;
}
/** Функция обратного вызова, отправляющая данные на сервер */
type SendCb = (data: Blob, offset: number) => Promise;
Теперь определим алгоритм работы загрузчика. Мы создадим массив «потоков», размер которого будет равен максимальному количеству параллельно отправляемых чанков.
Под потоком будем подразумевать промис, который последовательно считает и отправляет чанки друг за другом, пока они не закончатся. И с помощью Promise.all
будем ждать завершения всех потоков.
Для этого внутри функции upload объявим пустую функцию uploadNextChunk
(функция потока).
function uploadNextChunk(): Promise {
...
}
Тогда сам код функции загрузки будет выглядеть как:
await Promise.all(
new Array(options.maxChunks).fill(0).map(
() => uploadNextChunk(),
),
);
Конструкция new Array(<количество>).fill(0)
нужна для того, чтобы созданный массив имел значения. Если бы мы использовали только new Array(<количество>)
, то не смогли бы воспользоваться методом map.
Чтобы все части загрузились по одному разу, мы можем создать очередь, из которой будем брать описание следующего чанка для загрузки, или использовать счетчик блоков:
// Общее количество чанков, которое надо загрузить
const chunksCount = Math.ceil(file.size / options.chunkSize);
// Количество чанков, оставшихся незагруженными
let chunksLeft = chunksCount;
Теперь допишем нашу функцию uploadNextChunk:
// Если мы уже прочитали весь файл, то просто завершаем работу «потока»
if (!chunksLeft) {return Promise.resolve();}
// Вычисляем смещение, с которого надо прочитать данные
const offset = (chunksCount - chunksLeft) * options.chunkSize;
// и уменьшаем количество оставшихся частей
chunksLeft--;
// И отправляем чанк на сервер
return send(
file.read(offset, offset + options.chunkSize), offset)
.then(uploadNextChunk())
);
После того как загрузится текущий чанк, функция uploadNextChunk
встанет в очередь микрозадач для загрузки последующей части. В случае ошибки поток прервется и Promise.all
, используемый в основной функции, вернет выброшенную ошибку.
Но остальные потоки пока ничего не знают об ошибке и будут отправлять чанки, пока они есть, — не очень хорошее поведение. Чтобы его избежать, заведем переменную error с признаком ошибки и при каждом последующем вызове uploadNextChunk
будем анализировать ее значение. Тогда весь код будет выглядеть так:
export async function upload(file: Source, send: SendCb, options: Options): Promise {
// Общее количество чанков, которое надо загрузить
const chunksCount = Math.ceil(file.size / options.chunkSize);
// Количество чанков, оставшихся незагруженными
let chunksLeft = chunksCount;
let error = false;
await Promise.all(new Array(options.maxChunks).fill(0).map(() => uploadNextChunk()));
function uploadNextChunk(): Promise {
// Если мы уже прочитали весь файл, то просто завершаем работу «потока»
if (!chunksLeft) {return Promise.resolve();}
// Вычисляем смещение, с которого надо прочитать данные
const offset = (chunksCount - chunksLeft) * options.chunkSize;
// и уменьшаем количество оставшихся частей
chunksLeft--;
// И отправляем чанк на сервер
return send(file.read(offset, offset + options.chunkSize), offset).then(
// если ошибка была в другом «потоке», то досрочно завершаем этот
() => error ? Promise.resolve() : uploadNextChunk(),
e => {
error = true;
return Promise.reject(e);
}
);
}
}
SRE
Объявив подготовку к соревнованиям открытой, мы собрали оргкомитет по нашему треку и решили, что проведем все в формате хакатона. Запланировали командные соревнования с составом 1—5 человек.
Для заданий использовали реальные кейсы систем, необходимых для работы SRE-команд — как у нас в компании, так и для любого другого бизнеса. Если конкурсанты предлагали достойную систему, решающую свой кейс, то мы планировали приглашать их к дальнейшему сотрудничеству и развитию системы. Для соревнований отобрали два наиболее актуальных кейса.
Пробер веб-интерфейса — создание системы, позволяющей командам центров разработки проводить тестирование фронтенда создаваемых приложений. В центрах разработки много команд, создающих веб-приложения, и каждая команда использует свои инструменты для тестирования.
От конкурсантов хотели получить систему, которая предоставляет централизованный инструментарий тестирования для этих команд. Система должна предусматривать регистрацию команд разработки и участников в составе команд. Вот что нужно:
— возможность загрузки тестов с одновременным тестированием разных приложений и множества страниц в составе этих приложений;
— вывод результатов тестов;
— видеозапись в процессе тестирования.
При этом, если одна команда загрузит некорректные тесты, они не должны влиять на работу тестов других команд. Участники, выбравшие на отборочном туре эту задачу, хорошо с ней справились, но большинство команд захардкодили тесты в коде системы.
Нетривиальный планировщик календаря — создание такой системы, чтобы проведение работ в дата-центрах распределялось автоматически.
У Тинькофф несколько дата-центров, в которых нужны регламентные и срочные работы. При этом у каждого из них свои окна для проведения таких работ.
Работы могут быть разделены на ручные и автоматические, с разным приоритетом, могут продлеваться и отменяться, что влияет на другие работы.
Для решения нужно было написать алгоритм, позволяющий при заказе проведения работ в дата-центрах учитывать все факторы, сдвигать, сжимать или отменять работы, а в случае невозможности заказа работ уведомлять об этом с предложением оптимального времени для их проведения.
Сложность задачи была в том, что она NP-полная — то есть для полного решения пришлось бы перебирать все события в календаре, а это серьезные затраты вычислительных мощностей. Так что конкурсантам нужно было каким-то образом снизить количество потребляемых ресурсов.
Почти все команды, решавшие эту задачу на отборочном этапе, вместо алгоритма написали простые функции с элементарным календарем для заведения работ.
Перед финалом участникам для уточнения задач предложили тест-кейсы с пояснением, что требуется от разрабатываемых ими систем. На финале тест-кейсы расширили и усложнили.
Финал вышел напряженным, и выбрать лучшие работы было непросто.
Системный анализ
Выбрать задачи для системных аналитиков было сложно из-за разнообразия доменных областей, в которых они работают. Поэтому общая концепция состояла из двух пунктов:
Разноплановые знания, которые нужны для успешного выступления.
Ограниченное время, ведь можно стать профи в любой области — вопрос времени. Вот мы и проверяли умение быстро разобраться в вопросе.
На первом этапе во многом рулило умение быстро оценить сложность задачи и понять, сколько времени понадобится на ее решение. Задачи были на разные предметные области, и все знать было невозможно. Но при этом не было задач, которые нельзя было бы решить, имея под рукой Гугл.
Разберем одну из задач, которую участники отмечали больше остальных.
Задача про пиццы. Тимлид решил отметить удачный релиз пиццей. В его команде четыре разработчика, два тестировщика и один системный аналитик. Тимлид знает, что все разработчики любят «Маргариту», тестировщики — «Четыре сыра», системный аналитик — «Грибную», а сам он — «Гавайскую», потому что давно не был в отпуске.
Прикидывая, сколько пицц взять, он выяснил, что каждый член команды хочет попробовать по два кусочка своей любимой пиццы и по одному кусочку пицц с другим вкусом. Сможет ли каждый член команды припрятать на завтра хотя бы по одному кусочку своей любимой пиццы, если каждая пицца состоит из шести кусочков?
Первое, что нужно заметить, — каждый съест пять кусков: два куска своей любимой и по одному от всех других пицц.
Второе — мы точно понимаем, сколько кусков каждая пицца должна отдать наружу: по количеству человек, для которых эта пицца не является любимой. Для разработчиков — восемь кусков себе и четыре остальным коллегам: тимлиду, двум тестировщикам и системному аналитику.
По аналогии получается, что минимум необходимо кусков:
Гавайская (тимлид): 2 + 4 + 1 + 2 = 6 + 3 (две пиццы).
4 сыра (тестеры): 4 + 1 + 4 + 1 = 6 + 4 (две пиццы).
Маргарита (разрабы): 8 + 1 + 2 + 1 = 6 + 6 (две пиццы).
Грибная (аналитик): 2 + 4 + 1 + 2 = 6 + 3 (две пиццы).
А теперь включаем логику тимлида: он спросил у всех членов команды любимые пиццы. И он, как и вы, может провести вычисления и понять, какое минимальное количество пицц ему нужно заказать. Так как тимлид — самая рациональная часть коллектива, он не будет транжирить бюджет команды на дополнительные пиццы, которые, по его расчетам, никто не будет есть. Более того, он не знает, что остальные хотят заначить кусочек на завтра. Поэтому он закажет по две пиццы каждого вида.
В таком случае разрабы не смогут отложить пиццу на завтра, ведь обе пиццы будут разобраны тут же.
Ответ: нет, разработчики завтра останутся голодными.
Обо всех перипетиях подготовки к первому и второму туру читайте в следующих статьях.
Data Engineering
Для первого этапа мы составили начальный список тестов из 25 заданий. Сначала решали их вместе с другими организаторами соревнований, вносили правки, исключали ситуации, где вопрос может иметь несколько верных и неверных ответов одновременно. После попросили непричастных к мероприятию коллег пройти тест и дать обратную связь.
В итоге пару вопросов исключили, потому что тестируемый чувствовал себя калькулятором. Еще пару вопросов убрали, потому что правильный ответ зависел от конкретной СУБД, а мы не хотели создавать жесткую привязку к продукту. В некоторых вопросах коллеги подсветили нюансы в формулировках — мы их поправили, вопросы стали понятнее.
В первом туре вопросы были не только на SQL, но и на оптимизацию и понимание теории хранилищ данных. Эти темы важны, ведь в реальной жизни мы не только пишем запросы, но и занимаемся проектированием хранилища. Хорошие практики позволяют не превратить хранилище данных в свалку.
По отзывам, самым интересным оказался вопрос с функциями lead и lag. Этот же вопрос оказался самым сложным из всех: задачу решили только 28% участников отбора.
Оконные функции lead
и lag
позволяют получить следующее и предыдущее значения поля в рамках окна. Логично их использовать так, как они и задумывались: lead
— для следующего значения, а lag
— для предыдущего.
Обычно мы так и делаем. Если изменить сортировку для одной из этих функций, то она сделает то же, что и другая с нормальной сортировкой. Почему так происходит? Попробуем рассмотреть на таблице ниже:
При изменении направления сортировки оконные функции начинают работать как их противоположность
В примере ответ 1 — верный, поля из задания всегда равны. Но можно ли всегда полагаться на это? На самом деле нет. Когда в поле сортировки есть одинаковые значения, мы не можем гарантированно знать, каким будет порядок строк в окне.
Например, если бы в примере мы выполняли сортировку не по столбцу с, а по столбцу b или d, то мы и СУБД не смогли бы однозначно определить порядок строк. Тогда значения в полях col1 и col2 различались бы — и мы не смогли бы найти верный ответ.
Это говорит нам, что важно не только понимать, как работает запрос, но и иметь представление о данных внутри запроса.
Впечатления и итоги
Отзывы с очных площадок были очень теплыми. Многим понравился формат, и участники писали, что соскучились по живым мероприятиям. Мы классно провели время, пообщались.
Победителям, занявшим первые места в отборе, мы оплатили поездку в Москву на финал. Приятно, что две трети финалистов приехали из регионов и даже из Беларуси.
Финал в Москве прошел в лофтовом пространстве Goelro. Приехали почти 300 участников:
— Backend — 75;
— Frontend — 55;
— системный анализ — 35;
— Data Engineering — 35;
— iOS — 28;
— Android — 27;
— SRE — 20.
Тем же вечером провели награждение победителей.
Нас так зарядила атмосфера соревнований и дух комьюнити, что мы уже изучили обратную связь. К следующим соревнованиям постараемся улучшить технические аспекты, а правила и временные рамки сделать понятнее и проще.
Ловите атмосферу на фотографиях в альбоме и делитесь впечатлениями в комментариях. А полные разборы заданий публикуем на главной странице соревнования. Спасибо всем, кто был с нами!