За что я игнорирую решения на алгопроге
Каждое решение на алгопроге, которое успешно проходит все тесты, я просматриваю глазами и либо засчитываю, либо игнорирую. Если я засчитываю решение —значит, оно написано в целом нормально. Я могу к такому решению написать комментарий, но этот комментарий — он скорее «к сведению» ученика. А вот если я игнорирую решение, это значит, что решение надо переделать с учетом моего комментария. В этом посте я постараюсь описать общие принципы, которых я придерживаюсь при игнорировании решений.
Для начала, две причины, за которые я игнорю код, даже не особенно в него вчитываясь. Во-первых, если я вижу, что решение в принципе неправильное, что решение может не работать на каком-нибудь тесте. Вообще, конечно, в большинстве случаев такие решения детектируются автоматическим тестированием в тестирующей системе, но встречаются ситуации, когда ученик допускает настолько хитрую ошибку, что тесты ее не отлавливают; плюс у ряда задач на алгопроге на самом деле довольно слабые тесты в тестирующей системе. Я просматриваю все решения глазами, и как правило замечу ошибку, если она есть. (Ее обычно не так сложно заметить, тем более что по начальным задачам я видел на алгопроге уже сотни решений. Как говорят, что взрослый человек читает слова не по буквам, а сразу глазом окидывая всё слово, так и я в простых задачах по сути одним взглядом могу увидеть, нет ли тут чего необычного. Решения с ошибкой, но проходящие все тесты, обычно заметно отличается от решений без ошибок.) В таком случае я подберу контрпример и заигнорирую решение, указав контрпример в комментарии. Вообще, обычно не принято показывать ученикам конкретные тесты, на которых их решение не работает, но в таких случаях я думаю это правильнее, раз уж такого теста в тестирующей нет и потому без явного указания теста ученик не сможет проверить, исправил он проблему или нет.
В эту же тему решения, написанные с плохой сложностью тогда, когда очень легко их переделать на решения с хорошей сложностью, даже если решение с плохой сложностью вполне успевает по времени. Характерный пример — вставка в начало массива или строки работает долго, а вставка в конец работает быстро, поэтому если в решении ученик формирует какую-нибудь строку или массив из конца в начало, вставляя символы в начало, то я потребую переделать решение и формировать строку в обратном порядке, а в конце развернуть.
Во-вторых, я смотрю на общий стиль оформления кода, в первую очередь отступы. Есть преподаватели, которые требуют супер-строгого соблюдения конкретного стиля написания кода (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