Rosalind.info — динамическое программирование

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

Формула чисел Фибоначчистек будет содержать n вызовов F(n - 1), F(n - 2), …, F(1), F(0). Это означает, что при значениях n около 1000 стек вызовов Python исчерпается, возбудив ошибку времени выполнения (RuntimeError).

Для того чтобы избежать переполнения стека, можно воспользоваться техникой динамического программирования. В широком смысле динамическое программирование означает, что решение задачи зависит от решения ограниченного числа подзадач меньшего размера; очевидно, это утверждение справедливо для рекуррентных отношений. Вместе с тем, зачастую под динамическим программированием подразумевается частный способ его реализации, при котором вычисления производятся «снизу вверх» (bottom-up approach). Это означает, что сначала вычисляются простые задачи, а затем на их основе агрегируются решения более сложных задач. Именно этот подход позволяет избавиться от рекурсии и, следовательно, переполнения стека.

Динамическое программирование широко используется в биоинформатике, прежде всего для выравнивания биополимеров (участков ДНК и белков). В системе Rosalind.info выравниванию посвящены задачи EDIT, EDTA, GLOB, LOCA, GAFF, LAFF, OSYM, KSIM и другие. Другое применение динамического программирования — распознавание с использованием скрытых марковских моделей (алгоритм Витерби).

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

Значение n-го числа Фибоначчи зависит от значений чисел с меньшим индексом F(m), m < n; если их значения известны, то F(n) вычисляется тривиально. Следовательно, для вычисления n-го числа Фибоначчи достаточно определить в порядке возрастания все предыдущие числа. Декоратор, соответствующий этой логике, напоминает декоратор @cached:

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

Несколько аргументов

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

Биномиальные коэффициенты C(n, k) — количество способов выбрать подмножество размера k из n различимых объектов.

Формула вычисления биномиальных коэффициентов

Функция, возникающая при определении расстояния Левенштейна (англ. edit distance) между двумя строками s и t и равная расстоянию между префиксами этих строк определенной длины.

Формула расстояния Левенштейна

(Аналогичные функции возникают при выравнивании биологических последовательностей.)

Для обобщения декоратора @dp можно определить взаимно однозначное отображение I между пространством аргументов рекуррентно заданной функции F и целыми неотрицательными числами, которое упорядочивает аргументы в том порядке, в котором нужно вычислять F:

Упорядочивание наборов аргументов функцииДля биномиальных коэффициентов наборы аргументов можно упорядочить в виде треугольника:

(0, 0),
(1, 0), (1, 1),
(2, 0), (2, 1), (2, 2),
(3, 0), (3, 1), (3, 2), (3, 3), …

В качестве функции упорядочивания можно взять

При вычислении расстояния Левенштейна наборы аргументов упорядочиваются в виде прямоугольника:

(  0, 0), (  0, 1), …, (  0, |t| - 1), (  0, |t|),
(  1, 0), (  1, 1), …, (  1, |t| - 1), (  1, |t|),
…,
(|s|, 0), (|s|, 1), …, (|s|, |t| - 1), (|s|, |t|).

Функция упорядочивания в этом случае будет проще:

Обобщенный декоратор с использованием прямого и обратного преобразования пространства аргументов будет выглядеть так:

Генераторы

Хоть декоратор @lindp и работает, он не очень питоничен. Его можно улучшить за счет использования итераторов. Итератор — это объект с методом next(), при вызовах которого возвращаются элементы некоторой последовательности. Пусть есть итератор, который возвращает упорядоченные наборы аргументов для рекуррентного отношения; тогда декоратор динамического программирования приобретает вид

Обычные итераторы коллекций, созданные с помощью функции iter(…), не совсем годятся для перебора наборов аргументов — как-никак, количество таких наборов бесконечно; сколько их понадобится, неизвестно до вызова функции. К счастью, в Python есть средство для создания «бесконечных» итераторов — генераторы. Генератор выглядит как функция, в которой значения возвращаются с помощью ключевого слова yield вместо return. При каждом вызове метода next() код генератора выполняется до первой инструкции yield; аргумент этой инструкции и есть результат, возвращаемый next(). Затем состояние функции-генератора сохраняется и его выполнение прекращается до следующего вызова метода next().

С использованием генератора вычисление биномиальных коэффициентов приобретает следующий вид:

Бочка дегтя

Несмотря на то, что имплементация рекуррентных отношений в функциональном стиле красива, полученные с их помощью решения работают в Python существенно медленнее, чем императивные реализации (аналогичные реализациям, например, в Java или C++). В случае с кэшированием не всегда ясно, как можно реализовать метод по-другому; для динамического программирования последовательность вычисления рекуррентной функции известна, а значит, проблем с императивной реализацией вычислений «снизу вверх» не должно возникать.

Таким образом, в отличие от кэширования, реализация динамического программирования при помощи декораторов и генераторов носит скорее теоретическую ценность — показать возможности Python.

Ссылки

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *