Алгоритм Кнута-Морриса-Пратта
ICS 161: Проектирование и Анализ Алгоритмов лекция 27 февраля 1996
Поисковая машина Кнута-Морриса-Пратта
Проблема: учитывая (короткий) образец и длинный текст, по этим обеим строкам определяется - появляется ли образец где-либо в тексте. В прошлый раз В прошлый раз мы видели, как сделать это с конечными автоматами. На сей раз мы пройдем
алгоритм Кнута-Морриса-Пратта (KMP).
У меня также существует C++
исходный код который поможет Вам лучше понять этот алгоритм.
Сначала давайте рассмотрим наивное решение. предположите, что текст это множество:
char T[n] и шаблон - это другое множество: char P[m].
"Наивный метод" состоит в том, чтобы пересматривать каждую позицию, в которой образец мог появиться в тексте.
"Наивный метод": for (i=0; T[i] != '\0'; i++)
{
for (j=0; T[i+j] != '\0' && P[j] != '\0' && T[i+j]==P[j]; j++) ;
if (P[j] == '\0') found a match
}
Есть два вложенных цикла; внутренний берет O (m) итерации, и внешний берет O (n) итерации, так что полное время работы программы O (mn). Это медленно; мы хотели бы ускорить это.
Практически он работает неплохо, но обычно так же плохо как самый плохой O(mn) анализ случая. Это потому что внутренний цикл обычно ищет несоответствие и быстро переходит к следующей позиции, не проходя все m шагов. Но этот
метод может взять O(mn) для некоторых вводов. В одном плохом примере, все символы в T[] будут "a", и все символы из P[] будут "a" за исключением одного символа "b" в конце. Тогда требуется m сравнений каждый раз, чтобы обнаружить, что не существует соответствия, и так полный mn.
Это - типичный пример. Каждая строка представляет итерацию внешнего цикла, с каждым символом в строке, представляющей результат сравнения (X, если сравнение было неуспешно). Предположим, что мы ищем образец "nano" в тексте "banananobano". 0 1 2 3 4 5 6 7 8 9 10 11
T: b a n a n a n o b a n o
i=0: X
i=1: X
i=2: n a n X
i=3: X
i=4: n a n o
i=5: X
i=6: n X
i=7: X
i=8: X
i=9: n X
i=10: X
Работа по некоторым из этих сравнений прошла впустую! Например, после итерации i=2, мы знаем сравнения до T [3] = "a", нет никакого смысла сравнивающего это к "n" в итерации i=3. И мы также знаем, что T [4] = "n", так что нет никакого смысла делать то же самое сравнение в итерации i=4.
Пропуск внешних итерацийИдея Кнута-Морриса-Пратта идея, в этой ситуации, после того, как Вы сделали много работы, делающей сравнения во внутреннем цикле кода, Вы знаете много о том, что находится в тексте. Определенно, если Вы нашли частичное соответствие j символов, начинающихся в позиции i, Вы знаете то, что находится в позициях T [i]... T [i+j-1].
Вы можете использовать это знание, чтобы сэкономить работу двумя способами. Сначала, Вы можете пропустить некоторые итерации, для которых никакое соответствие не является возможным. Пробуйте положиться на частичное соответствие, которое Вы нашли с новым соответствием, которое Вы хотите найти: i=2: n a n
i=3: n a n o
Два размещения образца находятся в противоречии друг с другом - мы знаем от i=2 итерации, что T [3] и T [4] - "a" и "n", так что они не могут быть "n" и "a", что i=3 итерация ищет. Мы можем продолжить пропускать позиции, пока мы не находим тот, который не находится в противоречии: i=2: n a n
i=4: n a n o
Здесь два "n" совпадают. Определите перекрытие двух строк x и y, чтобы быть самым длинным словом, это является суффиксом x и префикса y. Здесь перекрытие "nan" и "nano" - только "n". (Мы не позволяем перекрытию быть всем x или y, так что это - не "nan"). Вообще значение i, к которому мы хотим перескочить - то, соответствующее наибольшему перекрытию с текущим частичным соответствием:
Соответствие строки с пропущенными итерациями: i=0;
while (i<n)
{
for (j=0; T[i+j] != '\0' && P[j] != '\0' && T[i+j]==P[j]; j++) ;
if (P[j] == '\0') found a match;
i = i + max(1, j-overlap(P[0..j-1],P[0..m]));
}
Пропуск внутренних итераций
Другая оптимизация, которая может быть сделана, должна пропустить некоторые итерации во внутреннем цикле. Давайте смотреть на тот же самый пример, в котором мы пропустили от i=2 до i=4: i=2: n a n
i=4: n a n o
В этом примере, "n", который перекрытия были уже проверены i=2 итерацией. Нет никакой потребности проверить это снова в i=4 итерации. Вообще, если мы имеем нетривиальное перекрытие с последним частичным соответствием, мы можем избежать проверять число символов, равняются длине перекрытия.
Это изменение версии KMП алгоритма:
KMP, version 1: i=0;
o=0;
while (i<n)
{
for (j=o; T[i+j] != '\0' && P[j] != '\0' && T[i+j]==P[j]; j++) ;
if (P[j] == '\0') found a match;
o = overlap(P[0..j-1],P[0..m]);
i = i + max(1, j-o);
}
Единственная остающаяся подробность - как вычислить функцию перекрытия. Это - функция только j, а не символов в T[], sтак что мы можем вычислить это однажды в стадии предварительной обработки прежде, чем мы принимаемся за эту часть алгоритма. Сначала давайте увидим быстродействие этого алгоритма.
KMП анализ времениМы все еще имеем внешний цикл и внутренний цикл, так что это напоминает, что время могло бы все еще быть O (МС). Но мы можем различным способом видеть, что это - фактически всегда меньше чем это. Идея - то, что каждый раз через внутренний цикл, мы делаем одно сравнение T [i+j] == P [j]. Мы можем считать полное время алгоритма, считая, сколько сравнений мы исполняем.
Мы разбиваем сравнения на две группы: те, что истина возвращения, и те та ложь возвращения. Если сравнение возвращает истину, мы определили значение T [i+j]. Тогда в будущих итерациях, пока есть нетривиальное перекрытие, вовлекающее T [i+j], мы перескочим мимо того перекрытия и будем не делать сравнение с той позицией снова. Так что каждая позиция T [] только вовлечена на одном истинном сравнении, и может быть n такое общее количество сравнений. С другой стороны, есть в большинстве одного ложного сравнения в итерацию внешнего цикла, так что может также только быть n тех. В результате мы видим, что эта часть KMП алгоритма делает не более 2n сравнения и занимает время O (n).
KMП и конечный автомат
Если мы смотрим только на то, что случается с j в течение алгоритма выше, это - сорт подобных конечный автомат. При каждом шаге j установлен любой в j+1 (во внутреннем цикле, после соответствия) или к перекрытию o (после того, как несоответствие). При каждом шаге значение o - только функция j и не зависит от другой информации подобно символам в T []. Так что мы можем нарисовать кое-что подобно автомату, со стрелками, подключающими значения j и помеченный парами и несоответствиями.

