Алгоритм МакКрейта — различия между версиями
Genyaz (обсуждение | вклад) (Добавлен псевдокод) |
м (rollbackEdits.php mass rollback) |
||
(не показаны 23 промежуточные версии 7 участников) | |||
Строка 1: | Строка 1: | ||
− | + | '''Алгоритм МакКрейта''' (англ. ''McCreight's algorithm'') — алгоритм построения [[Сжатое суффиксное дерево|суффиксного дерева]] для заданной строки <tex>s</tex> за линейное время. Отличается от [[Алгоритм Укконена| алгоритма Укконена]] тем, что добавляет суффиксы в порядке убывания длины. | |
− | '''Алгоритм МакКрейта''' — алгоритм построения [[Сжатое суффиксное дерево|суффиксного дерева]] для заданной строки <tex>s</tex> за линейное время. Отличается от [[Алгоритм Укконена| алгоритма Укконена]] тем, что добавляет суффиксы в порядке убывания длины. | ||
==Историческая справка== | ==Историческая справка== | ||
− | Первым оптимальным по времени был алгоритм, предложенный Вайнером в 1973 году. Идея алгоритма была в нахождении первых символов суффикса, которые находились в уже построенном дереве. Суффиксы просматривались от самого короткого к самому длинному, а для быстрого поиска использовались по два массива размера алфавита на каждую вершину, что затрудняло как понимание алгоритма, так и его реализацию и эффективность, особенно в плане занимаемой памяти. МакКрейт в 1976 году | + | Первым оптимальным по времени был алгоритм, предложенный Вайнером в 1973 году. Идея алгоритма была в нахождении первых символов суффикса, которые находились в уже построенном дереве. Суффиксы просматривались от самого короткого к самому длинному, а для быстрого поиска использовались по два массива размера алфавита на каждую вершину, что затрудняло как понимание алгоритма, так и его реализацию и эффективность, особенно в плане занимаемой памяти. МакКрейт в 1976 году предложил свой алоритм<ref>[http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.130.8022&rep=rep1&type=pdf Edward M. McCreight {{---}} A Space-Economical Suffix Tree Construction Algorithm]</ref>, в котором порядок добавления суффиксов заменен на обратный, а для быстрого вычисления места, откуда нужно продолжить построение нового суффикса, достаточно суффиксной ссылки в каждой вершине. В 1995 году Укконен представил свою версию алгоритма<ref>[http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf Esko Ukkonen {{---}} On-line construction of suffix trees]</ref>, которая считается наиболее простой для понимания, а также, в отличие от алгоритмов Вейнера и МакКрейта, является online алгоритмом, способным строить неявное суффиксное дерево по мере прочтения строки, а затем превратить его в настоящее. |
== Теоретическое обоснование == | == Теоретическое обоснование == | ||
Рассмотрим строку <tex>s</tex> длины <tex>n</tex>, которая заканчивается специальным символом, не встречающимся больше в строке. | Рассмотрим строку <tex>s</tex> длины <tex>n</tex>, которая заканчивается специальным символом, не встречающимся больше в строке. | ||
− | Заметим, что если два суффикса имеют <tex>lcp</tex> (''largest common prefix'') общих символов, то в построенном суффиксном дереве они будут иметь [[Сведение задачи LCA к задаче RMQ| наименьшего общего предка]] на этой глубине. Будем рассматривать суффиксы в порядке убывания длины, тогда имеет смысл узнавать наибольшее <tex>lcp</tex> с новым суффиксом среди всех суффиксов, добавленных раньше. Обозначим как <tex>head_i</tex> {{---}} максимальный префикс <tex>s[j | + | Заметим, что если два суффикса имеют <tex>lcp</tex> (''largest common prefix'') общих символов, то в построенном суффиксном дереве они будут иметь [[Сведение задачи LCA к задаче RMQ| наименьшего общего предка]] на этой глубине. Будем рассматривать суффиксы в порядке убывания длины, тогда имеет смысл узнавать наибольшее <tex>lcp</tex> с новым суффиксом среди всех суффиксов, добавленных раньше. Обозначим как <tex>head_i</tex> {{---}} максимальный префикс <tex>s[j \ldots n]</tex> и <tex>s[i \ldots n]</tex> среди всех <tex>j < i</tex>. |
Пусть мы знаем <tex>head_i</tex> и место в дереве, которое ему соответствует. Если позиция находится на ребре, разрежем его, а потом добавим новую вершину. Считать <tex>head_i</tex> по определению было бы очень затруднительно, но существует способ значительно сократить вычисления. | Пусть мы знаем <tex>head_i</tex> и место в дереве, которое ему соответствует. Если позиция находится на ребре, разрежем его, а потом добавим новую вершину. Считать <tex>head_i</tex> по определению было бы очень затруднительно, но существует способ значительно сократить вычисления. | ||
Строка 13: | Строка 12: | ||
{{Лемма | {{Лемма | ||
|id=lemma_head | |id=lemma_head | ||
− | |statement=Пусть <tex>head_i = s[i | + | |statement=Пусть <tex>head_i = s[i \ldots i+h]</tex>, тогда <tex>s[i+1 \ldots i+h]</tex> {{---}} префикс <tex>head_{i+1}</tex>. |
|proof= | |proof= | ||
* При <tex>h=0</tex>, очевидно, пустая строка является префиксом любой другой. | * При <tex>h=0</tex>, очевидно, пустая строка является префиксом любой другой. | ||
− | * Пусть <tex>h > 0</tex>. Из определения <tex>head_i</tex> существует суффикс <tex>s[j | + | * Пусть <tex>h > 0</tex>. Из определения <tex>head_i</tex> существует суффикс <tex>s[j \ldots n]</tex>, имеющий <tex>h</tex> общих символов с <tex>s[i \ldots n]</tex>. Тогда <tex>s[j+1 \ldots n]</tex> будет иметь с <tex>s[i+1 \ldots n]</tex> общий префикс длины <tex>h-1</tex>. Поскольку любой общий префикс будет по определению также являться и префиксом <tex>head_{i+1}</tex>, получаем искомое утверждение. |
}} | }} | ||
− | Если нам известны суффиксные ссылки <tex>u.suf</tex> для каждой вершины <tex>u</tex>, мы можем быстро перейти от позиции <tex>head_i</tex> к | + | Если нам известны суффиксные ссылки <tex>u.suf</tex> для каждой вершины <tex>u</tex>, мы можем быстро перейти от позиции <tex>head_i</tex> к её суффиксу и продолжить сравнение символов оттуда. Если бы новая позиция <tex>head_i</tex> всегда оказывалась существующей вершиной построенного дерева, этот алгоритм бы уже работал, но в реальности можно оказаться на середине ребра, для которой суффиксная ссылка неизвестна. Для нахождения её суффиксной ссылки на следующей итерации мы сначала перейдем к предку, пройдем по суффиксной ссылке, а уже затем будем продолжать сравнение. |
== Алгоритм == | == Алгоритм == | ||
Строка 32: | Строка 31: | ||
При добавлении каждого следующего суффикса будем выполнять следующие шаги: | При добавлении каждого следующего суффикса будем выполнять следующие шаги: | ||
* Если суффиксная ссылка <tex>head_{i-1}</tex> не определена: | * Если суффиксная ссылка <tex>head_{i-1}</tex> не определена: | ||
− | *# Поднимемся вверх к | + | *# Поднимемся вверх к её предку; |
*# Пройдем по суффиксной ссылке; | *# Пройдем по суффиксной ссылке; | ||
*# Спустимся вниз на столько символов, сколько мы прошли вверх к предку (''fast scanning''). | *# Спустимся вниз на столько символов, сколько мы прошли вверх к предку (''fast scanning''). | ||
Строка 44: | Строка 43: | ||
|id=proposal_invariant | |id=proposal_invariant | ||
|statement=Инвариант алгоритма сохраняется | |statement=Инвариант алгоритма сохраняется | ||
− | |proof=Инвариант мог бы нарушиться только в случае, если бы не существовало вершины в суффиксной ссылке для <tex>head_{i-1}</tex>, но мы продолжили бы сканирование по ребру дальше и получили две вершины <tex>head_{i-1}.suf, head_i</tex> с неопределенными суффиксными ссылками. | + | |proof=Инвариант мог бы нарушиться только в случае, если бы не существовало вершины в суффиксной ссылке для <tex>head_{i-1}</tex>, но мы продолжили бы сканирование по ребру дальше и получили две вершины <tex>head_{i-1}.suf,\ head_i</tex> с неопределенными суффиксными ссылками. |
− | Покажем, что это невозможно. Рассмотрим, что значит, что <tex>head_{i-1}.suf</tex> остановилась посередине ребра. Это означает, что все суффиксы <tex>s[j | + | Покажем, что это невозможно. Рассмотрим, что значит, что <tex>head_{i-1}.suf</tex> остановилась посередине ребра. Это означает, что все суффиксы <tex>s[j \ldots n],\ j < i - 1</tex>, которые дошли до этого места, имеют совпадающие следующие символы, по определению <tex>head_{i-1}</tex> отличающиеся от символа суффикса <tex>s[i - 1 \ldots n]</tex>. Тогда и <tex>s[i \ldots n]</tex> должен отличаться в этом символе, значит <tex>head_i = head_{i-1}.suf</tex>. |
}} | }} | ||
== Псевдокод == | == Псевдокод == | ||
В вершинах дерева <tex>Node</tex> будем хранить следующую информацию: | В вершинах дерева <tex>Node</tex> будем хранить следующую информацию: | ||
− | * <tex>parent</tex> - предок | + | * <tex>parent</tex> {{---}} предок |
− | * <tex>start, end</tex> - | + | * <tex>start, end</tex> {{---}} метка подстроки <tex> s[start \ldots end] </tex> на ребре от предка |
− | * <tex>length</tex> - длина ребра до предка | + | * <tex>length = end - start + 1</tex> {{---}} длина ребра до предка |
− | * <tex>depth</tex> - глубина вершины в символах | + | * <tex>depth</tex> {{---}} глубина вершины в символах |
− | * <tex>suf</tex> - суффиксная ссылка | + | * <tex>suf</tex> {{---}} суффиксная ссылка |
− | * <tex>children[]</tex> - массив детей | + | * <tex>children[]</tex> {{---}} массив детей |
− | Конструктор будет иметь вид <code>Node(Node parent, '''int''' start, '''int''' end, '''int''' depth)</code> | + | Конструктор будет иметь вид <code>Node(Node parent, '''int''' start, '''int''' end, '''int''' depth)</code>. |
+ | Пусть глобально известна строка <tex>s</tex> со специальным символом на конце, её длина <tex>n</tex> и используемый алфавит <tex>\Sigma</tex>. | ||
<code> | <code> | ||
− | + | Node buildSuffixTree(): | |
− | |||
− | |||
− | Node buildSuffixTree() | ||
superRoot = Node('''null''', 0, -1, 0) | superRoot = Node('''null''', 0, -1, 0) | ||
+ | superRoot.suf = superRoot | ||
root = Node(superRoot, 0, -1, 0) | root = Node(superRoot, 0, -1, 0) | ||
root.suf = superRoot | root.suf = superRoot | ||
Строка 74: | Строка 72: | ||
'''return''' root | '''return''' root | ||
− | Node addSuffix(Node head, '''int''' start) | + | Node addSuffix(Node head, '''int''' start): |
newHead = slowScan(fastScan(head), start) | newHead = slowScan(fastScan(head), start) | ||
− | + | добавим новый лист к newHead | |
− | |||
'''return''' newHead | '''return''' newHead | ||
− | Node fastScan(Node head) | + | Node fastScan(Node head): |
− | '''if''' head | + | '''if''' head {{---}} корень |
− | skipped = head.length | + | '''return''' head |
− | curPos = head.start | + | '''if''' не существует суффиксной ссылки head |
− | curNode = head.parent.suf | + | skipped = head.length <font color=green> // Сколько символов нам осталось пропустить без проверки </font> |
− | '''while''' | + | curPos = head.start <font color=green> // Текущая позиция на ребре, нужна для создания ребра по соответствующему символу </font> |
+ | '''if''' head совпадает с корнем | ||
+ | skipped-- | ||
+ | curPos++ | ||
+ | curNode = head.parent.suf <font color=green> // Текущая вершина </font> | ||
+ | '''while''' непройденная длина больше длины ребра | ||
curNode = curNode.children[s[curPos]] | curNode = curNode.children[s[curPos]] | ||
skipped -= curNode.length | skipped -= curNode.length | ||
curPos += curNode.length | curPos += curNode.length | ||
− | '''if''' | + | '''if''' остались непройденные символы |
− | + | разделим ребро и запишем новую вершину в curNode | |
− | + | head.suf = curNode | |
'''return''' head.suf | '''return''' head.suf | ||
− | + | Node slowScan(Node node, '''int''' start): | |
− | + | curNode = node <font color=green> // Текущая вершина </font> | |
− | + | curPos = start + node.depth <font color=green> // Текущий символ суффикса </font> | |
− | + | '''while''' существует ребро по символу curPos | |
− | + | child = curNode.children[s[curPos]] <font color=green> // Ребенок по символу суффикса </font> | |
− | + | edgePos = 0 <font color=green> // Текущая позиция на ребре </font> | |
− | + | '''while''' символы на ребре совпадают с суффиксом | |
− | |||
− | |||
− | Node slowScan(Node node, '''int''' start) | ||
− | curNode = node | ||
− | curPos = start + node.depth | ||
− | '''while''' | ||
− | |||
− | |||
− | child = curNode.children[s[curPos]] | ||
− | edgePos = 0 | ||
− | '''while''' | ||
curPos++ | curPos++ | ||
edgePos++ | edgePos++ | ||
− | '''if''' | + | '''if''' ребро пройдено до конца |
curNode = child | curNode = child | ||
'''else''' | '''else''' | ||
− | curNode | + | разделим ребро в месте несовпадения, запишем в curNode и выйдем из цикла |
− | |||
'''return''' curNode | '''return''' curNode | ||
</code> | </code> | ||
Строка 127: | Строка 117: | ||
''Slow scanning'' делает <tex>\left\vert head_i \right\vert - \left\vert head_{i-1} \right\vert + 1</tex> операций, что суммарно дает <tex>\left\vert head_n \right\vert - \left\vert head_0 \right\vert + n = O(n)</tex> операций. | ''Slow scanning'' делает <tex>\left\vert head_i \right\vert - \left\vert head_{i-1} \right\vert + 1</tex> операций, что суммарно дает <tex>\left\vert head_n \right\vert - \left\vert head_0 \right\vert + n = O(n)</tex> операций. | ||
− | ''Fast scanning'' работает с целыми | + | ''Fast scanning'' работает с целыми рёбрами, поэтому будем использовать в качестве [[Амортизационный анализ#Метод потенциалов| потенциала]] глубину в вершинах. Из структуры [[Сжатое суффиксное дерево| суффиксного дерева]] мы знаем, что суффиксная ссылка может уменьшить глубину вершины не более, чем на <tex>1</tex>, так что мы на каждой итерации поднимаемся не более, чем на <tex>2</tex> {{---}} один раз к предку, а потом по суффиксной ссылке, что составляет <tex>O(n)</tex> за весь алгоритм. Соответственно, спустимся мы тоже суммарно <tex>O(n)</tex> раз, так как и максимальная глубина составляет <tex>O(n)</tex>. |
Итоговая асимптотика алгоритма {{---}} <tex>O(n)</tex>. | Итоговая асимптотика алгоритма {{---}} <tex>O(n)</tex>. | ||
Строка 139: | Строка 129: | ||
* Преимущества: мы строим суффиксное дерево в явной форме, что может облегчить понимание алгоритма. | * Преимущества: мы строим суффиксное дерево в явной форме, что может облегчить понимание алгоритма. | ||
* Недостатки: является offline алгоритмом, то есть требует для начала работы всю строку целиком. | * Недостатки: является offline алгоритмом, то есть требует для начала работы всю строку целиком. | ||
− | |||
− | |||
− | |||
− | |||
− | |||
== См. также == | == См. также == | ||
* [[Сжатое суффиксное дерево]] | * [[Сжатое суффиксное дерево]] | ||
* [[Алгоритм Укконена]] | * [[Алгоритм Укконена]] | ||
+ | * [[Алгоритм Фарача]] | ||
+ | |||
+ | == Примечания == | ||
+ | <references /> | ||
+ | |||
+ | == Источники информации == | ||
+ | * [http://www.academia.edu/3146231/Algorithms_on_strings_trees_and_sequences_computer_science_and_computational_biology Dan Gusfield, Algorithms on Strings, Trees and Sequences: Computer Science and Computational Biology // Cambridge University Press, {{---}} 1999. {{---}} ISBN: 0-521-58519-8] | ||
+ | * [http://users-cs.au.dk/cstorm/courses/StrAlg_f12/slides/suffix-tree-construction.pdf C. N. Storm, McCreight's suffix tree construction algorithm] | ||
+ | * [[wikipedia:en:Suffix_tree| Wikipedia {{---}} Suffix tree ]] | ||
− | [[Категория: | + | [[Категория:Алгоритмы и структуры данных]] |
+ | [[Категория:Словарные структуры данных]] |
Текущая версия на 19:21, 4 сентября 2022
Алгоритм МакКрейта (англ. McCreight's algorithm) — алгоритм построения суффиксного дерева для заданной строки за линейное время. Отличается от алгоритма Укконена тем, что добавляет суффиксы в порядке убывания длины.
Содержание
Историческая справка
Первым оптимальным по времени был алгоритм, предложенный Вайнером в 1973 году. Идея алгоритма была в нахождении первых символов суффикса, которые находились в уже построенном дереве. Суффиксы просматривались от самого короткого к самому длинному, а для быстрого поиска использовались по два массива размера алфавита на каждую вершину, что затрудняло как понимание алгоритма, так и его реализацию и эффективность, особенно в плане занимаемой памяти. МакКрейт в 1976 году предложил свой алоритм[1], в котором порядок добавления суффиксов заменен на обратный, а для быстрого вычисления места, откуда нужно продолжить построение нового суффикса, достаточно суффиксной ссылки в каждой вершине. В 1995 году Укконен представил свою версию алгоритма[2], которая считается наиболее простой для понимания, а также, в отличие от алгоритмов Вейнера и МакКрейта, является online алгоритмом, способным строить неявное суффиксное дерево по мере прочтения строки, а затем превратить его в настоящее.
Теоретическое обоснование
Рассмотрим строку наименьшего общего предка на этой глубине. Будем рассматривать суффиксы в порядке убывания длины, тогда имеет смысл узнавать наибольшее с новым суффиксом среди всех суффиксов, добавленных раньше. Обозначим как — максимальный префикс и среди всех .
длины , которая заканчивается специальным символом, не встречающимся больше в строке. Заметим, что если два суффикса имеют (largest common prefix) общих символов, то в построенном суффиксном дереве они будут иметьПусть мы знаем
и место в дереве, которое ему соответствует. Если позиция находится на ребре, разрежем его, а потом добавим новую вершину. Считать по определению было бы очень затруднительно, но существует способ значительно сократить вычисления.Лемма: |
Пусть , тогда — префикс . |
Доказательство: |
|
Если нам известны суффиксные ссылки
для каждой вершины , мы можем быстро перейти от позиции к её суффиксу и продолжить сравнение символов оттуда. Если бы новая позиция всегда оказывалась существующей вершиной построенного дерева, этот алгоритм бы уже работал, но в реальности можно оказаться на середине ребра, для которой суффиксная ссылка неизвестна. Для нахождения её суффиксной ссылки на следующей итерации мы сначала перейдем к предку, пройдем по суффиксной ссылке, а уже затем будем продолжать сравнение.Алгоритм
Для удобства реализации вместе с корнем
создадим вспомогательную вершину , обладающую свойствами:- Для любого символа из вершины есть ребро в .
Будем поддерживать инвариант:
- Для всех вершин, кроме, возможно, последней добавленной , известны суффиксные ссылки.
- Суффиксная ссылка всегда ведет в вершину, а не в середину ребра.
При добавлении каждого следующего суффикса будем выполнять следующие шаги:
- Если суффиксная ссылка
- Поднимемся вверх к её предку;
- Пройдем по суффиксной ссылке;
- Спустимся вниз на столько символов, сколько мы прошли вверх к предку (fast scanning).
- Если мы оказались посередине ребра, разрежем его и добавим вершину.
- Установим суффиксную ссылку для
не определена:
- Иначе просто пройдем по суффиксной ссылке.
- Будем идти по дереву вниз, пока либо не будет перехода по символу, либо очередной символ на ребре не совпадет с символом нового суффикса (slow scanning)
- Добавим ребро/разрежем существующее, запомним новую позицию и добавим оставшуюся часть суффикса в качестве листа.
Утверждение: |
Инвариант алгоритма сохраняется |
Инвариант мог бы нарушиться только в случае, если бы не существовало вершины в суффиксной ссылке для Покажем, что это невозможно. Рассмотрим, что значит, что , но мы продолжили бы сканирование по ребру дальше и получили две вершины с неопределенными суффиксными ссылками. остановилась посередине ребра. Это означает, что все суффиксы , которые дошли до этого места, имеют совпадающие следующие символы, по определению отличающиеся от символа суффикса . Тогда и должен отличаться в этом символе, значит . |
Псевдокод
В вершинах дерева
будем хранить следующую информацию:- — предок
- — метка подстроки на ребре от предка
- — длина ребра до предка
- — глубина вершины в символах
- — суффиксная ссылка
- — массив детей
Конструктор будет иметь вид Node(Node parent, int start, int end, int depth)
.
Пусть глобально известна строка со специальным символом на конце, её длина и используемый алфавит .
Node buildSuffixTree():
superRoot = Node(null, 0, -1, 0)
superRoot.suf = superRoot
root = Node(superRoot, 0, -1, 0)
root.suf = superRoot
for c in
superRoot.children[c] = root
head = root
for i = 1 to n
head = addSuffix(head, i)
return root
Node addSuffix(Node head, int start): newHead = slowScan(fastScan(head), start) добавим новый лист к newHead return newHead
Node fastScan(Node head): if head — корень return head if не существует суффиксной ссылки head skipped = head.length // Сколько символов нам осталось пропустить без проверки curPos = head.start // Текущая позиция на ребре, нужна для создания ребра по соответствующему символу if head совпадает с корнем skipped-- curPos++ curNode = head.parent.suf // Текущая вершина while непройденная длина больше длины ребра curNode = curNode.children[s[curPos]] skipped -= curNode.length curPos += curNode.length if остались непройденные символы разделим ребро и запишем новую вершину в curNode head.suf = curNode return head.suf
Node slowScan(Node node, int start): curNode = node // Текущая вершина curPos = start + node.depth // Текущий символ суффикса while существует ребро по символу curPos child = curNode.children[s[curPos]] // Ребенок по символу суффикса edgePos = 0 // Текущая позиция на ребре while символы на ребре совпадают с суффиксом curPos++ edgePos++ if ребро пройдено до конца curNode = child else разделим ребро в месте несовпадения, запишем в curNode и выйдем из цикла return curNode
Асимптотическая оценка
В приведенном алгоритме используется константное число операций на добавление одного суффикса, не считая slow scanning и fast scanning.
Slow scanning делает
операций, что суммарно дает операций.Fast scanning работает с целыми рёбрами, поэтому будем использовать в качестве потенциала глубину в вершинах. Из структуры суффиксного дерева мы знаем, что суффиксная ссылка может уменьшить глубину вершины не более, чем на , так что мы на каждой итерации поднимаемся не более, чем на — один раз к предку, а потом по суффиксной ссылке, что составляет за весь алгоритм. Соответственно, спустимся мы тоже суммарно раз, так как и максимальная глубина составляет .
Итоговая асимптотика алгоритма —
.Сравнение с другими алгоритмами
В сравнении с алгоритмом Вайнера:
- Преимущества: каждая вершина хранит только суффиксную ссылку, а не массивы размера алфавита.
- Недостатки: нет.
В сравнении с алгоритмом Укконена:
- Преимущества: мы строим суффиксное дерево в явной форме, что может облегчить понимание алгоритма.
- Недостатки: является offline алгоритмом, то есть требует для начала работы всю строку целиком.