Это, возможно, одна из классических проблем кодирования, которая получила некоторый резонанс в 1986 году, когда обозреватель Джон Бентли попросил Дональда Кнута написать программу, которая нашла бы k наиболее часто встречающихся слов в файле. Кнут реализовал быстрое решение с использованием хэш-попыток в программе длиной 8 страниц, чтобы проиллюстрировать свою технику грамотного программирования. Дуглас Макилрой из Bell Labs раскритиковал решение Кнута, что он даже не способен обработать полный текст Библии, и ответил однострочно, что не так быстро, но выполняет свою работу:
tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q
В 1987 году была опубликована следующая статья с еще одним решением, на этот раз профессором из Принстона. Но это также не могло даже дать результат для одной Библии!
Описание проблемы
Исходное описание проблемы:
Учитывая текстовый файл и целое число k, вы должны напечатать k наиболее распространенных слов в файле (и количество их вхождений) с уменьшающейся частотой.
Дополнительные разъяснения проблемы:
- Кнут определил слова как строку латинских букв:
[A-Za-z]+
- все остальные символы игнорируются
- прописные и строчные буквы считаются эквивалентными (
WoRd
==word
) - нет ограничений ни на размер файла, ни на длину слова
- расстояния между последовательными словами могут быть сколь угодно большими
- самая быстрая программа - та, которая использует наименьшее общее время процессора (многопоточность, вероятно, не поможет)
Примеры тестовых случаев
Тест 1: Улисс Джеймсом Джойсом объединен 64 раза (файл 96 МБ).
- Скачать Ulysses от Project Gutenberg:
wget http://www.gutenberg.org/files/4300/4300-0.txt
- Объединить это 64 раза:
for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
- Чаще всего встречается слово «968832».
Тест 2: специально сгенерированный случайный текст giganovel
(около 1 ГБ).
- Сценарий генератора Python 3 здесь .
- Текст содержит 148391 различных слов, похожих на естественные языки.
- Наиболее часто встречающиеся слова: «е» (11309 появлений) и «их» (11290 появлений).
Тест на общность: произвольно большие слова с произвольно большими пробелами.
Реализованные реализации
После изучения Rosetta Code для этой проблемы и понимания того, что многие реализации невероятно медленны (медленнее, чем сценарий оболочки!), Я протестировал несколько хороших реализаций здесь . Ниже приведена производительность, ulysses64
а также сложность времени:
ulysses64 Time complexity
C++ (prefix trie + heap) 4.145 O((N + k) log k)
Python (Counter) 10.547 O(N + k log Q)
AWK + sort 20.606 O(N + Q log Q)
McIlroy (tr + sort + uniq) 43.554 O(N log N)
Вы можете победить это?
тестирование
Производительность будет оценена с использованием 13-дюймового MacBook Pro 2017 года со стандартным Unix time
командой («пользовательское» время). Если возможно, используйте современные компиляторы (например, используйте последнюю версию Haskell, а не устаревшую).
Рейтинг пока что
Сроки, в том числе справочные программы:
k=10 k=100K
ulysses64 giganovel giganovel
C++ (trie) by ShreevatsaR 0.671 4.227 4.276
C (trie + bins) by Moogie 0.704 9.568 9.459
C (trie + list) by Moogie 0.767 6.051 82.306
C++ (hash trie) by ShreevatsaR 0.788 5.283 5.390
C (trie + sorted list) by Moogie 0.804 7.076 x
Rust (trie) by Anders Kaseorg 0.842 6.932 7.503
J by miles 1.273 22.365 22.637
C# (trie) by recursive 3.722 25.378 24.771
C++ (trie + heap) 4.145 42.631 72.138
APL (Dyalog Unicode) by Adám 7.680 x x
Python (dict) by movatica 9.387 99.118 100.859
Python (Counter) 10.547 102.822 103.930
Ruby (tally) by daniero 15.139 171.095 171.551
AWK + sort 20.606 213.366 222.782
McIlroy (tr + sort + uniq) 43.554 715.602 750.420
Совокупный рейтинг * (%, наилучший возможный балл - 300):
# Program Score Generality
1 C++ (trie) by ShreevatsaR 300 Yes
2 C++ (hash trie) by ShreevatsaR 368 x
3 Rust (trie) by Anders Kaseorg 465 Yes
4 C (trie + bins) by Moogie 552 x
5 J by miles 1248 Yes
6 C# (trie) by recursive 1734 x
7 C (trie + list) by Moogie 2182 x
8 C++ (trie + heap) 3313 x
9 Python (dict) by movatica 6103 Yes
10 Python (Counter) 6435 Yes
11 Ruby (tally) by daniero 10316 Yes
12 AWK + sort 13329 Yes
13 McIlroy (tr + sort + uniq) 40970 Yes
* Сумма времени выполнения по отношению к лучшим программам в каждом из трех тестов.
Лучшая программа на данный момент: здесь (второе решение)
источник
Ответы:
[С]
Следующий тест выполняется менее чем за 1,6 секунды для теста 1 на моем 2,8 ГГц Xeon W3530. Построен с использованием MinGW.org GCC-6.3.0-1 в Windows 7:
В качестве входных данных он принимает два аргумента (путь к текстовому файлу и количество k наиболее часто встречающихся слов в списке)
Он просто создает ветвление дерева по буквам слов, а затем по буквам листьев увеличивает счетчик. Затем проверяет, является ли текущий счетчик листьев больше, чем наименьшее наиболее часто встречающееся слово в списке наиболее часто встречающихся слов. (размер списка - это число, определяемое аргументом командной строки). Если это так, то продвигайте слово, представленное буквой листа, как одно из самых частых. Все это повторяется до тех пор, пока не будет прочитано больше букв. После чего список наиболее часто встречающихся слов выводится посредством неэффективного итеративного поиска наиболее часто встречающегося слова из списка наиболее часто встречающихся слов.
В настоящее время по умолчанию выводится время обработки, но в целях согласованности с другими представлениями отключите определение TIMING в исходном коде.
Кроме того, я отправил это с рабочего компьютера и не смог загрузить текст теста 2. Он должен работать с этим тестом 2 без изменений, однако может потребоваться увеличить значение MAX_LETTER_INSTANCES.
Для теста 1, а также для 10 самых распространенных слов и с включенной синхронизацией он должен вывести:
источник
letters
массива, тогда как эталонная реализация динамически распределяет узлы дерева.mmap
-ную должен быть быстрее (~ 5% на моем ноутбуке Linux):#include<sys/mman.h>
,<sys/stat.h>
,<fcntl.h>
, замените файл для чтения сint d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);
и закомментируйтеfree(data);
Ржавчина
На моем компьютере он работает с гигановелом 100000 примерно на 42% быстрее (10,64 с против 18,24 с), чем C-решение Moogie «префикс-дерево + корзины». Также он не имеет предопределенных ограничений (в отличие от решения C, в котором заданы ограничения на длину слова, уникальные слова, повторяющиеся слова и т. Д.).
src/main.rs
Cargo.toml
использование
источник
-O3
, и-Ofast
не измерил разницу.gcc -O3 -march=native -mtune=native program.c
.APL (Dyalog Unicode)
Следующее выполняется менее чем за 8 секунд на моем 2.6 ГГц i7-4720HQ с использованием 64-разрядного Dyalog APL 17.0 в Windows 10:
Сначала запрашивается имя файла, затем k. Обратите внимание, что значительная часть времени работы (около 1 секунды) просто читает файл в.
Чтобы рассчитать время, вы должны иметь возможность передать следующее
dyalog
исполняемый файл (для десяти наиболее часто встречающихся слов):Следует напечатать:
источник
export MAXWS=4096M
. Я думаю, он использует хеш-таблицы? Потому что уменьшение размера рабочего пространства до 2 ГБ замедляет его на целых 2 секунды.∊
использует хэш - таблицу в соответствии с этим , и я уверен , что⌸
делает внутренне тоже.WS FULL
, хотя я увеличил рабочее пространство до 6 ГБ.[C] Префикс Дерево + Бункеры
ПРИМЕЧАНИЕ. Используемый компилятор значительно влияет на скорость выполнения программы! Я использовал gcc (MinGW.org GCC-8.2.0-3) 8.2.0. При использовании ключа -Ofast программа работает почти на 50% быстрее, чем нормально скомпилированная программа.
Сложность алгоритма
С тех пор я осознал, что сортировка бинов, которую я выполняю, является формой сортировки Pigeonhost, это означает, что я могу получить сложность Big O этого решения.
Я рассчитываю это быть:
Сложность построения дерева эквивалентна обходу дерева, так как на любом уровне правильный узел для обхода равен O (1) (поскольку каждая буква отображается непосредственно на узел, и мы всегда пересекаем только один уровень дерева для каждой буквы)
Сортировка голубиных отверстий - это O (N + n), где n - диапазон значений ключа, однако для этой задачи нам не нужно сортировать все значения, только число k, поэтому наихудшим случаем будет O (N + k).
Объединение вместе дает O (1 + N + k).
Сложность пространства для построения дерева связана с тем, что наихудший случай равен 26 * M узлам, если данные состоят из одного слова с числом букв M и что каждый узел имеет 26 узлов (то есть для букв алфавита). Таким образом, O (26 * M) = O (M)
Для голубиных ям сортировка имеет пространственную сложность O (N + n)
Объединение вместе дает O (26 * M + N + n) = O (M + N + n)
Алгоритм
В качестве входных данных он принимает два аргумента (путь к текстовому файлу и количество k наиболее часто встречающихся слов в списке)
Основываясь на других моих записях, эта версия имеет очень небольшое увеличение затрат времени с увеличением значений k по сравнению с другими моими решениями. Но заметно медленнее для низких значений k, однако должно быть намного быстрее для больших значений k.
Он создает дерево, разветвляющееся на буквы слов, затем на листовых буквах увеличивает счетчик. Затем добавляет слово в корзину слов того же размера (после первого удаления слова из корзины, в которой оно уже находилось). Все это повторяется до тех пор, пока больше не будут прочитаны буквы. После чего ячейки подвергаются обратной итерации ите раз k, начиная с самого большого ячейки, и выводятся слова каждого ячейки.
В настоящее время по умолчанию выводится время обработки, но в целях согласованности с другими представлениями отключите определение TIMING в исходном коде.
РЕДАКТИРОВАТЬ: теперь отложить заполнение бинов до тех пор, пока дерево не будет построено и оптимизировать построение вывода.
EDIT2: теперь используется арифметика указателей вместо доступа к массиву для оптимизации скорости.
источник
ulysses64
один раз, поэтому сейчас он является лидером.J
Запустите как скрипт с
jconsole <script> <input> <k>
. Например, вывод изgiganovel
сk=100K
:Там нет ограничений, за исключением количества доступной системной памяти.
источник
...
происходит из-за усечения вывода в строке. Я добавил одну строку в начале, чтобы отключить все усечения. Это замедляет работу гигановела, поскольку он использует гораздо больше памяти, поскольку в нем больше уникальных слов.C ++ (а-ля Кнут)
Мне было любопытно, как продвинется программа Кнута, поэтому я перевел его (первоначально Паскаль) программу на C ++.
Несмотря на то, что основной целью Кнута была не скорость, а иллюстрирование его WEB-системы грамотного программирования, программа на удивление конкурентоспособна и приводит к более быстрому решению, чем любой из ответов на этот вопрос. Вот мой перевод его программы (соответствующие номера "раздела" WEB-программы упоминаются в комментариях как "
{§24}
"):Отличия от программы Кнута:
link
,sibling
,count
иch
в массив аstruct Node
(найти его легче понять этот путь).fread
иdata[i] | 32 - 'a'
вместо него можно как и в других ответах, вместо обходного пути Pascal.Кроме того, это в значительной степени в точности программа Кнута (использующая его структуру хеш-данных / упакованных данных и сортировку блоков), и она выполняет почти те же операции (что и программа Кнута на языке Паскаль) во время циклического перебора всех символов на входе; обратите внимание, что он не использует внешний алгоритм или библиотеки структур данных, а также что слова одинаковой частоты будут печататься в алфавитном порядке.
тайминг
Составлено с
Когда я запускаю самый большой тестовый пример (
giganovel
с запрошенными 100 000 слов) и сравниваю с самой быстрой программой, опубликованной здесь, я нахожу это немного, но последовательно быстрее:(Верхняя строка - это решение Anders Kaseorg по Rust; нижняя часть - вышеуказанная программа. Это временные интервалы из 100 запусков со средним, минимальным, максимальным, медианным и квартилями.)
Анализ
Почему это быстрее? Дело не в том, что C ++ быстрее, чем Rust, или в том, что программа Кнута является самой быстрой из возможных - на самом деле, программа Кнута медленнее при вставках (как он упоминает) из-за трип-упаковки (для сохранения памяти). Я подозреваю, что причина в том, что Кнут пожаловался в 2008 году :
В вышеприведенной программе используются 32-битные индексы массивов (а не 64-битные указатели), поэтому структура «Узел» занимает меньше памяти, поэтому в стеке больше узлов и меньше пропусков кэша. (На самом деле была некоторая работа над этим, как с x32 ABI , но он, кажется, не в хорошем состоянии, хотя идея, очевидно, полезна, например, см. Недавнее объявление о сжатии указателей в V8 . Ну, хорошо.) Так далее
giganovel
эта программа использует 12,8 МБ для (упакованного) времени, по сравнению с 32,18 МБ программы Rust для его времени (вклgiganovel
). Мы можем увеличить масштаб в 1000 раз (скажем, от «гигановела» до «терановела») и все же не превысить 32-битные индексы, так что это кажется разумным выбором.Более быстрый вариант
Мы можем оптимизировать скорость и отказаться от упаковки, поэтому мы можем фактически использовать (неупакованный) три, как в решении Rust, с индексами вместо указателей. Это дает что-то быстрее и не имеет заранее установленных ограничений на количество отдельных слов, символов и т. Д .:
Эта программа, несмотря на то, что она делает что-то более глупое для сортировки, чем решения здесь, использует (для
giganovel
) только 12,2 МБ для своей задачи и работает быстрее. Сроки этой программы (последняя строка), по сравнению с упомянутыми ранее сроками:Я хотел бы увидеть, что это (или программа хэш-три) хотели бы, если перевести на Rust . :-)
Дальнейшие подробности
О структуре данных, использованной здесь: объяснение попыток «упаковки» дано кратко в Упражнении 4 Раздела 6.3 (Цифровой поиск, т.е. попытки) в Томе 3 TAOCP, а также в тезисе студента Кнута Фрэнка Ляна о переносе слов в TeX : Слово Hy-Phen-a -tion от Com-put-er .
Контекст колонок Бентли, программы Кнута и обзора Макилроя (только небольшая часть которого была посвящена философии Unix) яснее в свете предыдущих и последующих колонок , а также предыдущего опыта Кнута, включая компиляторы, TAOCP и TeX.
Там целая книга Упражнения в стиле программирования , в которой показаны различные подходы к этой конкретной программе и т. Д.
У меня есть незаконченное сообщение в блоге, подробно описывающее пункты выше; может отредактировать этот ответ, когда это будет сделано. Между тем, публиковать этот ответ здесь в любом случае, по случаю (10 января) дня рождения Кнута. :-)
источник
Segmentation fault: 11
для тестовых случаев со сколь угодно большими словами и пробелами; 2) хотя мне может показаться, что я сочувствую «критике» Макилроя, я хорошо знаю, что намерение Кнута заключалось только в том, чтобы показать его грамотную технику программирования, в то время как Макилрой критиковал ее с инженерной точки зрения. Сам Макилрой позже признал, что это было нечестно.word_for
; исправил это сейчас. Да, Макилрой, как изобретатель труб Unix, воспользовался возможностью, чтобы евангелизировать философию Unix по созданию небольших инструментов. Это хорошая философия, по сравнению с разочаровывающим (если вы пытаетесь читать его программы) монолитическим подходом Кнута, но в контексте это было немного несправедливо, в том числе и по другой причине: сегодня способ Unix широко доступен, но в 1986 был ограничен в Bell Labs, Berkeley и др. («его фирма делает лучшие сборные в бизнесе»)Python 3
Эта реализация с простым словарем немного быстрее, чем та, которая используется
Counter
в моей системе.источник
heapq
не добавляет производительности кCounter.most_common
методу, либоenumerate(sorted(...))
использует егоheapq
внутренне.Counter.most_common
.[C] Префикс Дерево + Сортированный связанный список
В качестве входных данных он принимает два аргумента (путь к текстовому файлу и количество k наиболее часто встречающихся слов в списке)
Основываясь на моей другой записи, эта версия намного быстрее для больших значений k, но с небольшими затратами производительности при более низких значениях k.
Он создает дерево, разветвляющееся на буквы слов, затем на листовых буквах увеличивает счетчик. Затем проверяет, является ли текущий счетчик листьев больше, чем наименьшее наиболее часто встречающееся слово в списке наиболее часто встречающихся слов. (размер списка - это число, определяемое аргументом командной строки). Если это так, то продвигайте слово, представленное буквой листа, как одно из самых частых. Если слово уже является наиболее частым, то поменяйте местами следующее наиболее частое, если число слов теперь больше, сохраняя список отсортированным. Все это повторяется до тех пор, пока не будут прочитаны буквы. После чего выводится список наиболее часто встречающихся слов.
В настоящее время по умолчанию выводится время обработки, но в целях согласованности с другими представлениями отключите определение TIMING в исходном коде.
источник
12 eroilk 111 iennoa 10 yttelen 110 engyt
.C #
Этот должен работать с последними .net SDK .
Вот пример вывода.
Сначала я пытался использовать словарь со строковыми ключами, но это было слишком медленно. Я думаю, это потому, что строки .net внутренне представлены с 2-байтовой кодировкой, что является расточительным для этого приложения. Итак, я просто переключился на чистые байты и на уродливый конечный автомат в стиле гото. Преобразование регистра - побитовый оператор. Проверка диапазона символов выполняется в одном сравнении после вычитания. Я не тратил усилий на оптимизацию окончательной сортировки, поскольку обнаружил, что она использует менее 0,1% времени выполнения.
Исправление: алгоритм был по существу правильным, но он переоценивал общее количество слов, подсчитывая все префиксы слов. Поскольку общее количество слов не является требованием проблемы, я удалил этот вывод. Чтобы вывести все k слов, я также скорректировал вывод. В конце концов я остановился на использовании
string.Join()
и написании всего списка сразу. Удивительно, но это примерно на секунду быстрее на моей машине, когда пишу каждое слово отдельно за 100к.источник
tolower
и одиночные уловки сравнения. Однако я не понимаю, почему ваша программа сообщает больше разных слов, чем ожидалось. Кроме того, согласно исходному описанию проблемы, программа должна выводить все k слов в порядке убывания частоты, поэтому я не учел вашу программу в последнем тесте, который должен вывести 100 000 наиболее часто встречающихся слов.Ruby 2.7.0-preview1 с
tally
В последней версии Ruby появился новый метод
tally
. Из примечаний к выпуску :Это почти решает всю задачу для нас. Нам просто нужно сначала прочитать файл, а потом найти максимум.
Вот и все:
edit: добавлено
k
в качестве аргумента командной строкиЭто может быть запущено с
ruby k filename.rb input.txt
использованием версии 2.7.0-preview1 Ruby. Его можно скачать по различным ссылкам на странице заметок о выпуске или установить с помощью rbenvrbenv install 2.7.0-dev
.Пример запуска на моем собственном избитом старом компьютере:
источник