В этом году наша компания впервые провела конкурс по базам данных в рамках международной олимпиады IT-Планета по информационным технологиям. Раньше на олимпиаде использовалась СУБД Oracle; наш коллега Евгений Бредня в свое время делился таким опытом. Олимпиада проходила в три этапа. Первым шел заочный теоретический тест, который преодолели примерно двести человек из двух тысяч зарегистрировавшихся. На втором этапе участникам было предложено подумать над пятью задачами, каждую из которых следовало решить одним SQL-запросом. Этот этап также проводился заочно: на раздумья было дано примерно три недели. Условия всех задач были опубликованы одновременно, но у каждой был свой крайний срок; поэтому первыми шли задачи полегче, чтобы на более сложные осталось больше времени. Задачи проверялись на корректность (автоматическими тестами) и на качество кода (вручную). По результатам мы отобрали двадцать человек для последнего, очного этапа. Третий этап состоялся 27 мая в Сочи. К сожалению, из двадцати приглашенных приехать смогли только четырнадцать; между ними и состоялось соревнование. Задачи этого этапа также предполагали решение одним запросом, но сами задания были объединены общей темой, навеянной игрой Го, и строились так, что решение одной задачи помогало подступиться к следующей. Я занимался придумыванием задач для второго и третьего этапов. Хочу поблагодарить участников олимпиады, которым пришлось их решать, организаторов, собравших нас вместе, и своих коллег: Дарью Рисухину, взвалившую на себя все оргвопросы, Евгения Моргунова, предоставившего задания для первого этапа, а также всех помогавших мне с задачами. В этой статье поговорим о втором этапе. Но сначала пара слов о том, зачем вообще нужны эти задачи «одним запросом». На прошлой работе один умудренный опытом администратор не уставал повторять зеленым разработчикам: «вы не на олимпиаде по программированию!» И действительно, часто ли в реальной жизни приходится использовать SQL для чего-то эдакого? Не часто. Зато гораздо чаще, чем хотелось бы, разработчики городят циклы на процедурных языках в задачах, которые легко решаются на SQL. Думаю, что SQL ни у кого не был первым языком; все начинают с чего-то процедурного-императивного. Перестраиваться на другую парадигму всегда тяжело, поэтому большинству проще написать цикл, в котором вызываются небольшие запросы, вместо того чтобы одним запросом решить задачу целиком. Проблема здесь не только в избыточном коде, но и в том, что база данных не может оптимизировать всю задачу, а оптимизация отдельных запросов помогает далеко не всегда. Решение же на SQL «ненормальных», «атипичных» задач раздвигает границы сознания. После этого и стандартные повседневные задачи будут решаться легче — снимается барьер переключения парадигм. Ну и наконец, это просто весело. Нельзя же всю жизнь ковыряться с поставщиками и деталями, надо и удовольствие получать. Итак, приступим к задачам. В основном тексте я ограничусь кратким условием, а полное буду приводить под катом. Напишите интерпретатор упрощенной версии нормальных алгоритмов Маркова (НАМ). Алгоритм задан в таблице nma в виде последовательности правил: Алгоритм применяется к строке, начальное значение которой задано в конфигурационном параметре nma.string. (Для получения значения параметра можно воспользоваться функцией current_setting). Шаг алгоритма состоит в том, что выбирается первое по порядку правило, подстрока a которого содержится в строке; первое слева вхождение подстроки a заменяется на подстроку b. Если подходящее правило нашлось, выполняется следующий шаг алгоритма. Если ни одного правила не удалось применить, алгоритм заканчивает работу. Запрос должен вывести последовательность применений правил алгоритма. В выводе должно быть три столбца: n — номер шага алгоритма; нулевой шаг соответствует исходному значению строки; s — состояние строки после выполнения данного шага; id — номер примененного на данном шаге правила (NULL для нулевого шага). Алгоритм может зациклиться, поэтому его работу необходимо ограничить 1000 шагами. Следующие входные данные определяют алгоритм, увеличивающий запись двоичного числа на единицу: запрос должен вывести:  Небольшое упрощение состояло в том, что из НАМ были исключены терминальные замены. Я дал эту задачу из ностальгических соображений: когда-то давным-давно наша команда от Школы Юных Программистов Протвино участвовала в олимпиаде в Красноярске. Там, правда, задача была сложнее: надо было написать нормальный алгоритм, складывающий два числа (попробуйте). А чтобы его отладить, пришлось написать и интерпретатор (не на SQL, конечно, а на Бейсике. Или на Паскале, не помню уже). Кстати, алгоритм сложения я использовал в качестве одного из тестов. Надеюсь, участники погуглили про Маркова и его нормальные алгоритмы. Казалось бы, единственной сложностью в задаче мог оказаться перевод процедурной формулировки «если ... то» в действия над множествами строк. В процедурной реализации мы бы сначала нашли нужное правило по подстроке, а затем применили бы его, а в запросе применяем все правила сразу, а затем выбираем подходящий вариант. Однако проблем оказалось достаточно. Забавно, что некоторые присланные участниками запросы вообще не работали, падая с синтаксической ошибкой. У меня есть предположение, что это варианты решений от ChatGPT. Не подумайте, я не против. Если какой-то инструмент может ускорить вашу работу — это прекрасно. Если (точнее, когда) ИИ научится щелкать олимпиадные задачи, можно будет давать задачи еще более сложные и интересные. Но за проверку решения вы сами отвечаете, по-любому. Дальше. Строка передается алгоритму в конфигурационном параметре, и никто не догадался обработать ситуацию, когда параметр не задан. Довольно очевидная же подстава со стороны организаторов. Так что максимальный балл за первую задачу никто не получил. Одной из частых проблем было использование функции replace. Она не годится, поскольку заменяет сразу все вхождения подстроки, а нам нужна только первая. Подойдет overlay, или можно вручную вырезать из строки нужные части и склеить их. И еще многие пытались искать подстроку с помощью выражения LIKE, приклеивая к ней «проценты». Но такой способ некорректно работает, если подстрока содержит тот же знак процента. Конкатенация вообще опасная штука, чреватая внедрением SQL-кода, так что рекомендую изучить ассортимент строковых функций. Многие использовали в рекурсивном подзапросе UNION вместо UNION ALL. Результат получается тот же, но UNION сопряжен с лишней работой по устранению дубликатов, которых в нашем случае нет. В последующих заданиях такая оплошность могла довести и до таймаута, а здесь я просто уменьшал бонусный коэффициент за качество кода. Про критерии качества кода стоит сказать особо, поскольку они не формализуются. Задание намекает: «Аккуратно отформатированное решение с комментариями, поясняющими удачно выбранный алгоритм и его тонкие места, и разумно подобранными именами столбцов и подзапросов, может удвоить набранные баллы>. С аккуратным форматированием проблем почти ни у кого не возникло (кто поленился — ССЗБ), а вот что такое хорошие комментарии? Плохо, когда комментариев нет совсем. Но и когда их больше самого кода — тоже радости мало: ведь их надо прочитать, потом прочитать код, а потом найти, в чем одно противоречит другому. Не противоречит? Смотрите внимательнее. Хрестоматийный пример плохого комментария (насчитал таких штук пять минимум): Комментировать надо то, что сложно понять из самого кода. Мотивацию выбора алгоритма, верхнеуровневое описание, тонкие места, допущения. Скажем, во многих решениях на «отлично» было отмечено, почему нельзя использовать replace — это полезный комментарий, в реальной жизни он может удержать последователей от поспешных решений «все переписать по-человечески». Ну и хватит о первой задаче. Вот одно из решений, набравших больше всего баллов:  Напишите запрос, выводящий календарь на заданный месяц. Номер месяца передается в параметре calendar.month (в формате MM), номер года — в параметре calendar.year (в формате YYYY). Запрос должен вывести семь строк со следующими столбцами: n — день недели, от 1 (понедельник) до 7 (воскресенье); calendar — строка календаря, соответствующая дню недели. Строка образована следующей последовательностью символов:- название дня недели в текущей локали с заглавной буквы;- пробел;- номера дней (дополненные пробелами до двух символов), разделенные пробелом. Строки календаря не должны содержать замыкающих пробелов. Входные данные: В локали en_US запрос должен вывести: В локали ru_RU запрос должен вывести:  Задание с календариком с незапамятных времен бывает на всех олимпиадах по SQL, ну и я не удержался. В качестве изюминки запрос должен был учитывать локаль, и на этом многие посыпались. Кто-то ее просто игнорировал, кто-то пытался хардкодить названия месяцев в разных языках (угадайте, предусмотрел ли кто-то непальский). Хотя решается это простым добавлением модификатора TM к маске формата. В целом это задание тоже было несложным, не было никаких подвохов, связанных с переходом на григорианский календарь, да и сам Постгрес работает только с григорианскими датами. Так что, по сути, требовалась лишь определенная аккуратность. Так или иначе надо было сформировать «сетку» дней (учитывая, что в месяце может быть разное количество недель) и заполнить ее конкретными числами. Кто-то на самом деле формировал сетку из символов, заменяя их затем датами; кто-то делал это «в уме», конкатенируя даты и пробелы в нужном порядке — можно придумать много разных вариантов. Тем не менее много ошибок было связано именно с тем, что иногда цифры оказывались не на своих местах. Стоит лучше тестировать решение. Очень много баллов сгорело из-за невнимательности. В задании было сказано: «строки календаря не должны содержать замыкающих пробелов», а про trim многие забыли. Ну и рекурсивный запрос тут не нужен, достаточно функции generate_series.  Напишите запрос, определяющий сумму складских запасов методом FIFO. Продавец закупает апельсины ящиками на оптовом складе. Он продает их, красиво раскладывая на витрине своего ларька, и поэтому в конце рабочего дня не знает, из каких именно ящиков сколько апельсинов было продано. Однако продавец любит порядок и хочет представлять, на какую сумму оставляет вечером в ларьке нераспроданный товар. Поэтому он делает предположение, что покупатели берут апельсины из ящиков в том порядке, в котором он привозил эти ящики. В таблице oranges продавец фиксирует приобретение апельсинов на складе (дату, массу нетто в килограммах и цену за килограмм) и продажи апельсинов в ларьке (то же, но с отрицательной массой; цена продажи не влияет на стоимость остатков, но нужна продавцу для других целей). Запрос должен вывести один столбец amount и одну строку, содержащую стоимость остатка апельсинов в ларьке, округленную до копеек. Для следующих входных данных: запрос должен вывести: (Первый ящик считаем распроданным полностью, второй — частично, а третий ящик пока полон.) Вот пример задачи, которая имеет непосредственное отношение к обычной практике в системах, связанных с бухгалтерией и складским учетом. Чтобы было не так грустно, я заменил в задаче товарные позиции апельсинами, но так и не придумал, куда вставить Чебурашку. Рекурсивный алгоритм и в этой задаче избыточен, для решения достаточно уметь считать сумму нарастающим итогом, то есть немного владеть оконными функциями. Для тех, кто хочет решить задачу самостоятельно, приведу подсказку: поступило поступило (нарастающим итогом) всего должны продать разница A B = sum(A) OVER (ORDER BY ...) C D = B - C 5 5 6 -1 4 9 6 3 2 11 6 5 Тут возможны три варианта: Разница отрицательна (D < 0):этого и предыдущих ящиков не хватило для продажи, остаток равен нулю. Разница меньше количества в данном ящике (0 ≤ D < A):этого ящика уже хватило, остаток равен разнице. Разница не меньше количества в данном ящике (D ≥ A):до апельсинов из этого ящика дело не дошло. «Алгоритм простейший», как написал один участник. Но можно и рекурсивным запросом решать, конечно. Но именно с алгоритмом было больше всего сложностей. Зачастую решения выводили совсем не то, что требовалось. Надо хорошенько тестировать! Много ошибки было связано с округлением. Его то вовсе забывали, то применяли слишком рано, из-за чего терялись копеечки. Здесь еще возможен интересный случай, не оговоренный в задании. Что, если продавец ошибется и зарегистрирует больше продаж, чем приобретений? Автоматический тест предполагал, что остаток в этом случае должен быть равен нулю, а не уходить в минус. Но всем, кто сделал иначе и описал этот случай в комментариях, я, разумеется, зачел решение.  Напишите запрос, визуализирующий «змейку» из известной игры. В таблице snake хранятся координаты сегментов «змейки» от хвоста до головы в порядке возрастания идентификатора. Размер поля — 10×10 с началом координат в левом верхнем углу, координаты отсчитываются от 0 до 9. Гарантируется, что: каждый следующий сегмент прилегает к предыдущему по горизонтали или вертикали; нет самопересечений. Запрос должен вывести два столбца: line — номер строки; snake — текстовая строка поля, состоящая из десяти символов. Пустая клетка обозначается точкой «.», голова змейки — символом «ö», а остальные ее сегменты — символами «│», «─», «┌», «┐», «└» и «┘» в зависимости от взаимоположения сегментов. В выводе должно быть ровно десять строк. Для следующих входных данных: запрос должен вывести:  Если задача вызывает сложности, можно начать с решения более простой: вывести все сегменты змейки одинаковыми символами — это совсем просто. А дальше останется только выбрать правильные символы в зависимости от предыдущих и последующих. Здесь и кроются два основных подвоха. Во-первых, за хвостом нет следующего сегмента, так что для него потребуется какое-то исключение. Во-вторых, змейка может ползти не только в одну сторону (по часовой стрелке, как в примере), но и в другую, а символы при этом остаются теми же самыми (роме хвоста и головы, конечно). Из-за того что каждый символ может возникать в двух случаях (змейка ползет в одну сторону или в другую), решение получается громоздким. Его можно немного сократить, если придумать функцию, отображающую движение в обе стороны в одно и то же число. К счастью, никто из участников не стал этим заморачиваться. Почему-то многие поленились скопировать символы из примера и заменили, скажем, горизонтальную черту на минус. Из-за этого автоматические тесты не проходили и мне приходилось поправлять символы вручную (что отразилось на бонусных баллах не в лучшую сторону).  Напишите запрос, «выдающий» требуемую сумму минимальным количеством купюр. Номиналы и количество имеющихся в банкомате купюр хранятся в таблице: Запрашиваемая сумма передается в параметре atm.amount; значение гарантированно приводится к целому числу. Запрос должен вывести номиналы и количество купюр, в сумме дающих требуемую сумму. Количество купюр было минимально возможным. Если задача не имеет решения, запрос не должен вывести ни одной строки. Для следующих входных данных: запрос должен вывести:  Для частного случая, когда все номиналы кратны, задача решается даже без рекурсивного запроса, поскольку достаточно двигаться от крупных купюр к мелким. Такой пример разобран у нас в курсе DEV2-12; он демонстрирует пользовательские оконные функции, но можно обойтись и стандартными. Но номиналы не обязаны быть кратными. Скажем, купюрами в 5000 и 2000 можно выдать 6000, но если начать с пятерки, ничего не получится. В общем случае необходим перебор, то есть рекурсивный запрос. В качестве текущего состояния удобно взять массив пар (номинал, количество купюр), определяющий выданную на текущий момент сумму. Начать можно с пустого состояния и добавлять к нему разные купюры, двигаясь от бóльших к меньшим. Но при этом нужно аккуратно отбрасывать заведомо непроходные и просто лишние варианты, чтобы алгоритм без надобности не грел процессор в попытках выдать миллион сторублевками. Те, кто решил задачу правильно, но не озаботился эффективностью, недобрали баллов из-за таймаутов.  На этом пока все, а в следующий раз расскажу о задачах финала.