Esse talvez seja um dos desafios clássicos de codificação que tiveram alguma ressonância em 1986, quando o colunista Jon Bentley pediu a Donald Knuth que escrevesse um programa que encontrasse k palavras mais frequentes em um arquivo. Knuth implementou uma solução rápida usando tentativas de hash em um programa de 8 páginas para ilustrar sua técnica de programação. Douglas McIlroy, do Bell Labs, criticou a solução de Knuth por não conseguir processar um texto completo da Bíblia e respondeu com uma única frase, que não é tão rápida, mas faz o trabalho:
tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q
Em 1987, um artigo de acompanhamento foi publicado com mais uma solução, desta vez por um professor de Princeton. Mas também não conseguiu retornar o resultado para uma única Bíblia!
Descrição do Problema
Descrição original do problema:
Dado um arquivo de texto e um número inteiro k, você deve imprimir as k palavras mais comuns no arquivo (e o número de ocorrências) em frequência decrescente.
Esclarecimentos adicionais sobre problemas:
- Knuth definiu as palavras como uma sequência de letras latinas:
[A-Za-z]+
- todos os outros caracteres são ignorados
- letras maiúsculas e minúsculas são consideradas equivalentes (
WoRd
==word
) - sem limite de tamanho de arquivo nem comprimento de palavra
- distâncias entre palavras consecutivas podem ser arbitrariamente grandes
- O programa mais rápido é o que utiliza menos tempo total de CPU (o multithreading provavelmente não ajudará)
Casos de teste de amostra
Teste 1: Ulisses de James Joyce concatenou 64 vezes (arquivo de 96 MB).
- Faça o download de Ulisses do Projeto Gutenberg:
wget http://www.gutenberg.org/files/4300/4300-0.txt
- Concatene 64 vezes:
for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
- A palavra mais frequente é "the" com 968832 aparências.
Teste 2: texto aleatório gerado especialmente giganovel
(cerca de 1 GB).
- Script do gerador Python 3 aqui .
- O texto contém 148391 palavras distintas que aparecem de maneira semelhante aos idiomas naturais.
- Palavras mais frequentes: “e” (11309 aparições) e “ihit” (11290 aparições).
Teste de generalidade: palavras arbitrariamente grandes com lacunas arbitrariamente grandes.
Implementações de referência
Depois de analisar o Rosetta Code para esse problema e perceber que muitas implementações são incrivelmente lentas (mais lentas que o shell script!), Testei algumas boas implementações aqui . Abaixo está o desempenho para, ulysses64
juntamente com a complexidade do tempo:
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)
Você pode vencer isso?
Teste
O desempenho será avaliado usando o MacBook Pro de 13 "de 2017 com o padrão Unix time
comando (horário do" usuário "). Se possível, use compiladores modernos (por exemplo, use a versão mais recente do Haskell, e não a legada).
Rankings até agora
Horários, incluindo os programas de referência:
k=10 k=100K
ulysses64 giganovel giganovel
C (trie + bins) by Moogie 0.704 9.568 9.459
C (trie + list) by Moogie 0.767 6.051 82.306
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
Classificação acumulada * (%, melhor pontuação possível - 300):
# Program Score Generality
1 Rust (trie) by Anders Kaseorg 334 Yes
2 C (trie + bins) by Moogie 384 x
3 J by miles 852 Yes
4 C# (trie) by recursive 1278 x
5 C (trie + list) by Moogie 1306 x
6 C++ (trie + heap) 2255 x
7 Python (dict) by movatica 4316 Yes
8 Python (Counter) 4583 Yes
9 Ruby (tally) by daniero 7264 Yes
10 AWK + sort 9422 Yes
11 McIlroy (tr + sort + uniq) 28014 Yes
* Soma do desempenho do tempo em relação aos melhores programas em cada um dos três testes.
Melhor programa: aqui .
fonte
Respostas:
[C]
O seguinte é executado em menos de 1,6 segundos para o Teste 1 no meu 2,8 Ghz Xeon W3530. Construído usando MinGW.org GCC-6.3.0-1 no Windows 7:
São necessários dois argumentos como entrada (caminho para o arquivo de texto e para o número de k de palavras mais frequentes a serem listadas)
Ele simplesmente cria uma árvore que se ramifica nas letras das palavras e, nas letras das folhas, incrementa um contador. Em seguida, verifica se o contador de folhas atual é maior que a menor palavra mais frequente na lista de palavras mais frequentes. (o tamanho da lista é o número determinado pelo argumento da linha de comando). Se sim, promova a palavra representada pela letra da folha como uma das mais frequentes. Tudo isso se repete até que não sejam lidas mais letras. Após o qual a lista de palavras mais frequentes é exibida por meio de uma pesquisa iterativa ineficiente pela palavra mais frequente da lista de palavras mais frequentes.
Atualmente, o padrão é gerar o tempo de processamento, mas, para fins de consistência com outros envios, desative a definição TIMING no código-fonte.
Além disso, enviei isso a partir de um computador de trabalho e não consegui baixar o texto do Teste 2. Ele deve funcionar com este Teste 2 sem modificação, no entanto, o valor MAX_LETTER_INSTANCES pode precisar ser aumentado.
Para o Teste 1 e para as 10 principais palavras frequentes e com o tempo ativado, ele deve ser impresso:
fonte
letters
matriz, enquanto a implementação de referência aloca nós de árvore dinamicamente.mmap
ing deve ser mais rápido (~ 5% no meu linux laptop):#include<sys/mman.h>
,<sys/stat.h>
,<fcntl.h>
, substitua a leitura com arquivoint d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);
e comentário forafree(data);
Ferrugem
No meu computador, isso executa o giganovel 100000 cerca de 42% mais rápido (10,64 s vs. 18,24 s) do que a solução C de Moogie C “prefix tree + bins”. Além disso, ele não possui limites predefinidos (ao contrário da solução C, que pré-define limites no comprimento das palavras, palavras únicas, palavras repetidas etc.).
src/main.rs
Cargo.toml
Uso
fonte
-O3
e-Ofast
não faz uma diferença mensurável.gcc -O3 -march=native -mtune=native program.c
.APL (Dyalog Unicode)
O seguinte é executado em menos de 8 segundos no meu i7-4720HQ de 2,6 Ghz usando o Dyalog APL 17.0 de 64 bits no Windows 10:
Primeiro, solicita o nome do arquivo e, em seguida, k. Observe que uma parte significativa do tempo de execução (cerca de 1 segundo) está apenas lendo o arquivo.
Para cronometrar, você poderá canalizar o seguinte para o seu
dyalog
executável (para as dez palavras mais frequentes):Deve imprimir:
fonte
export MAXWS=4096M
. Eu acho que ele usa tabelas de hash? Como reduzir o tamanho da área de trabalho para 2 GB, torna-a mais lenta por 2 segundos inteiros.∊
usa uma tabela de hash de acordo com isso , e tenho certeza⌸
que também internamente.WS FULL
, embora eu tenha aumentado o espaço de trabalho para 6 GB.[C] Árvore de prefixo + posições
NOTA: O compilador usado tem um efeito significativo na velocidade de execução do programa! Eu usei o gcc (MinGW.org GCC-8.2.0-3) 8.2.0. Ao usar o -Ofast opção , o programa executa quase 50% mais rápido que o programa normalmente compilado.
Complexidade do algoritmo
Desde então, percebi que a classificação de Bin que estou realizando é uma forma de classificação de Pigeonhost , o que significa que posso reduzir a complexidade do Big O dessa solução.
Eu calculo que seja:
A complexidade da construção da árvore é equivalente à passagem da árvore, portanto, a qualquer nível, o nó correto para o qual atravessar é O (1) (como cada letra é mapeada diretamente para um nó e sempre estamos atravessando apenas um nível da árvore para cada letra)
A classificação de Pigeon Hole é O (N + n), em que n é o intervalo de valores-chave, no entanto, para este problema, não precisamos classificar todos os valores, apenas o número k, de modo que o pior caso seria O (N + k).
A combinação resulta em O (1 + N + k).
A complexidade do espaço para construção de árvores deve-se ao fato de que o pior caso são 26 * M nós, se os dados consistirem em uma palavra com número M de letras e que cada nó tiver 26 nós (ou seja, para as letras do alfabeto). Assim O (26 * M) = O (M)
Para a classificação de Pigeon Hole, a complexidade do espaço é O (N + n)
Combinando, produz-se O (26 * M + N + n) = O (M + N + n)
Algoritmo
São necessários dois argumentos como entrada (caminho para o arquivo de texto e para o número de k de palavras mais frequentes a serem listadas)
Com base em minhas outras entradas, esta versão possui uma rampa de custo de tempo muito pequena, com valores crescentes de k em comparação com minhas outras soluções. Mas é visivelmente mais lento para valores baixos de k, no entanto, deve ser muito mais rápido para valores maiores de k.
Ele cria uma árvore ramificada nas letras das palavras e, nas letras da folha, incrementa um contador. Em seguida, adiciona a palavra a um compartimento de palavras do mesmo tamanho (após remover a palavra do compartimento em que ela já residia). Isso tudo se repete até que não sejam lidas mais letras. Após o que os compartimentos são revertidos iterados k vezes, iniciando no compartimento maior e as palavras de cada compartimento são exibidas.
Atualmente, o padrão é gerar o tempo de processamento, mas, para fins de consistência com outros envios, desative a definição TIMING no código-fonte.
EDIT: agora adiando o preenchimento de compartimentos até a construção da árvore e otimizando a construção da saída.
EDIT2: agora usando aritmética de ponteiro em vez de acesso a matriz para otimização de velocidade.
fonte
ulysses64
uma vez, por isso é um líder atual agora.J
Executar como um script com
jconsole <script> <input> <k>
. Por exemplo, a saída dogiganovel
comk=100K
:Não há limite, exceto a quantidade de memória disponível no sistema.
fonte
...
ocorre devido ao truncamento da saída por linha. Eu adicionei uma linha no início para desativar todos os truncamentos. Ele diminui a velocidade no giganovel, pois usa muito mais memória, pois há mais palavras únicas.Python 3
Essa implementação com um dicionário simples é um pouco mais rápida que a que está sendo usada
Counter
no meu sistema.fonte
heapq
não adiciona nenhum desempenho aoCounter.most_common
método ouenumerate(sorted(...))
também usaheapq
internamente.Counter.most_common
.[C] Árvore de prefixos + lista vinculada classificada
São necessários dois argumentos como entrada (caminho para o arquivo de texto e para o número de k de palavras mais frequentes a serem listadas)
Com base na minha outra entrada, esta versão é muito mais rápida para valores maiores de k, mas com um custo menor de desempenho em valores mais baixos de k.
Ele cria uma árvore ramificada nas letras das palavras e, nas letras da folha, incrementa um contador. Em seguida, verifica se o contador de folhas atual é maior que a menor palavra mais frequente na lista de palavras mais frequentes. (o tamanho da lista é o número determinado pelo argumento da linha de comando). Se sim, promova a palavra representada pela letra da folha como uma das mais frequentes. Se já for uma palavra mais frequente, troque pela próxima mais frequente se a contagem de palavras for maior, mantendo a lista classificada. Isso tudo se repete até que não sejam lidas mais letras. Após o qual a lista de palavras mais frequentes é exibida.
Atualmente, o padrão é gerar o tempo de processamento, mas, para fins de consistência com outros envios, desative a definição TIMING no código-fonte.
fonte
12 eroilk 111 iennoa 10 yttelen 110 engyt
.C #
Este deve funcionar com os SDKs .net mais recentes .
Aqui está um exemplo de saída.
No começo, tentei usar um dicionário com chaves de string, mas isso era muito lento. Eu acho que é porque as cadeias .net são representadas internamente com uma codificação de 2 bytes, o que é um desperdício para este aplicativo. Então, mudei para bytes puros e para uma máquina de estado feia, estilo goto. A conversão de caso é um operador bit a bit. A verificação do intervalo de caracteres é feita em uma única comparação após a subtração. Não fiz nenhum esforço para otimizar a classificação final, pois descobri que ela está usando menos de 0,1% do tempo de execução.
Correção: o algoritmo estava essencialmente correto, mas apresentava um excesso de relatório de palavras totais, contando todos os prefixos de palavras. Como a contagem total de palavras não é um requisito do problema, eu removi essa saída. Para gerar todas as k palavras, também ajustei a saída. Acabei decidindo usar
string.Join()
e depois escrever a lista inteira de uma só vez. Surpreendentemente, isso é cerca de um segundo mais rápido na minha máquina que escrever cada palavra separadamente por 100k.fonte
tolower
truques de comparação bit a bit e únicos. No entanto, não entendo por que seu programa relata palavras mais distintas do que o esperado. Além disso, de acordo com a descrição original do problema, o programa precisa gerar todas as k palavras em ordem decrescente de frequência, portanto, não contei o seu programa para o último teste, que precisa produzir 100.000 palavras mais frequentes.Ruby 2.7.0-preview1 com
tally
A versão mais recente do Ruby possui um novo método chamado
tally
. Nas notas de versão :Isso quase resolve toda a tarefa para nós. Só precisamos ler o arquivo primeiro e encontrar o máximo mais tarde.
Aqui está a coisa toda:
edit: adicionado
k
como argumento de linha de comandoPode ser executado com o
ruby k filename.rb input.txt
uso da versão 2.7.0-preview1 do Ruby. Isso pode ser baixado de vários links na página de notas de versão ou instalado com o rbenv usandorbenv install 2.7.0-dev
.Exemplo executado em meu próprio computador antigo e obsoleto:
fonte