За что я игнорирую решения на алгопроге

22.08.2020

Каждое решение на алгопроге, которое успешно проходит все тесты, я просматриваю глазами и либо засчитываю, либо игнорирую. Если я засчитываю решение —значит, оно написано в целом нормально. Я могу к такому решению написать комментарий, но этот комментарий — он скорее «к сведению» ученика. А вот если я игнорирую решение, это значит, что решение надо переделать с учетом моего комментария. В этом посте я постараюсь описать общие принципы, которых я придерживаюсь при игнорировании решений.

Для начала, две причины, за которые я игнорю код, даже не особенно в него вчитываясь. Во-первых, если я вижу, что решение в принципе неправильное, что решение может не работать на каком-нибудь тесте. Вообще, конечно, в большинстве случаев такие решения детектируются автоматическим тестированием в тестирующей системе, но встречаются ситуации, когда ученик допускает настолько хитрую ошибку, что тесты ее не отлавливают; плюс у ряда задач на алгопроге на самом деле довольно слабые тесты в тестирующей системе. Я просматриваю все решения глазами, и как правило замечу ошибку, если она есть. (Ее обычно не так сложно заметить, тем более что по начальным задачам я видел на алгопроге уже сотни решений. Как говорят, что взрослый человек читает слова не по буквам, а сразу глазом окидывая всё слово, так и я в простых задачах по сути одним взглядом могу увидеть, нет ли тут чего необычного. Решения с ошибкой, но проходящие все тесты, обычно заметно отличается от решений без ошибок.) В таком случае я подберу контрпример и заигнорирую решение, указав контрпример в комментарии. Вообще, обычно не принято показывать ученикам конкретные тесты, на которых их решение не работает, но в таких случаях я думаю это правильнее, раз уж такого теста в тестирующей нет и потому без явного указания теста ученик не сможет проверить, исправил он проблему или нет.

В эту же тему решения, написанные с плохой сложностью тогда, когда очень легко их переделать на решения с хорошей сложностью, даже если решение с плохой сложностью вполне успевает по времени. Характерный пример — вставка в начало массива или строки работает долго, а вставка в конец работает быстро, поэтому если в решении ученик формирует какую-нибудь строку или массив из конца в начало, вставляя символы в начало, то я потребую переделать решение и формировать строку в обратном порядке, а в конце развернуть.

Во-вторых, я смотрю на общий стиль оформления кода, в первую очередь отступы. Есть преподаватели, которые требуют супер-строгого соблюдения конкретного стиля написания кода (coding style), и готовы проигнорировать решение за банально не в том месте поставленный пробел. Я считаю это неправильным; в конце концов, в реальной жизни у вас или не будут требовать жесткого соблюдения coding style, или будут средства автоматического форматирования или хотя бы контроля. Ну и конкретный coding style — это всегда вкусовщина, заставлять учеников писать по конкретному стилю имхо неправильно.

Поэтому я игнорирую только за очень грубые нарушения стиля. Как правило, это или полное отсутствие отступов в хоть сколько-то сложной программе, или настолько неаккуратная расстановка отступов, что я сам с ходу не смог разобраться, какой код внутри какой управляющей конструкции находится. Также я могу заигнорить, если ученик жалеет переводы строк (например, если на одной строке написан и for, и внутри него if, и внутри него еще какая-то операция, или если он использует оператор «запятая» в C++ там, где вполне можно было бы поставить точку с запятой), но это уже не всегда. Зачастую я тут еще учитываю уровень ученика — крутые ученики обычно поймут и комментарий к зачтенному решения, а ученикам, которые только начинают писать сложные программы, можно игнорировать активнее, чтобы сразу прививать правильный стиль.

Ну и учитываю соседние решения — одно дело, если школьник чисто в одной задаче не заметил неаккуратные отступы, и другое дело если школьник стабильно пишет некрасиво. В последнем случае я иногда игнорю даже за небольшие недочеты. Тут иногда хочется говорить и банально об уважении ко мне (хотя я и очень не люблю такие авторитарные аргументы) — примерно как не принято на контрольной по математике сдавать решения на обрывке бумаги, даже если все по сути решено правильно — просто из уважения к учителю, или даже не важно что к учителю, а в принципе к человеку, который это будет читать — примерно по той же причине, если я вижу, что ученик стабильно по мелочи пишет код неаккуратно, я попрошу его поправить, даже если каждое конкретное решение в принципе несложно понять, и неаккуратность не влияет на качество кода.

