Desafio de codificação da Bentley: k palavras mais frequentes


17

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, ulysses64juntamente 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 .


O placar é apenas o tempo em Ulisses? Ele parece implícita, mas não é explicitamente disse
post rock Garf Hunter

@ SriotchilismO'Zaic, por enquanto, sim. Mas você não deve confiar no primeiro caso de teste, pois podem ocorrer casos de teste maiores. ulysses64 tem a desvantagem óbvia de ser repetitivo: nenhuma palavra nova aparece após 1/64 do arquivo. Portanto, não é um caso de teste muito bom, mas é fácil de distribuir (ou reproduzir).
Andriy Makukha

3
Eu quis dizer os casos de teste ocultos dos quais você estava falando anteriormente. Se você publicar os hashes agora, quando revelar os textos reais, podemos garantir que seja justo com as respostas e que você não será rei. Embora eu suponha que o hash para Ulisses seja um pouco útil.
Post Rock Garf Hunter

1
@tsh Esse é o meu entendimento: por exemplo, seria duas palavras E e G
Moogie

1
@AndriyMakukha Ah, obrigado. Aqueles eram apenas insetos; Eu os consertei.
Anders Kaseorg 16/07/19

Respostas:


5

[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.

// comment out TIMING if using external program timing mechanism
#define TIMING 1

// may need to increase if the source text has many unique words
#define MAX_LETTER_INSTANCES 1000000

// increase this if needing to output more top frequent words
#define MAX_TOP_FREQUENT_WORDS 1000

#define false 0
#define true 1
#define null 0

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef TIMING
#include <sys/time.h>
#endif

struct Letter
{
    char mostFrequentWord;
    struct Letter* parent;
    char asciiCode;
    unsigned int count;
    struct Letter* nextLetters[26];
};
typedef struct Letter Letter;

int main(int argc, char *argv[]) 
{
#ifdef TIMING
    struct timeval tv1, tv2;
    gettimeofday(&tv1, null);
#endif

    int k;
    if (argc !=3 || (k = atoi(argv[2])) <= 0 || k> MAX_TOP_FREQUENT_WORDS)
    {
        printf("Usage:\n");
        printf("      WordCount <input file path> <number of most frequent words to find>\n");
        printf("NOTE: upto %d most frequent words can be requested\n\n",MAX_TOP_FREQUENT_WORDS);
        return -1;
    }

    long  file_size;
    long dataLength;
    char* data;

    // read in file contents
    FILE *fptr;
    size_t read_s = 0;  
    fptr = fopen(argv[1], "rb");
    fseek(fptr, 0L, SEEK_END);
    dataLength = ftell(fptr);
    rewind(fptr);
    data = (char*)malloc((dataLength));
    read_s = fread(data, 1, dataLength, fptr);
    if (fptr) fclose(fptr);

    unsigned int chr;
    unsigned int i;

    // working memory of letters
    Letter* letters = (Letter*) malloc(sizeof(Letter) * MAX_LETTER_INSTANCES);
    memset(&letters[0], 0, sizeof( Letter) * MAX_LETTER_INSTANCES);

    // the index of the next unused letter
    unsigned int letterMasterIndex=0;

    // pesudo letter representing the starting point of any word
    Letter* root = &letters[letterMasterIndex++];

    // the current letter in the word being processed
    Letter* currentLetter = root;
    root->mostFrequentWord = false;
    root->count = 0;

    // the next letter to be processed
    Letter* nextLetter = null;

    // store of the top most frequent words
    Letter* topWords[MAX_TOP_FREQUENT_WORDS];

    // initialise the top most frequent words
    for (i = 0; i<k; i++)
    {
        topWords[i]=root;
    }

    unsigned int lowestWordCount = 0;
    unsigned int lowestWordIndex = 0;
    unsigned int highestWordCount = 0;
    unsigned int highestWordIndex = 0;

    // main loop
    for (int j=0;j<dataLength;j++)
    {
        chr = data[j]|0x20; // convert to lower case

        // is a letter?
        if (chr > 96 && chr < 123)
        {
            chr-=97; // translate to be zero indexed
            nextLetter = currentLetter->nextLetters[chr];

            // this is a new letter at this word length, intialise the new letter
            if (nextLetter == null)
            {
                nextLetter = &letters[letterMasterIndex++];
                nextLetter->parent = currentLetter;
                nextLetter->asciiCode = chr;
                currentLetter->nextLetters[chr] = nextLetter;
            }

            currentLetter = nextLetter;
        }
        // not a letter so this means the current letter is the last letter of a word (if any letters)
        else if (currentLetter!=root)
        {

            // increment the count of the full word that this letter represents
            ++currentLetter->count;

            // ignore this word if already identified as a most frequent word
            if (!currentLetter->mostFrequentWord)
            {
                // update the list of most frequent words
                // by replacing the most infrequent top word if this word is more frequent
                if (currentLetter->count> lowestWordCount)
                {
                    currentLetter->mostFrequentWord = true;
                    topWords[lowestWordIndex]->mostFrequentWord = false;
                    topWords[lowestWordIndex] = currentLetter;
                    lowestWordCount = currentLetter->count;

                    // update the index and count of the next most infrequent top word
                    for (i=0;i<k; i++)
                    {
                        // if the topword  is root then it can immediately be replaced by this current word, otherwise test
                        // whether the top word is less than the lowest word count
                        if (topWords[i]==root || topWords[i]->count<lowestWordCount)
                        {
                            lowestWordCount = topWords[i]->count;
                            lowestWordIndex = i;
                        }
                    }
                }
            }

            // reset the letter path representing the word
            currentLetter = root;
        }
    }

    // print out the top frequent words and counts
    char string[256];
    char tmp[256];

    while (k > 0 )
    {
        highestWordCount = 0;
        string[0]=0;
        tmp[0]=0;

        // find next most frequent word
        for (i=0;i<k; i++)
        {
            if (topWords[i]->count>highestWordCount)
            {
                highestWordCount = topWords[i]->count;
                highestWordIndex = i;
            }
        }

        Letter* letter = topWords[highestWordIndex];

        // swap the end top word with the found word and decrement the number of top words
        topWords[highestWordIndex] = topWords[--k];

        if (highestWordCount > 0)
        {
            // construct string of letters to form the word
            while (letter != root)
            {
                memmove(&tmp[1],&string[0],255);
                tmp[0]=letter->asciiCode+97;
                memmove(&string[0],&tmp[0],255);
                letter=letter->parent;
            }

            printf("%u %s\n",highestWordCount,string);
        }
    }

    free( data );
    free( letters );

#ifdef TIMING   
    gettimeofday(&tv2, null);
    printf("\nTime Taken: %f seconds\n", (double) (tv2.tv_usec - tv1.tv_usec)/1000000 + (double) (tv2.tv_sec - tv1.tv_sec));
#endif
    return 0;
}

Para o Teste 1 e para as 10 principais palavras frequentes e com o tempo ativado, ele deve ser impresso:

 968832 the
 528960 of
 466432 and
 421184 a
 322624 to
 320512 in
 270528 he
 213120 his
 191808 i
 182144 s

 Time Taken: 1.549155 seconds

Impressionante! O uso da lista supostamente o torna O (Nk) no pior caso, por isso é executado mais lentamente que o programa C ++ de referência para giganovel com k = 100.000. Mas para k << N é um vencedor claro.
Andriy Makukha 12/07/19

1
@AndriyMakukha Thanks! Fiquei um pouco surpreso que uma implementação tão simples produzisse grande velocidade. Eu poderia melhorar para valores maiores de k ordenando a lista. (a classificação não deve ser muito cara, pois a ordem da lista mudaria lentamente), mas isso aumenta a complexidade e provavelmente afeta a velocidade dos valores mais baixos de k. Terá que experimentar
Moogie

Sim, eu também fiquei surpresa. Pode ser porque o programa de referência usa muitas chamadas de função e o compilador falha ao otimizá-lo corretamente.
Andriy Makukha 12/07/19

Outro benefício de desempenho provavelmente vem da alocação semestática de lettersmatriz, enquanto a implementação de referência aloca nós de árvore dinamicamente.
Andriy Makukha 12/07/19

mmaping deve ser mais rápido (~ 5% no meu linux laptop): #include<sys/mman.h>, <sys/stat.h>, <fcntl.h>, substitua a leitura com arquivo int 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);
NGN

4

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

use memmap::MmapOptions;
use pdqselect::select_by_key;
use std::cmp::Reverse;
use std::default::Default;
use std::env::args;
use std::fs::File;
use std::io::{self, Write};
use typed_arena::Arena;

#[derive(Default)]
struct Trie<'a> {
    nodes: [Option<&'a mut Trie<'a>>; 26],
    count: u64,
}

fn main() -> io::Result<()> {
    // Parse arguments
    let mut args = args();
    args.next().unwrap();
    let filename = args.next().unwrap();
    let size = args.next().unwrap().parse().unwrap();

    // Open input
    let file = File::open(filename)?;
    let mmap = unsafe { MmapOptions::new().map(&file)? };

    // Build trie
    let arena = Arena::new();
    let mut num_words = 0;
    let mut root = Trie::default();
    {
        let mut node = &mut root;
        for byte in &mmap[..] {
            let letter = (byte | 32).wrapping_sub(b'a');
            if let Some(child) = node.nodes.get_mut(letter as usize) {
                node = child.get_or_insert_with(|| {
                    num_words += 1;
                    arena.alloc(Default::default())
                });
            } else {
                node.count += 1;
                node = &mut root;
            }
        }
        node.count += 1;
    }

    // Extract all counts
    let mut index = 0;
    let mut counts = Vec::with_capacity(num_words);
    let mut stack = vec![root.nodes.iter()];
    'a: while let Some(frame) = stack.last_mut() {
        while let Some(child) = frame.next() {
            if let Some(child) = child {
                if child.count != 0 {
                    counts.push((child.count, index));
                    index += 1;
                }
                stack.push(child.nodes.iter());
                continue 'a;
            }
        }
        stack.pop();
    }

    // Find frequent counts
    select_by_key(&mut counts, size, |&(count, _)| Reverse(count));
    // Or, in nightly Rust:
    //counts.partition_at_index_by_key(size, |&(count, _)| Reverse(count));

    // Extract frequent words
    let size = size.min(counts.len());
    counts[0..size].sort_by_key(|&(_, index)| index);
    let mut out = Vec::with_capacity(size);
    let mut it = counts[0..size].iter();
    if let Some(mut next) = it.next() {
        index = 0;
        stack.push(root.nodes.iter());
        let mut word = vec![b'a' - 1];
        'b: while let Some(frame) = stack.last_mut() {
            while let Some(child) = frame.next() {
                *word.last_mut().unwrap() += 1;
                if let Some(child) = child {
                    if child.count != 0 {
                        if index == next.1 {
                            out.push((word.to_vec(), next.0));
                            if let Some(next1) = it.next() {
                                next = next1;
                            } else {
                                break 'b;
                            }
                        }
                        index += 1;
                    }
                    stack.push(child.nodes.iter());
                    word.push(b'a' - 1);
                    continue 'b;
                }
            }
            stack.pop();
            word.pop();
        }
    }
    out.sort_by_key(|&(_, count)| Reverse(count));

    // Print results
    let stdout = io::stdout();
    let mut stdout = io::BufWriter::new(stdout.lock());
    for (word, count) in out {
        stdout.write_all(&word)?;
        writeln!(stdout, " {}", count)?;
    }

    Ok(())
}

Cargo.toml

[package]
name = "frequent"
version = "0.1.0"
authors = ["Anders Kaseorg <andersk@mit.edu>"]
edition = "2018"

[dependencies]
memmap = "0.7.0"
typed-arena = "1.4.1"
pdqselect = "0.1.0"

[profile.release]
lto = true
opt-level = 3

Uso

cargo build --release
time target/release/frequent ulysses64 10

1
Soberbo! Muito bom desempenho nas três configurações. Eu estava literalmente assistindo a uma conversa recente de Carol Nichols sobre Rust :) Sintaxe um tanto incomum, mas estou empolgada em aprender o idioma: parece ser o único idioma fora das linguagens do sistema pós-C ++ que não sacrifique muito desempenho e torne a vida do desenvolvedor muito mais fácil.
Andriy Makukha

Muito rápido! eu estou impressionado! Gostaria de saber se a melhor opção de compilador para C (árvore + bin) dará um resultado semelhante?
Moogie

@ Moogie Eu já estava testando o seu -O3e -Ofastnão faz uma diferença mensurável.
Anders Kaseorg 15/07/19

@ Moogie, eu estava compilando seu código como gcc -O3 -march=native -mtune=native program.c.
Andriy Makukha

@Andriy Makukha ah. Isso explicaria a grande diferença de velocidade entre os resultados que você está obtendo e os meus resultados: você já estava aplicando sinalizadores de otimização. Eu não acho que existem muitas otimizações de código grandes restantes. Não consigo testar usando o mapa, conforme sugerido por outros, pois as matrizes mingw não têm uma implementação ... E só dariam um aumento de 5%. Acho que vou ter que ceder à entrada incrível de Anders. Bem feito!
Moogie

3

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:

⎕{m[⍺↑⍒⊢/m←{(⊂⎕UCS⊃⍺),≢⍵}⌸(⊢⊆⍨96∘<∧<∘123)83⎕DR 819⌶80 ¯1⎕MAP⍵;]}⍞

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 dyalogexecutável (para as dez palavras mais frequentes):

⎕{m[⍺↑⍒⊢/m←{(⊂⎕UCS⊃⍺),≢⍵}⌸(⊢⊆⍨96∘<∧<∘123)83⎕DR 819⌶80 ¯1⎕MAP⍵;]}⍞
/tmp/ulysses64
10
⎕OFF

Deve imprimir:

 the  968832
 of   528960
 and  466432
 a    421184
 to   322624
 in   320512
 he   270528
 his  213120
 i    191808
 s    182144

Muito agradável! Ele supera o Python. Funcionou melhor depois 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.
Andriy Makukha

@AndriyMakukha Sim, usa uma tabela de hash de acordo com isso , e tenho certeza que também internamente.
Adám 10/07/19

Por que é O (N log N)? Parece mais uma solução Python (k vezes restaurando um monte de todas as palavras exclusivas) ou AWK (classificando apenas palavras únicas) para mim. A menos que você classifique todas as palavras, como no shell script de McIlroy, não deve ser O (N log N).
Andriy Makukha

@AndriyMakukha Ele avalia todas as contagens. Aqui está o que nosso especialista em desempenho me escreveu: A complexidade do tempo é O (N log N), a menos que você acredite em algumas coisas teoricamente duvidosas sobre tabelas de hash, nesse caso, é O (N).
Adám 10/07/19

Bem, quando executo seu código contra 8, 16 e 32 Ulysses, ele diminui exatamente linearmente. Talvez o seu profissional de desempenho precise reconsiderar seus pontos de vista sobre a complexidade de tempo das tabelas de hash :) Além disso, esse código não funciona no caso de teste maior. Ele retorna WS FULL, embora eu tenha aumentado o espaço de trabalho para 6 GB.
Andriy Makukha 11/07/19

2

[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:

Worst Time complexity: O(1 + N + k)
Worst Space complexity: O(26*M + N + n) = O(M + N + n)

Where N is the number of words of the data
and M is the number of letters of the data
and n is the range of pigeon holes
and k is the desired number of sorted words to return
and N<=M

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.

// comment out TIMING if using external program timing mechanism
#define TIMING 1

// may need to increase if the source text has many unique words
#define MAX_LETTER_INSTANCES 1000000

// may need to increase if the source text has many repeated words
#define MAX_BINS 1000000

// assume maximum of 20 letters in a word... adjust accordingly
#define MAX_LETTERS_IN_A_WORD 20

// assume maximum of 10 letters for the string representation of the bin number... adjust accordingly
#define MAX_LETTERS_FOR_BIN_NAME 10

// maximum number of bytes of the output results
#define MAX_OUTPUT_SIZE 10000000

#define false 0
#define true 1
#define null 0
#define SPACE_ASCII_CODE 32

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef TIMING
#include <sys/time.h>
#endif

struct Letter
{
    //char isAWord;
    struct Letter* parent;
    struct Letter* binElementNext;
    char asciiCode;
    unsigned int count;
    struct Letter* nextLetters[26];
};
typedef struct Letter Letter;

struct Bin
{
  struct Letter* word;
};
typedef struct Bin Bin;


int main(int argc, char *argv[]) 
{
#ifdef TIMING
    struct timeval tv1, tv2;
    gettimeofday(&tv1, null);
#endif

    int k;
    if (argc !=3 || (k = atoi(argv[2])) <= 0)
    {
        printf("Usage:\n");
        printf("      WordCount <input file path> <number of most frequent words to find>\n\n");
        return -1;
    }

    long  file_size;
    long dataLength;
    char* data;

    // read in file contents
    FILE *fptr;
    size_t read_s = 0;  
    fptr = fopen(argv[1], "rb");
    fseek(fptr, 0L, SEEK_END);
    dataLength = ftell(fptr);
    rewind(fptr);
    data = (char*)malloc((dataLength));
    read_s = fread(data, 1, dataLength, fptr);
    if (fptr) fclose(fptr);

    unsigned int chr;
    unsigned int i, j;

    // working memory of letters
    Letter* letters = (Letter*) malloc(sizeof(Letter) * MAX_LETTER_INSTANCES);
    memset(&letters[0], null, sizeof( Letter) * MAX_LETTER_INSTANCES);

    // the memory for bins
    Bin* bins = (Bin*) malloc(sizeof(Bin) * MAX_BINS);
    memset(&bins[0], null, sizeof( Bin) * MAX_BINS);

    // the index of the next unused letter
    unsigned int letterMasterIndex=0;
    Letter *nextFreeLetter = &letters[0];

    // pesudo letter representing the starting point of any word
    Letter* root = &letters[letterMasterIndex++];

    // the current letter in the word being processed
    Letter* currentLetter = root;

    // the next letter to be processed
    Letter* nextLetter = null;

    unsigned int sortedListSize = 0;

    // the count of the most frequent word
    unsigned int maxCount = 0;

    // the count of the current word
    unsigned int wordCount = 0;

////////////////////////////////////////////////////////////////////////////////////////////
// CREATING PREFIX TREE
    j=dataLength;
    while (--j>0)
    {
        chr = data[j]|0x20; // convert to lower case

        // is a letter?
        if (chr > 96 && chr < 123)
        {
            chr-=97; // translate to be zero indexed
            nextLetter = currentLetter->nextLetters[chr];

            // this is a new letter at this word length, intialise the new letter
            if (nextLetter == null)
            {
                ++letterMasterIndex;
                nextLetter = ++nextFreeLetter;
                nextLetter->parent = currentLetter;
                nextLetter->asciiCode = chr;
                currentLetter->nextLetters[chr] = nextLetter;
            }

            currentLetter = nextLetter;
        }
        else
        {
            //currentLetter->isAWord = true;

            // increment the count of the full word that this letter represents
            ++currentLetter->count;

            // reset the letter path representing the word
            currentLetter = root;
        }
    }

////////////////////////////////////////////////////////////////////////////////////////////
// ADDING TO BINS

    j = letterMasterIndex;
    currentLetter=&letters[j-1];
    while (--j>0)
    {

      // is the letter the leaf letter of word?
      if (currentLetter->count>0)
      {
        i = currentLetter->count;
        if (maxCount < i) maxCount = i;

        // add to bin
        currentLetter->binElementNext = bins[i].word;
        bins[i].word = currentLetter;
      }
      --currentLetter;
    }

////////////////////////////////////////////////////////////////////////////////////////////
// PRINTING OUTPUT

    // the memory for output
    char* output = (char*) malloc(sizeof(char) * MAX_OUTPUT_SIZE);
    memset(&output[0], SPACE_ASCII_CODE, sizeof( char) * MAX_OUTPUT_SIZE);
    unsigned int outputIndex = 0;

    // string representation of the current bin number
    char binName[MAX_LETTERS_FOR_BIN_NAME];
    memset(&binName[0], SPACE_ASCII_CODE, MAX_LETTERS_FOR_BIN_NAME);


    Letter* letter;
    Letter* binElement;

    // starting at the bin representing the most frequent word(s) and then iterating backwards...
    for ( i=maxCount;i>0 && k>0;i--)
    {
      // check to ensure that the bin has at least one word
      if ((binElement = bins[i].word) != null)
      {
        // update the bin name
        sprintf(binName,"%u",i);

        // iterate of the words in the bin
        while (binElement !=null && k>0)
        {
          // stop if we have reached the desired number of outputed words
          if (k-- > 0)
          {
              letter = binElement;

              // add the bin name to the output
              memcpy(&output[outputIndex],&binName[0],MAX_LETTERS_FOR_BIN_NAME);
              outputIndex+=MAX_LETTERS_FOR_BIN_NAME;

              // construct string of letters to form the word
               while (letter != root)
              {
                // output the letter to the output
                output[outputIndex++] = letter->asciiCode+97;
                letter=letter->parent;
              }

              output[outputIndex++] = '\n';

              // go to the next word in the bin
              binElement = binElement->binElementNext;
          }
        }
      }
    }

    // write the output to std out
    fwrite(output, 1, outputIndex, stdout);
   // fflush(stdout);

   // free( data );
   // free( letters );
   // free( bins );
   // free( output );

#ifdef TIMING   
    gettimeofday(&tv2, null);
    printf("\nTime Taken: %f seconds\n", (double) (tv2.tv_usec - tv1.tv_usec)/1000000 + (double) (tv2.tv_sec - tv1.tv_sec));
#endif
    return 0;
}

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.


Uau! 100.000 palavras mais frequentes de um arquivo de 1 GB em 11 segundos ... Parece algum tipo de truque de mágica.
Andriy Makukha

Não há truques ... Basta trocar o tempo da CPU pelo uso eficiente da memória. Estou surpreso com o seu resultado ... No meu PC mais antigo, leva mais de 60 segundos. Percebi que estou fazendo comparações desnecessárias e posso adiar o binning até que o arquivo seja processado. Deve torná-lo ainda mais rápido. Vou tentar em breve e atualizar minha resposta.
Moogie

@AndriyMakukha Agora, adiei o preenchimento das Lixeiras até que todas as palavras tenham sido processadas e a árvore seja construída. Isso evita comparações desnecessárias e manipulação de elementos bin. Também mudei a maneira como a saída é construída, pois achei que a impressão estava demorando bastante!
Moogie

Na minha máquina, esta atualização não faz nenhuma diferença perceptível. No entanto, ele teve um desempenho muito rápido ulysses64uma vez, por isso é um líder atual agora.
Andriy Makukha

Deve ser um problema exclusivo do meu PC, então :) Notei uma aceleração de 5 segundos ao usar esse novo algoritmo de saída
Moogie

