LINUX.ORG.RU

Подсчёт подстрок в списке строк. Как быстрее?

 


0

1

Какие в Питоне самые быстрые способы искать подстроку в списке строк?

Есть список 16 000 URL-ов. Требуется узнать, сколько раз встречается какой домен. Отдельно требуется посчитать число доменов второго уровня. То есть домены вида dpmmax.livejournal.com учтутся дважды — и как dpmmax.livejournal.com, и как livejournal.com.

Список уникальных доменов можно получить регулярным выражением или через urllib:

unique_domains = set( urllib.parse.urlsplit(url).hostname for url in allurls )
unique_domains = sorted(unique_domains.union(m[1] for m in (re.match('.*?([^.]+\.[^.]+)$', d) for d in unique_domains) if m))

Можно для каждого домена в сете перебирать список URL-ов функцией:

def d_count(domain): 
    d1, d2 = '.' + domain + '/', '/' + domain + '/' 
    return sum( 1 for url in allurls if d1 in url or d2 in url )

Но это долго — 4-5 секунд для 1500 доменов и 16 000 URLов.

Можно заранее сделать список доменов, соответствующих URLам (allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]), и проверять по нему функцией:

def d_count(domain):
    d1 = '.' + domain
    return sum( 1 for hostname in allhosts if hostname.endswith(d1) or hostname == domain )

Но выигрыш получается в пределах погрешности.

Если использовать регулярные выражения, получится на 2 порядка дольше.

sum( 1 for <итератор> if <условие> ) работает на 20-30% быстрее sum( <условие> for <итератор> ). Бывает и все 60%.

Как ещё можно ускорить?

Ответ:

list.count() примерно втрое быстрее.

second_level_domains = [ m[1] for m in (re.match('^.*?([^.]+\.[^.]+)$', host) for host in allhosts) if m ]
counts = { domain: max(allhosts.count(domain), second_level_domains.count(domain)) for domain in unique_domains }

collections.Counter() ещё втрое быстрее:

counts =
collections.Counter(urllib.parse.urlsplit(url).hostname for url in allurls) |
collections.Counter(m[1] for m in (re.match('^.*?([^.]+\.[^.]+)$', urllib.parse.urlsplit(url).hostname) for url in allurls) if m)

Это быстрее в 10 раз.

Ещё вдвое можно ускорить, если не вычислять список хостов дважды:

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter(allhosts) | collections.Counter(m[1] for m in (re.match('^.*?([^.]+\.[^.]+)$', host) for host in allhosts) if m)

Ещё несколько процентов можно получить, если заменить объединение сложением, а для этого регулярным выражением взять только домены 2 уровня из хостов глубже 2 уровня, отбросив всё остальное. Ещё немного даёт замена re.search и re.match на re.fullmatch с отказом от ^ и $:

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter(allhosts) + collections.Counter(m[1] for m in (re.fullmatch('.*?\.([^.]+\.[^.]+)', host) for host in allhosts) if m)
★★★★★

Последнее исправление: question4 (всего исправлений: 8)

Можно просто по всем строкам пробежаться один раз и делать из урл и домена второго уровня ключи словаря. При повторе делать инкремент для значения.

Не знаю, быстрее будет или нет, но сразу готовый результат.

vvn_black ★★★★★
()
Последнее исправление: vvn_black (всего исправлений: 1)

Быстрее, ёпть, не искать домены, извлечённые из списка урлов, в списке урлов, а просто их посчитать.

collections.Counter(urllib.parse.urlsplit(url).hostname for url in allurls)

подсчёт доменов второго уровня сам допишешь.

slovazap ★★★★★
()
Ответ на: комментарий от slovazap

collections.Counter(urllib.parse.urlsplit(url).hostname for url in allurls)

Быстрее ли это, чем list.count()?

P.S. Примерно в 2-3 раза быстрее.

