Jest to być może jedno z klasycznych wyzwań związanych z kodowaniem, które zyskało pewien rezonans w 1986 r., Kiedy felietonista Jon Bentley poprosił Donalda Knutha o napisanie programu, który znalazłby k najczęściej występujących słów w pliku. Knuth zaimplementował szybkie rozwiązanie za pomocą prób skrótu w 8-stronicowym programie, aby zilustrować swoją umiejętność programowania. Douglas McIlroy z Bell Labs skrytykował rozwiązanie Knutha jako nawet niezdolnego do przetworzenia pełnego tekstu Biblii i odpowiedział jednokierunkowym tekstem, który nie jest tak szybki, ale wykonuje zadanie:
tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q
W 1987 r. Opublikowano artykuł uzupełniający z jeszcze innym rozwiązaniem, tym razem autorstwa profesora z Princeton. Ale nie mogło nawet zwrócić wyniku dla jednej Biblii!
Opis problemu
Oryginalny opis problemu:
Biorąc pod uwagę plik tekstowy i liczbę całkowitą k, należy wydrukować k najczęstszych słów w pliku (i liczbę ich występowania) ze zmniejszającą się częstotliwością.
Dodatkowe wyjaśnienia problemów:
- Knuth zdefiniował słowa jako ciąg liter łacińskich:
[A-Za-z]+
- wszystkie inne postacie są ignorowane
- wielkie i małe litery są uważane za równoważne (
WoRd
==word
) - brak limitu rozmiaru pliku i długości słowa
- odległości między kolejnymi słowami mogą być dowolnie duże
- najszybszy program to taki, który zużywa najmniej łącznego czasu procesora (wielowątkowość prawdopodobnie nie pomoże)
Przykładowe przypadki testowe
Test 1: Ulissesa Jamesa Joyce'a połączony 64 razy (plik 96 MB).
- Pobierz Ulysses z projektu Gutenberg:
wget http://www.gutenberg.org/files/4300/4300-0.txt
- Połącz to 64 razy:
for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
- Najczęstszym słowem jest „the” z 968832 występami.
Test 2: Specjalnie generowany losowy tekst giganovel
(około 1 GB).
- Skrypt generatora Python 3 tutaj .
- Tekst zawiera 148391 różnych wyrazów przypominających języki naturalne.
- Najczęstsze słowa: „e” (11309 występów) i „ihit” (11290 występów).
Test ogólności: dowolnie duże słowa z dowolnie dużymi przerwami.
Referencyjne implementacje
Po spojrzeniu w Kodeksie Rosetta tego problemu i zdając sobie sprawę, że wiele implementacji są bardzo powolne, przetestowałem kilka dobrych implementacje (wolniej niż w skrypcie!) Tutaj . Poniżej przedstawiamy wydajność ulysses64
wraz ze złożonością czasową:
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)
Czy potrafisz to pokonać?
Testowanie
Wydajność zostanie oceniona przy użyciu 13-calowego MacBooka Pro ze standardowym time
poleceniem uniksowym (czas „użytkownika”). Jeśli to możliwe, użyj nowoczesnych kompilatorów (np. Użyj najnowszej wersji Haskell, a nie starszej).
Dotychczasowe rankingi
Terminy, w tym programy referencyjne:
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
Ranking skumulowany * (%, najlepszy możliwy wynik - 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
* Suma wydajności czasowej w stosunku do najlepszych programów w każdym z trzech testów.
Najlepszy program do tej pory: tutaj (drugie rozwiązanie)
źródło
Odpowiedzi:
[DO]
Następujące przebiega w czasie poniżej 1,6 sekundy dla testu 1 na moim 2.8 GHz Xeon W3530. Zbudowany przy użyciu MinGW.org GCC-6.3.0-1 w systemie Windows 7:
Przyjmuje dwa argumenty jako dane wejściowe (ścieżka do pliku tekstowego i dla liczby k najczęściej używanych słów na liście)
Po prostu tworzy rozgałęzienie drzewa na literach słów, a następnie na listach liści zwiększa licznik. Następnie sprawdza, czy bieżący licznik liści jest większy niż najmniejsze najczęstsze słowo na liście najczęstszych słów. (rozmiar listy to liczba określona za pomocą argumentu wiersza poleceń). Jeśli tak, wypromuj słowo reprezentowane przez literę liścia jako jedno z najczęstszych. Wszystko to powtarza się, dopóki nie zostaną wczytane kolejne litery. Po czym lista najczęstszych słów jest wyprowadzana przez nieefektywne iteracyjne wyszukiwanie najczęstszych słów z listy najczęstszych słów.
Obecnie domyślnie jest wyświetlany czas przetwarzania, ale w celu zachowania spójności z innymi zgłoszeniami wyłącz definicję TIMING w kodzie źródłowym.
Ponadto przesłałem to z komputera roboczego i nie udało mi się pobrać tekstu testu 2. Powinien działać z tym testem 2 bez modyfikacji, jednak może być konieczne zwiększenie wartości MAX_LETTER_INSTANCES.
W przypadku testu 1 oraz dla 10 najczęściej używanych słów i przy włączonym taktowaniu należy wydrukować:
źródło
letters
tablicy, natomiast implementacja referencyjna dynamicznie przydziela węzły drzew.mmap
-ing powinno być szybsze (~ 5% na moim laptopie linux)#include<sys/mman.h>
,<sys/stat.h>
,<fcntl.h>
zastąp plik czytanie zeint d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);
i wykomentujfree(data);
Rdza
Na moim komputerze działa to giganovel 100000 około 42% szybciej (10,64 s vs. 18,24 s) niż rozwiązanie C „drzewa prefiksów + pojemników” Moogie C. Ponadto nie ma żadnych zdefiniowanych limitów (w przeciwieństwie do rozwiązania C, które określa ograniczenia długości słowa, słów unikalnych, słów powtarzanych itp.).
src/main.rs
Cargo.toml
Stosowanie
źródło
-O3
i-Ofast
nie robi mierzalnej różnicy.gcc -O3 -march=native -mtune=native program.c
.APL (Dyalog Unicode)
Następujące działa w mniej niż 8 sekund na moim 2,6 Ghz i7-4720HQ przy użyciu 64-bitowego programu Dyalog APL 17.0 w systemie Windows 10:
Najpierw wyświetla monit o podanie nazwy pliku, a następnie o k. Zauważ, że znaczna część czasu działania (około 1 sekundy) to tylko wczytywanie pliku.
Aby to zrobić, powinieneś być w stanie przesłać następujące elementy do swojego
dyalog
pliku wykonywalnego (dla dziesięciu najczęstszych słów):Powinien wydrukować:
źródło
export MAXWS=4096M
. Chyba używa tabel haszujących? Ponieważ zmniejszenie obszaru roboczego do 2 GB powoduje spowolnienie o całe 2 sekundy.∊
używa tabeli skrótów zgodnie z tym i jestem pewien, że⌸
również wewnętrznie.WS FULL
, mimo że zwiększyłem przestrzeń roboczą do 6 GB.[C] Drzewo prefiksów + kosze
UWAGA: Używany kompilator ma znaczący wpływ na szybkość wykonywania programu! Użyłem gcc (MinGW.org GCC-8.2.0-3) 8.2.0. Podczas korzystania zprzełącznika -Ofast program działa prawie 50% szybciej niż normalnie skompilowany program.
Złożoność algorytmu
Od tego czasu zdałem sobie sprawę, że sortowanie bin, które wykonuję, jest formą sortowania Pigeonhost, co oznacza, że mogę pozbyć się złożoności tego rozwiązania.
Obliczam to jako:
Złożoność konstrukcji drzewa jest równoważna przechodzeniu przez drzewo, ponieważ na każdym poziomie prawidłowym węzłem do przejścia jest O (1) (ponieważ każda litera jest mapowana bezpośrednio do węzła, a my zawsze przechodzimy tylko o jeden poziom drzewa dla każdej litery)
Sortowanie gołębi otworów to O (N + n), gdzie n jest zakresem kluczowych wartości, jednak w przypadku tego problemu nie musimy sortować wszystkich wartości, tylko liczbę k, więc najgorszym przypadkiem byłoby O (N + k).
Połączenie razem daje O (1 + N + k).
Złożoność przestrzeni dla konstrukcji drzewa wynika z faktu, że najgorszym przypadkiem jest 26 * węzłów M, jeśli dane składają się z jednego słowa z liczbą M liter i że każdy węzeł ma 26 węzłów (tj. Dla liter alfabetu). Zatem O (26 * M) = O (M)
Ponieważ sortowanie otworów w gołębiach ma złożoność przestrzenną O (N + n)
Po połączeniu daje O (26 * M + N + n) = O (M + N + n)
Algorytm
Przyjmuje dwa argumenty jako dane wejściowe (ścieżka do pliku tekstowego i dla liczby k najczęściej używanych słów na liście)
W oparciu o moje inne wpisy, ta wersja ma bardzo mały czasowy wzrost kosztu przy rosnących wartościach k w porównaniu do moich innych rozwiązań. Ale jest zauważalnie wolniejszy dla niskich wartości k, jednak powinien być znacznie szybszy dla większych wartości k.
Tworzy drzewo rozgałęziające się na literach słów, a następnie na listach liści zwiększa licznik. Następnie dodaje słowo do kosza słów tego samego rozmiaru (po pierwszym usunięciu słowa z kosza, w którym już się znajdowało). Wszystko to powtarza się, dopóki nie zostaną wczytane kolejne litery. Po czym pojemniki są iterowane w odwrotnej kolejności k razy, zaczynając od największego przedziału i wyprowadzane są słowa z każdego przedziału.
Obecnie domyślnie jest wyświetlany czas przetwarzania, ale w celu zachowania spójności z innymi zgłoszeniami wyłącz definicję TIMING w kodzie źródłowym.
EDYCJA: teraz odkłada zapełnianie pojemników do czasu po zbudowaniu drzewa i optymalizuje konstrukcję wyników.
EDYCJA 2: teraz używa arytmetyki wskaźnika zamiast dostępu do tablicy w celu optymalizacji prędkości.
źródło
ulysses64
Raz jednak działał bardzo szybko , więc obecnie jest liderem.jot
Uruchom jako skrypt za pomocą
jconsole <script> <input> <k>
. Na przykład dane wyjściowegiganovel
zk=100K
:Nie ma limitu, z wyjątkiem ilości dostępnej pamięci systemowej.
źródło
...
występuje z powodu obcięcia wyjścia na linię. Dodałem jedną linię na początku, aby wyłączyć wszystkie obcinanie. Zwalnia na giganovel, ponieważ zużywa dużo więcej pamięci, ponieważ jest więcej unikalnych słów.C ++ (a la Knuth)
Byłem ciekawy, jak sobie poradzi program Knutha, więc przetłumaczyłem jego (pierwotnie Pascal) program na C ++.
Mimo że głównym celem Knutha nie była szybkość, ale zilustrowanie jego internetowego systemu czytania i pisania, program jest zaskakująco konkurencyjny i prowadzi do szybszego rozwiązania niż jakakolwiek z dotychczasowych odpowiedzi. Oto moje tłumaczenie jego programu (odpowiednie numery „sekcji” programu WEB są wymienione w komentarzach takich jak „
{§24}
”):Różnice w stosunku do programu Knutha:
link
,sibling
,count
ich
w tablicy Astruct Node
(łatwiej zrozumieć w ten sposób).fread
idata[i] | 32 - 'a'
jak w innych odpowiedziach tutaj, zamiast obejścia Pascala.Poza tym jest to właściwie program Knutha (wykorzystujący jego strukturę danych mieszania / upakowanej struktury danych i sortowanie kubełkowe) i wykonuje prawie takie same operacje (jak zrobiłby to program Knutha Pascala), jednocześnie zapętlając wszystkie znaki na wejściu; zwróć uwagę, że nie używa on żadnych zewnętrznych algorytmów ani bibliotek struktury danych, a także, że słowa o równej częstotliwości będą drukowane w kolejności alfabetycznej.
wyczucie czasu
Kompilowany z
Po uruchomieniu na największej teście tutaj (
giganovel
z żądaniem 100 000 słów) i porównaniu z najszybszym opublikowanym tutaj programem, znajduję ją nieco, ale konsekwentnie szybciej:(Górna linia to rozwiązanie Rust Andersa Kaseorga; dolna część to powyższy program. Są to czasy od 100 przebiegów, ze średnią, min, maks., Medianą i kwartylami.)
Analiza
Dlaczego to jest szybsze? Nie chodzi o to, że C ++ jest szybszy niż Rust, ani że program Knutha jest najszybszy z możliwych - w rzeczywistości program Knutha jest wolniejszy przy wstawianiu (jak wspomina) z powodu trie-upakowania (w celu oszczędzania pamięci). Podejrzewam, że przyczyna jest związana z czymś, na co narzekał Knuth w 2008 roku :
Powyższy program używa 32-bitowych wskaźników tablicowych (nie wskaźników 64-bitowych), więc struktura „Węzła” zajmuje mniej pamięci, więc na stosie jest więcej Węzłów i mniej braków pamięci podręcznej. (W rzeczywistości, nie było pewne prace na ten temat jako x32 ABI , ale wydaje się, że nie jest w stanie dobrym , chociaż pomysł jest oczywiście przydatne, np patrz niedawne oświadczenie o kompresji wskaźnika w V8 . No cóż.) Więc na
giganovel
, ten program zużywa 12,8 MB dla (spakowanego) trie, w porównaniu do 32,18 MB z programu Rust dla jego trie (ongiganovel
). Możemy zwiększyć skalę o 1000x (powiedzmy „giganovel” do „teranovel”) i nadal nie przekraczać 32-bitowych indeksów, więc wydaje się to rozsądnym wyborem.Szybszy wariant
Możemy zoptymalizować szybkość i zrezygnować z pakowania, dzięki czemu możemy faktycznie używać (niezapakowanego) trie jak w rozwiązaniu Rust, z indeksami zamiast wskaźników. Daje to coś, co jest szybsze i nie ma ustalonych limitów liczby różnych słów, znaków itp .:
Ten program, mimo że robi coś znacznie głupszego do sortowania niż rozwiązania tutaj, używa (
giganovel
tylko) 12,2 MB dla swojej wersji i jest szybszy. Czasy działania tego programu (ostatnia linia) w porównaniu z wcześniejszymi wymienionymi czasami:Chciałbym zobaczyć, co by to (lub program hash-trie) przełożyło na Rust . :-)
Dalsze szczegóły
O użytej tutaj strukturze danych: wyjaśnienie „prób pakowania” podano zwięźle w ćwiczeniu 4 w sekcji 6.3 (Wyszukiwanie cyfrowe, tj. Próby) w tomie 3 TAOCP, a także w pracy studenta Knutha, Franka Lianga, na temat dzielenia wyrazów w TeX : Słowo Hy-phen-a-tion autorstwa Com-put-er .
Kontekst kolumn Bentleya, programu Knutha i recenzji McIlroy'a (tylko niewielka część dotyczyła filozofii uniksowej) jest jaśniejszy w świetle poprzednich i późniejszych kolumn oraz poprzednich doświadczeń Knutha, w tym kompilatorów, TAOCP i TeX.
Jest cała książka Ćwiczenia w stylu programowania , pokazująca różne podejścia do tego konkretnego programu itp.
Mam niedokończony post na blogu opisujący powyższe punkty; może edytować tę odpowiedź po jej zakończeniu. Tymczasem i tak zamieszczam tutaj tę odpowiedź, przy okazji (10 stycznia) urodzin Knutha. :-)
źródło
Segmentation fault: 11
dla przypadków testowych z dowolnie dużymi słowami i lukami; 2) chociaż może się wydawać, że sympatyzuję z „krytyką” McIlroya, doskonale zdaję sobie sprawę, że Knuth chciał tylko pochwalić się swoją umiejętnością programowania, podczas gdy McIlroy skrytykował to z punktu widzenia inżynierii. Sam McIlroy przyznał później, że nie było to uczciwe.word_for
; naprawiłem to teraz. Tak, McIlroy, wynalazca rur uniksowych, skorzystał z okazji, aby ewangelizować filozofię uniksowego komponowania małych narzędzi. To dobra filozofia, w porównaniu z frustrującym (jeśli próbujesz czytać jego programy) podejściem Knutha, ale w kontekście było to nieco niesprawiedliwe, również z innego powodu: dziś sposób uniksowy jest szeroko dostępny, ale w 1986 roku był ograniczony do Bell Labs, Berkeley itp. („jego firma robi najlepsze prefabrykaty w branży”)Python 3
Ta implementacja z prostym słownikiem jest nieco szybsza niż ta, która korzysta z
Counter
jednego w moim systemie.źródło
heapq
nie dodaje żadnej wydajności doCounter.most_common
metody, alboenumerate(sorted(...))
używa jejheapq
wewnętrznie.Counter.most_common
.[C] Drzewo prefiksów + posortowana lista połączona
Przyjmuje dwa argumenty jako dane wejściowe (ścieżka do pliku tekstowego i dla liczby k najczęściej używanych słów na liście)
Na podstawie mojego drugiego wpisu ta wersja jest znacznie szybsza dla większych wartości k, ale przy niewielkim koszcie wydajności przy niższych wartościach k.
Tworzy drzewo rozgałęziające się na literach słów, a następnie na listach liści zwiększa licznik. Następnie sprawdza, czy bieżący licznik liści jest większy niż najmniejsze najczęstsze słowo na liście najczęstszych słów. (rozmiar listy to liczba określona za pomocą argumentu wiersza poleceń). Jeśli tak, wypromuj słowo reprezentowane przez literę liścia jako jedno z najczęstszych. Jeśli jest to już najczęstsze słowo, zamień na następne najczęściej, jeśli liczba słów jest teraz wyższa, dzięki czemu lista będzie posortowana. Wszystko to powtarza się, dopóki nie zostaną wczytane żadne litery. Następnie wyświetlana jest lista najczęstszych słów.
Obecnie domyślnie jest wyświetlany czas przetwarzania, ale w celu zachowania spójności z innymi zgłoszeniami wyłącz definicję TIMING w kodzie źródłowym.
źródło
12 eroilk 111 iennoa 10 yttelen 110 engyt
.DO#
Ten powinien działać z najnowszymi zestawami SDK .net .
Oto przykładowy wynik.
Na początku próbowałem użyć słownika z kluczami łańcuchowymi, ale było to zbyt wolne. Myślę, że dzieje się tak, ponieważ ciągi .net są wewnętrznie reprezentowane przez 2-bajtowe kodowanie, co jest trochę marnotrawstwem dla tej aplikacji. Więc właśnie przełączyłem się na czyste bajty i brzydką maszynę stanu w stylu goto. Konwersja wielkości liter jest operatorem bitowym. Sprawdzanie zakresu znaków odbywa się w jednym porównaniu po odjęciu. Nie poświęciłem żadnego wysiłku na optymalizację ostatecznego sortowania, ponieważ odkryłem, że używa on mniej niż 0,1% czasu wykonywania.
Poprawka: algorytm był zasadniczo poprawny, ale nadmiernie raportował wszystkie słowa, licząc wszystkie prefiksy słów. Ponieważ całkowita liczba słów nie jest wymagana dla problemu, usunąłem ten wynik. Aby wypisać wszystkie k słów, dostosowałem również wynik. W końcu zdecydowałem się na używanie,
string.Join()
a następnie pisanie całej listy na raz. Zaskakujące jest to, że na mojej maszynie jest to około sekundy szybciej niż każde pisanie każdego słowa osobno na 100 000.źródło
tolower
i pojedyncze sztuczki porównawcze. Nie rozumiem jednak, dlaczego Twój program zgłasza wyraźniejsze słowa niż oczekiwano. Ponadto, zgodnie z pierwotnym opisem problemu, program musi wypisać wszystkie k słów w malejącej kolejności częstotliwości, więc nie liczyłem twojego programu do ostatniego testu, który musi wypisać 100 000 najczęstszych słów.Ruby 2.7.0-Preview1 z
tally
Najnowsza wersja Ruby ma nową metodę o nazwie
tally
. Z informacji o wersji :To prawie rozwiązuje dla nas całe zadanie. Musimy najpierw przeczytać plik, a później znaleźć maksimum.
Oto cała sprawa:
edycja: Dodano
k
jako argument wiersza poleceńMożna go uruchomić przy
ruby k filename.rb input.txt
użyciu wersji Ruby w wersji 2.7.0-Preview1. Można go pobrać z różnych łączy na stronie informacji o wersji lub zainstalować przy użyciu rbenvrbenv install 2.7.0-dev
.Przykład uruchamiany na moim starym, zniszczonym komputerze:
źródło