Раздел 28 · Clojure

Алгопрактика на Clojure

middle~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: задачи с собеседований на Clojure решаются теми же идеями, что на любом языке (хеш-таблица, очередь, свёртка), только без изменяемого массива под рукой. Чаще всего это помогает: нужная функция уже есть в clojure.core, а состояние цикла видно в loop целиком. Где неизменяемость мешает скорости, локально берут транзиент.

Студия, которая делает «Хроники Пограничья», зовёт тебя на собеседование. Интервьюер открывает редактор с REPL и говорит: «Пиши на чём удобно». Ты выбираешь Clojure, и первая задача звучит так: «В лавке список цен. Есть ли два разных предмета, которые вместе стоят ровно столько, сколько у героя золота?»

На Python ты бы завёл множество и прошёл циклом по ценам. На Clojure то же самое, только цикл это reduce, а множество не меняется, а пересобирается:

Один проход, O(n). reduced останавливает свёртку, как только пара нашлась. Вторая строка показывает ловушку, которую интервьюер заметит сразу: если пары нет, reduce вернул накопленное множество, а не false. Множество истинно, и функция соврала. Чинится обёрткой (true? ...) или явной проверкой в конце. Проговори это вслух: крайний случай «ответа нет» на собеседовании проверяют первым.

В задаче ниже ты напишешь вариант, который возвращает индексы пары. Дальше пройдём по типовым задачам и посмотрим, какой инструмент Clojure закрывает каждую.

Частоты и группировка

Половина задач с собеседований это «посчитай» или «сгруппируй». В Clojure для них есть готовые функции из урока про библиотеку последовательностей:

Группировка анаграмм уложилась в одну строку: ключ группы это отсортированные буквы слова, group-by сам собирает слова с одинаковым ключом. В уроке про паттерны на Janet то же решение заняло цикл с таблицей.

sort-by с juxt сортирует по нескольким ключам сразу: сначала по убыванию частоты, при равенстве по имени. Это сортировка «топ-N», которая встретится в финальном проекте. sorted-map держит ключи в порядке, когда ответ надо выдать отсортированным.

Два указателя без указателей

Если цены отсортированы, пару можно найти без множества: один указатель с начала, другой с конца. Сумма больше нужной, двигаем правый, меньше, левый. В Clojure указатели это два числа в loop, а вектор отдаёт элемент по индексу почти за O(1) (честно за O(log₃₂ n), как в уроке про персистентность):

Всё состояние алгоритма в двух связываниях loop, и каждый recur явно говорит, какой указатель сдвинулся. Ошибиться с «забыл увеличить i» здесь труднее, чем в while с изменяемыми переменными: recur требует назвать оба новых значения.

Обход в ширину

Карта Пограничья это граф: области и тропы между ними. «За сколько переходов дойти от деревни до замка?» это обход в ширину. Граф удобно хранить мапой из вершины в множество соседей. Классический BFS держит очередь, но в Clojure его часто пишут слоями: текущий фронт это множество, следующий собирается из соседей фронта.

Каждый виток loop это один слой, steps считает слои. Множество seen не даёт вернуться назад, иначе обход ходил бы между деревней и лесом вечно.

Если нужна настоящая очередь, она в Clojure тоже есть и тоже неизменяемая. На JVM это clojure.lang.PersistentQueue/EMPTY, в ClojureScript литерал #queue []:

(def trails {:village #{:forest :river} :forest #{:ruins} :river #{:bridge}
             :bridge #{:castle} :ruins #{} :castle #{}})

(defn bfs-order [graph start]
  (loop [queue (conj clojure.lang.PersistentQueue/EMPTY start)
         seen #{start}
         order []]
    (if-let [node (peek queue)]
      (let [fresh (remove seen (sort (graph node)))]
        (recur (into (pop queue) fresh) (into seen fresh) (conj order node)))
      order)))

(def order (bfs-order trails :village))
(println order)
(assert (= [:village :forest :river :ruins :bridge :castle] order))

peek смотрит голову, pop возвращает очередь без неё, into добавляет в хвост. Все три операции не трогают старую очередь.

Динамика: reduce и memoize

Динамическое программирование в императивном языке это массив, который заполняется в цикле. В Clojure массив становится вектором, а цикл свёрткой. «Сколькими способами собрать 10 золотых монетами 1, 2 и 5?»:

Внешняя свёртка идёт по монетам, внутренняя по суммам. (w (- a coin)) берёт уже посчитанное число способов для суммы поменьше. Та же задача в уроке про графы и динамику на Janet решена изменяемым массивом, сравни, насколько близки решения.

Второй способ это рекурсия сверху вниз с запоминанием. memoize превращает функцию в такую, которая помнит прошлые ответы. Рекурсивную функцию для этого объявляют через def, чтобы внутренний вызов шёл уже в запомненную версию:

Без memoize вызов (stairs 80) считался бы дольше, чем живёт сервер: дерево вызовов растёт как числа Фибоначчи. С ним каждый n считается один раз.

Когда честнее транзиент

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

Снаружи функция чистая: вход число, выход обычный вектор. Изменяемость живёт только между transient и persistent!. Так устроены frequencies, into и group-by внутри clojure.core, поэтому сами они уже быстрые. Транзиент пишут руками, когда профилировщик показал горячий цикл. На собеседовании хватит сказать, что такой приём есть и когда он оправдан.

Шпаргалка

ЗадачаИнструмент
Посчитать повторыfrequencies
Сгруппировать по признакуgroup-by
«Видели ли мы это раньше»множество в reduce, reduced для выхода
Топ-N с правилом для равныхsort-by с juxt, take
Два указателя, окноloop с индексами и recur
Кратчайший путь по шагамBFS слоями множеств или PersistentQueue
Динамика снизу вверхreduce над вектором ответов
Динамика сверху внизmemoize
Горячий циклtransient и persistent!

Попробуй руками

Две задачи. В первой лавка: найти индексы пары предметов на сумму золота и самую длинную серию дней подряд, когда игрок заходил в игру. Во второй подземелье: кратчайший путь по сетке и число путей до выхода.

Проверь себя

Задание

Дальше

Разминка закончена. В финальном проекте те же frequencies, sort-by и трансдьюсеры разберут настоящий events.log курса аналитика, и ответы сойдутся с его цифрами.

домашка

Домашка