question4 ★★★★★
() автор топика
Последнее исправление: question4 (всего исправлений: 3)
Ответ на: комментарий от slovazap

подсчёт доменов второго уровня сам допишешь.

collections.Counter( m[1] for m in (re.match('^.*?([^.]+\.[^.]+)$', urllib.parse.urlsplit(url).hostname) for url in allurls) if m)

Как их теперь объединить? Нужно пробежаться по ключам и собрать словарь, беря большее из значений в двух каунтерах. Либо при конфликте брать значение из второго. .update() их вместо этого сложит. Другие функции для объединения там есть?

P.S. Нашёл, кажется. «union» — |

question4 ★★★★★
() автор топика
Последнее исправление: question4 (всего исправлений: 1)
Ответ на: комментарий от goingUp

grep | sort | uniq -c

Продублирую вопрос. Как grep-ом извлечь домен второго уровня? Вообще, как это сделать, если доступны только жадные регулярные выражения?

question4 ★★★★★
() автор топика
Ответ на: комментарий от question4

если доступны только жадные регулярные выражения?

Я вообще POSIX регэкспы не знаю, но grep поддерживает же и PCRE. Там вроде можно нежадные. Еще есть pcregrep. Но у меня милторговский вопрос «зачем нежадность»?)))

$ echo http://mail.google.com/bla-bla.html | grep -Po '[^.]+\.[^.]+\/' 
google.com/
goingUp ★★★★★
()
Последнее исправление: goingUp (всего исправлений: 1)
Ответ на: комментарий от goingUp

grep -Po '[^.]+\.[^.]+\/'

Спасибо.

Но это даст неверные результаты для URL-ов вида http://tvtropes.org/pmwiki/pmwiki.php/Main/RandomlyGeneratedLevels

question4 ★★★★★
() автор топика
Последнее исправление: question4 (всего исправлений: 4)
Ответ на: комментарий от question4

Как их теперь объединить? Нужно пробежаться по ключам и собрать словарь, беря большее из значений в двух каунтерах. Либо при конфликте брать значение из второго. .update() их вместо этого сложит. Другие функции для объединения там есть?

А, всё-таки, головой подумать? Может, например, регулярку модифицировать так чтобы она матчила только домены глубже второго уровня и каунтеры можно было просто сложить?

А вообще правильное решение проходиться по урлам один раз и для каждого порождать либо один либо два хоста (если хост глубже 2 уровня), и их один раз считать.

slovazap ★★★★★
()
Ответ на: комментарий от AntonI

написать функцию на плюсах и дернуть ее из питона.

Исхожу из того, что нужная функция уже написана, и нужно только найти её. Пока предложили collections.Counter.

question4 ★★★★★
() автор топика
Ответ на: комментарий от seiken

Интересно было бы сравнить найденное решение с аналогичным на перле, ведь перл был королём в таких задачах на регулярках и проч.

Давай скрипт на перле, который читает список строк из файла и считает. Сравню.

question4 ★★★★★
() автор топика
Ответ на: комментарий от question4
from urllib.parse import urlsplit

import pandas as pd


allurls = ["http://ya.ru/xxx?q=x#zz", "https://google.com/", "https://google.com/zzz"]
df = pd.DataFrame({"url": allurls})
df['protocol'], df['domain'], df['path'], df['query'], df['fragment'] = zip(*df['url'].map(urlsplit))
df.domain.value_counts().sort_values(ascending=False)[:10]
ei-grad ★★★★★
()
Последнее исправление: ei-grad (всего исправлений: 2)
Ответ на: комментарий от question4

from urllib.parse import urlsplit

import pandas as pd


allurls = [
    "http://ya.ru/xxx?q=x#zz",
    "https://google.com/",
    "https://google.com/zzz",
]

print(
    pd.DataFrame.from_records(
        pd.Series(allurls).map(urlsplit),
        columns=['schema', 'domain', 'path', 'query', 'fragment']
    )
    .domain
    .value_counts()
    .sort_values(ascending=False)[:10]
)
ei-grad ★★★★★
()
Ответ на: комментарий от ei-grad