2

J

9!:37 ] 0 _ _ _

'input k' =: _2 {. ARGV
k =: ". k

lower =: a. {~ 97 + i. 26
words =: ((lower , ' ') {~ lower i. ]) (32&OR)&.(a.&i.) fread input
words =: ' ' , words
words =: -.&(s: a:) s: words
uniq =: ~. words
res =: (k <. # uniq) {. \:~ (# , {.)/.~ uniq&i. words
echo@(,&": ' ' , [: }.@": {&uniq)/"1 res

exit 0

Executar como um script com jconsole <script> <input> <k>. Por exemplo, a saída do giganovelcom k=100K:

$ time jconsole solve.ijs giganovel 100000 | head 
11309 e
11290 ihit
11285 ah
11260 ist
11255 aa
11202 aiv
11201 al
11188 an
11187 o
11186 ansa

real    0m13.765s
user    0m11.872s
sys     0m1.786s

Não há limite, exceto a quantidade de memória disponível no sistema.


Muito rápido para o caso de teste menor! Agradável! No entanto, para palavras arbitrariamente grandes, trunca as palavras na saída. Não tenho certeza se existe um limite para o número de caracteres em uma palavra ou se é apenas para tornar a saída mais concisa.
Andriy Makukha

@AndriyMakukha Sim, isso ...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.
milhas

Ótimo! Agora passa no teste de generalidade. E não diminuiu a velocidade na minha máquina. De fato, houve uma aceleração menor.
Andriy Makukha

1

Python 3

Essa implementação com um dicionário simples é um pouco mais rápida que a que está sendo usada Counterno meu sistema.

def words_from_file(filename):
    import re

    pattern = re.compile('[a-z]+')

    for line in open(filename):
        yield from pattern.findall(line.lower())


def freq(textfile, k):
    frequencies = {}

    for word in words_from_file(textfile):
        frequencies[word] = frequencies.get(word, 0) + 1

    most_frequent = sorted(frequencies.items(), key=lambda item: item[1], reverse=True)

    for i, (word, frequency) in enumerate(most_frequent):
        if i == k:
            break

        yield word, frequency


from time import time

start = time()
print('\n'.join('{}:\t{}'.format(f, w) for w,f in freq('giganovel', 10)))
end = time()
print(end - start)

1
Eu só pude testar com giganovel no meu sistema e leva muito tempo (~ 90s). gutenbergproject é bloqueada na Alemanha por razões legais ...
movatica

Interessante. Ou heapqnão adiciona nenhum desempenho ao Counter.most_commonmétodo ou enumerate(sorted(...))também usa heapqinternamente.
Andriy Makukha 13/07/19

Eu testei com Python 2 e o desempenho foi semelhante, então, eu acho, a classificação funciona tão rápido quanto Counter.most_common.
Andriy Makukha 13/07/19

Sim, talvez tenha sido apenas instabilidade no meu sistema ... Pelo menos não é mais lento :) Mas a pesquisa de expressões regulares é muito mais rápida do que a iteração nos caracteres. Parece ter sido implementado com bastante desempenho.
movatica 14/07/19

1

[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.

// comment out TIMING if using external program timing mechanism
#define TIMING 1

// may need to increase if the source text has many unique words
#define MAX_LETTER_INSTANCES 1000000

#define false 0
#define true 1
#define null 0

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef TIMING
#include <sys/time.h>
#endif

struct Letter
{
    char isTopWord;
    struct Letter* parent;
    struct Letter* higher;
    struct Letter* lower;
    char asciiCode;
    unsigned int count;
    struct Letter* nextLetters[26];
};
typedef struct Letter Letter;

int main(int argc, char *argv[]) 
{
#ifdef TIMING
    struct timeval tv1, tv2;
    gettimeofday(&tv1, null);
#endif

    int k;
    if (argc !=3 || (k = atoi(argv[2])) <= 0)
    {
        printf("Usage:\n");
        printf("      WordCount <input file path> <number of most frequent words to find>\n\n");
        return -1;
    }

    long  file_size;
    long dataLength;
    char* data;

    // read in file contents
    FILE *fptr;
    size_t read_s = 0;  
    fptr = fopen(argv[1], "rb");
    fseek(fptr, 0L, SEEK_END);
    dataLength = ftell(fptr);
    rewind(fptr);
    data = (char*)malloc((dataLength));
    read_s = fread(data, 1, dataLength, fptr);
    if (fptr) fclose(fptr);

    unsigned int chr;
    unsigned int i;

    // working memory of letters
    Letter* letters = (Letter*) malloc(sizeof(Letter) * MAX_LETTER_INSTANCES);
    memset(&letters[0], 0, sizeof( Letter) * MAX_LETTER_INSTANCES);

    // the index of the next unused letter
    unsigned int letterMasterIndex=0;

    // pesudo letter representing the starting point of any word
    Letter* root = &letters[letterMasterIndex++];

    // the current letter in the word being processed
    Letter* currentLetter = root;

    // the next letter to be processed
    Letter* nextLetter = null;
    Letter* sortedWordsStart = null;
    Letter* sortedWordsEnd = null;
    Letter* A;
    Letter* B;
    Letter* C;
    Letter* D;

    unsigned int sortedListSize = 0;


    unsigned int lowestWordCount = 0;
    unsigned int lowestWordIndex = 0;
    unsigned int highestWordCount = 0;
    unsigned int highestWordIndex = 0;

    // main loop
    for (int j=0;j<dataLength;j++)
    {
        chr = data[j]|0x20; // convert to lower case

        // is a letter?
        if (chr > 96 && chr < 123)
        {
            chr-=97; // translate to be zero indexed
            nextLetter = currentLetter->nextLetters[chr];

            // this is a new letter at this word length, intialise the new letter
            if (nextLetter == null)
            {
                nextLetter = &letters[letterMasterIndex++];
                nextLetter->parent = currentLetter;
                nextLetter->asciiCode = chr;
                currentLetter->nextLetters[chr] = nextLetter;
            }

            currentLetter = nextLetter;
        }
        // not a letter so this means the current letter is the last letter of a word (if any letters)
        else if (currentLetter!=root)
        {

            // increment the count of the full word that this letter represents
            ++currentLetter->count;

            // is this word not in the top word list?
            if (!currentLetter->isTopWord)
            {
                // first word becomes the sorted list
                if (sortedWordsStart == null)
                {
                  sortedWordsStart = currentLetter;
                  sortedWordsEnd = currentLetter;
                  currentLetter->isTopWord = true;
                  ++sortedListSize;
                }
                // always add words until list is at desired size, or 
                // swap the current word with the end of the sorted word list if current word count is larger
                else if (sortedListSize < k || currentLetter->count> sortedWordsEnd->count)
                {
                    // replace sortedWordsEnd entry with current word
                    if (sortedListSize == k)
                    {
                      currentLetter->higher = sortedWordsEnd->higher;
                      currentLetter->higher->lower = currentLetter;
                      sortedWordsEnd->isTopWord = false;
                    }
                    // add current word to the sorted list as the sortedWordsEnd entry
                    else
                    {
                      ++sortedListSize;
                      sortedWordsEnd->lower = currentLetter;
                      currentLetter->higher = sortedWordsEnd;
                    }

                    currentLetter->lower = null;
                    sortedWordsEnd = currentLetter;
                    currentLetter->isTopWord = true;
                }
            }
            // word is in top list
            else
            {
                // check to see whether the current word count is greater than the supposedly next highest word in the list
                // we ignore the word that is sortedWordsStart (i.e. most frequent)
                while (currentLetter != sortedWordsStart && currentLetter->count> currentLetter->higher->count)
                {
                    B = currentLetter->higher;
                    C = currentLetter;
                    A = B != null ? currentLetter->higher->higher : null;
                    D = currentLetter->lower;

                    if (A !=null) A->lower = C;
                    if (D !=null) D->higher = B;
                    B->higher = C;
                    C->higher = A;
                    B->lower = D;
                    C->lower = B;

                    if (B == sortedWordsStart)
                    {
                      sortedWordsStart = C;
                    }

                    if (C == sortedWordsEnd)
                    {
                      sortedWordsEnd = B;
                    }
                }
            }

            // reset the letter path representing the word
            currentLetter = root;
        }
    }

    // print out the top frequent words and counts
    char string[256];
    char tmp[256];

    Letter* letter;
    while (sortedWordsStart != null )
    {
        letter = sortedWordsStart;
        highestWordCount = letter->count;
        string[0]=0;
        tmp[0]=0;

        if (highestWordCount > 0)
        {
            // construct string of letters to form the word
            while (letter != root)
            {
                memmove(&tmp[1],&string[0],255);
                tmp[0]=letter->asciiCode+97;
                memmove(&string[0],&tmp[0],255);
                letter=letter->parent;
            }

            printf("%u %s\n",highestWordCount,string);
        }
        sortedWordsStart = sortedWordsStart->lower;
    }

    free( data );
    free( letters );

#ifdef TIMING   
    gettimeofday(&tv2, null);
    printf("\nTime Taken: %f seconds\n", (double) (tv2.tv_usec - tv1.tv_usec)/1000000 + (double) (tv2.tv_sec - tv1.tv_sec));
#endif
    return 0;
}

Não retorna muito classificadas saída para k = 100000: 12 eroilk 111 iennoa 10 yttelen 110 engyt.
Andriy Makukha 12/07/19

Eu acho que tenho uma idéia do motivo. Meu pensamento é que precisarei repetir as palavras trocadas na lista ao verificar se a palavra mais alta é a próxima. Quando tenho tempo vou verificar
Moogie

hmm bem, parece que a correção simples de alterar um if para enquanto funciona, no entanto, também reduz significativamente o algoritmo para valores maiores de k. Talvez eu tenha que pensar em uma solução mais inteligente.
Moogie

1

C #

Este deve funcionar com os SDKs .net mais recentes .

using System;
using System.IO;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using static System.Console;

class Node {
    public Node Parent;
    public Node[] Nodes;
    public int Index;
    public int Count;

    public static readonly List<Node> AllNodes = new List<Node>();

    public Node(Node parent, int index) {
        this.Parent = parent;
        this.Index = index;
        AllNodes.Add(this);
    }

    public Node Traverse(uint u) {
        int b = (int)u;
        if (this.Nodes is null) {
            this.Nodes = new Node[26];
            return this.Nodes[b] = new Node(this, b);
        }
        if (this.Nodes[b] is null) return this.Nodes[b] = new Node(this, b);
        return this.Nodes[b];
    }

    public string GetWord() => this.Index >= 0 
        ? this.Parent.GetWord() + (char)(this.Index + 97)
        : "";
}

class Freq {
    const int DefaultBufferSize = 0x10000;

    public static void Main(string[] args) {
        var sw = Stopwatch.StartNew();

        if (args.Length < 2) {
            WriteLine("Usage: freq.exe {filename} {k} [{buffersize}]");
            return;
        }

        string file = args[0];
        int k = int.Parse(args[1]);
        int bufferSize = args.Length >= 3 ? int.Parse(args[2]) : DefaultBufferSize;

        Node root = new Node(null, -1) { Nodes = new Node[26] }, current = root;
        int b;
        uint u;

        using (var fr = new FileStream(file, FileMode.Open))
        using (var br = new BufferedStream(fr, bufferSize)) {
            outword:
                b = br.ReadByte() | 32;
                if ((u = (uint)(b - 97)) >= 26) {
                    if (b == -1) goto done; 
                    else goto outword;
                }
                else current = root.Traverse(u);
            inword:
                b = br.ReadByte() | 32;
                if ((u = (uint)(b - 97)) >= 26) {
                    if (b == -1) goto done;
                    ++current.Count;
                    goto outword;
                }
                else {
                    current = current.Traverse(u);
                    goto inword;
                }
            done:;
        }

        WriteLine(string.Join("\n", Node.AllNodes
            .OrderByDescending(count => count.Count)
            .Take(k)
            .Select(node => node.GetWord())));

        WriteLine("Self-measured milliseconds: {0}", sw.ElapsedMilliseconds);
    }
}

Aqui está um exemplo de saída.

C:\dev\freq>csc -o -nologo freq-trie.cs && freq-trie.exe giganovel 100000
e
ihit
ah
ist
 [... omitted for sanity ...]
omaah
aanhele
okaistai
akaanio
Self-measured milliseconds: 13619

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.


1
Muito impressionante! Gosto dos seus tolowertruques 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.
Andriy Makukha 13/07/19

@AndriyMakukha: Percebo que também estou contando prefixos de palavras que nunca ocorreram na contagem final. Evitei escrever toda a saída porque a saída do console é muito lenta no Windows. Posso gravar a saída em um arquivo?
recursivo

Apenas imprima a saída padrão, por favor. Para k = 10, deve ser rápido em qualquer máquina. Você também pode redirecionar a saída para um arquivo a partir de uma linha de comando. Assim .
Andriy Makukha

@AndriyMakukha: Acredito que já resolvi todos os problemas. Eu encontrei uma maneira de produzir toda a saída necessária sem muito custo de tempo de execução.
recursivo

Esta saída é incrivelmente rápida! Muito agradável. Modifiquei seu programa para também imprimir contagens de frequência, como outras soluções.
Andriy Makukha

1

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 :

Enumerable#tallyEstá adicionado. Conta a ocorrência de cada elemento.

["a", "b", "c", "b"].tally
#=> {"a"=>1, "b"=>2, "c"=>1}

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:

k = ARGV.shift.to_i

pp ARGF
  .each_line
  .lazy
  .flat_map { @1.scan(/[A-Za-z]+/).map(&:downcase) }
  .tally
  .max_by(k, &:last)

edit: adicionado kcomo argumento de linha de comando

Pode ser executado com o ruby k filename.rb input.txtuso 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:

$ time ruby bentley.rb 10 ulysses64 
[["the", 968832],
 ["of", 528960],
 ["and", 466432],
 ["a", 421184],
 ["to", 322624],
 ["in", 320512],
 ["he", 270528],
 ["his", 213120],
 ["i", 191808],
 ["s", 182144]]

real    0m17.884s
user    0m17.720s
sys 0m0.142s

1
Eu instalei o Ruby a partir de fontes. Ele roda aproximadamente tão rápido quanto em sua máquina (15 segundos vs 17).
Andriy Makukha 17/07/19
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.