(Еще тут, кстати, есть проблема путаницы табов и пробелов. К сожалению, почему-то не все современные IDE и редакторы кода способы стабильно выдерживать какой-то определенный стиль, и реглярно у отдельных учеников получаются сабмиты, в которых часть отступов сделаны табами, часть пробелами. На начальных уровнях заставлять учеников вникать в такие тонкости — это конечно очень некруто, но к сожалению приходится; если в решении поплыли отступы из-за путаницы табов и пробелов, я решение заигнорю.)

(Именам переменных я не придаю особого значения, все-таки в простых олимпиадных задачах это не так существенно.)

* * *

Остальные же причины для игнора кода обычно так или иначе связаны с его алгоритмической структурой, с тем, какое конструкции использует ученик, какие особые случаи он выделяет и т.д. Здесь я в первую очередь смотрю на то, чтобы код был написан, во-первых, чисто и оптимально по использованию конструкций, во-вторых, чтобы код было легко осознать и понять.

А именно, я очень жестко игнорирую за лишний код. Очень частая ситуация — когда часть кода из программы можно просто выкинуть, при этом программа все равно останется корректной. Например, зачем-то может быть отдельно рассмотрен какой-то случай (например, n==1), хотя основной алгоритм прекрасно его обработает. Или например написана конструкция типа

if n > 2:
    for i in range(2, n):
        ....

здесь если n<=2, то цикл и так ни разу не исполнится, и потому if можно спокойно убрать.

Вообще — это даже не про критерии игнора, а про написание надежных программ в целом, и про это я уже где-то писал — всегда, когда вы пишете какой-нибудь код, управляющие конструкции и т.д., над каждой конструкцией надо думать, почему она написана и зачем. Например, про каждый особый случай вы должны понимать, почему это действительно особый случай, что такого не так в основном алгоритме, что основной алгоритм не работает. Зачастую случай нужен, если вы можете оправдать этот особый случай особенностями задачи или вашего алгоритма, а не какими-то особенностями реализации. Грубо говоря, вы должны человеку, который не видел вашего кода, объяснить, что такого особенного в вашем особом случае. Если же именно по задаче, или в крайнем случае по вашему алгоритму не можете объяснить, зачем этот особый случай — так может он вам и не нужен? Помимо прочего, вы должны уметь объяснить, почему нет других особых случаев. Например, если вы пишете if n==2, то вдруг на самом деле тут проблема не только при n==2, а при любом четном n, или при любом n, являющемся степенью двойки? Если вы можете ответить на этот вопрос, то да, ок, случай наверное действительно особый. Иначе вы рискуете или лишний код написать, или пропустить ошибку.

Кроме того, я так же довольно жестко игнорирую за повторяющийся код. Если в программе есть несколько очень похожих фрагментов, то почти всегда можно собственно повторяющийся код изолировать. Типичный пример — задача «Четные числа». Ее иногда пишут так (не то чтобы это самый простой способ):

if a % 2 == 0:
    for i in range(a, b + 1, 2):
        ...
else:
    for i in range(a + 1, b + 1, 2):
        ...

(и хорошо еще, если тут не разбирается особо случай b%2==0). Тут циклы отличаются только левой границей, поэтому собственно цикл можно вынести из if‘ов, оставив внутри только собственно то, что отличается, ну и введя для этого переменную:

if a % 2 == 0:
    с = a
else:
    c = a + 1
for i in range(c, b + 1, 2):
        ...

В более сложных случаях, конечно, для избавления от дублирующегося кода можно написать цикл, вынести код в функцию и т.п. Аналогично, например, в разных задачах по перемещению на клетчатой доске я требую не дублировать код для всех возможных ходов во все стороны, а сделать цикл длины 4 или 8, и константный массив всех возможных сдвигов.