Различие между этим и автоматами, к которым мы используемся - то, что это имеет только две стрелки из каждого круга, вместо один в символ. Но мы можем все еще моделировать это точно так же как любой другой автомат, помещая маркер на состоянии начала (j=0) и перемещая это вокруг стрелок. Всякий раз, когда мы получаем символ соответствия в T [], мы переходим к следующему символу текста. Но всякий раз, когда мы получаем несоответствие, мы смотрим на тот же самый символ в следующем шаге, если бы не случай несоответствия в состоянии j=0.
Так в этом примере (то же самое как тот выше) автомат проходит последовательность состояний: j=0
mismatch T[0] != "n"
j=0
mismatch T[1] != "n"
j=0
match T[2] == "n"
j=1
match T[3] == "a"
j=2
match T[4] == "n"
j=3
mismatch T[5] != "o"
j=1
match T[5] == "a"
j=2
match T[6] == "n"
j=3
match T[7] == "o"
j=4
found match
j=0
mismatch T[8] != "n"
j=0
mismatch T[9] != "n"
j=0
match T[10] == "n"
j=1
mismatch T[11] != "a"
j=0
mismatch T[11] != "n"
Это - по существу та же самая последовательность сравнений, сделанных KMП псевдокодом
выше. Так что этот автомат обеспечивает эквивалентное определение KMП алгоритма.
Поскольку один студент указал в лекции, один переход в этом автомате, который не может быть ясен - тот от j=4 до j=0. Вообще, должен быть переход от j=m до небольшого количества меньшего значения j, который должен случиться на любом символе (нет больше пар, чтобы проверить перед созданием этого перехода). Если мы хотим найти все возникновения образца, мы должны быть способны найти возникновение, даже если это накладывается на другой. Так например, если образцом был "nana", мы должны найти оба возникновения этого в тексте "nanana". Так что переход от j=m должен идти в следующую самую длинную позицию, которая может соответствовать, который является просто j=overlap (pattern,pattern). В этом перекрытии случая{регистра} ("nano", "nano") пуст (все суффиксы "nano" используют символ "o", и никакой префикс не делает), так что мы идем в j=0.
Дополнительная версия KMПАвтомат выше может быть оттранслирован назад в псевдокод, смотря немного отличен от псевдокода, который мы видели прежде, но выполнение тех же самых сравнений.
KMП, версия 2: j = 0;
for (i = 0; i < n; i++)
for (;;) { // loop until break
if (T[i] == P[j]) { // matches?
j++; // yes, move on to next state
if (j == m) { // maybe that was the last state
found a match;
j = overlap[j];
}
break;
} else if (j == 0) break; // no match in state j=0, give up
else j = overlap[j]; // try shorter partial match
}
Код в каждой итерации внешнего цикла по существу тот же самый как функциональное соответствие от выполнения C++, которое я сделал доступным. Одно преимущество этой версии кода состоит в том, что это проверяет символы один за другим, вместо того, чтобы выполнить произвольный доступ в T [] массив, так (как в выполнении) это может быть сделано, чтобы работать для ввода на основе потока вместо того, чтобы иметь необходимость читать целый текст в память сначала.
Перекрытие [j] массив сохраняет значения перекрытия (образец [0.. j-1], образец), которому мы все еще должны показывать, как вычислить.
Так как этот алгоритм исполняет те же самые сравнения как другая версия KMП, требуется то же самое время, O (n). Один способ доказывать это связывал{обязывал}, непосредственно должен обратить внимание, сначала, что есть одно истинное сравнение (в каком T [я] == P [j]) в итерацию внешнего цикла, так как мы убегаем из внутреннего цикла, когда это случается. Так что есть n их общее количество. Каждое из этих сравнений приводит к увеличению j один. Каждая итерация внутреннего цикла, в котором мы не убегаем из цикла, приводит к выполнению инструкции j=overlap [j], который уменьшает j. С тех пор j может только уменьшиться так много раз, как это увеличено, полное число раз это случается - также O (n).
Вычисление функции перекрытияПовторно вызовите, что мы определили перекрытие двух строк x, и y, чтобы быть самым длинным словом это - суффикс x и префикса y. Отсутствующий компонент KMП алгоритма - вычисление этой функции перекрытия: мы должны знать перекрытие (P [0.. j-1], P) для каждого значения j*gt; 0. Как только мы вычислили эти значения, мы можем сохранить их в массиве и искать их, когда мы нуждаемся в них.
Чтобы вычислять эти функции перекрытия, мы должны знать для строк x и y не только самое длинное слово, это является суффиксом x и префикса y, но всех таких слов. Ключевой факт, чтобы обратить внимание здесь - то, что, если w является суффиксом x и префикса y, и это - не самое длинное такое слово, тогда это - также суффикс перекрытия (x, y). (Это следует просто от факта, что это является суффиксом x, который короче чем перекрытие (x, y) непосредственно.), так что мы можем перечислить все слова, которые являются суффиксами x и префиксов y следующим циклом: while (x != empty) {
x = overlap(x,y);
output x;
}
Теперь давайте делать другое определение: скажите, что это сокращается (x) - префикс x с один меньше символа. Следующее простое наблюдение, которое будет делать - это сокращается (перекрытие (x, y)) - все еще префикс y, но - также суффикс, сокращаются (x).
Так что мы можем найти перекрытие (x, y), добавляя еще один символ к некоторому слову это - суффикс, сокращаются (x) и префикс y. Мы можем только найти все такие слова, используя цикл выше, и возвратить первый, для которого добавление еще одного символа производит допустимое перекрытие:
Вычисление перекрытия: z = overlap(shorten(x),y)
while (last char of x != y[length(z)])
{
if (z = empty) return overlap(x,y) = empty
else z = overlap(z,y)
}
return overlap(x,y) = z
Так что это дает нам рекурсивный алгоритм для того, чтобы вычислить функцию перекрытия вообще. Если мы применяем этот алгоритм для x=some префикса образца, и образца y=the непосредственно, мы видим, что все рекурсивные обращения имеют подобные параметры. Так, если мы сохраняем каждое значение, поскольку мы вычисляем это, мы можем искать это вместо того, чтобы вычислить это снова. (Эта простая идея сохранять результаты вместо перевычисления их известна как динамическое программирование; мы обсуждали это в первой лекции and will see it in more detail и будем рассматривать более подробно в следующий раз.)
Так заменяя x P [0.. j-1] и y P [0.. m-1] в псевдокоде выше и заменяющий рекурсивные обращения поисками предварительно вычисленных значений дает нам подпрограмму для проблемы, которую мы пробуем решить, из вычисления этих специфических значений перекрытия. Следующий псевдокод взят (с некоторыми измененными названиями) от кода инициализации выполнения C++, которое я сделал доступным. Значение в перекрытии [0] - только флажок, чтобы делать остальную часть более простого цикла. Код в для цикла - часть, которая вычисляет каждое значение перекрытия.
KMП накладываются на вычисление: overlap[0] = -1;
for (int i = 0; pattern[i] != '\0'; i++) {
overlap[i + 1] = overlap[i] + 1;
while (overlap[i + 1] > 0 &&
pattern[i] != pattern[overlap[i + 1] - 1])
overlap[i + 1] = overlap[overlap[i + 1] - 1] + 1;
}
return overlap;
Давайте конец, анализируя время, взятое этой частью KMP алгоритма. Внешний цикл выполняет м. времена. Каждая итерация внутреннего цикла уменьшает значение перекрытия формулы [i+1], и значение этой формулы только увеличивается на тот, когда мы двигаемся от одной итерации внешнего цикла к следующему. Так как номер уменьшений - не более номер увеличений, внутренний цикл также имеет в большинстве м. итерации, и полное время для алгоритма - O (m).
Полный KMП алгоритм состоит из этого вычисления перекрытия, сопровождаемого основной частью алгоритма, в котором мы просматриваем текст (использование значений перекрытия, чтобы ускорить просмотр). Первая часть берет O (m), и вторая часть берет O (n) время, так что полное время - O (m+n).
Наверх
|