1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 4 из 7

Z-функция: те же знания, записанные от начала строки

Коротко

Z-функция строки - список чисел: z[i] равно числу символов, на которое кусок строки с позиции i совпадает с её началом. Для abracadabra это 0 0 0 1 0 1 0 4 0 0 1. Считается за линейное время благодаря одному наблюдению: если кусок [l, r) уже совпал с началом, то про позиции внутри него всё известно из ранее посчитанных значений. Поиск подстроки сводится к Z-функции склейки образец + "#" + текст: вхождения там, где z равно длине образца. Замер на строке из тысячи букв a: по определению 499 500 сравнений, с этим наблюдением - 999.

Если ты посчитаешь Z-функцию для aabaab, объяснишь, зачем в формуле минимум из двух чисел, и найдёшь образец через склейку с разделителем, - листай до «Теперь сам».

Как думаешь, сколько сравнений нужно на самом деле

Строка - тысяча букв a. Для каждой позиции надо узнать, на сколько символов кусок, начинающийся с неё, совпадает с началом строки. Ответ для позиции i очевиден: 1000 − i, до самого конца.

Если считать по определению - прикладывать начало строки к каждой позиции и сравнивать, пока совпадает, - выйдет 999 + 998 + … + 1 = 499 500 сравнений. Прежде чем читать дальше, прикинь: сколько сравнений действительно необходимо, чтобы заполнить весь список? И что именно нужно запомнить, чтобы не сравнивать лишнего?

На что это похоже

Представь длинный договор, в середине которого слово в слово повторён вводный раздел - скажем, первые три страницы. Ты уже вычитал начало и знаешь про него всё: где какой абзац повторяет заголовок, где какая фраза начинается так же, как сам договор.

Дойдя до повтора, ты не вычитываешь его заново. Это копия, и любой вопрос к странице копии - это вопрос к соответствующей странице оригинала, на который ответ уже есть. Осторожность нужна в одном месте: у края копии. Знание об оригинале говорит, что было бы дальше в оригинале, а за краем копии договор может продолжаться иначе. Там приходится читать.

Механизм: Z-блок и три случая

индекс    0  1  2  3  4  5  6  7  8  9  10
строка    a  b  r  a  c  a  d  a  b  r  a
z         0  0  0  1  0  1  0  4  0  0  1

z[7] = 4:  с позиции 7 стоит abra - это первые четыре символа строки

Значение z[0] по определению равнялось бы длине всей строки; его принято писать нулём, оно нигде не используется.

Z-блок - это кусок [l, r), про который уже известно, что он совпадает с началом строки, причём из всех таких выбран тот, что кончается правее всех. Символ с индексом r в блок не входит.

Пусть мы считаем z[i], и i попало внутрь блока. Блок - копия начала, значит позиция i в нём соответствует позиции i − l в начале, а про неё значение z[i − l] уже посчитано. До правого края блока от i остаётся r − i символов.

случай 1:  i >= r                 про i ничего не известно: сравниваем с нуля
случай 2:  z[i − l] < r − i       совпадение кончается внутри блока: z[i] = z[i − l];
                                  одно сравнение только подтверждает, что дальше не совпадает
случай 3:  z[i − l] >= r − i      до края блока совпадает точно; что за краем - неизвестно:
                                  начинаем с r − i и сравниваем дальше символ за символом

Все три случая укладываются в одну строку: начальное значение min(r − i, z[i − l]), потом попытка продлить сравнениями. Во втором случае продление сразу упирается в расхождение. В третьем каждое успешное сравнение сдвигает край r вправо.

Правило

Для позиции i внутри Z-блока [l, r) значение z[i] не меньше min(r − i, z[i − l]): начинать сравнение можно с этого числа, а не с нуля. Если после продления i + z[i] оказалось правее r, блоком становится [i, i + z[i]). Успешное сравнение всегда сдвигает r вправо, а r не может превысить n, поэтому успешных сравнений не больше n; неуспешных не больше одного на позицию. Всего не больше 2n.

Разбор: программа и проход по abacaba

def zf(s):
    n = len(s)
    z = [0] * n
    l = 0
    r = 0
    sravn = 0
    i = 1
    while i < n:
        if i < r:
            z[i] = min(r - i, z[i - l])       # то, что известно из блока
        while i + z[i] < n:
            sravn = sravn + 1
            if s[z[i]] != s[i + z[i]]:
                break
            z[i] = z[i] + 1                   # продлили на символ
        if i + z[i] > r:
            l = i                             # новый блок кончается правее
            r = i + z[i]
        i = i + 1
    return [z, sravn]

print(zf("abacaba"))                          # [[0, 0, 1, 0, 3, 0, 1], 8]
i   блок до    начальное z[i]            сравнения                    z[i]   блок после
1   пусто           0                    b ≠ a                          0     пусто
2   пусто           0                    a = a,  c ≠ b                  1     [2, 3)
3   [2, 3)          0  (i вне блока)     c ≠ a                          0     [2, 3)
4   [2, 3)          0  (i вне блока)     a = a, b = b, a = a, конец     3     [4, 7)
5   [4, 7)     min(2, z[1]) = 0          b ≠ a                          0     [4, 7)
6   [4, 7)     min(1, z[2]) = 1          нет: i + z[i] = 7 = n          1     [4, 7)

Строка i = 6 - тот самый выигрыш: значение взято из z[2] готовым, ни одного сравнения. Строка i = 5 - второй случай: z[1] = 0 меньше расстояния до края, и единственное сравнение только подтверждает ноль. Всего восемь сравнений; по определению было бы девять. На семи символах разница в одно сравнение, на длинных повторах - на порядки.

Разбор: замер и поиск через склейку

строка          по определению    с Z-блоком
"a" * 100             4 950            99
"a" * 1000          499 500           999
"a" * 2000        1 999 000         1 999
"ab" * 500          250 000         1 498

Ответ на вопрос из начала урока: 999 сравнений. На позиции 1 программа честно сравнивает до конца строки - 999 успешных сравнений - и получает блок [1, 1000). Для всех остальных позиций значение берётся из блока готовым: min(1000 − i, z[i − 1]), и продлевать некуда - строка кончилась.

Теперь поиск. Склеим образец, разделитель и текст в одну строку:

def poisk_z(t, p):
    s = p + "#" + t
    z = zf(s)[0]
    m = len(p)
    nashli = 0
    i = m + 1                         # первая позиция текста внутри склейки
    while i < len(s):
        if z[i] == m:
            nashli = nashli + 1       # в тексте это позиция i - m - 1
        i = i + 1
    return nashli

Начало склейки - это образец. Значит z[i] = m на позиции текста означает ровно одно: с этого места стоит образец целиком. Разделитель # - символ, которого нет ни в образце, ни в тексте; на нём любое совпадение обрывается, поэтому z не бывает больше m.

Цена на худших данных для наивного поиска - текст "a" * 1000, образец "a" * 9 + "b": 2009 сравнений, из них 18 уходит на часть «образец и разделитель» и 1991 - на текст. У наивного было 9910. У КМП по тексту те же 1991 сравнение; подготовку образца его счётчик не считал. Z-функция и префикс-функция несут одно и то же знание и стоят одинаково; одну можно пересчитать в другую за линейное время.

Различие в том, с какой стороны записан ответ. π[i] смотрит назад: сколько символов ПЕРЕД позицией i включительно совпадают с началом. z[i] смотрит вперёд: сколько символов НАЧИНАЯ с позиции i совпадают с началом. Вопрос «на сколько символов образец совпадает с текстом, если приложить его вот сюда» - это дословно z, и там, где нужен именно он, Z-функция удобнее.

Что ломается без этого

Первое: минимум забыт. Если написать z[i] = z[i − l] без min, значение может вылезти за край блока - туда, где про строку ничего не известно. Строка aaa: правильный ответ 0 2 1, без минимума выходит 0 2 2 - на позиции 2 записано совпадение в два символа, хотя до конца строки остался один. Значение z[i − l] говорит, что было в начале строки; внутри блока это верно и для копии, а за краем блока - уже нет. Продление сравнениями такую ошибку не исправит: оно умеет только увеличивать значение.

Второе: блок не обновляется. Строки l = i и r = i + z[i] можно забыть - ответ останется верным, потому что продление сравнениями всё равно доведёт z[i] до правильного значения. Пропадёт только скорость: на тысяче букв a снова будет 499 500 сравнений. Ошибка, которую не ловит ни один тест на правильность, - только замер.

Третье: разделитель встречается в тексте. Образец ab, текст ab#ab, склейка ab#ab#ab. На позиции 3 совпадение перешагивает через # и даёт z = 5, а проверка z[i] == m ждёт ровно 2 - вхождение потеряно. Либо разделитель гарантированно чужой, либо проверка пишется как z[i] >= m.

Четвёртое: путаница с z[0]. Одни пишут туда ноль, другие длину строки. Программы этого урока z[0] не читают вовсе, но чужая формула, взятая из книги с другой договорённостью, может на него рассчитывать. Смешивать две договорённости в одной программе нельзя.

Зачем это в работе

Первый сюжет - поиск с одной опечаткой. Посчитай Z-функцию для склейки образца с текстом и такую же для перевёрнутых строк. Первая говорит, сколько символов образца совпало от начала окна, вторая - сколько совпало от конца. Если сумма не меньше m − 1, в окне стоит образец с не более чем одной заменённой буквой. Два линейных прохода вместо перебора всех вариантов опечатки.

Второй - подготовка образца для быстрых алгоритмов. Правило «хорошего суффикса» в алгоритме Бойера-Мура (следующая глава) требует знать для каждой позиции образца, сколько символов перед ней совпадает с его концом. Это Z-функция перевёрнутого образца, и в книге Гасфилда вся теория поиска построена на ней как на основной заготовке.

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

Теперь сам

Посчитай Z-функцию для строки aabaab. Для каких позиций значение получилось без единого успешного сравнения благодаря блоку?

Ответ.

индекс    0  1  2  3  4  5
строка    a  a  b  a  a  b
z         0  1  0  3  1  0

Позиция 1: a = a, затем b ≠ a - единица, блок [1, 2). Позиция 2: b ≠ a - ноль. Позиция 3: aab совпало с началом до самого конца строки - тройка, блок [3, 6).

Позиции 4 и 5 лежат внутри блока [3, 6). Для 4: min(6 − 4, z[1]) = min(2, 1) = 1 - значение взято готовым; одно сравнение (b против a) только подтверждает, что дальше не совпадает. Для 5: min(1, z[2]) = 0, одно неуспешное сравнение. Успешных сравнений на этих двух позициях нет: всё, что совпало, было известно из блока.

Главное

Z-функция хранит для каждой позиции длину совпадения с началом строки, и считается за линейное время потому, что найденное совпадение - это копия начала, а про начало всё уже посчитано. Сравнивать приходится только за правым краем самого дальнего совпадения, и каждый успех двигает этот край вперёд - отсюда 2n. Поиск образца превращается в одну строку: посчитать Z-функцию склейки p + "#" + t и найти значения, равные m. Знание то же, что у префикс-функции; выбор между ними - вопрос удобства формулировки, а не скорости.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.