Рекурсия + мемоизация = динамическое программированиеДмитрий Астапов |
Аннотация: Статья рассказывает о том, как ленивая модель вычислений, принятая в языке Haskell, помогает кратко и эффективно реализовывать алгоритмы с использованием метода динамического программирования.
The article shows how lazy evaluation combined with memoization leads to succinct and efficient implementations of various dynamic programming algorithms
Обсуждение статьи ведётся по адресу
http://community.livejournal.com/fprog/4277.html.
1 Введение
Сложно найти программу на функциональном языке, которая не использовала бы рекурсивные или взаиморекурсивные вызовы функций. Циклические вычисления (которые в императивном языке требуют управляющих конструкций типа for, while, foreach) записываются либо с помощью функций высших порядков, либо рекурсивно. Сложные структуры данных часто обрабатываются рекурсивно (это называется «структурная рекурсия»). Кроме того, нередко используются и рекурсивно порождаемые структуры данных, классический учебный пример — треугольник Паскаля.
Однако при реализации и использовании рекурсивных алгоритмов легко допустить одну популярную ошибку: одни и те же вычисления могут без необходимости повторяться многократно. Рассмотрим для примера простейшую реализацию на Haskell функции, вычисляющей числа Фибоначчи:
Если мы попробуем вычислить с ее помощью 100 первых чисел Фибоначчи, то обнаружим, что с каждым следующим числом скорость вычисления ощутимо падает. Происходит это потому, что для получения каждого последующего числа выполняется вычисление всех предыдущих чисел, причем многих — по несколько раз, и общая временная сложность алгоритма получается Θ(φn), где φ = 1+√5/2.
Данная статья (с примерами на Haskell) посвящена тому, как эффективно — со сложностью порядка Θ(n) или Θ(n logn) — реализовывать подобные рекурсивные алгоритмы.
В качестве примеров для этой статьи взяты задачи из отборочного раунда ежегодного конкурса программистов Google Code Jam 2009.
Полные исходные тексты программ, описываемых в этой статье, можно найти на сайте журнала.
2 Задача 1: Подсчет числа подпоследовательностей
Дана текстовая строка. Необходимо посчитать, сколько раз в ней встречается подпоследовательность символов «welcome to code jam». Для этого необходимо найти в исходной строке одну из букв «w», правее нее — букву «e», и так далее до буквы «m». Говоря более формально, для данной строки input необходимо посчитать, сколькими способами можно выбрать индексы s0, s1, … ,s18 так, что s0 < s1 < … < s18 и конкатенация input[s0], input[s1], … , input[s18] дает строку «welcome to code jam»1.
В оригинальном решении авторы просят вывести только последние четыре цифры получившегося значения, то есть расчеты можно вести по модулю 10000. В этом есть практический смысл: даже для относительно небольших строк количество вариантов может представлять 20-значное число, что затрудняет чтение и проверку результатов. Примеры, иллюстрирующие статью, также будут использовать модульную арифметику.
Подобные задачи встречаются в реальной жизни: выравнивание последовательностей (англ. sequence alignment) генов в биоинформатике (см. [5], [10, стр. 539]), компьютерный анализ и генерация текстов на естественных языках (см. [8]), метод «критического пути» (англ. critical path method) для оценки длительности проектов.
2.1 Наивное решение
Попробуем дать простое словесное описание алгоритма, позволяющего подсчитать, сколько раз строка-шаблон встречается как подпоследовательность в другой строке (области поиска).
Если область поиска пуста, то количество вхождений, очевидно, равно нулю. Если же пуст шаблон, то результат положим равным единице — считаем, что пустой шаблон можно «совместить» с концом строки и таким образом он входит в произвольную строку ровно один раз.
Если же и шаблон, и область поиска не пусты, то необходимо: найти первую букву шаблона в области поиска; посчитать, сколькими способами можно разместить остаток шаблона правее этой точки; прибавить количество способов, которыми можно разместить весь исходный шаблон правее этой точки (то есть, начиная с другой стартовой позиции).
Таким образом, имеем рекурсивное определение, которое можно практически дословно перевести в код на Haskell:
Чтобы получить решение изначальной задачи, необходимо реализовать чтение исходных данных из файла и вывод правильно отформатированных результатов вычислений, но этот код не имеет непосредственного отношения к теме статьи и тут не приведен. Полный текст программы можно найти в файле naive.hs в директории welcome_to_code_jam архива с исходными текстами.
2.2 Проблемы с производительностью
Посмотрим, как будет работать указанная функция при поиске шаблона «jam» в строке «jjaamm». Поскольку первые буквы шаблона и области поиска совпадают, результат будет состоять из суммы двух значений (взятие результата по модулю 10000 для простоты опущено):
Распишем следующий шаг рекурсии. Чтобы вычислить выражение в строке 2, нужно пропустить следующую букву области поиска, а чтобы вычислить выражение в строке 3, нужно снова вычислить сумму результатов рекурсивных вызовов:
Значение в строке 3 будет равно нулю, так как остаток области поиска больше не содержит букв «j». Выражения в строках 1 и 2 полностью совпадают, что в будущем приведет к многократному вычислению одних и тех же значений. Посмотрим, что произойдет на следующем шаге:
Полностью последовательность вызовов для этого примера представлена на рисунке 1, при этом там изображены только значения параметров, а имя функции occurs опущено. Легко заметить, что по мере продвижения к концу области поиска объем повторяющихся вычислений очень быстро растет (временная сложность реализации Θ(Cnm), где n — длина области поиска, а m — длина шаблона).
Таким образом, налицо разбиение исходной задачи на перекрывающиеся подзадачи, причем оптимальное решение задачи размера N можно вычислить на основании решений задач размера N−1.
Если бы подзадачи не имели перекрытий, то использованный подход (разбить задачу на подзадачи, рекурсивно их решить, а решения — объединить) был бы оптимальным по скорости. Такой способ решения называется «разделяй и властвуй» (англ. divide and conquer). Однако в нашем случае подзадачи перекрываются, и это является прямым показанием для применения метода динамического программирования (см. [12], [1], [7]).
2.3 Решение с использованием динамического программирования
Ключевой идеей динамического программирования является запоминание решений подзадач и дальнейшее их использование без дополнительных повторных вычислений. Применительно к нашей задаче это означает, что нужно запоминать значения occurs p s для разных значений p и s, начиная с самых коротких. Например, для эффективного вычисления occurs "jam" "jjaamm" наверняка необходимо запомнить результаты вычисления occurs "m" "m", occurs "m" "mm", occurs "am" "amm" и так далее.
При использовании языков с энергичной моделью вычислений (англ. eager evaluation) переход от наивного решения к решению с использованием динамического программирования почти всегда заключается в полном или существенном переписывании кода для того, чтобы он работал «от конца к началу». То есть, сначала вычисляются подзадачи минимального размера и запоминается их результат, а затем запомненные значения используются для вычисления подзадач большего размера, и так далее. В нашей задаче, например, необходимо изменить порядок сканирования шаблона и области поиска так, чтобы он происходил от последних символов к первым. Из-за этого многие начинающие программисты не могут написать решение задачи с использованием динамического программирования «с нуля» — им тяжело переиначить «в уме» интуитивно понятный наивный подход к решению.
Кроме того, далеко не всегда требуется вычислять результаты всех возможных подзадач для того, чтобы найти решение исходной задачи. Однако определение того, какие именно подзадачи можно пропустить, может быть достаточно нетривиально.
Благодаря тому, что в Haskell используется ленивая модель вычислений, можно обойтись без переписывания кода и размышлений о том, что и в каком порядке надо вычислять и запоминать. Решение общей задачи может ссылаться на сохраненные решения более мелких подзадач еще до того, как они фактически будут вычислены.
Вот как это выглядит на практике: предположим, что у нас есть ассоциативный массив cache, ставящий в соответствие паре строк (p, s) искомое количество вхождений p в s. Тогда функция occurs сведется к тривиальному поиску нужного значения в этом массиве:
Как же сформировать массив cache? Если взять реализацию примитивного алгоритма из предыдущего раздела (изменив имя функции на occurs’), то можно построить cache таким образом:
В cache будут помещены результаты вычисления occurs’ для всех возможных суффиксов (окончаний) строк pattern и str. Благодаря тому, что модель вычисления — ленивая, такое объявление помещает в ассоциативный массив только код для отложенного вызова occurs’. В англоязычной литературе этот служебный код называют thunk. Когда функция occurs обратится за значением конкретного элемента ассоциативного массива, thunk выполнится и будет заменен на результат вычисления. Все последующие обращения к этому элементу массива будут возвращать готовый результат без дополнительных вычислений.
Раз уж cache содержит все возможные результаты occurs’, можно использовать его для того, чтобы ускорить работу самой функции occurs’:
Таким образом, получаем косвенную рекурсию: функция occurs извлекает значения из cache, куда они попадают в результате вычисления функции occurs’, которая обращается к occurs.
Базой рекурсии являются два первых уравнения функции occurs’ — именно к ним рано или поздно сойдутся все рекурсивные вызовы.
Полностью код, вычисляющий решение поставленной задачи, будет выглядеть так:
Обратите внимание, что объявление cache не имеет параметров, а вместо этого обращается к именам str и pattern, определенным в той же самой области видимости (блоке where). Это сделано для того, чтобы вызовы solve с любым значением параметра str использовали один и тот же cache и не создавали его заново каждый раз. Таким образом могут быть ускорены вызовы solve для значений str, содержащих одинаковые подстроки. По той же причине значение pattern не передается параметром в solve, а статически определено локально.
Получается, что значение любого элемента ассоциативного массива cache — это либо 0, либо 1, либо ссылка на другой элемент массива, либо сумма из каких-то двух других элементов массива. Полная схема связей между элементами cache при вычислении occurs "jam" "jjaamm" представлена на рисунке 2. Вычислительная сложность этой реализации — Θ(n2 logn), так как требуется n операций доступа к Map, каждая из которых имеет сложность Θ(n logn).
Подобная техника запоминания («кэширования») результатов работы функции имеет название «мемоизация» и используется не только для реализации задач динамического программирования, но и везде, где происходят регулярные вызовы «тяжелых» функций с одними и теми же аргументами (см. [4], [12], [9]).
В качестве классического примера мемоизации можно привести способ быстрого вычисления чисел Фибоначчи, встречающийся во множестве учебных материалов по Haskell:
Полный текст программы можно найти в файле memoized.hs в директории welcome_to_code_jam архива с исходными текстами.
В качестве самостоятельного упражнения по дальнейшей оптимизации программы читателю предлагается дописать в определение функции occurs еще одно уравнение, возвращающее 0 в случае, если первый символ шаблона не входит в область поиска.
3 Задача 2: Водосборы
Помимо рекурсивных вычислений (вычисление чисел Фибоначчи, вычисление факториала, решение первой задачи из этой статьи) существует целый класс задач, для решения которых требуется рекурсивное создание какой-либо сложной структуры данных. В качестве примера рассмотрим еще одну задачу из отборочного тура Code Jam 2009.
Дана прямоугольная «карта местности», каждая клетка которой имеет определенную высоту, выраженную целым числом. Необходимо разделить эти карты на «бассейны водосбора» по следующим правилам:
- Из каждой клетки карты вода стекает не более чем в одном из четырех возможных направлений («север», «юг», «запад», «восток»).
- Если у клетки нет соседей с высотой, меньшей ее собственной высоты, то эта клетка — водосток, и вода из нее никуда дальше не течет.
- Иначе, вода из текущей клетки стекает на соседнюю клетку с минимальной высотой.
- Если таких соседей несколько, вода стекает по первому из возможных направлений из следующего списка: «на север», «на запад», «на восток», «на юг».
Полностью текст условия доступен на сайте Code Jam.
Все клетки, вода из которых стекает в один и тот же водосток, принадлежат одному бассейну водосбора, и обозначаются одной и той же буквой латинского алфавита. Бассейны водосбора отмечаются буквами, начиная с ’a’, в порядке, в котором они встречаются при просмотре карты сверху вниз, слева направо. Необходимо превратить карту высот в карту бассейнов водосбора, например:
1 2 3 4 5 a a a a a 2 9 3 9 6 a a b b a 3 3 0 8 7 -> a b b b a 4 9 8 9 8 a b b b a 5 6 7 8 9 a a a a a
Подобные задачи встречаются в реальной жизни при программировании роботов (отыскание пути при помощи «волнового алгоритма» или карты потенциалов — см. [11]), компьютерных игр (навигация по карте), отыскании оптимального способа выбрать несколько предметов из большого набора (задачи «упаковки рюкзака» и «набора суммы минимальным количеством банкнот»).
Типичным примером подобной задачи будет отыскание пути на доске с квадратными (шестиугольными) клетками из точки A в точку B с минимальными затратами ресурса R, если для всех возможных переходов между клетками карты известно количество ресурса, которое придется израсходовать.
3.1 Наивное решение
Легко видеть, что решение задачи можно разбить на три простых этапа: Для каждой клетки карты определить, в какую сторону с нее стекает вода. В процессе идентифицировать водостоки. Для всех найденных водостоков определить, какие клетки (прямо или косвенно) доставляют в них воду и сгруппировать клетки по этому признаку. Отсортировать группы клеток по координатам самой левой верхней клетки и пометить их буквами.
Поиск клеток, прямо или косвенно доставляющих воду в выбранный водосток, можно производить с помощью одной из разновидностей «волнового алгоритма» (см. [6]). Получается, что для отыскания решения будут выполнены несколько итераций по всем клеткам карты, с просмотром и анализом непосредственных соседей каждой клетки.
На первой итерации будут идентифицированы направления стока воды (обозначенные буквами «n», «w», «e», «s»), а также клетки-водостоки, обозначенные буквой «S»:
1 2 3 4 5 S w w w w 2 9 3 9 6 n n s w n 3 3 0 8 7 -> n e S w n 4 9 8 9 8 n n n n n 5 6 7 8 9 n w w w n
На второй — отнесены к тому или иному бассейну водосбора клетки, непосредственно соседствующие с водостоками (цифры обозначают принадлежность к тому или иному бассейну):
S w w w w 1 1 w w w n n s w n 1 n 0 w n n e S w n -> n 0 0 0 n n n n n n n n 0 n n n w w w n n w w w n
На следующей итерации к бассейнам будут присоединены клетки, отстоящие от водостоков на два шага:
1 1 w w w 1 1 1 w w 1 n 0 w n 1 1 0 0 n n 0 0 0 n -> 1 0 0 0 n n n 0 n n n 0 0 0 n n w w w n n w w w n
И так далее, пока не будут классифицированы все имеющиеся клетки:
1 1 1 w w 1 1 1 1 w 1 1 1 1 1 1 1 1 1 1 1 1 0 0 n 1 1 0 0 n 1 1 0 0 n 1 1 0 0 1 1 0 0 0 n -> 1 0 0 0 n -> 1 0 0 0 n -> .. -> 1 0 0 0 1 n 0 0 0 n 1 0 0 0 n 1 0 0 0 n 1 0 0 0 1 n w w w n n w w w n 1 w w w n 1 1 1 1 1
Если реализовывать «волновой алгоритм» нерекурсивно, то количество итераций будет прямо пропорционально размеру самого большого бассейна водосбора. Временная сложность такого наивного решения будет Θ(n4).
А можно ли обойтись всего одной итерацией по карте для того, чтобы сгруппировать все клетки по бассейнам водосбора?
3.2 Решение с одной итерацией и мемоизацией
Итак, мы хотим для каждой клетки карты вычислить координаты ее водостока. Можно сформулировать простой и медленно работающий рекурсивный алгоритм: для данной клетки определить направление стока воды. Далее, определить куда стекает дальше вода на следующем шаге — и так до тех пор, пока мы не дойдем до водостока.
Естественно, что для карты большого размера такое решение будет работать недопустимо медленно. Однако легко видеть, что его можно ускорить при помощи динамического программирования. Достаточно запоминать результаты для всех клеток, которые были пройдены в ходе рекурсивного поиска.
Определим несколько простых типов данных для хранения информации о координатах клеток, карты высот и карты водостоков:
Почему для хранения карты был взят обычный массив (Array), а не ассоциативный (Map)? У Array есть четко определенные границы, которые можно узнать с помощью функции bounds. Если же хранить карту в виде Map, то потребуется отдельно хранить, передавать и обрабатывать информацию о размере — например в виде пары координат левого верхнего и правого нижнего углов ((Int,Int),(Int,Int)).
Реализация описанного алгоритма с использованием динамического программирования будет выглядеть так:
Казалось бы, вычислительная сложность этой реализации должна быть Θ(n2). Но сложность функции (//) для стандартного типа Array составляет Θ(m), где m — длина массива. Соответственно, общая временная сложность получается по-прежнему Θ(n4). В Haskell есть изменяемые (англ. mutable) массивы с обновлением элементов за Θ(1), но сравнение различных подходов к реализации массивов на функциональных языках выходит за рамки этой статьи.
В следующем разделе будет показано, как можно упростить приведенное решение и избавиться от необходимости обновлять cache.
Полный текст этой программы можно найти в файле single-pass.hs в директории watersheds архива с исходными текстами.
3.3 Решение без использования cache
Видно, что значения элементов cache вычисляются на основании значений других элементов cache — наподобие того, как это происходило в задаче «Welcome To Code Jam». Однако взаимные рекурсивные обращения друг другу функций setSink, findSink и cache достаточно сложно проследить без детального анализа кода. Можно ли сократить и упростить программу, избавившись от явного упоминания cache — так, чтобы рекурсивная природа setSink и findSink была более очевидна?
Вспомним, что результат flow arr — это свертка исходного массива функцией setSink, при этом суть свертки заключается в том, чтобы породить искомый массив водостоков путем заполнения элементов cache.
Предположим, что мы можем непосредственно вычислить все элементы искомого массива и таким образом исключить создание cache и свертку arr для заполнения cache значениями. Тогда функция flow будет выглядеть так:
Функция, которой тут дано имя f, должна заменить собой и setSink, и findSink.
Как именно выглядит f, пока не ясно, но можно с уверенностью сказать, что она будет использовать в своих вычислениях result — так как и setSink, и findSink нужны результаты поиска водостоков для соседних с обрабатываемой клеток.
Выясняется, что если использовать данное ранее определение neighbors, то функцию f можно реализовать с помощью одного условного выражения:
Подобный подход используется достаточно часто, и для упрощения написания кода в таком стиле в модуле Data.Function даже определена специальная функция fix с определением:
С ее использованием функция flow может быть переписана так:
Временная сложность решения составляет Θ(n2).
Посмотрим, как работает это определение flow для простой карты высот из двух элементов: arr = array ((1,1),(1,2)) [20,10]:
Странное на первый взгляд имя функции fix объясняется тем, что она является реализацией на Haskell так называемого комбинатора неподвижной точки (см. [3], [2]). Полезные свойства этого комбинатора не исчерпываются упрощением записи рекурсивного кода, однако, всестороннее его описание требует серьезного экскурса в теорию и выходит за рамки данной статьи.
Полный текст программы можно найти в файле with-fix.hs в директории watersheds архива с исходными текстами.
В качестве самостоятельного упражнения читателю предлагается переписать решение первой задачи с использованием fix.
4 Заключение
Статья на примерах продемонстрировала, как мемоизация позволяет просто и эффективно превращать «наивные» реализации алгоритмов на Haskell в эффективные. Кроме того, как показывает пример второй задачи, эти эффективные реализации зачастую еще и более компактные и легкие для понимания.
Список литературы
- [1]
- Divide and conquer. Статья в Wikipedia (en), http://en.wikipedia.org/wiki/Divide_and_conquer_algorithm.
- [2]
- Fix and recursion. Раздел в Wiki-книге Haskell, http://en.wikibooks.org/wiki/Haskell/Fix_and_recursion.
- [3]
- Fixed point combinator. Статья в Wikipedia (en), http://en.wikipedia.org/wiki/Fixed_point_combinator.
- [4]
- Memoization. Статья в Haskell Wiki, http://www.haskell.org/haskellwiki/Memoization.
- [5]
- Sequence alignment. Статья в Wikipedia (en), http://en.wikipedia.org/wiki/Sequence_alignment.
- [6]
- Волновой алгоритм. Статья на сайте algolist.manual.ru, http://algolist.manual.ru/maths/graphs/shortpath/wave.php.
- [7]
- Динамическое программирование. Статья в Wikipedia (ru), http://ru.wikipedia.org/?oldid=18970272.
- [8]
- Sequence learning - paradigms, algorithms, and applications, 2001.
- [9]
- Maxime Crochemore, Christophe Hancart, and Thierry Lecroq. Algorithms on Strings. Cambridge University Press, New York, NY, USA, 2007.
- [10]
- Stephen A. Krawetz and David D. Womble. Introduction to Bioinformatics: A Theoretical and Practical Approach. Humana Press, 2003.
- [11]
- Roland Siegwart and Illah R. Nourbakhsh. Introduction to Autonomous Mobile Robots. Bradford Book, 2004.
- [12]
- Т. Кормен, Ч. Лейзерсон, Р. Ривест. Алгоритмы. Построение и анализ. Издание 2-е. Вильямс, 2007.
- 1
- При наличии учетной записи в Google с оригинальным авторским условием можно ознакомиться на сайте Code Jam.
Этот документ был получен из LATEX при помощи HEVEA