Забыл про домены второго уровня.

df = pd.DataFrame.from_records(
    pd.Series(allurls).map(urlsplit),
    columns=['schema', 'domain', 'path', 'query', 'fragment']
)
df['domain_l2'] = df.domain.str.extract(r'([^.]+\.[^.]+$)', expand=False)
print(
    pd.concat([df[df.domain != df.domain_l2].domain, df.domain_l2])
    .value_counts()
    .sort_values(ascending=False)[:10]
)
ei-grad ★★★★★
()
Последнее исправление: ei-grad (всего исправлений: 4)
Ответ на: комментарий от slovazap

Может, например, регулярку модифицировать так чтобы она матчила только домены глубже второго уровня и каунтеры можно было просто сложить?

Это как?

Если заменить re.match('^.*?([^.]+\.[^.]+)$', d)[1] на re.match('^.*?\.[^.]+\.[^.]+$', d)[0], выдаст только домены 3 уровня и дальше. re.match('^.*?.\([^.]+\.[^.]+)$', d)[1] пропустит домены 2 уровня, а для 3 и дальше выдаст соответствующий домен 2-го уровня. И сложить это с результатом подсчёта всех доменов без регулярки.

Но это даёт выигрыш всего 6-7%.

А вообще правильное решение проходиться по урлам один раз и для каждого порождать либо один либо два хоста (если хост глубже 2 уровня), и их один раз считать.

Как сделать «один либо два хоста» средствами re? Или чем его заменить? re.match('^(.*?([^.]+\.[^.]+))$', d).groups() даёт кортеж из 2 доменов, которые могут совпадать. Я не нашёл ничего лучше set(re.match('^(.*?([^.]+\.[^.]+))$', d).groups()). dict.fromkeys чуть медленнее.

И как эти сеты объединять? Добавить ещё один цикл? В итоге получается проигрыш 11%, если оставить промежуточный массив allhosts, или 22%, если без него. Можно вместо цикла пользоваться itertools.chain.from_iterable(), но разницы никакой.

Итого:

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter(allhosts) | collections.Counter(m[1] for m in (re.match('^.*?([^.]+\.[^.]+)$', host) for host in allhosts) if m)

0.2559347152709961 с

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter(allhosts) + collections.Counter(m[1] for m in (re.match('^.*?\.([^.]+\.[^.]+)$', host) for host in allhosts) if m)

0.24040746688842773 с

counts = collections.Counter( e for m in (re.match('^(.*?([^.]+\.[^.]+))$', host) for host in (urllib.parse.urlsplit(url).hostname for url in allurls)) if m for e in set(m.groups()) )

0.29389190673828125 с

counts = collections.Counter(itertools.chain.from_iterable( set(m.groups()) for m in (re.match('^(.*?([^.]+\.[^.]+))$', host) for host in (urllib.parse.urlsplit(url).hostname for url in allurls)) if m ))

0.29105114936828613 с

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter( e for m in (re.match('^(.*?([^.]+\.[^.]+))$', host) for host in allhosts) if m for e in set(m.groups()) )

0.2658066749572754 с

allhosts = [urllib.parse.urlsplit(url).hostname for url in allurls]
counts = collections.Counter(itertools.chain.from_iterable( set(m.groups()) for m in (re.match('^(.*?([^.]+\.[^.]+))$', host) for host in allhosts) if m ))

0.26801037788391113 с

Второй вариант — заранее сделать список доменов, фильтровать регэкспом и складывать — получается быстрее всего.

question4 ★★★★★
() автор топика
Ответ на: комментарий от ei-grad

pd.DataFrame.from_records(pd.Series(allurls).map(urllib.parse.urlsplit), columns=['schema', 'domain', 'path', 'query', 'fragment']).domain.value_counts().sort_values(ascending=False)

