Оптимизируйте с точки зрения алгоритма и реализации следующий Python‑фрагмент для подсчёта уникальных подстрок строки: def uniq_subs(s): res = set() for i in range(len(s)): for j in range(i+1,len(s)+1): res.add(s[i:j]) return res — оцените сложность, предложите более эффективные подходы и обсудите компромиссы памяти/времени
def uniq_subssss: res = set
for i in rangelen(s)len(s)len(s): for j in rangei+1,len(s)+1i+1, len(s)+1i+1,len(s)+1: res.adds[i:j]s[i:j]s[i:j]
return res
корректен, но очень неэффективен. Разберём почему и какие есть более быстрые подходы, с оценкой сложности и примерами реализации на Python.
1) Оценка сложности исходного кода
Количество порождаемых подстрок = nn+1n+1n+1/2 = Θn2n^2n2.Каждое si:ji:ji:j — срез строки, который копирует j−ij-ij−i символов. Суммарный объём копируемых символов = Θn3n^3n3точноn(n+1)(n+2)/6точно n(n+1)(n+2)/6точноn(n+1)(n+2)/6.Хеширование/вставка строки в set также занимает Olengthlengthlength для каждой строки. Итого: время Θn3n^3n3 в худшем случае; память — хранение всех уникальных подстрок занимает Θn2n^2n2 строк и Θn2n^2n2 символов в сумме.
Для даже умеренно больших n напримерn=104например n = 10^4напримерn=104 это невозможно.
2) Цель: посчитать количество различных подстрок илипростополучитьмножествоуникальныхили просто получить множество уникальныхилипростополучитьмножествоуникальных — алгоритмы лучше On2n^2n2 по времени и Onnn по памяти существуют.
Основные эффективные подходы
Суффиксный массив + LCP: построить суффиксный массив SA времяO(nlogn)впростыхреализациях;существуютO(n)алгоритмывремя O(n log n) в простых реализациях; существуют O(n) алгоритмывремяO(nlogn)впростыхреализациях;существуютO(n)алгоритмы, затем LCP КэсайКэсайКэсай за Onnn. Количество уникальных подстрок = n*n+1n+1n+1/2 − sumLCPLCPLCP.
Сложность: Onlognn log nnlognвпростомвариантессортировкойв простом варианте с сортировкойвпростомвариантессортировкой или Onnn. Память: Onnn.Подходит, если нужно только количество, и если реализовать SA аккуратно вPythonSAO(nlogn)обычноприемлемв Python SA O(n log n) обычно приемлемвPythonSAO(nlogn)обычноприемлем.
Суффиксный автомат SuffixAutomaton,SAMSuffix Automaton, SAMSuffixAutomaton,SAM: строится за Onnn и позволяет посчитать число различных подстрок как сумма по вершинам: sumlen[v]−len[link[v]]len[v] − len[link[v]]len[v]−len[link[v]].
Сложность: Onnn время, Onnn память числосостояний≤2n−1число состояний ≤ 2n−1числосостояний≤2n−1. В Python реализуется компактно.Очень удобен для подсчёта, поиска k‑й лексикографической подстроки и т. п.
Суффиксное дерево UkkonenUkkonenUkkonen: Onnn время, Onnn память, но сложнее в реализации на Python.
Хеши rollinghashrolling hashrollinghash: можно посчитать хеши всех подстрок фиксированной длины L за Onnn и получить число различных подстрок длины L. Но суммирование по всем L даёт On2n^2n2 время. Можно комбинировать с бинпоиском — но для общего подсчёта уникальных подстрок всё равно менее эффективно, чем SAM/SA.
3) Рекомендации
Если нужно только количество различных подстрок — используйте SAM или SA+LCP.Если нужно сам набор всех уникальных подстрок перечислитьперечислитьперечислить, то размер результата может быть Θn2n^2n2 и это физически дорого; в этом случае подумайте, действительно ли нужно перечисление, или достаточно статистики/ограничения по длине.В Python обычно проще и быстрее писать SAM для задач подсчёта; SA с сортировкой doubling+tuplesortdoubling + tuple sortdoubling+tuplesort тоже часто хорош и понятен.
4) Код: суффиксный автомат быстрыйикомпактныйбыстрый и компактныйбыстрыйикомпактный. Возвращает количество различных подстрок.
def count_distinct_substrings_samsss:
Возвращает количество различных подстрок строки ssa_trans = # список словарей переходов sa_link =
sa_len = sa_trans.append{} # state 0 sa_link.append−1-1−1
sa_len.append000
last = 0 for ch in s: cur = lensatranssa_transsatrans
sa_trans.append{}
sa_len.appendsalen[last]+1sa_len[last] + 1salen[last]+1
sa_link.append000 p = last while p != -1 and ch not in sa_transppp: sa_transpppchchch = cur p = sa_linkppp
if p == -1: sa_linkcurcurcur = 0 else: q = sa_transpppchchch
if sa_lenppp + 1 == sa_lenqqq: sa_linkcurcurcur = q else: # clone q clone = lensatranssa_transsatrans
sa_trans.appendsatrans[q].copy()sa_trans[q].copy()satrans[q].copy()
sa_len.appendsalen[p]+1sa_len[p] + 1salen[p]+1
sa_link.appendsalink[q]sa_link[q]salink[q]
while p != -1 and sa_transppp.getchchch == q: sa_transpppchchch = clone p = sa_linkppp
sa_linkqqq = sa_linkcurcurcur = clone last = cur # число различных подстрок = сумма len[v]−len[link[v]]len[v] - len[link[v]]len[v]−len[link[v]] по всем состояниям v>0 res = 0 for v in range1,len(satrans)1, len(sa_trans)1,len(satrans): res += sa_lenvvv - sa_lensalink[v]sa_link[v]salink[v]
return res
Сложность: Onnn в среднем и худшем для реализаций SAM; память Onnn состояний каждое—dictпереходов,попамятинемного«дороже»чеммассивы,новсёжелинейнокаждое — dict переходов, по памяти немного «дороже» чем массивы, но всё же линейнокаждое—dictпереходов,попамятинемного«дороже»чеммассивы,новсёжелинейно.
постройка SA методом "doubling"sa = listrange(n)range(n)range(n)
rank_ = ord(c)forcinsord(c) for c in sord(c)forcins + −1-1−1
k = 1 while k <= n: sa.sort(key=lambda i: (rank_[i], rank_[i + k] if i + k < n else -1)) tmp = 000 * n for i in range1,n1, n1,n: prev, cur = sai−1i-1i−1, saiii
tmpcurcurcur = tmpprevprevprev + ( (rank_[prev], rank_[prev+k] if prev+k<n else -1) != (rank_[cur], rank_[cur+k] if cur+k<n else -1) ) rank_:n:n:n = tmp k <<= 1 # Kasai LCP rank_sa = 000*n for i, p in enumeratesasasa: rank_sappp = i lcp = 000*n−1n-1n−1
h = 0 for i in rangennn: r = rank_saiii
if r == 0: continue j = sar−1r-1r−1
while i+h < n and j+h < n and si+hi+hi+h == sj+hj+hj+h: h += 1 lcpr−1r-1r−1 = h if h: h -= 1 total = n*n+1n+1n+1//2 - sumlcplcplcp
return total
Сложность: Onlognn log nnlogn из-за сортировки с ключем; LCP — Onnn. Память Onnn.
6) Компромиссы памяти/времени
Naive срезы+setсрезы + setсрезы+set: очень дорого по времени Θ(n3)Θ(n^3)Θ(n3) и памяти Θ(n2)Θ(n^2)Θ(n2), но очень прост и достаточен для маленьких строк (n < ~500).Rolling hash: можно хранить только хеши целыечислацелые числацелыечисла вместо строк, что экономит память по сравнению со строками, но вычислительно всё ещё Θn2n^2n2 по времени, и нужны меры против коллизий двойнойхешдвойной хешдвойнойхеш.SAM: лучшее сочетание — линейное время и линейная память, но реализация на Python использует словари для переходов — это добавляет константные факторы по памяти/времени.SA+LCP: чуть более простая структура по памяти массивы/спискимассивы/спискимассивы/списки, быстрее в Python для некоторых входов используетсортировкусбыстрымиC‑реализациямииспользует сортировку с быстрыми C‑реализациямииспользуетсортировкусбыстрымиC‑реализациями, но асимптотика Onlognn log nnlogn в простых реализациях.
7) Практические советы по Python
Если n ≤ ~2000, исходный подход можно использовать нолучшезаменитьстрокинапредставленияввидесрезовпамяти—вPythonэтонереализуемовстроенноно лучше заменить строки на представления в виде срезов памяти — в Python это не реализуемо встроеннонолучшезаменитьстрокинапредставленияввидесрезовпамяти—вPythonэтонереализуемовстроенно.Для n ~ 10^4–10^6 используйте SAM или хорошо оптимизированный SA иливызывайтеC/C++реализациюили вызывайте C/C++ реализациюиливызывайтеC/C++реализацию.Если готовы потреблять больше памяти, храните в set не строки, а 64‑бит хеши двойноймодульныйхешдвойной модульный хешдвойноймодульныйхеш, но помните о рисках коллизий.
8) Резюме
Исходный алгоритм: Θn3n^3n3 время, Θn2n^2n2 память — неприемлем для больших n.Лучший выбор для подсчёта количества различных подстрок: суффиксный автомат O(n)время/памятьO(n) время/памятьO(n)время/память или суффиксный массив + LCP O(nlogn)простаяреализацияO(n log n) простая реализацияO(nlogn)простаяреализация.Для перечисления всех уникальных подстрок размер вывода сам по себе может быть Θn2n^2n2 — это ограничивает возможную оптимизацию.
Если нужно, могу:
Прислать готовую и оптимизированную реализацию SAM/SA+LCP, тесты и бенчмарки на ваших входных данных.Написать вариант, который возвращает само множество уникальных подстрок спредупредениемобобъёмерезультатас предупредением об объёме результатаспредупредениемобобъёмерезультата или вариант с хранением только хешей.
Код, который вы привели,
def uniq_subssss:
res = set for i in rangelen(s)len(s)len(s):
for j in rangei+1,len(s)+1i+1, len(s)+1i+1,len(s)+1:
res.adds[i:j]s[i:j]s[i:j] return res
корректен, но очень неэффективен. Разберём почему и какие есть более быстрые подходы, с оценкой сложности и примерами реализации на Python.
1) Оценка сложности исходного кода
Количество порождаемых подстрок = nn+1n+1n+1/2 = Θn2n^2n2.Каждое si:ji:ji:j — срез строки, который копирует j−ij-ij−i символов. Суммарный объём копируемых символов = Θn3n^3n3 точноn(n+1)(n+2)/6точно n(n+1)(n+2)/6точноn(n+1)(n+2)/6.Хеширование/вставка строки в set также занимает Olengthlengthlength для каждой строки.Итого: время Θn3n^3n3 в худшем случае; память — хранение всех уникальных подстрок занимает Θn2n^2n2 строк и Θn2n^2n2 символов в сумме.
Для даже умеренно больших n напримерn=104например n = 10^4напримерn=104 это невозможно.
2) Цель: посчитать количество различных подстрок илипростополучитьмножествоуникальныхили просто получить множество уникальныхилипростополучитьмножествоуникальных — алгоритмы лучше On2n^2n2 по времени и Onnn по памяти существуют.
Основные эффективные подходы
Суффиксный массив + LCP: построить суффиксный массив SA времяO(nlogn)впростыхреализациях;существуютO(n)алгоритмывремя O(n log n) в простых реализациях; существуют O(n) алгоритмывремяO(nlogn)впростыхреализациях;существуютO(n)алгоритмы, затем LCP КэсайКэсайКэсай за Onnn. Количество уникальных подстрок = n*n+1n+1n+1/2 − sumLCPLCPLCP.
Сложность: Onlognn log nnlogn впростомвариантессортировкойв простом варианте с сортировкойвпростомвариантессортировкой или Onnn. Память: Onnn.Подходит, если нужно только количество, и если реализовать SA аккуратно вPythonSAO(nlogn)обычноприемлемв Python SA O(n log n) обычно приемлемвPythonSAO(nlogn)обычноприемлем.Суффиксный автомат SuffixAutomaton,SAMSuffix Automaton, SAMSuffixAutomaton,SAM: строится за Onnn и позволяет посчитать число различных подстрок как сумма по вершинам: sumlen[v]−len[link[v]]len[v] − len[link[v]]len[v]−len[link[v]].
Сложность: Onnn время, Onnn память числосостояний≤2n−1число состояний ≤ 2n−1числосостояний≤2n−1. В Python реализуется компактно.Очень удобен для подсчёта, поиска k‑й лексикографической подстроки и т. п.Суффиксное дерево UkkonenUkkonenUkkonen: Onnn время, Onnn память, но сложнее в реализации на Python.
Хеши rollinghashrolling hashrollinghash: можно посчитать хеши всех подстрок фиксированной длины L за Onnn и получить число различных подстрок длины L. Но суммирование по всем L даёт On2n^2n2 время. Можно комбинировать с бинпоиском — но для общего подсчёта уникальных подстрок всё равно менее эффективно, чем SAM/SA.
3) Рекомендации
Если нужно только количество различных подстрок — используйте SAM или SA+LCP.Если нужно сам набор всех уникальных подстрок перечислитьперечислитьперечислить, то размер результата может быть Θn2n^2n2 и это физически дорого; в этом случае подумайте, действительно ли нужно перечисление, или достаточно статистики/ограничения по длине.В Python обычно проще и быстрее писать SAM для задач подсчёта; SA с сортировкой doubling+tuplesortdoubling + tuple sortdoubling+tuplesort тоже часто хорош и понятен.4) Код: суффиксный автомат быстрыйикомпактныйбыстрый и компактныйбыстрыйикомпактный. Возвращает количество различных подстрок.
def count_distinct_substrings_samsss:
Возвращает количество различных подстрок строки ssa_trans = # список словарей переходовsa_link = sa_len =
sa_trans.append{} # state 0
sa_link.append−1-1−1 sa_len.append000 last = 0
for ch in s:
cur = lensatranssa_transsat rans sa_trans.append{} sa_len.appendsalen[last]+1sa_len[last] + 1sal en[last]+1 sa_link.append000
p = last
while p != -1 and ch not in sa_transppp:
sa_transpppchchch = cur
p = sa_linkppp if p == -1:
sa_linkcurcurcur = 0
else:
q = sa_transpppchchch if sa_lenppp + 1 == sa_lenqqq:
sa_linkcurcurcur = q
else:
# clone q
clone = lensatranssa_transsat rans sa_trans.appendsatrans[q].copy()sa_trans[q].copy()sat rans[q].copy() sa_len.appendsalen[p]+1sa_len[p] + 1sal en[p]+1 sa_link.appendsalink[q]sa_link[q]sal ink[q] while p != -1 and sa_transppp.getchchch == q:
sa_transpppchchch = clone
p = sa_linkppp sa_linkqqq = sa_linkcurcurcur = clone
last = cur
# число различных подстрок = сумма len[v]−len[link[v]]len[v] - len[link[v]]len[v]−len[link[v]] по всем состояниям v>0
res = 0
for v in range1,len(satrans)1, len(sa_trans)1,len(sat rans):
res += sa_lenvvv - sa_lensalink[v]sa_link[v]sal ink[v] return res
Сложность: Onnn в среднем и худшем для реализаций SAM; память Onnn состояний каждое—dictпереходов,попамятинемного«дороже»чеммассивы,новсёжелинейнокаждое — dict переходов, по памяти немного «дороже» чем массивы, но всё же линейнокаждое—dictпереходов,попамятинемного«дороже»чеммассивы,новсёжелинейно.
5) Код: суффиксный массив + LCP простаяреализацияO(nlogn)из−засортировкипростая реализация O(n log n) из-за сортировкипростаяреализацияO(nlogn)из−засортировки
def count_distinct_substrings_sasss:
постройка SA методом "doubling"sa = listrange(n)range(n)range(n) rank_ = ord(c)forcinsord(c) for c in sord(c)forcins + −1-1−1 k = 1n = lensss
while k <= n:
sa.sort(key=lambda i: (rank_[i], rank_[i + k] if i + k < n else -1))
tmp = 000 * n
for i in range1,n1, n1,n:
prev, cur = sai−1i-1i−1, saiii tmpcurcurcur = tmpprevprevprev + ( (rank_[prev], rank_[prev+k] if prev+k<n else -1) != (rank_[cur], rank_[cur+k] if cur+k<n else -1) )
rank_:n:n:n = tmp
k <<= 1
# Kasai LCP
rank_sa = 000*n
for i, p in enumeratesasasa:
rank_sappp = i
lcp = 000*n−1n-1n−1 h = 0
for i in rangennn:
r = rank_saiii if r == 0:
continue
j = sar−1r-1r−1 while i+h < n and j+h < n and si+hi+hi+h == sj+hj+hj+h:
h += 1
lcpr−1r-1r−1 = h
if h:
h -= 1
total = n*n+1n+1n+1//2 - sumlcplcplcp return total
Сложность: Onlognn log nnlogn из-за сортировки с ключем; LCP — Onnn. Память Onnn.
6) Компромиссы памяти/времени
Naive срезы+setсрезы + setсрезы+set: очень дорого по времени Θ(n3)Θ(n^3)Θ(n3) и памяти Θ(n2)Θ(n^2)Θ(n2), но очень прост и достаточен для маленьких строк (n < ~500).Rolling hash: можно хранить только хеши целыечислацелые числацелыечисла вместо строк, что экономит память по сравнению со строками, но вычислительно всё ещё Θn2n^2n2 по времени, и нужны меры против коллизий двойнойхешдвойной хешдвойнойхеш.SAM: лучшее сочетание — линейное время и линейная память, но реализация на Python использует словари для переходов — это добавляет константные факторы по памяти/времени.SA+LCP: чуть более простая структура по памяти массивы/спискимассивы/спискимассивы/списки, быстрее в Python для некоторых входов используетсортировкусбыстрымиC‑реализациямииспользует сортировку с быстрыми C‑реализациямииспользуетсортировкусбыстрымиC‑реализациями, но асимптотика Onlognn log nnlogn в простых реализациях.7) Практические советы по Python
Если n ≤ ~2000, исходный подход можно использовать нолучшезаменитьстрокинапредставленияввидесрезовпамяти—вPythonэтонереализуемовстроенноно лучше заменить строки на представления в виде срезов памяти — в Python это не реализуемо встроеннонолучшезаменитьстрокинапредставленияввидесрезовпамяти—вPythonэтонереализуемовстроенно.Для n ~ 10^4–10^6 используйте SAM или хорошо оптимизированный SA иливызывайтеC/C++реализациюили вызывайте C/C++ реализациюиливызывайтеC/C++реализацию.Если готовы потреблять больше памяти, храните в set не строки, а 64‑бит хеши двойноймодульныйхешдвойной модульный хешдвойноймодульныйхеш, но помните о рисках коллизий.8) Резюме
Исходный алгоритм: Θn3n^3n3 время, Θn2n^2n2 память — неприемлем для больших n.Лучший выбор для подсчёта количества различных подстрок: суффиксный автомат O(n)время/памятьO(n) время/памятьO(n)время/память или суффиксный массив + LCP O(nlogn)простаяреализацияO(n log n) простая реализацияO(nlogn)простаяреализация.Для перечисления всех уникальных подстрок размер вывода сам по себе может быть Θn2n^2n2 — это ограничивает возможную оптимизацию.Если нужно, могу:
Прислать готовую и оптимизированную реализацию SAM/SA+LCP, тесты и бенчмарки на ваших входных данных.Написать вариант, который возвращает само множество уникальных подстрок спредупредениемобобъёмерезультатас предупредением об объёме результатаспредупредениемобобъёмерезультата или вариант с хранением только хешей.