Несколько месяцев назад в блоге компании Яндекс вышла статья, в которой обсуждалось прохождение алгоритмической секции интервью. Помимо всего прочего, в этой статье была указана ссылка на специальный контест, содержащий задачи похожие на те, которые в Яндексе предлагают своим кандидатам. Зарегистрировавшись в системе, моё внимание сразу привлекла возможность решать задачи на Haskell. Дело в том, что я хоть и увлекаюсь программированием на этом языке, но не продвинулся дальше реализации задач из различных курсов образовательных on-line платформ. Решив, что их решение может оказаться интересным вызовом и повысит мой уровень, как разработчика, я приступил к их решению. Кому интересно, что в итоге из этого вышло, добро пожаловать под кат. Первая задача является разминочной, будем решать её «в лоб». Определим функцию jeweleryCount :: String -> String -> Int, которая с помощью свертки по переданному вторым аргументом списку просуммирует все случаи нахождения обрабатываемого элемента в первом списке. Для этих целей определим функцию elemInt на основе функции elem, которая в отличии от последней вернёт не True или False, а число 0 или 1. В функции main остаётся только считать две строки, передать их в соответствующую функцию и напечатать результат. Вердикт системы тестирования – OK, переходим ко второй задаче. Исходный код решения этой и других задач также доступен в github-репозитории  Для решения этой задачи реализуем рекурсивную функцию, которая будет проходить по переданному списку и вычислять длину требуемой последовательности. Аргументами функции кроме самого списка будем передавать текущий максимум длины и количество подряд идущих единиц на текущем вызове. Сначала определим базу рекурсии на пустом списке, а затем и сам шаг рекурсии. Для чтения входных данных определим функцию getUserInputs :: IO [Char], в которой сначала прочитаем число n – размер списка, а затем с помощью комбинатора replicateM получим функцию, которая n раз выполнит вызов функции head <$> getLine и объеденит полученные результаты в список. Отправляем решение, вердикт — OK. Двигаемся дальше. Начнём с простой реализации. Определим функцию initial, которая считывает число, печатает его и возвращает завернутым в монаду IO. Также определим функцию deleteDoubles :: Int -> Int -> IO(), которая считывает число и печатает в его только в случае, если оно не равно второму аргументу (будем передавать туда число прочитанное на предыдущем шаге). После этого функция рекурсивно вызывает сама себя и таким образом переходит к следующему числу во входном потоке. Базой рекурсии является количество чисел, которое предстоит прочитать, будем передавать его первым аргументом. Отправляем решение, оно проходит все тесты, и, казалось бы, можно двигаться к следующей задаче, но на мой взгляд рекурсивный вызов функции, работающей в монаде IO, является скорее запутанным, чем лаконичным. Попробуем его улучшить. Заметим, что, вообще говоря, можно сначала прочитать весь список чисел (воспользуемся уже знакомым по решению второй задачи комбинатором replicateM), затем передать его в чистую функцию, которая отфильтрует все повторения, и в итоге напечатать результат. Отправляю решение, и первое разочарование – программа не проходит 193 тест из-за превышения лимита используемой памяти. Главная ошибка – чтение всего списка в память целиком. Попытаемся этого избежать и реализуем некий гибрид первой и второй версии. Заметим, что задача по удалению дубликатов чем-то напоминает левоассоциативную свертку: на каждом шаге мы вычисляем функцию, которая в зависимости от текущего прочитанного элемента и некого своего результата на предыдущем шаге принимает решение о печати, после чего переходит к следующей паре значений. Функция, которая печатает или не печатает результат в зависимости от своих аргументов, после чего возвращает свой второй аргумент, завёрнутый в монаду IO, довольно проста, назовём её step: С печатью или не печатью в зависимости от переданных значений мы разобрались, но как организовать чтение? Для этого воспользуемся функцией монадической свертки foldM :: (Foldable t, Monad m) => (b -> a -> m b) -> b -> t a -> m b, которую применим к списку функций чтения.
По типу функции foldM заметим, что на каждом шаге «распаковка» результата прошлого применения функции происходит под капотом самой foldM. Таким образом, на каждом шаге нам необходимо только запустить монадическое вычисление текущего элемента списка (по сути – прочитать следующее число) с помощью оператора bind (>>=) и вместе с предыдущим числом передать в step. В итоге получаем следующую программу  Эта задача, как и многие другие, в которых необходимо выводить последовательности, удовлетворяющим определенным условиям (например задачи о размене монет, расстановки восьми ферзей и другие, более подробно можно почитать здесь), лаконично решается с помощью монады списков. Если кратко, данный подход основывается на монадическом связывании для списков, смысл которого заключается в соединении вместе набора операций, производимых над каждым элементом списка. Определим рекурсивную функцию  generate' :: Int -> Int -> [[Char]], которая вторым аргументом принимает количество скобок, которое ещё предстоит поставить, а первым – количество уже поставленных незакрытых открывающих скобок. Для шага рекурсии нам понадобится две вспомогательные функции: possible — возвращает список скобок, которые можно разместить на следующем шаге, и step – производит рекурсивный вызов функции generate' с необходимыми параметрами. Отправляем решение, и понимаем, что мы не учли ограничение, которое накладывалось на используемое программой количество памяти – решение не проходит 14 тест из-за превышения лимита используемой памяти. Модифицируем функцию generate' таким образом, чтобы она вместо конструирования всего списка правильных скобочных последовательностей сразу выводила их на экран. Для этого нам придётся добавить третий аргумент к функции – фрагмент последовательности, сконструированный к текущему шагу. Отмечу, что в данной реализации будем конструировать последовательность в обратном порядке – это позволит нам использовать конструктор списка (:) вместо более дорогостоящего оператора конкатенации (++). Для решения этой задачи будем подсчитывать сколько раз в каждой строке встречается та или иная буква и сравнивать полученные результаты. Сразу понимаем, что стандартные списки нам не подходят, и необходимо использовать структуру данных, которая бы позволила эффективно обращаться к элементу по его индексу. Существует несколько типов данных, которые бы удовлетворяли нашим условиям, мы же воспользуемся стандартным неизменяемым массивом Data.Array (ещё существуют как минимум различные изменяемые массивы, а также Data.Vector).  Для конструирования необходимых массивов воспользуемся функцией hist :: (Ix a, Num b) => (a,a) -> [a] -> Array a b, которая по переданному списку элементов и диапазону, которому данные элементы должны принадлежать, формирует массив, который хранит в себе количество повторов элементов из списка. Данная функция хоть и не входит в модуль Data.Array, но часто приводится как пример использования другой, уже библиотечной функции accumArray. Нам остаётся только скопировать её реализацию и написать main – благо сравнение на равенство для Array Char Int уже определено. Обращаю ваше внимание на одну приятную особенность – в качестве индекса мы можем воспользоваться не только целыми числами, а любым представителем класса Ix. В нашем случае на эту роль естественным образом подходит Char. Слияние двух отсортированных списков является классической задачей на списки и рассматривается во множестве курсов посвящённых программированию на Haskell. Например, её можно решить следующим образом.  Хорошо, мы умеем производить слияние двух списков. А что нам делать со списком списков? Выполнить его свёртку с этой функцией! Таким образом мы объединим все списки в один, и нам останется его только распечатать.  Однако, у этого решения имеются две серьёзные проблемы – вычислительная сложность оказывается выше требуемой — O(k^2 ⋅ n) вместо О(k ⋅ log(k) ⋅ n), плюс ко всему оно использует довольно много дополнительной памяти. Как итог, такое решение проваливает тест номер 17 из-за превышения лимита используемой памяти — 17.27Mb вместо разрешенных 10Mb.  Пока не будем обращать внимание на тот факт, что числа, подаваемые на вход, принадлежат ограниченному диапазону значений, и продолжим искать решения для более общего случая.  Следующим шагом попробуем реализовать подход, который был предложен в исходной статье с разбором данных задач. Напомню, он основывается на использовании структуры данных, предоставляющей эффективный способ извлечения минимального элемента. В качестве такой структуры выберем Data.Set. Инициализируем Set списком первых элементов, затем на каждом шаге будем извлекать и печатать минимальный элемент, после чего добавлять следующий элемент из соответствующего списка. Кроме этого, нам понадобится структура Data.Sequence для хранения самих списков. Она была выбрана из соображений, что на каждом шаге необходимо как иметь быстрый доступ к списку по его индексу (что не может обеспечить список), так и изменять элемент этот элемент без необходимости копирования всей структуры (что в общем случае не может обеспечить неизменяемый Data.Array). Таким образом, имеем следующую программу: Отправляем решение и узнаём, что хоть программа и стала потреблять память значительно меньше (10.26Mb вместо 17.27Mb на 17 тесте), она всё равно не уложилась в лимит. Причина этого кроется в том, что при таком решении нам так или иначе приходится целиком читать в память входные данные. Попробуем избежать этого с помощью третьего варианта решения данной задачи – сортировкой подсчётом. Мы уже выполняли подсчёт количества входящих символов при решении предыдущей задачи об анаграммах. Также, как и при её решении, воспользуемся Data.Array. Для начала реализуем функцию addToArray :: Array Int Int -> [Int] -> Array Int Int, которая формирует массив на основе существующего путём увеличения значений по индексам, которые соответствуют значениям из списка. Затем, воспользуемся подходом, известным нам по задаче об удалении повторов — с помощью монадической свёртки последовательно применим функцию addToArray к k исходным массивам. И… получаем всё тот же результат 10.26Mb на 17 тесте. И тут самое время вспомнить что foldl (аналогом которого является foldM) согласно принятому порядку редукции сначала развернёт всю цепочку вложенных выражений и только потом приступит к их активному вычислению. Как известно, для борьбы с этим фактом в модуле Data.List реализована функция foldl', использующая функцию seq :: a -> b -> b, которая сначала приводит первый аргумент в слабую головную нормальную форму, то есть редуцирует до получения внешней части — значения функции или конструктора, а затем возвращает второй (https://www.ibm.com/developerworks/ru/library/l-haskell4/index.html). Нам же ничего не остается делать кроме того как самостоятельно реализовать функцию foldM'. В результате количество используемой памяти на 17 тесте снизилось почти в два раза и составило 5.64Mb! И хотя 17 и 18 тесты были успешно пройдены, данная реализация не проходила уже 19 тест по той же причине превышения лимита использования памяти — 10.25Mb. Окей, идём дальше — мы ещё не попробовали Data.Array.Unboxed. Этот вид массивов примечателен тем, что, в отличии от стандартного, своими элементами может хранить сами значения, а не указатели на них (https://wiki.haskell.org/Arrays#Unboxed_arrays). Благодаря этому, такие массивы занимают меньшее пространство в памяти и более производительны. Для того чтобы ими воспользоваться нам необходимо только поменять импорт и типы функций, так как Data.Array и Data.Array.Unboxed реализуют один интерфейс неизменяемых массивов IArray. Отправляем решение – потребление памяти снизилось в 4.5 раза до 2,26 MB, но оно не прошло уже ограничение по времени — время исполнения составило 1.09 секунды. С чем это может быть связано? Судя по тому, что время исполнения остальных тестов осталось прежним, думаю, что причина не в том, что unboxed-массив оказался медленнее boxed, а в особенности системы тестирования. Похоже, выполнение задачи прерывается, как только нарушено одно из ограничений. Однако, в очень редких случаях эта реализация всё-таки проходит 19 тест с результатом 0.98 секунды, но заваливает тест номер 20 также из-за превышения лимита времени. После этого я попробовал воспользоваться unsafe аналогом функции accum, которая в теории должна быть быстрее, различными способами буферизации (функция hSetBuffering :: Handle -> BufferMode -> IO ()) и изменяемыми массивами IOArray, но не один из этих способов не принёс никаких результатов. Я не склонен считать, что лимиты для Haskell заданы слишком жестко, и надеюсь, что всё-таки существует решение, которое пройдёт все тесты. В репозиторий проекта я выложил несколько различных версий кода решения этой задачи (с Array и IOArray), возможно это станет отправной точкой для решения, которое пройдёт уже все тесты. Даже несмотря на то, что по итогу мне поддались только пять задач из шести, свою главную задачу – попрактиковаться в функциональном программировании, я выполнил. Не последнюю роль в этом сыграли и жесткие ограничения на потребляемые программой ресурсы, которые вынуждали искать всё новые и новые подходы к решению задач. Надеюсь их описание будет полезно для тех, кто только начинает свой путь в функциональном программировании Оказался ли функциональный подход удобен для решения подобного рода задач? Честно говоря, у меня осталось двоякое впечатление. С одной стороны, решения большинства задач оказывались весьма лаконичными, и не последнюю роль в этом сыграли выразительные средства самого Haskell, а также его богатая стандартная библиотека. С другой, нельзя не признать, что в большинстве случаев управление потребляемыми ресурсами может представлять собой определенную проблему, что не позволит решить задачу в заданных ограничения.