# теория · шаг 1 из 7
Наивный поиск: где в нём прячется квадрат
Коротко
Задача поиска подстроки: дан текст t длины n и образец p длины m, найти все позиции, с которых
образец читается в тексте целиком. Наивный поиск прикладывает образец к каждой из n − m + 1 позиций и
сравнивает символы до первого расхождения. Худший случай - (n − m + 1) · m сравнений: тысяча букв a и
образец из 499 букв a с буквой b в конце стоят 250 500 сравнений. На случайном тексте из 26 букв
образец в десять символов - это 991 позиция и 1046 сравнений, 1,06 на позицию. Квадрат прячется не в длине текста, а в длинных
повторах: чем дольше образец совпадает перед расхождением, тем дороже каждая позиция.
Если ты выпишешь формулу худшего случая, назовёшь данные, на которых она достигается, и объяснишь, почему на обычном тексте наивный поиск почти линеен, - листай до «Теперь сам».
Как думаешь, сколько сравнений
Текст - тысяча букв a подряд. Образец - девять букв a и буква b в конце, всего десять символов.
В тексте его нет: там вообще нет буквы b.
Прежде чем читать дальше, прикинь, сколько раз программа сравнит символ текста с символом образца, прежде
чем ответит «не найдено». Около тысячи - по разу на позицию? Около десяти тысяч? И что изменится, если
букву b переставить из конца образца в начало?
Слова, которыми пользуется весь раздел
Префикс строки - её начало любой длины, суффикс - её конец; на этих двух словах стоит половина
раздела. Текст - строка, в которой ищут; её длина всюду дальше n. Образец - строка, которую ищут; её
длина m, и почти всегда m намного меньше n. Вхождение - позиция i в тексте, начиная с которой
все m символов совпадают с образцом: t[i] = p[0], t[i + 1] = p[1] и так до t[i + m − 1] = p[m − 1].
Позиции считаются с нуля, как индексы в Python.
Алфавит - набор символов, из которых составлены обе строки. Важен его размер: у двоичных данных он
равен двум, у записи ДНК - четырём (A, C, G, T), у латинского текста без заглавных - двадцати шести.
Дальше станет видно, что цена поиска зависит от размера алфавита сильнее, чем кажется.
Вхождений может быть несколько, и они могут перекрываться: в тексте ababab образец abab входит с
позиции 0 и с позиции 2. Задача - найти все.
На что это похоже
Представь трафарет с прорезанным словом и длинную ленту с буквами. Ты кладёшь трафарет на начало ленты и смотришь в прорези слева направо: первая буква совпала, вторая совпала, третья нет. Сдвигаешь трафарет на одну клетку и снова смотришь с первой прорези.
Способ честный: ни одно положение трафарета не пропущено, значит и ни одно вхождение. Плата тоже видна: после сдвига ты заново читаешь буквы ленты, которые только что видел. Всё, что ты узнал о ленте на прошлом положении, выброшено. Весь раздел про строки - о том, как это знание не выбрасывать.
Механизм: окно, которое едет по одной клетке
текст a b r a c a d a b r a n = 11
образец a b r a m = 4
i = 0 a b r a 4 сравнения, вхождение
i = 1 a . . . 1 сравнение: b ≠ a
i = 2 a . . . 1 сравнение: r ≠ a
i = 3 a b . . 2 сравнения: a = a, c ≠ b
i = 4 a . . . 1
i = 5 a b . . 2 сравнения: a = a, d ≠ b
i = 6 a . . . 1
i = 7 a b r a 4 сравнения, вхождение
Последняя позиция, на которую образец ещё помещается, - n − m = 7: дальше он вылез бы за край текста.
Всего позиций n − m + 1 = 8. Сравнений вышло 4 + 1 + 1 + 2 + 1 + 2 + 1 + 4 = 16, вхождений два - с
позиций 0 и 7.
Программа повторяет эту таблицу буквально. Счётчик sravn в алгоритм не входит - он нужен, чтобы мерить
цену:
def naive(t, p):
n = len(t)
m = len(p)
sravn = 0
nashli = 0
i = 0
while i + m <= n: # образец ещё помещается
j = 0
while j < m:
sravn = sravn + 1
if t[i + j] != p[j]:
break # первое расхождение - дальше не смотрим
j = j + 1
if j == m: # дошли до конца образца без расхождений
nashli = nashli + 1
i = i + 1
return [nashli, sravn]
print(naive("abracadabra", "abra")) # [2, 16]
Правило
Наивный поиск проверяет n − m + 1 позиций и на каждой делает от одного до m сравнений. В худшем
случае это (n − m + 1) · m сравнений; при m около n / 2 это примерно n² / 4, то есть квадрат от
длины текста. В лучшем случае - около n сравнений; на типичном тексте - от n до 2n (чем меньше алфавит,
тем ближе к 2n), потому что расхождение находится на первых же символах. Худший случай достигается, когда образец долго совпадает и
расходится в самом конце: на данных из длинных повторов.
Разбор: тысяча букв и образец с подвохом в хвосте
Ответ на вопрос из начала урока. Текст "a" * 1000, образец "a" * 9 + "b". На каждой позиции первые
девять сравнений успешны - там и там буква a, - и только десятое находит расхождение. Позиций
1000 − 10 + 1 = 991, сравнений на каждой ровно десять:
образец позиций сравнений на позицию
"a" * 9 + "b" 991 9 910 10
"a" * 99 + "b" 901 90 100 100
"a" * 499 + "b" 501 250 500 500
Текст один и тот же, а цена выросла в двадцать пять раз. Позиций при этом стало МЕНЬШЕ - 501 вместо 991.
Растёт произведение: (n − m + 1) · m самое большое, когда m равно половине n, и последняя строка
таблицы стоит ровно на этом максимуме.
Теперь переставим b в начало: образец "b" + "a" * 9. Первое же сравнение на каждой позиции - a
против b - даёт расхождение. Сравнений 991, по одному на позицию. Те же буквы, тот же текст, цена в
десять раз меньше. Наивному поиску важно не из чего состоит образец, а как рано он расходится с текстом.
Разбор: почему на обычном тексте квадрата не видно
Возьмём текст, в котором буквы идут без закономерности. Чтобы замер можно было повторить, текст строит
маленький генератор: он гоняет число s по формуле и на каждом круге берёт по остатку одну из первых k
букв алфавита. Числа 75, 74 и 65537 - параметры давно известного простого генератора; важно в нём одно:
при одном и том же начальном s текст выходит один и тот же, у тебя и у меня.
def tekst(n, k, s):
bukvy = "abcdefghijklmnopqrstuvwxyz"
t = []
i = 0
while i < n:
s = (s * 75 + 74) % 65537
t.append(bukvy[s % k])
i = i + 1
return "".join(t)
t = tekst(1000, 4, 1)
print(naive(t, t[500:510])) # [1, 1328]
Образец - десять символов, вырезанных из середины самого текста, так что одно вхождение есть наверняка.
Замер для трёх размеров алфавита, n = 1000, m = 10, позиций 991:
алфавит k сравнений на позицию k / (k − 1)
2 1 962 1,98 2,00
4 1 328 1,34 1,33
26 1 046 1,06 1,04
Откуда последний столбец. Первое сравнение на позиции делается всегда. Второе - только если первое
совпало, а при k равновозможных буквах это случается в среднем на одной позиции из k. Третье - если
совпали оба, то есть на одной из k². Сумма S = 1 + 1/k + 1/k² + … равна k / (k − 1). Проверить
просто: умножь её на k - получится k + 1 + 1/k + …, то есть k + S; из k · S = k + S выходит
S = k / (k − 1). Слагаемых на деле не больше m, но дальние так малы, что на ответ не влияют. Замер
ложится на формулу: при двух буквах около двух сравнений на позицию, при двадцати шести - чуть больше
одного.
Вывод с границей. На тексте без длинных повторов наивный поиск линеен, и множитель у него тем ближе к единице, чем больше алфавит. Квадрат появляется, когда нарушено «расхождение находится рано»: маленький алфавит и длинные одинаковые куски. Таблица из прошлого разбора - это тот же алгоритм на данных, где расхождение находится поздно.
Что ломается без этого
Первое: граница цикла. Условие i + m <= n легко написать как i + m < n - и последняя позиция не
проверяется: образец bra в тексте abracadabra найдётся один раз вместо двух. Обратная ошибка, i < n,
коварнее: чтение t[i + j] за концом строки случается, только когда хвост текста совпал с началом
образца. Образец abra в том же тексте на последней позиции совпадает первым символом, читает t[11] и
падает с IndexError; образец bra расходится сразу, и ошибка молча проходит тест.
Второе: перекрывающиеся вхождения. После находки хочется прыгнуть сразу на m символов вперёд. В
тексте abababababab образец abab входит пять раз - с позиций 0, 2, 4, 6, 8, - а прыжок на m найдёт
три. Встроенный "abababababab".count("abab") возвращает именно 3: он считает вхождения без перекрытий, и
это записано в его описании.
Третье: срез вместо цикла. Строка if t[i:i + m] == p короче, но не дешевле: срез копирует m
символов, сравнение читает их ещё раз. Цена m на позицию платится всегда, даже там, где цикл с break
остановился бы на первом символе. Квадрат спрятан в одну строку, а не убран.
Четвёртое: пустой образец. При m = 0 условие j == m истинно сразу, и программа честно сообщает
n + 1 вхождений. Обычно это не то, чего ждал вызывающий код, - проверку на пустой образец ставят до
поиска.
Зачем это в работе
Первый сюжет - честная оценка. Короткий образец, обычный текст, несколько мегабайт: наивный цикл отработает
за линейное время, и городить сложный алгоритм незачем. Таблица с k / (k − 1) - аргумент в таком
разговоре.
Второй - данные, на которых наивный поиск опасен. Двоичные файлы с длинными полосами нулей, записи ДНК из
четырёх букв, журналы, где каждая строка начинается с одного и того же длинного префикса. Здесь образец
совпадает долго, и цена на позицию ползёт от единицы к m.
Третий - чужой ввод. Если и текст, и образец присылает пользователь, он может прислать ровно ту пару, что в первом разборе: текст из одной буквы и образец с другой буквой в хвосте. Сервис с наивным поиском можно так занять на секунды одним запросом. Это одна из причин, по которой библиотечный поиск устроен сложнее, - до него дойдём в следующей главе.
Теперь сам
Текст - двенадцать букв a. Сколько сравнений сделает наивный поиск для образца aab и сколько для
образца baa? Вхождений нет ни у одного.
Ответ. Позиций 12 − 3 + 1 = 10. Для aab на каждой позиции два сравнения успешны и третье находит
расхождение: 10 · 3 = 30 сравнений, и это ровно (n − m + 1) · m - худший случай. Для baa расхождение
на первом же символе: 10 сравнений. Образцы состоят из одних и тех же букв; разница в том, что у первого
расхождение стоит в конце, а у второго в начале.
Проверь себя программой из урока: naive("a" * 12, "aab") возвращает [0, 30], а
naive("a" * 12, "baa") - [0, 10].
Главное
Наивный поиск не помнит ничего: сдвинув образец на одну позицию, он заново читает символы текста, которые
уже видел. Пока расхождение находится на первом-втором символе, это ничего не стоит - на случайном тексте
выходит k / (k − 1) сравнений на позицию, от двух при двоичном алфавите до 1,04 при латинском. Когда
образец совпадает долго и расходится в хвосте, забывчивость оплачивается полностью: (n − m + 1) · m
сравнений, 250 500 на тысяче символов. Все следующие алгоритмы раздела отличаются от наивного одним - тем,
что именно они запоминают между сдвигами.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий