Эффективный способ найти самую длинную повторяющуюся строку для Python (из Programming Pearls)

Из раздела 15.2 Programming Pearls

Коды C можно посмотреть здесь: http://www.cs.bell-labs.com/cm/cs/pearls/longdup.c

Когда я реализую это на Python, используя суффикс-массив:

example = open("iliad10.txt").read()
def comlen(p, q):
    i = 0
    for x in zip(p, q):
        if x[0] == x[1]:
            i += 1
        else:
            break
    return i

suffix_list = []
example_len = len(example)
idx = list(range(example_len))
idx.sort(cmp = lambda a, b: cmp(example[a:], example[b:]))  #VERY VERY SLOW

max_len = -1
for i in range(example_len - 1):
    this_len = comlen(example[idx[i]:], example[idx[i+1]:])
    print this_len
    if this_len > max_len:
        max_len = this_len
        maxi = i

Я нашел это очень медленным для шага idx.sort. Я думаю, что это медленно, потому что Python должен передавать подстроку по значению, а не по указателю (как коды C выше).

Тестируемый файл можно загрузить с здесь

Кодам C требуется всего 0,3 секунды для завершения.

time cat iliad10.txt |./longdup 
On this the rest of the Achaeans with one voice were for
respecting the priest and taking the ransom that he offered; but
not so Agamemnon, who spoke fiercely to him and sent him roughly
away. 

real    0m0.328s
user    0m0.291s
sys 0m0.006s

Но для кодов Python он никогда не заканчивается на моем компьютере (я подождал 10 минут и убил его)

У кого-нибудь есть идеи, как сделать коды эффективными? (Например, менее 10 секунд)


person Hanfei Sun    schedule 26.11.2012    source источник
comment
Сколько времени занимает код C? Сколько времени занимает ваш код?   -  person beatgammit    schedule 26.11.2012
comment
Коды @tjameson C используют 0,3 секунды. Я не знаю, сколько времени занимают мои коды, так как они никогда не заканчиваются (минимум 10 минут).   -  person Hanfei Sun    schedule 26.11.2012
comment
Код C медленный, потому что он не может отследить самое длинное совпадение при сортировке и должен проверять все во второй раз. Python медленный по той же причине, плюс потому, что он работает со строками, а не с указателями на строки, плюс потому, что это Python.   -  person Brendan    schedule 26.11.2012
comment
example[a:] каждый раз копирует строку (O(N)). Итак, ваш сорт O(N*N*logN). Для iliad это ~10**12 медленная операция.   -  person jfs    schedule 26.11.2012
comment
Так как Programming Swines, эээ, простите, Pearls, сильно зависит от различных форм неопределенного, неопределенного и неопределенного поведения, вы не можете легко перевести код с него на другой язык, который не имеет такого же неуказанного поведения.   -  person Lundin    schedule 26.11.2012
comment
@HanfeiSun, я не думаю, что лучший ответ - принятый. Ради будущих посетителей вроде меня, не могли бы вы взглянуть?   -  person Shihab Shahriar Khan    schedule 08.12.2017


Ответы (4)


Мое решение основано на массивах суффиксов. Он создается путем удвоения префикса самого длинного общего префикса. Сложность в наихудшем случае составляет O (n (log n) ^ 2). Файл iliad.mb.txt на моем ноутбуке занимает 4 секунды. Функция longest_common_substring короткая и может быть легко изменена, например. для поиска 10 самых длинных непересекающихся подстрок. Этот код Python быстрее, чем исходный код C из вопроса, если повторяющиеся строки длиннее 10000 символов.

from itertools import groupby
from operator import itemgetter

def longest_common_substring(text):
    """Get the longest common substrings and their positions.
    >>> longest_common_substring('banana')
    {'ana': [1, 3]}
    >>> text = "not so Agamemnon, who spoke fiercely to "
    >>> sorted(longest_common_substring(text).items())
    [(' s', [3, 21]), ('no', [0, 13]), ('o ', [5, 20, 38])]

    This function can be easy modified for any criteria, e.g. for searching ten
    longest non overlapping repeated substrings.
    """
    sa, rsa, lcp = suffix_array(text)
    maxlen = max(lcp)
    result = {}
    for i in range(1, len(text)):
        if lcp[i] == maxlen:
            j1, j2, h = sa[i - 1], sa[i], lcp[i]
            assert text[j1:j1 + h] == text[j2:j2 + h]
            substring = text[j1:j1 + h]
            if not substring in result:
                result[substring] = [j1]
            result[substring].append(j2)
    return dict((k, sorted(v)) for k, v in result.items())

def suffix_array(text, _step=16):
    """Analyze all common strings in the text.

    Short substrings of the length _step a are first pre-sorted. The are the 
    results repeatedly merged so that the garanteed number of compared
    characters bytes is doubled in every iteration until all substrings are
    sorted exactly.

    Arguments:
        text:  The text to be analyzed.
        _step: Is only for optimization and testing. It is the optimal length
               of substrings used for initial pre-sorting. The bigger value is
               faster if there is enough memory. Memory requirements are
               approximately (estimate for 32 bit Python 3.3):
                   len(text) * (29 + (_size + 20 if _size > 2 else 0)) + 1MB

    Return value:      (tuple)
      (sa, rsa, lcp)
        sa:  Suffix array                  for i in range(1, size):
               assert text[sa[i-1]:] < text[sa[i]:]
        rsa: Reverse suffix array          for i in range(size):
               assert rsa[sa[i]] == i
        lcp: Longest common prefix         for i in range(1, size):
               assert text[sa[i-1]:sa[i-1]+lcp[i]] == text[sa[i]:sa[i]+lcp[i]]
               if sa[i-1] + lcp[i] < len(text):
                   assert text[sa[i-1] + lcp[i]] < text[sa[i] + lcp[i]]
    >>> suffix_array(text='banana')
    ([5, 3, 1, 0, 4, 2], [3, 2, 5, 1, 4, 0], [0, 1, 3, 0, 0, 2])

    Explanation: 'a' < 'ana' < 'anana' < 'banana' < 'na' < 'nana'
    The Longest Common String is 'ana': lcp[2] == 3 == len('ana')
    It is between  tx[sa[1]:] == 'ana' < 'anana' == tx[sa[2]:]
    """
    tx = text
    size = len(tx)
    step = min(max(_step, 1), len(tx))
    sa = list(range(len(tx)))
    sa.sort(key=lambda i: tx[i:i + step])
    grpstart = size * [False] + [True]  # a boolean map for iteration speedup.
    # It helps to skip yet resolved values. The last value True is a sentinel.
    rsa = size * [None]
    stgrp, igrp = '', 0
    for i, pos in enumerate(sa):
        st = tx[pos:pos + step]
        if st != stgrp:
            grpstart[igrp] = (igrp < i - 1)
            stgrp = st
            igrp = i
        rsa[pos] = igrp
        sa[i] = pos
    grpstart[igrp] = (igrp < size - 1 or size == 0)
    while grpstart.index(True) < size:
        # assert step <= size
        nextgr = grpstart.index(True)
        while nextgr < size:
            igrp = nextgr
            nextgr = grpstart.index(True, igrp + 1)
            glist = []
            for ig in range(igrp, nextgr):
                pos = sa[ig]
                if rsa[pos] != igrp:
                    break
                newgr = rsa[pos + step] if pos + step < size else -1
                glist.append((newgr, pos))
            glist.sort()
            for ig, g in groupby(glist, key=itemgetter(0)):
                g = [x[1] for x in g]
                sa[igrp:igrp + len(g)] = g
                grpstart[igrp] = (len(g) > 1)
                for pos in g:
                    rsa[pos] = igrp
                igrp += len(g)
        step *= 2
    del grpstart
    # create LCP array
    lcp = size * [None]
    h = 0
    for i in range(size):
        if rsa[i] > 0:
            j = sa[rsa[i] - 1]
            while i != size - h and j != size - h and tx[i + h] == tx[j + h]:
                h += 1
            lcp[rsa[i]] = h
            if h > 0:
                h -= 1
    if size > 0:
        lcp[0] = 0
    return sa, rsa, lcp

Я предпочитаю это решение более сложному O(n log n), потому что Python имеет очень быстрый список алгоритм сортировки (Timsort). Сортировка Python, вероятно, быстрее, чем необходимые операции линейного времени в методе из этой статьи, которые должны быть O (n) при очень особых предположениях случайных строк вместе с небольшим алфавитом (типично для анализа генома ДНК). Я прочитал в Gog 2011, что в худшем случае O(n log n ) моего алгоритма может быть на практике быстрее, чем многие алгоритмы O (n), которые не могут использовать кэш памяти ЦП.

Код в другом ответе на основе grow_chains в 19 раз медленнее исходного примера из вопроса, если текст содержит повторяющийся строка длиной 8 КБ. Длинные повторяющиеся тексты не характерны для классической литературы, но встречаются, например, в в сборниках домашних заданий независимой школы. Программа не должна зависать на нем.

Я написал пример и тесты с тем же кодом для Python 2.7, 3.3–3.6.

person hynekcer    schedule 03.12.2012
comment
приведенная выше ссылка примера с тестами не работает. Не могли бы вы обновить его? - person gkoul; 11.01.2018
comment
Я исправил ссылки на свой код и на оригинальный C, вставив свои копии. - person hynekcer; 12.01.2018

Основная проблема заключается в том, что python выполняет нарезку по копии: https://stackoverflow.com/a/5722068/538551

Вместо этого вам придется использовать memoryview, чтобы получить ссылку. копии. Когда я это сделал, программа зависла после функции idx.sort (что было очень быстро).

Я уверен, что немного поработав, вы сможете заставить работать все остальное.

Изменить:

Вышеупомянутое изменение не будет работать в качестве замены, потому что cmp не работает так же, как strcmp. Например, попробуйте следующий код C:

#include <stdio.h>
#include <string.h>

int main() {
    char* test1 = "ovided by The Internet Classics Archive";
    char* test2 = "rovided by The Internet Classics Archive.";
    printf("%d\n", strcmp(test1, test2));
}

И сравните результат с этим питоном:

test1 = "ovided by The Internet Classics Archive";
test2 = "rovided by The Internet Classics Archive."
print(cmp(test1, test2))

Код C печатает -3 на моей машине, а версия Python печатает -1. Похоже, что пример кода C злоупотребляет возвращаемым значением strcmp (в конце концов, оно используется в qsort). Я не смог найти документацию о том, когда strcmp вернет что-то отличное от [-1, 0, 1], но добавление printf к pstrcmp в исходном коде показало много значений за пределами этого диапазона (3, -31, 5 были первыми 3 значениями).

Чтобы убедиться, что -3 не является кодом ошибки, если мы поменяем местами test1 и test2, то получим 3.

Изменить:

Вышеупомянутое является интересной мелочью, но на самом деле неправильно с точки зрения воздействия на любой фрагмент кода. Я понял это, как только закрыл свой ноутбук и вышел из зоны Wi-Fi... На самом деле нужно перепроверить все, прежде чем я нажму Save.

FWIW, cmp наверняка работает с memoryview объектами (выводит -1 как и ожидалось):

print(cmp(memoryview(test1), memoryview(test2)))

Я не уверен, почему код не работает должным образом. Распечатка списка на моей машине выглядит не так, как ожидалось. Я рассмотрю это и попытаюсь найти лучшее решение вместо того, чтобы хвататься за соломинку.

person beatgammit    schedule 26.11.2012
comment
Спасибо, tjameson! Но даже используя memoryview, вам все равно нужно передать строку cmp, верно? Тогда все равно нужно передавать по значению? - person Hanfei Sun; 26.11.2012
comment
Этот не работает. Поскольку cmp нельзя использовать для объекта memoryview - person Hanfei Sun; 26.11.2012
comment
Кодекс Bentley не злоупотребляет strcmp. Он просто использует его для сравнения строк в qsort, который, в свою очередь, никогда не полагается ни на что, кроме знака возвращаемого значения. - person Fred Foo; 26.11.2012
comment
@larsmans - Как упоминалось в моем комментарии, я понял это примерно через 5 минут после публикации. Как раз в то время, когда я перестал смотреть на код... Пересматриваю ответ. - person beatgammit; 27.11.2012
comment
@Firegun - cmp определенно работает с memoryview объектами. Я предполагаю, что тут что-то другое. - person beatgammit; 27.11.2012
comment
сравнение memoryview не работает. См. пример в мом ответе. - person jfs; 27.11.2012
comment
@tjameson: сравнение памяти работает в PyPy 1.9, но не в CPython 2.7.3. - person jfs; 27.11.2012
comment

Перевод алгоритма на Python:

from itertools import imap, izip, starmap, tee
from os.path   import commonprefix

def pairwise(iterable): # itertools recipe
    a, b = tee(iterable)
    next(b, None)
    return izip(a, b)

def longest_duplicate_small(data):
    suffixes = sorted(data[i:] for i in xrange(len(data))) # O(n*n) in memory
    return max(imap(commonprefix, pairwise(suffixes)), key=len)

buffer() позволяет получить подстроку без копирования:

def longest_duplicate_buffer(data):
    n = len(data)
    sa = sorted(xrange(n), key=lambda i: buffer(data, i)) # suffix array
    def lcp_item(i, j):  # find longest common prefix array item
        start = i
        while i < n and data[i] == data[i + j - start]:
            i += 1
        return i - start, start
    size, start = max(starmap(lcp_item, pairwise(sa)), key=lambda x: x[0])
    return data[start:start + size]

На моей машине iliad.mb.txt требуется 5 секунд.

В принципе можно найти дубликат за O(n) времени и O(n) памяти, используя суффикс массив, дополненный lcp-массивом.


Примечание. *_memoryview() устарело в *_buffer() версии

Версия с более эффективным использованием памяти (по сравнению с longest_duplicate_small()):

def cmp_memoryview(a, b):
    for x, y in izip(a, b):
        if x < y:
            return -1
        elif x > y:
            return 1
    return cmp(len(a), len(b))

def common_prefix_memoryview((a, b)):
    for i, (x, y) in enumerate(izip(a, b)):
        if x != y:
            return a[:i]
    return a if len(a) < len(b) else b

def longest_duplicate(data):
    mv = memoryview(data)
    suffixes = sorted((mv[i:] for i in xrange(len(mv))), cmp=cmp_memoryview)
    result = max(imap(common_prefix_memoryview, pairwise(suffixes)), key=len)
    return result.tobytes()

На моей машине для iliad.mb.txt требуется 17 секунд. Результат:

On this the rest of the Achaeans with one voice were for respecting
the priest and taking the ransom that he offered; but not so Agamemnon,
who spoke fiercely to him and sent him roughly away. 

Мне пришлось определить пользовательские функции для сравнения memoryview объектов, потому что сравнение memoryview либо вызывает исключение в Python 3, либо дает неверный результат в Python 2:

>>> s = b"abc"
>>> memoryview(s[0:]) > memoryview(s[1:])
True
>>> memoryview(s[0:]) < memoryview(s[1:])
True

Связанные вопросы:

Найдите самая длинная повторяющаяся строка и количество раз, которое она повторяется в данной строке

поиск длинных повторяющихся подстрок в массивной строке

person jfs    schedule 26.11.2012
comment
поскольку для вашего кода требуется Python 3.+, и в данный момент у меня нет доступа к этой версии, не могли бы вы также предоставить время работы моей версии кода в вашей среде? - person lenik; 27.11.2012
comment
@lenik: код работает на Python 2.7. Что могло заставить вас думать, что это было для Python 3? - person jfs; 27.11.2012
comment
не могли бы вы перестать спорить о несвязанных вещах и просто указать время работы? - person lenik; 27.11.2012
comment
@lenik: если вы не можете запустить Python 2.7 и 3. Вот время работы: 12 секунд. - person jfs; 27.11.2012
comment
Примечание: причина, по которой он дает неправильный результат на Python 2 (и исключение на Py3), заключается в том, что memoryview определяет только эквивалент __eq__ и __ne__, а не остальные расширенные операторы сравнения; на Py2 это означает, что он переходит к сравнению последней инстанции (которое заканчивается сравнением адресов памяти объектов, что совершенно бесполезно), в то время как Python 3 сообщает вам, что сравнение не поддерживается. Существует ошибка, которую можно исправить, но за последние пять лет не предпринималось никаких действий. - person ShadowRanger; 18.01.2019

Эта версия занимает около 17 секунд на моем рабочем столе примерно 2007 года с использованием совершенно другого алгоритма:

