У меня есть два списка, каждый из которых содержит около 5 миллионов элементов. List_1
— список кортежей, по две строки в каждом кортеже. List_2
— длинный список строк.
Я пытаюсь найти составную строку, состоящую из этих кортежей, в List_2
.
Итак, если кортеж из List_1
равен ("foo", "bar")
, а List_2
содержит ["flub", "blub", "barstool", "foo & bar: misadventures in python"]
, я бы попытался получить «foo & bar: Misadventures in Python» из List_2
.
Сейчас я это делаю, перебирая List_1
и просматривая List_2
с помощью понимания. Несмотря на то, что поиск по List_2
выполняется быстро, его выполнение занимает около секунды, для его выполнения потребуется перебрать все List_1
, и, следовательно, для завершения требуется непомерное количество времени (большая часть 1000 часов), что заставило меня задуматься, существует ли был более быстрый и эффективный способ сделать то же самое.
Пример кода:
list_1 = [] #Insert List
list_2 = [] #Insert List
for search_term in list_1:
compound_string = "{search_first} & {search_second}".format(search_first=search_term[0], search_second=search_term[1])
result = next((s for s in list_2 if compound_string in s), None) #Short-circuit, so we don't need to search through the whole list
if result:
#do exciting things
Я рассмотрел возможность использования набора и пересечения для выполнения сравнения, однако использование набора пересечений для сравнения работает только с целыми строками. Поскольку я не знаю всю строку заранее, использование этого метода кажется невозможным без использования цикла for и списков, что может привести к той же проблеме.
Я думаю, что лучшим улучшением было бы использование чего-то вроде панд. попытался ли ты?
Возможно, регулярные выражения будут работать лучше, чем if x in s
@RodrigoRodrigues Да, я ожидаю, что все искомые термины будут в этом формате или что-то похожее на него.
Всегда ли это целые слова или это может быть любая подстрока? Если это целые слова, вам следует создать индекс для list_2
, который сопоставляет все слова с соответствующими строками. Для этого вы можете использовать словарь.
@RodrigoRodrigues Я не уверен, что регулярное выражение с 5 миллионами элементов в альтернативном списке будет работать намного лучше.
@folengateis Нет. Я не пробовал панд. || @Бармар, мне не совсем понятно, что ты имеешь в виду, так что прости меня, если я неправильно понял то, что ты сказал. Значениями обычно являются целые слова, но compound_string
не обязательно является целым словом, поскольку оно может быть частью большей строки.
Вы можете использовать алгоритм сопоставления строк, который поддерживает несколько шаблонов, например Aho-Corasick — возможно, где-то есть такой пакет. Идея регулярного выражения тоже может сработать, а может и не сработать — не сбрасывайте со счетов ее только потому, что она звучит как большое регулярное выражение. (За разовый раз я бы раскошелился на grep -F
.)
В качестве альтернативы, если ваши входные данные настолько удобны, что вы можете использовать для них регулярное выражение, например (\w+) & (\w+)
, то вы можете найти эти совпадения в set
кортежей.
Сколько строк в List_2
вообще содержат подстроку " & "
? (Все остальные, такие как «ляп», «баб» и «барный стул», можно сразу отбросить.)
Какова длина струн?
@nocomment Большинство из них, к сожалению. При этом будет отброшено только 1 миллион предметов, поэтому большая их часть (4+ миллиона) все еще там. Что касается самих строк, то в среднем они состоят из 24 символов, но самая длинная из них имеет длину около 263 символов. Не очень длинный, но и не такой уж короткий.
Это длина всех строк или только тех, которые находятся в List_2
?
Если то, что вы ищете, представляет собой отдельные токены, разделенные пробелами, из входных данных, фильтр Блума позволит вам с уверенностью сказать «эти токены не встречаются в этих входных данных» или «эти токены, вероятно, встречаются» и взять их оттуда. Если пространство поиска является динамическим и токены фиксированы, это не совсем оптимизация, но в противоположном случае это может работать довольно хорошо. (Вы могли бы даже пропустить требование наличия отдельных токенов и посмотреть, например, на биграммы или триграммы отдельных символов, но это не работает даже для умеренно больших входных данных.)
@nocomment Просто List_2
. Большинство строк List_1
относительно короткие, даже если составная строка длиннее.
Итак, каково распределение длин строк в List_1
?
@nocomment В среднем 22, но самый длинный — 663. К сожалению, у меня нет более подробной информации.
Я бы сказал, что эта информация о длине важна и должна быть упомянута в вопросе.
И ранее вы сказали: «Значения обычно представляют собой целые слова». Какие у вас есть «слова», длина которых в среднем составляет 22 символа? Или я что-то неправильно понимаю?
Проблема кажется несколько недоопределенной, но в то же время и переопределенной. Например, какое отношение к этому имеют кортежи?
Я позволю себе перефразировать это так: у вас есть список строк needles
и еще один список строк haystacks
. Вы хотите найти все стога сена, в которых есть (хотя бы одна) иголка.
Первое, что приходит на ум, — это предварительно обработать иглы, построить древовидную структуру, позволяющую более эффективно искать любую из них. Затем пройдите по стогам сена по одному, используя эту конструкцию для их проверки.
Вот простой код, который пришел мне в голову. Не похоже, что ОЗУ станет для вас проблемой, но если это так, то есть более интересные способы создания «сжатых» попыток. Кстати, если все ваши иглы содержат трехсимвольную подстроку " & "
, то лучше всего предположить, что большинство стогов сена ее не содержат, поэтому в большинстве случаев вы можете дешево выйти из строя, сначала проверив именно это количество.
from collections import defaultdict
class Node:
__slots__ = 'final', 'ch2node'
def __init__(self):
self.final = False
self.ch2node = defaultdict(Node)
def add(trie, s):
for ch in s:
trie = trie.ch2node[ch]
trie.final = True
# Does s[i:] start with a string in the trie?
def find(trie, s, i):
for i in range(i, len(s)):
if trie.final:
return True
ch = s[i]
if ch in trie.ch2node:
trie = trie.ch2node[ch]
else:
return False
return trie.final
def search(trie, s):
return any(find(trie, s, i) for i in range(len(s)))
needles = ["a & b", "kik", "c & as"]
haystacks = ["sldjkfa & b", "c&as", "akiko", "xc & asx", "kkc & a"]
root = Node()
for n in needles:
add(root, n)
print(list(h for h in haystacks if search(root, h)))
Что печатает
['sldjkfa & b', 'akiko', 'xc & asx']
В комментарии упоминается алгоритм Ахо-Корасика, который примерно похож на простой три-код, приведенный выше, но более удобен и эффективен (он эффективно выполняет поиск «по всему стогу сена одновременно»).
Я еще не использовал его, но, похоже, для этого есть подходящий пакет Python , доступный на PyPI .
Я пытаюсь вывести вас из тупика, а не дать вам теоретически оптимальное решение. Попробуйте что-нибудь! Вы можете быть удивлены тем, насколько хорошо может работать даже тот простой код, который я привел.
Я использовал вышеописанное, чтобы создать 5 миллионов «иголок», каждая из которых состоит из 2 словарных слов (каждое не менее 10 букв), разделенных одним пробелом. Построение дерева заняло менее 45 секунд (Python 3.12.4). Проверка 5_008_510 строк по ним заняла еще 55 секунд, то есть менее 2 минут от начала до конца. Сравните с «лучшей частью 1000 часов», с которой, как вы думаете, вам придется столкнуться сейчас.
И это без каких-либо попыток «оптимизировать» что-либо, кроме простого использования простой попытки.
Если бы я хотел заняться этим, я бы сначала посмотрел на использование памяти, а не на скорость. Это потребляло около 8,2 ГБ пиковой оперативной памяти. Один из способов сократить это — выполнить постобработку дерева, удалить пустой словарь на узлах final
(или вообще не выделять словарь, если в этом нет необходимости). Но это несколько усложнит код. Другой вариант — рассмотреть возможность использования байтовых строк вместо строк Unicode. А еще есть чудовищные абсурды, например, вообще не использовать класс Node
, вместо этого использовать необработанные 2-кортежи или 2-списки.
Но, учитывая все, что вы сказали о своей проблеме, это уже было бы «достаточно хорошо для меня».
Большим преимуществом попытки является то, что она нечувствительна к количеству иголок: время проверки стога сена примерно одинаково, независимо от того, нужно искать одну или миллиард иголок.
Большим потенциальным недостатком является память, необходимая для удержания пробы иглы. 5 миллионов иголок — это, конечно, много, поэтому я использовал максимально простую структуру.
Компромисс здесь заключается в том, что для стога сена длиной L может потребоваться выполнить L различных поисков. Соответствующему автомату Ахо-Корасика достаточно выполнить только один поиск, независимо от размера L. Но это более сложный вариант дерева, требующий больше памяти и более сложного кода.
При отсутствии какой-либо информации о распределении размеров вашего стога сена (или даже иголки) действует правило «сначала попробуйте самое простое, что может сработать». Потенциальная квадратичная (в L) временная природа поиска по принципу «просто-просто-три» убила бы его, если бы, например, L могло достигать миллиона, но это относительно незначительная вещь, если L не станет больше, чем, скажем, 100 (быть в 100 раз медленнее, чем теоретически необходимо, просто не имеет большого значения по сравнению с экономией в 5 миллионов раз).
Для описания всех возможных компромиссов потребуется небольшая книга. Чтобы получить более конкретные предложения, вам необходимо предоставить количественную информацию об ожидаемых иголках и стогах сена.
Если это не очевидно, вот прагматичная вещь: если оперативная память для попытки иглы «слишком велика», вы можете сократить ее примерно в K раз, выполнив K прогонов, используя только len(needles)/K
игл за прогон. В этом случае сначала следует отсортировать иглы (общие префиксы физически являются общими в дереве, и сортировка объединит иглы с общими префиксами).
Или вы можете проделать гораздо больше работы по созданию дискового дерева.
Возможное пространство решений велико.
Как и выше, 5 миллионов игл, но я сократил их средний размер примерно вдвое, примерно с 21 символа до примерно 10,5 - в противном случае с другим пакетом будет нехватка оперативной памяти. Чуть более 5 миллионов стогов сена. Большинству из них было меньше 70 лет, но некоторые исчислялись сотнями. В нескольких стогах сена была иголка (всего 910 штук).
Для другого кода я использовал ahocorapy, полноценную реализацию Aho-Corasick на чистом Python.
Использование памяти для другого пакета было значительно выше. Ожидал. Если мой класс Node
содержит только 2 члена, то аналогичный ему класс State
содержит 7. Ему необходимо сохранить гораздо больше информации, чтобы гарантировать производительность в линейном времени в наихудшем случае.
По той же причине строительство игольной машины шло медленнее. Около 24 секунд для моего кода, около 60 для ахокотерапии.
Но вы получаете то, за что платите ;-) Поиск по более чем 5 миллионам стогов сена занял около 55 секунд для моего кода и всего около 22 для ахокопии. Поскольку иголки обнаруживались редко, для моего кода это почти худший случай (он должен попробовать len(haystack)
отдельные поиски, чтобы прийти к выводу, что иголок нет).
В целом мой код в целом работал немного быстрее, благодаря тому, что он с самого начала делал гораздо меньше работы для создания своей тупой игольной попытки.
В реализации Python PyPy весь этот код выполняется как минимум в два раза быстрее.
И с любой базой кода, Pickle можно использовать для сохранения игольной выборки для повторного использования в другой коллекции стогов сена.
Вот новый «тупой» код, работающий быстрее, использующий меньше памяти, лучше структурированный и обобщенный, чтобы дать возможность найти все содержащиеся в нем иголки. В худшем случае рассмотрите набор игл типа
x
ax
aax
aaax
aaaax
aaaaax
...
aaaaa.....aaax
и стог сена типа aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay
. Совпадений нет, но поиск по одному займет много времени. Полный Ахо-Корасик гораздо быстрее.
from collections import defaultdict
class Node:
__slots__ = 'final', 'ch2node'
def __init__(self):
self.final = ""
self.ch2node = None
class Trie:
def __init__(self):
self.root = Node()
self.leaders = set()
# Add a needle.
def add(self, s):
if not s:
raise ValueError("empty string not allowed")
trie = self.root
for ch in s:
if (ch2node := trie.ch2node) is None:
ch2node = trie.ch2node = defaultdict(Node)
trie = ch2node[ch]
trie.final = s
self.leaders.add(s[0])
# Search a haystack `s`. Generates a 2-tuple, (needle, i), for each needle the
# haystack contains. The needle starts at s[i].
def search(self, s):
leaders = self.leaders
root = self.root
for i, ch in enumerate(s):
if ch not in leaders:
continue
trie = root
for i in range(i, len(s)):
if ((ch2node := trie.ch2node)
and (ch := s[i]) in ch2node):
trie = ch2node[ch]
if trie.final:
yield trie.final, i - len(trie.final) + 1
else:
break
Тогда, например,
t = Trie()
for n in 'eat', 'tea', 'car', 'care', 'cares', 'arc':
t.add(n)
for m in t.search('eateacarcares'):
print(m)
отображает:
('eat', 0)
('tea', 2)
('car', 5)
('arc', 6)
('car', 8)
('care', 8)
('cares', 8)
Другой подход — использовать алгоритм Рабина-Карпа (RK) для сопоставления нескольких игл. Конкретный хеш-код предварительно вычисляется для каждой иглы, и все необходимое хранилище — это словарь, сопоставляющий хеш-код со списком игл с этим хеш-кодом.
Для поиска в стоге сена достаточно один раз просмотреть его слева направо и вычислить «скользящий хэш» RK для каждого окна. Хэш-функция спроектирована таким образом, что хэш следующего окна можно вычислить с использованием небольшого и фиксированного числа (независимо от размера окна) арифметических операций. Если хеш есть в словаре, напрямую сравните каждую иголку с этим хешем с окном стога сена.
Это просто, быстро и очень экономно по памяти. НО. Увы, это просто, если все иглы имеют одинаковую длину и размер окна фиксирован. Если есть K игл разной длины, получается путаница. Вы можете, например, использовать K различных скользящих хешей, но это замедлит его в K раз. Или вы можете использовать размер окна, равный длине самой короткой иглы, но тогда количество ложных срабатываний может увеличиться.
Я использовал последнюю стратегию и не смог сделать ее конкурентоспособной под управлением CPython. Тем не менее, PyPy превосходно ускоряет код Python, выполняющий арифметические операции с целыми числами машинного размера, и при этом скорость была сопоставима. Использование памяти стало в 10 раз меньше, что является огромным выигрышем. РК также хорошо умеет избегать патологических случаев, хотя это вероятностно, а не гарантировано.
Теперь, когда мы знаем, что ваши иглы не крошечные и не огромные, это должно дать хорошее ускорение и требует гораздо меньше памяти:
Обновлено: добавлен leaders
, что значительно ускоряет обработку моих данных.
Обновлено: при поиске .startswith()
позволяет избежать необходимости создавать новый строковый объект.
class RK: # not Rabin-Karp, but inspired by it
def __init__(self):
from collections import defaultdict
self.needles = []
self.h2ns = defaultdict(list)
self.leaders = set()
def add(self, s):
if not s:
raise ValueError("empty string not allowed")
if self.h2ns:
raise ValueError("cannot add() after finalize() called")
self.needles.append(s)
self.leaders.add(s[0])
def finalize(self):
h2ns = self.h2ns
if h2ns:
return # already finalized
w = self.w = min(map(len, self.needles))
for n in self.needles:
h2ns[hash(n[:w])].append(n)
del self.needles
def search(self, s):
if not self.h2ns:
raise ValueError("must call finalize() before search()")
h2nsget = self.h2ns.get
w = self.w
leaders = self.leaders
for i in range(len(s) - w + 1):
if (s[i] in leaders
and (ns := h2nsget(hash(s[i : i + w])))):
for n in ns:
if s.startswith(n, i):
yield n, i
Тогда, например,
t = RK()
for n in 'eat', 'tea', 'car', 'care', 'cares', 'arc', 's':
t.add(n)
t.finalize()
for m in t.search('eateacarcares'):
print(m)
принты
('eat', 0)
('tea', 2)
('car', 5)
('arc', 6)
('car', 8)
('care', 8)
('cares', 8)
('s', 12)
Поисковая часть на самом деле не быстрее, но finalize()
намного быстрее, чем построение дерева. Длина самой короткой иглы имеет решающее значение: чем она короче, тем больше вероятность того, что при поиске придется отсеивать «ложные срабатывания».
По моим же тестовым данным общее время от начала до конца составляет около 15 секунд.
Вариант: зачем вообще хеш? Вместо этого h2ns
мог бы сопоставить строку w-символов с иглами, начинающимися с этой строки. Конечно, хеширование все равно будет происходить, но скрыто, как часть Python, выполняющего поиск по диктовке. Это не имеет большого значения для скорости, но увеличивает использование памяти (клавиши w-символов dict требуют больше места, чем машинные ключи int). Это, в свою очередь, можно уменьшить, сохранив needle[w:]
в списках, для возможного уменьшения общего объема необходимого хранилища символов. Но это делает finalize()
медленнее — нарезка не бесплатна. Все такие варианты кажутся мне «достаточно хорошими».
Кортежи — это то, что я использую для поиска, на случай, если есть лучший способ сделать это. Например, если бы было быстрее/эффективнее превратить List_2
в набор и пересечь каждый элемент с List_1
, чтобы ограничить объем данных, которые мне нужно было бы просмотреть и сравнить с ними строку.
Не могли бы вы проверить это? (У меня не так много оперативной памяти, и в любом случае у меня нет ваших тестовых данных.)
Для таких коротких игл это большой выигрыш в оперативной памяти (примерно вдвое меньше необходимого), но поиск на 50% медленнее (хотя это по сравнению с моим текущим кодом, который быстрее, чем все, что показано здесь - простые оптимизации, например, только 1 вызов к функции уровня Python и «раннему выходу», если s[i] not in root.ch2node
). Не знаю, как долго иглы могут получиться, прежде чем потребуется хранилище, пропорциональное квадрату их длины, и это превысит значительные объемы пространства, занимаемые тройными диктовками.
Спасибо. При меньшем объеме данных я также увидел многообещающую экономию оперативной памяти. И я использовал такие наборы префиксов вместо попыток пару раз, думаю, с хорошим успехом. Поскольку строки имеют 49 байт служебных данных метаданных + 0–15 байт служебных данных выравнивания памяти + 27–106 байт служебных данных хранения в наборе, какое-то время они «не ведут себя квадратично». Для всех префиксов строки с n символами это примерно n(n+1)/2 байта для их символов и 120n байт служебных данных. Всего (n^2)/2 + 120,5n байт. Для этого требуется n = 241, чтобы квадратичная часть хотя бы просто равнялась линейной части.
К вашему сведению, я делаю иголки, склеивая их combinations(list_of_words, 2)
. Как работает combinations
, в пакетах происходит совместное использование префиксов. Таким образом, дерево здесь потребляет меньше оперативной памяти, чем, например, если бы использовались «случайные» иглы.
Реализация заняла немного времени по сравнению с ответом jsbueno (поскольку я уже использовал SQLite для чего-то еще в коде и немного модифицировал Trie, чтобы определить функции внутри класса), но могу честно сказать, что он запускает намного быстрее, несмотря на некоторую неэффективность кода (замена узла в каждом цикле, что медленнее, требует около 5 минут, но требует меньше памяти. Даже несмотря на это, это огромное улучшение по сравнению с предыдущими 1000 часами, спасибо!
@TimPeters Пробовали ли вы это как Рабин-Карп, но вместо этого просто с обычным Python set
игл и своим обычным хешированием? Учитывая короткость строк, я не уверен, что скользящее хеширование помогает.
@techno156, рад, что помогло! См. более поздний код в разделе «ДРУГОЙ СТАБ» для лучшего кода. Пакет C Aho-Corasick будет работать намного быстрее, но использование оперативной памяти может резко возрасти. Использование PyPy вместо CPython также ускорит работу.
@nocomment, нет. Мы никогда не имели ни малейшего понятия о длине иглы OP или длине стога сена, поэтому я не пробовал ничего, предполагая «короткий». f RK легко кодировать, а PyPy выполняет арифметику примерно в 9 раз быстрее, чем CPython. И мы не можем использовать собственный hash()
для подстроки без физического создания нового строкового объекта. РК зацикливается map(ord, haystack)
. который в PyPy не создает никаких новых объектов (целые числа собственного размера «распаковываются»)..
О стогах сена они сказали: «В среднем это строки из 24 символов, но самая длинная из них имеет длину около 263 символов». Что касается половин игл, они сказали, что они «обычно представляют собой целые слова» и что они «относительно короткие, даже если составная строка длиннее». Да, создание строковых объектов, но его все же можно сравнить с операциями RK (я имею в виду в CPython).
@nocomment, эта информация была раскрыта в комментариях настолько недавно, что я их никогда не видел ;-) Да, для игл такой короткой длины, как я использую (самый короткий имеет длину 21), использование встроенного hash()
намного быстрее в CPython (как фактор из 4), несмотря на создание новых объектов — и, по сути, это самый быстрый код, который я видел. Значительно, но не кардинально, быстрее и под PyPy. Но теперь решающее значение приобретает длина самой короткой иглы. Если, например, какая-то игла представляет собой один символ, ложные срабатывания будут взрываться. Оказывается, в этих тестовых данных нет ложных срабатываний как есть.
В общем, смотрите новое редактирование внизу кода, использующего иглы «скромного размера», о чем мы с @nocomment говорили.
И только что добавленная простая оптимизация дает еще одно ускорение моих данных в два раза.
Для эффективной работы потребуются некоторые сложные структуры данных, выполненные должным образом - таким образом, чтобы термин из второго списка можно было найти по поисковому запросу в более удобной, чем линейная, форме.
Например, если поисковый запрос фиксирован (всегда обе строки объединяются с помощью &
и всегда находятся в начале строки в целевом списке (список2), то это будет вопрос сортировки этого второго списка и создания двоичного списка. выполните поиск по нему - это позволит вам получить элементы, пропорциональные LOG(N) (где N - количество элементов в списке2).
Как я могу использовать расширение FTS5 с модулем Python sqlite3 с Python 3.7?
Однако если поисковый запрос не всегда будет находиться в начале строк в списке list2, вам понадобится другая структура данных, например Trie. Хорошая реализация trie, вероятно, сможет справиться с таким объемом данных. У меня есть программа, написанная на чистом Python, которая использует некоторые высокоуровневые структуры данных под капотом и не сможет обрабатывать что-либо близкое к 5 миллионам фраз.
Но затем вы можете просто перенести свои фразы в базу данных, добавить индекс полнотекстового поиска в таблицу «list2» и просто выполнить запрос.
Даже в базе данных sqlite3, поставляемой с Python, включен полнотекстовый поиск — проверьте этот вопрос — ответы включают фрагменты того, как его использовать. Записывая 5 миллионов фраз на диск, что может сделать sqlite, у вас не должно быть ограничений по памяти:
Как я могу использовать расширение FTS5 с модулем Python sqlite3 с Python 3.7?
Ваш поисковый запрос всегда имеет форму
{first} & {second}
? Или может быть любое вхождение двух строк из кортежа, например «lololofoolalalabar!»?