0.248 с

df = pd.DataFrame.from_records( pd.Series(allurls).map(urllib.parse.urlsplit), columns=['schema', 'domain', 'path', 'query', 'fragment'] ); df['domain_l2'] = df.domain.str.extract(r'([^.]+\.[^.]+$)', expand=False); count_pd2 = pd.concat([df[df.domain != df.domain_l2].domain, df.domain_l2]).value_counts().sort_values(ascending=False);

0.269 c

Немного медленнее, чем через collections.Counter и re.match.

Спасибо. Буду изучать, как оно работает.

question4 ★★★★★
() автор топика
Ответ на: комментарий от peregrine

Если тебе прямо сильно-сильно скорость надо, то есть вариант сделать задачку на C/C++/Rust/любой другой язык и обернуть библиотеку для python-а, будет быстрее.

Сейчас уже бо́льшая часть делается сишными библиотеками. Причём замена этой мешанины на сишную pandas уже привела к небольшому замедлению.

question4 ★★★★★
() автор топика
Ответ на: комментарий от peregrine

ты следующим сообщением предлагаешь сделать ровно то же что сделано в pandas

define «медленное»? с 16к записей оно должно справиться за 0.1с… как вариант быстрое - clickhouse, но в конкретной задаче пофиг вообще, главное не говнокодить на чистом питоне

ei-grad ★★★★★
()
Ответ на: комментарий от question4

ну можно соптимизировать чтобы оно не делало колонок для других частей url’а, то есть взять list comprehension с urlsplit(i).hostname, а заменить только Counter на value_counts, должно быть чуть быстрее

либо регулярку вместо urlsplit попробовать, тоже должно быть чуть быстрее (за счет того что лишние строчки не будет создавать)

ei-grad ★★★★★
()
Последнее исправление: ei-grad (всего исправлений: 2)
Ответ на: комментарий от question4

в целом, если там ничо другого не нужно считать, то тащить ради этого pandas - вероятно оверкилл, вариант с Counter’ом вполне ок

какого-то значимого ускорения можно разве что параллелизацией добиться, в pandas из коробки его нет, можно dask попробовать :-)

но надо понимать задачу, чтобы сказать стоит ли оно того или нет

ei-grad ★★★★★
()
Последнее исправление: ei-grad (всего исправлений: 1)
Ответ на: комментарий от question4

Ещё, это, вариант с Counter дважды считает example.com (если он дважды просто как http://example.com), вариант с pandas это обрабатывает, и считает дважды example.com только для www.example.com и example.com, например.

ei-grad ★★★★★
()
Ответ на: комментарий от ei-grad

Ты код пандаса когда-нибудь читал? Я вот читал. Хуже написанного кода чем там я не видел ни в одном опенсорсном проекте, даже студенты некоторые лучше код пишут.

peregrine ★★★★★
()
Ответ на: комментарий от ei-grad

Учитывая что ТС душится за 16к записей, рискну предположить, что дело происходит в цикле, вероятно ему приходят пачки по 16к адресов, которые надо проверять. Ещё более вероятно что это железяка типа роутера или ТСПУ от РКН.

peregrine ★★★★★
()
Последнее исправление: peregrine (всего исправлений: 1)
Ответ на: комментарий от peregrine

Учитывая что ТС душится за 16к записей

Нет, задача совсем другая. Я этот скрипт гоняю вручную. Просто неприятно было ждать ответа по 5 секунд. Сейчас ему нужно 0,4-0,5 с с учётом загрузки библиотек, и это почти незаметно.

question4 ★★★★★
() автор топика
Ответ на: комментарий от ei-grad

но надо понимать задачу, чтобы сказать стоит ли оно того или нет

Фактически, уже list.count() обеспечил приемлемую производительность, и я пометил тему как решённую. Но после этого посыпались советы, и я стал их пробовать :)

question4 ★★★★★
() автор топика