Наконец, самый плохо формализуемый случай — бывают случаи, когда просто банально можно упростить код. Это бывает что-нибудь совсем простое, например, объединить какие-нибудь две проверки в одну, или еще характерный случай — код типа следующего, когда в цикле крайний случай рассматривается особо:

for i in range(n):
    if i == n - 1:
        ...
    else:
        ...

В таких случаях намного проще сделать цикл in range(n-1), а уже после цикла явно написать код для n-1. Бывают и более хитрые ситуации, когда код можно упростить и/или сделать более надежным, если я вижу такой способ, я заигнорю решение.

Помимо такого упрощения программы, я также требую писать так, чтобы код был понятным. Тут тоже много возможных причин для игнора. Я игнорирую за «слишком умные» конструкции, которые с ходу стороннему наблюдателю не очевидны. Характерный пример — в задаче про правильную скобочную последовательность надо по двум символам-скобкам определять, являются ли они парными скобками (круглыми, квадратными или фигурными). К сожалению, составители таблицы ASCII не продумали этот момент — можно было бы, например, предположить, что каждая закрывающая скобка в таблице идет за открывающей, но это не так. Можно придумать хитрую формулу на основе кодов ASCII, проверяющую, являются ли два данных символа парными скобками, но я такую формулу проигнорирую, потому что она очень неочевидна, ее сложно проверять и в ней легко допустить ошибку (я такие ошибки несколько раз видел в решениях школьников). Поэтому я требую явно составить соответствия, например, сделав функцию, или словарь типа pair = {'(': ')', '[': ']', '{': '}'}. Есть также ряд олимпиадных штучек а-ля " \n"[i == n - 1], которые тоже мне очень не нравятся (лучше уж явный if), но, к счастью, на начальных уровнях ученики алгопрога еще не испорчены такими конструкциями, а на старших уровнях я уже не считаю это большой проблемой.

Этот пункт может показаться противоречащим тому, что я писал выше — если выше я требовал избавляться от конструкций языка, то тут я требую наоборот писать больше кода. Но противоречия нет — выше я говорил про лишний код, для которого нет никаких реальных оснований, а тут речь про код, который полностью оправдывается задачей (см. еще выше абзац про особые случаи).

Аналогичная тема тут — любые константы, которые есть в программе, должны очевидно следовать из условия. Например, если ученик пишет программу про то, как на часах двигаются часовая и минутная стрелки, то числа 12, 60 и 360 очевидно следуют из условия. А вот число 30 — нет. Если ученик в программе пишет 30, то очевидно он его как-то вычислял, в уме или на калькуляторе — и я такое решение заигнорю, потому что намного лучше и понятнее и надежнее прямо в программе написать формулу. И человеку, который будет читать код, будет понятно, откуда взялось это число, и меньше риска ошибиться.

Соображения простого и понятного кода вступают также в конфликт с оптимизацией кода, и я в многих случаях игнорю попытки слишком сильно оптимизировать код в ущерб чистоте и понятности. Конечно, это не касается применения продвинутых алгоритмов и вообще асимптотических оптимизаций (т.е. улучшения сложности алгоритма), но неасимптотические оптимизации (влияющие на константу, а не на сложность) я могу заигнорить, если они негативно отражаются на понятности и чистоте алгоритма. Вообще, это известное понятие преждевременной оптимизации — не надо оптимизировать код путем его усложнения, если нет уверенности в том, что оптимизация нужна. В частности, на алгопроге в большинстве задач проходит любой код, главное чтобы сложность была нужной (ну и собственно алгопрог, и алгоритмы вообще — это в первую очередь именно про написание кода оптимальной сложности). Поэтому дальше оптимизировать константу как правило не нужно, намного лучше написать более простой и чистый код, в нем будет меньше ошибок, их искать будет проще, да и напишете вы его быстрее.

Типичный тут пример — задача проверки заданного числа N на простоту. Для этого надо просто идти циклом по числам по порядку начиная с 2 и проверять, делится или не делится N на текущее число. Асимптотическая оптимизация тут — что идти надо не до N, а до корня из N. На больших N это ускоряет вашу программу в тысячи и сотни тысяч раз (ну и даже еще больше, если N еще больше). Неасимптотическая оптимизация — это отдельно проверить делимость на 2, после чего идти циклом с шагом 2, только по нечетным числам. Конечно, это ускоряет программу раза в два, но также примерно удваивает объем кода, и соответственно удваивает риск допустить ошибку. Я один раз видел школьника, который потратил три попытки на алгопроге чисто чтобы добиться правильной работы этой оптимизации. Но и без нее все прекрасно работало бы, поэтому не надо писать такие оптимизации. В данном конкретном случае я игнорить код скорее всего не буду, потому что в принципе аргументация понятна (см. еще ниже про аргументацию), но в похожих случаях могу и заигнорить. (При этом есть неасимптотические оптимизации, которые не сильно усложняют код, например, в задаче про проверку является ли слово палиндромом можно делать цикл до половины длины слова, а не до полной длины. Против таких оптимизаций я, конечно, ничего против не имею; наоборот, даже посоветую в комментарии к зачтенному решению.)

* * *

Ну и наконец, есть еще несколько причин, когда я заигнорю код не из-за его непонятности или простоты. Это, во-первых, нарушение каких-нибудь базовых правил и принципов написания надежного кода, в первую очередь нарушение правил работы с вещественными числами. Во-вторых, если по коду очевидно, что ученик не понимает каких-то принципиальных моментов, не освоил что-то из теории. Типичный пример тут — если ученик, особенно на начальных уровнях, использует цикл while там, где можно легко написать for. Или (хотя это относится и к лишнему коду) если ученик не использует операцию взятия остатка (a%b), вместо этого используя более громоздкие выражения с целочисленным делением и вычитанием (a-(a//b)*b); или пишет int(a/b) вместо a//b; сюда же относится многообразие прекрасных конструкций вида (a or b) == 0. Аналогично, и в более продвинутых алгоритмах бывает, что по коду очевидно непонимание фундаментальных моментов; например, в правильно написанном бинарном поиске после собственно цикла поиска как правило вы сразу знаете, какое значение вам нужно, l или r, и не надо писать отдельный if, чтобы это проверить. В эту же тему проверка ограничений, указанных в условии (чего делать не надо); сюда же, например, слишком активное использование «сырых» массивов в C++ вместо std::vector. Также, конечно, я заигнорю решение, если задача была дана на конкретную тему, а ученик, например, воспользовался соответствующей стандартной функцией или структурой, или другим алгоритмом вообще, или если в условии запрещено, например, использовать массивы, а ученик их использует.

* * *

Понятно, что все это во многом субъективно, поэтому конечно бывает что из двух очень похожих программ я одну засчитываю, а другую игнорирую; или даже если программы разные, но в них однотипные проблемы — возможно, я в одном случае зачту, в другом проигнорирую. Иногда я это делаю осознанно — например, как я уже писал выше, я более толерантно отношусь к программам на высоких уровнях, потому что там уже как правило ученик понимает, что делает; или например обычно в задаче про максимум из двух чисел я игнорирую решение, использующее стандартную функцию max, но если толковый ученик начинает решать задачи с самого начала, и я вижу по соседним решениям, что он прекрасно умеет использовать if, то я не вижу смысла ему игнорировать решение с max.

Но, конечно, бывает и так, что я случайно похожие задачи то засчитываю, то игнорирую, или бывает, что я со временем меняю свое отношение к тем или иным проблемам. Хотя, конечно, я стараюсь тут соблюдать более-менее единые стандарты.

И, конечно, я всегда готов обсудить игнор с учеником. Если он может привести конкретную аргументацию, почему он считает, что его вариант не хуже того, что я от него требую, то я вполне могу изменить свое мнение и зачесть его решение; в конце концов, мне надо не чтобы ученик слепо реализовал то, что я от него хочу, а чтобы он понимал возможные проблемы и взвешенно принимал решение. В частности, иногда я игнорирую решения, даже если я хорошо понимаю, какой аргументацией можно такой код обосновать — и если ученик эту аргументацию приведет, т.е. я увижу, что он понимает, что делает, то я решение зачту.


Мой курс по алгоритмическому программированию (и подготовке к олимпиадам) для школьников, студентов и всех желающих — algoprog.ru