#!/usr/bin/env python

ex = open("iliad.mb.txt").read()

chains = dict()

# populate initial chains dictionary
for (a,b) in enumerate(zip(ex,ex[1:])) :
    s = ''.join(b)
    if s not in chains :
        chains[s] = list()

    chains[s].append(a)

def grow_chains(chains) :
    new_chains = dict()
    for (string,pos) in chains :
        offset = len(string)
        for p in pos :
            if p + offset >= len(ex) : break

            # add one more character
            s = string + ex[p + offset]

            if s not in new_chains :
                new_chains[s] = list()

            new_chains[s].append(p)
    return new_chains

# grow and filter, grow and filter
while len(chains) > 1 :
    print 'length of chains', len(chains)

    # remove chains that appear only once
    chains = [(i,chains[i]) for i in chains if len(chains[i]) > 1]

    print 'non-unique chains', len(chains)
    print [i[0] for i in chains[:3]]

    chains = grow_chains(chains)

Основная идея состоит в том, чтобы создать список подстрок и позиций, в которых они встречаются, что устраняет необходимость снова и снова сравнивать одни и те же строки. Результирующий список выглядит как [('ind him, but', [466548, 739011]), (' bulwark bot', [428251, 428924]), (' his armour,', [121559, 124919, 193285, 393566, 413634, 718953, 760088])]. Уникальные строки удаляются. Затем каждый член списка увеличивается на 1 символ и создается новый список. Уникальные строки снова удаляются. И так далее...

person lenik    schedule 26.11.2012
comment
Если более одной повторяющейся подстроки имеют одинаковую максимальную длину, ничего не возвращается. Пример: ex = 'ABCxABCyDEFzDEF' - person hynekcer; 26.11.2012
comment
@hynekcer последний набор всегда пуст (это условие остановки цикла), но предыдущий содержит: ['ABC', 'DEF'] - я не понимаю, почему это неправильно? в моем коде есть очевидные ограничения - печатаются только 3 первые цепочки, если их больше - вам нужно изменить код или что-то в этом роде, красивая печать никогда не была моей целью. - person lenik; 26.11.2012
comment
Я ожидаю, что результат будет наконец в переменной цепочки, но они потеряны. Отладочная печать не важна для алгоритма. - person hynekcer; 26.11.2012
comment
Отладочная печать @hynekcer помогает понять, как это работает. если вам нужен только ответ — сохраните результат фильтрации во временной переменной, а когда она станет пустой — напечатайте все, что у вас есть в chains — это должно работать нормально для любого количества подстрок любой длины. - person lenik; 26.11.2012
comment
Самая большая проблема заключается в том, что вашему алгоритму может потребоваться более N * N / 4 байта памяти, где N — длина входной строки. Пример: ex = ' '.join('%03s' % i for i in range(500)) Я печатаю sum(len(string) for string in chains) и вижу, что наибольшее значение равно 1001000. Требуемое время пропорционально N * N * N. - person hynekcer; 26.11.2012
comment
что бы вы ни говорили, это работает. если у вас есть решение больше/лучше/сильнее, я был бы рад увидеть его и сравнить. - person lenik; 26.11.2012
comment
Если мы позволим O(N*N) памяти, вы можете использовать более простой код. Например, см. longest_duplicate_small() в моем ответе. - person jfs; 27.11.2012
comment
@ J.F.Sebastian suffixes = sorted(data[i:] for i in xrange(len(data))) просто переполняет память на моем компьютере, это определенно не рабочее решение. Вы когда-нибудь запускали его, и если да, то сколько памяти он действительно занимал (в гигабайтах)? - person lenik; 27.11.2012
comment
@lenik: _small в названии означает небольшие входные данные. Вот почему есть второй пример, который требует меньше памяти - person jfs; 27.11.2012
comment
@ J.F.Sebastian, ты не должен так легко верить тому, что говорят другие. мой алгоритм не требует O(N*N) памяти, и ваш предыдущий комментарий, если мы позволим... спорный. - person lenik; 27.11.2012
comment
@lenik Я пишу свой алгоритм, а также сравнение вашего, оригинала и оригинала на C. - person hynekcer; 04.12.2012