Rosalind.info — алгоритмы на деревьях

Во многих приложениях биоинформатики используются деревья, то есть связные графы без циклов. В частности, деревья — удобный инструмент для описания взаимоотношений родства между биологическими видами (так называемые филогенетические или эволюционные деревья). Для определения отношений используются морфологические признаки (размер индивидов, цвет волосяного покрова и тому подобное) и / или данные биохимических исследований (например, различающиеся нуклеотиды в генах или, соответственно, аминокислоты в аналогичных белках). Построением филогенетических деревьев занимается отдельная область биоинформатики — вычислительная филогенетика; методы создания деревьев обычно основаны на машинном обучении (например, иерархической кластеризации) или математической статистике (тестирование статистических гипотез, выбор наиболее вероятной вероятностной модели).

В системе Rosalind.info филогенетические деревья используются в нескольких задачах:

  • построение дерева  на основе признаков (CHBP);
  • восстановление генетической информации предков на основе этих данных у потомков (ALPH);
  • сравнение двух филогенетических деревьев с  помощью расстояния на основе разделений (англ. split) и квартетов (англ. quartet) — SPTD и QRTD, соответственно.

Имеет смысл рассмотреть общие задачи, полезные для их решения:

  • построение дерева на основе текстового представления;
  • вычисление рекуррентных функций на деревьях;
  • сравнение деревьев между собой.

Хранение деревьев

Первая задача, которая возникает при работе с деревьями — их хранение в памяти. В случае Python имеет смысл использовать объектно-ориентированный поход, представив все вершины дерева в виде объектов класса Tree, обладающего следующими полями:

  • data — данные, связанные с вершиной, например название биологического вида, связанного с вершиной;
  • parent — родительская вершина, None для корня дерева;
  • children — дочерние вершины.

Помимо этого, для минимальной функциональности класс Tree содержит методы append() и prepend(), добавляющие поддерево в конец и начало списка дочерних вершин, соответственно.

Для текстового представления деревьев в филогенетике часто используется формат Newick. Согласно формату, дерево рекурсивно записывается как ограниченное скобками перечисление дочерних вершин через запятую, за которым могут следовать текст, связанный с корневой вершиной дерева. Запись завершается точкой с запятой. Формально с использованием БНФ-образной записи синтаксиса:

TREE ::= NODE ';'
NODE ::= '(' NODE ( ',' NODE )* ')' data
data ::= [^,();]*
Пример дерева
Дерево, соответствующее записи ((,A,)B,((C,D),E),(F,G))H;

Задачу создания дерева на основе записи можно решить с помощью произвольного генератора LR- или LL-анализаторов (см. список инструментов для синтаксического анализа для Python). С другой стороны, поскольку грамматика Newick-записи настолько проста, анализатор легко пишется вручную; его можно выделить в отдельный метод класса Tree. Разбор удобнее проводить справа налево, с конца текстового представления в начало. Во время разбора хранятся два объекта класса Tree:

  • node — последняя созданная вершина дерева;
  • parent — вершина, в которую в данный момент добавляются дочерние вершины.

Первоначально node = None, а parent равняется вершине, для которой вызван метод.

На каждом этапе синтаксического разбора создается новая вершина node, после чего читаются связанные с ней данные. Эти данные соответствуют регулярному выражению [^,();]*, то есть корректно обрабатываются вершины без связанных данных. Новая вершина не создается, если последним обработанным символом была открывающаяся скобка, поскольку в этом и только в этом месте Newick-представления вершин быть не может. Последующие действия зависят от последнего необработанного символа текстового представления.

символ Действие
) Начинается список дочерних вершин для последней созданной вершины. parent := node.
( Список дочерних вершин для текущей родительской вершины закончился; новые вершины будут добавляться в родительскую для нее вершину. parent := parent.parent.
, Игнорировать.
; Игнорировать.

Итерации алгоритма продолжаются, пока не достигнуто начало Newick-представления.

Рекуррентные отношения на деревьях

Подобно рекуррентным отношениям, заданным на множестве натуральных чисел, в задачах на деревьях часто используются отношения, заданные на вершинах дерева. Обычно значение некоторой функции F в вершине дерева зависит от значения этой функции в дочерних вершинах v.children (хотя бывают и обратные случаи, когда F(v) определяется величиной F(v.parent)). Одно из таких отношений — преобразование дерева к Newick-представлению, то есть функция, обратная по своей сути синтаксическому разбору.

Простейший способ вычисления рекуррентных отношений — через рекурсивные вызовы — чреват теми же проблемами, что и, например, рекурсивное вычисление чисел Фибоначчи. Для более эффективного определения рекуррентных функций нужно поступить так же, как в динамическом программировании, — линейно упорядочить последовательность вызовов. Для этого годится обход дерева в ширину, начиная с корня; очередь, создаваемая при таком обходе, содержит все вершины дерева, причем родитель вершины всегда расположен до самой вершины. Для хранения вычисленных значений функции можно использовать словарь. При этом надо использовать функцию Python id(), так как деревья являются изменяемыми и не могут сами использоваться в качестве ключей:

Код создания очереди стоит выделить в отдельный метод класса Tree (этот метод достаточно полезен сам по себе). Логика прохода по очереди выделяется в декоратор, аналогичный декоратору динамического программирования:

В отличие от декоратора @dp, кэш очищается после каждого вычисления функции, поскольку содержащиеся в нем значения могут устареть (например, если в дерево добавить новые вершины).

С помощью декоратора легко реализовать, например, подсчет количества листьев в дереве:

Равенство деревьев

Другой пример задачи на деревьях, которую можно решить при помощи рекуррентных функций — определение его разбиений (англ. split). Согласно определению, разбиение дерева T — это два непересекающихся множества его листьев, которые соответствуют двум поддеревьям, образуемым в результате удаления из T одного из ребер.

Каждое из ребер дерева можно представить в виде пары вершин (v, v.parent). Легко заметить, что одно из двух множеств в разбиении, которое порождается этим ребром, состоит из всех листьев, являющихся потомками v (включая саму вершину v). В дальнейшем будем подразумевать под произвольным разбиением именно это одно множество. Пусть S(v) — множество разбиений в поддереве с корнем в v, включая «полное» разбиение — множество всех листьев в этом поддереве. Для функции S существует рекуррентное отношение. В самом деле, множество разбиений для некоторой вершины равно объединению разбиений (включая полные) для дочерних вершин, плюс новое полное разбиение:

Определение функции S

(Через max обозначен элемент S(u) с максимальным числом элементов, то есть «полное» разбиение для этой вершины.)

Значит, все разбиения можно вычислить за один проход по вершинам дерева:

Разбиения полезны для определения равенства между деревьями. Одно и то же дерево можно записать в формате Newick несколькими способами, в зависимости от выбранной в качестве корня вершины и порядка перечисления дочерних вершин. Например, приведенное выше на рисунке дерево можно записать как

((,A,)B,((C,D),E),(F,G))H;
(,A,,(((C,D),E),(F,G))H)B;
(C,D,(E,((F,G),(,A,)B)H));
((G,((,A,)B,(E,(C,D)))H))F;

и еще кучей способов. Известно, что два дерева равны тогда и только тогда, когда равны их разбиения; значит, операцию равенства для класса Tree можно определить так:

(В приведенном ниже сценарии на Github используется немного более быстрая проверка равенства с использованием разбиений, содержащих более одного элемента.)

Ссылки

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

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