La sfida del codice di Bentley: k parole più frequenti


18

Questa è forse una delle sfide classiche di codifica che ha avuto una certa risonanza nel 1986, quando il editorialista Jon Bentley chiese a Donald Knuth di scrivere un programma che avrebbe trovato k parole più frequenti in un file. Knuth ha implementato una soluzione rapida usando i tentativi di hash in un programma di 8 pagine per illustrare la sua tecnica di programmazione alfabetica. Douglas McIlroy di Bell Labs ha criticato la soluzione di Knuth in quanto non è nemmeno in grado di elaborare un testo completo della Bibbia, e ha risposto con una sola riga, che non è così veloce, ma ottiene il lavoro svolto:

tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q

Nel 1987 fu pubblicato un articolo di follow-up con l'ennesima soluzione, questa volta da un professore di Princeton. Ma non poteva nemmeno restituire il risultato per una singola Bibbia!

Descrizione del problema

Descrizione del problema originale:

Dato un file di testo e un intero k, è necessario stampare le k parole più comuni nel file (e il numero delle loro occorrenze) in frequenza decrescente.

Ulteriori chiarimenti sui problemi:

  • Knuth ha definito una parola come una stringa di lettere latine: [A-Za-z]+
  • tutti gli altri personaggi vengono ignorati
  • le lettere maiuscole e minuscole sono considerate equivalenti ( WoRd== word)
  • nessun limite alla dimensione del file né alla lunghezza della parola
  • le distanze tra parole consecutive possono essere arbitrariamente grandi
  • il programma più veloce è quello che utilizza il minor tempo totale della CPU (il multithreading probabilmente non aiuterà)

Esempi di casi di test

Test 1: Ulisse di James Joyce concatenato 64 volte (file da 96 MB).

  • Scarica Ulisse dal Progetto Gutenberg:wget http://www.gutenberg.org/files/4300/4300-0.txt
  • Concatenalo 64 volte: for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
  • La parola più frequente è "the" con 968832 presenze.

Test 2: testo casuale appositamente generato giganovel(circa 1 GB).

  • Script del generatore Python 3 qui .
  • Il testo contiene 148391 parole distinte che appaiono in modo simile alle lingue naturali.
  • Parole più frequenti: “e” (11309 apparizioni) e “ihit” (11290 apparizioni).

Test di generalità: parole arbitrariamente grandi con spazi arbitrariamente grandi.

Implementazioni di riferimento

Dopo aver esaminato il codice Rosetta per questo problema e aver realizzato che molte implementazioni sono incredibilmente lente (più lente dello script della shell!), Ho testato alcune buone implementazioni qui . Di seguito è riportato il rendimento ulysses64insieme alla complessità temporale:

                                     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)

Riesci a batterlo?

analisi

Le prestazioni saranno valutate utilizzando il MacBook Pro 13 "del 2017 con il timecomando Unix standard (tempo" utente "). Se possibile, utilizzare compilatori moderni (ad esempio, utilizzare l'ultima versione di Haskell, non quella precedente).

Classifiche finora

Tempi, compresi i programmi di riferimento:

                                              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

Classifica cumulativa * (%, miglior punteggio possibile - 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

* Somma delle prestazioni temporali relative ai migliori programmi in ciascuna delle tre prove.

Il miglior programma finora: qui (seconda soluzione)


Il punteggio è proprio il tempo su Ulisse? Sembra implicito ma non è esplicitamente detto
Post Rock Garf Hunter

@ SriotchilismO'Zaic, per ora sì. Ma non dovresti fare affidamento sul primo caso di test perché potrebbero seguire casi di test più grandi. ulysses64 ha l'ovvio svantaggio di essere ripetitivo: dopo 1/64 del file non compaiono nuove parole. Quindi, non è un ottimo test, ma è facile da distribuire (o riprodurre).
Andriy Makukha,

3
Intendevo i casi di test nascosti di cui parlavi prima. Se pubblichi gli hash ora quando riveli i testi reali, possiamo assicurarci che sia giusto per le risposte e che tu non stia realizzando il re. Anche se suppongo che l'hash per Ulisse sia in qualche modo utile.
Post Rock Garf Hunter,

1
@tsh Questa è la mia comprensione: ad es. sarebbero due parole eeg
Moogie,

1
@AndriyMakukha Ah, grazie. Erano solo bug; Li ho riparati.
Anders Kaseorg,

Risposte:


5

[C]

Quanto segue dura meno di 1,6 secondi per il Test 1 sul mio 2.8 Ghz Xeon W3530. Costruito utilizzando MinGW.org GCC-6.3.0-1 su Windows 7:

Sono necessari due argomenti come input (percorso del file di testo e per k il numero delle parole più frequenti da elencare)

Crea semplicemente un albero che si ramifica su lettere di parole, quindi alle lettere foglia incrementa un contatore. Quindi controlla se il contatore delle foglie corrente è maggiore della parola più piccola più frequente nell'elenco delle parole più frequenti. (la dimensione dell'elenco è il numero determinato tramite l'argomento della riga di comando) In tal caso, promuovere la parola rappresentata dalla lettera a foglia come una delle più frequenti. Tutto ciò si ripete fino a quando non si leggono più lettere. Dopo di che l'elenco delle parole più frequenti viene emesso tramite una ricerca iterativa inefficace per la parola più frequente dall'elenco delle parole più frequenti.

Al momento, per impostazione predefinita è impostato il tempo di elaborazione, ma per motivi di coerenza con altri invii, disabilitare la definizione TIMING nel codice sorgente.

Inoltre, l'ho inviato da un computer di lavoro e non sono stato in grado di scaricare il testo Test 2. Dovrebbe funzionare con questo Test 2 senza modifiche, tuttavia potrebbe essere necessario aumentare il valore MAX_LETTER_INSTANCES.

// 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;
}

Per Test 1, e per le prime 10 parole frequenti e con tempismo abilitato dovrebbe stampare:

 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

Degno di nota! L'uso dell'elenco presumibilmente lo rende O (Nk) nel peggiore dei casi, quindi funziona più lentamente del programma C ++ di riferimento per giganovel con k = 100.000. Ma per k << N è un chiaro vincitore.
Andriy Makukha,

1
@AndriyMakukha Grazie! Sono rimasto un po 'sorpreso dal fatto che un'implementazione così semplice abbia prodotto una grande velocità. Potrei renderlo migliore per valori più grandi di k facendo ordinare l'elenco. (l'ordinamento non dovrebbe essere troppo costoso in quanto l'ordine delle liste cambierebbe lentamente) ma ciò aumenta la complessità e influirebbe probabilmente sulla velocità per valori inferiori di k. Dovrà sperimentare
Moogie il

sì, sono stato sorpreso anch'io. Potrebbe essere perché il programma di riferimento utilizza molte chiamate di funzione e il compilatore non riesce a ottimizzarlo correttamente.
Andriy Makukha,

Un altro vantaggio in termini di prestazioni deriva probabilmente dall'allocazione semistatica lettersdell'array, mentre l'implementazione di riferimento alloca dinamicamente i nodi dell'albero.
Andriy Makukha,

mmap-ing dovrebbe essere più veloce (~ 5% sul mio portatile Linux): #include<sys/mman.h>, <sys/stat.h>, <fcntl.h>, sostituire il file lettura con 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 commentare outfree(data);
NGN

4

Ruggine

Sul mio computer, questo esegue giganovel 100000 circa il 42% più veloce (10,64 s contro 18,24 s) rispetto alla soluzione C "prefisso albero + bidoni" di Moogie. Inoltre non ha limiti predefiniti (a differenza della soluzione C che predefinisce i limiti di lunghezza delle parole, parole uniche, parole ripetute, ecc.).

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
Stupendo! Ottime prestazioni in tutte e tre le impostazioni. Ero letteralmente nel bel mezzo di un recente discorso di Carol Nichols su Rust :) Sintassi un po 'insolita, ma sono entusiasta di imparare la lingua: sembra essere l'unica lingua fuori dai linguaggi di sistema post-C ++ che non sacrificare molte prestazioni rendendo la vita degli sviluppatori molto più semplice.
Andriy Makukha,

Molto veloce! sono impressionato! Mi chiedo se la migliore opzione del compilatore per C (albero + cestino) darà un risultato simile?
Moogie,

@Moogie con cui stavo già testando il tuo -O3e -Ofastnon fa alcuna differenza misurabile.
Anders Kaseorg,

@Moogie, stavo compilando il tuo codice come gcc -O3 -march=native -mtune=native program.c.
Andriy Makukha,

@Andriy Makukha ah. Ciò spiegherebbe la grande differenza di velocità tra i risultati che stai ottenendo rispetto ai miei risultati: stavi già applicando flag di ottimizzazione. Non credo che ci siano molte grandi ottimizzazioni di codice rimaste. Non posso testare usando la mappa come suggerito da altri poiché mingw muore non ha un'implementazione ... E darebbe solo un aumento del 5%. Penso che dovrò arrendermi alla fantastica voce di Anders. Molto bene!
Moogie,

3

APL (Dyalog Unicode)

Quanto segue viene eseguito in meno di 8 secondi sul mio i7-4720HQ da 2,6 Ghz utilizzando Dyalog APL 17.0 a 64 bit su Windows 10:

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

Richiede prima il nome del file, quindi k. Si noti che una parte significativa del tempo di esecuzione (circa 1 secondo) sta solo leggendo il file.

Per cronometrare, dovresti essere in grado di convogliare quanto segue nel tuo dyalogeseguibile (per le dieci parole più frequenti):

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

Dovrebbe stampare:

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

Molto bella! Batte Python. Ha funzionato meglio dopo export MAXWS=4096M. Immagino, usa le tabelle hash? Perché ridurre la dimensione dell'area di lavoro a 2 GB rende più lento di 2 secondi interi.
Andriy Makukha,

@AndriyMakukha Sì, usa una tabella hash secondo questo , e sono abbastanza sicuro che lo faccia anche internamente.
Adám,

Perché è O (N log N)? Sembra più una soluzione Python (k volte ripristinando un mucchio di tutte le parole uniche) o AWK (ordinando solo parole uniche). A meno che non si ordinino tutte le parole, come nello script della shell di McIlroy, non dovrebbe essere O (N log N).
Andriy Makukha,

@AndriyMakukha Esso gradi tutti i conteggi. Ecco cosa mi ha scritto il nostro ragazzo delle prestazioni : La complessità temporale è O (N log N), a meno che tu non creda ad alcune cose teoricamente dubbie sulle tabelle hash, nel qual caso è O (N).
Adám,

Bene, quando eseguo il tuo codice contro 8, 16 e 32 Ulisse, questo rallenta esattamente in modo lineare. Forse il tuo ragazzo delle prestazioni ha bisogno di riconsiderare le sue opinioni sulla complessità temporale delle tabelle hash :) Inoltre, questo codice non funziona per il caso di test più grande. Ritorna WS FULL, anche se ho aumentato lo spazio di lavoro a 6 GB.
Andriy Makukha,

2

[C] Prefisso Tree + Bins

NOTA: il compilatore utilizzato ha un effetto significativo sulla velocità di esecuzione del programma! Ho usato gcc (MinGW.org GCC-8.2.0-3) 8.2.0. Quando si utilizza l' opzione -Ofast , il programma viene eseguito quasi il 50% più velocemente rispetto al programma normalmente compilato.

Complessità dell'algoritmo

Da allora ho capito che l'ordinamento di Bin che sto eseguendo è una forma di ordinamento Pigeonhost, questo significa che posso deragliare la complessità di Big O di questa soluzione.

Lo calcolo per essere:

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

La complessità della costruzione dell'albero è equivalente alla traversata dell'albero, quindi poiché a qualsiasi livello il nodo corretto da attraversare è O (1) (poiché ogni lettera è mappata direttamente su un nodo e attraversiamo sempre solo un livello dell'albero per ogni lettera)

L'ordinamento di Pigeon Hole è O (N + n) dove n è l'intervallo di valori chiave, tuttavia per questo problema non è necessario ordinare tutti i valori, solo il numero k, quindi il caso peggiore sarebbe O (N + k).

Combinando insieme si ottiene O (1 + N + k).

La complessità spaziale per la costruzione dell'albero è dovuta al fatto che il caso peggiore è 26 * nodi M se i dati sono costituiti da una parola con il numero M di lettere e che ogni nodo ha 26 nodi (cioè per le lettere dell'alfabeto). Quindi O (26 * M) = O (M)

Per l'ordinamento Pigeon Hole ha complessità spaziale di O (N + n)

Combinando insieme si ottiene O (26 * M + N + n) = O (M + N + n)

Algoritmo

Sono necessari due argomenti come input (percorso del file di testo e per k il numero delle parole più frequenti da elencare)

Sulla base delle mie altre voci, questa versione ha una rampa di costo di tempo molto piccola con valori crescenti di k rispetto alle altre mie soluzioni. Ma è notevolmente più lento per valori bassi di k, tuttavia dovrebbe essere molto più veloce per valori più grandi di k.

Crea un albero che si ramifica su lettere di parole, quindi alle lettere foglia incrementa un contatore. Quindi aggiunge la parola a un cestino di parole della stessa dimensione (dopo aver prima rimosso la parola dal cestino in cui risiedeva già). Tutto ciò si ripete fino a quando non si leggono più lettere. Dopodiché i bin vengono ripetuti ripetutamente k volte a partire dal contenitore più grande e vengono emesse le parole di ciascun contenitore.

Al momento, per impostazione predefinita è impostato il tempo di elaborazione, ma per motivi di coerenza con altri invii, disabilitare la definizione TIMING nel codice sorgente.

// 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: ora rinviare i bin di popolamento fino a quando non viene costruito l'albero e ottimizzare la costruzione dell'output.

EDIT2: ora utilizza l'aritmetica del puntatore anziché l'accesso all'array per l'ottimizzazione della velocità.


Wow! 100.000 parole più frequenti da un file da 1 GB in 11 secondi ... Sembra una sorta di trucco magico.
Andriy Makukha,

Nessun trucco ... Basta scambiare il tempo della CPU per un uso efficiente della memoria. Sono sorpreso del tuo risultato ... Sul mio vecchio PC ci vogliono più di 60 secondi. Ho notato che sto facendo confronti non necessari e posso rinviare il binning fino a quando il file non è stato elaborato. Dovrebbe renderlo ancora più veloce. Lo proverò presto e aggiornerò la mia risposta.
Moogie,

@AndriyMakukha Ho rinviato popolando i bin fino a quando tutte le parole sono state elaborate e l'albero è stato costruito. Questo evita confronti inutili e manipolazione degli elementi bin. Ho anche cambiato il modo in cui l'output è costruito mentre ho scoperto che la stampa impiegava molto tempo!
Moogie,

Sulla mia macchina questo aggiornamento non fa alcuna differenza evidente. Tuttavia, ha funzionato molto velocemente ulysses64una volta, quindi ora è un leader attuale.
Andriy Makukha,

Devo essere un problema unico con il mio PC quindi :) Ho notato una velocità di 5 secondi quando si utilizza questo nuovo algoritmo di output
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

Esegui come script con jconsole <script> <input> <k>. Ad esempio, l'output da giganovelcon 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

Non ci sono limiti se non per la quantità di memoria di sistema disponibile.


Molto veloce per il test case più piccolo! Bello! Tuttavia, per parole arbitrariamente grandi, tronca le parole nell'output. Non sono sicuro se ci sia un limite al numero di caratteri in una parola o se sia solo per rendere l'output più conciso.
Andriy Makukha,

@AndriyMakukha Sì, si ...verifica a causa del troncamento dell'output per riga. Ho aggiunto una riga all'inizio per disabilitare tutti i troncamenti. Rallenta il giganovel poiché usa molta più memoria poiché ci sono parole più uniche.
miglia

Grande! Ora supera il test di generalità. E non ha rallentato la mia macchina. In effetti, c'è stato un piccolo aumento di velocità.
Andriy Makukha,

2

C ++ (a la Knuth)

Ero curioso di sapere come sarebbe andato il programma di Knuth, quindi ho tradotto il suo programma (originariamente Pascal) in C ++.

Anche se l'obiettivo principale di Knuth non era la velocità, ma quello di illustrare il suo sistema WEB di programmazione alfabetica, il programma è sorprendentemente competitivo e porta a una soluzione più rapida di qualsiasi risposta finora finora. Ecco la mia traduzione del suo programma (i corrispondenti numeri di "sezione" del programma WEB sono citati in commenti come " {§24}"):

#include <iostream>
#include <cassert>

// Adjust these parameters based on input size.
const int TRIE_SIZE = 800 * 1000; // Size of the hash table used for the trie.
const int ALPHA = 494441;  // An integer that's approximately (0.61803 * TRIE_SIZE), and relatively prime to T = TRIE_SIZE - 52.
const int kTolerance = TRIE_SIZE / 100;  // How many places to try, to find a new place for a "family" (=bunch of children).

typedef int32_t Pointer;  // [0..TRIE_SIZE), an index into the array of Nodes
typedef int8_t Char;  // We only care about 1..26 (plus two values), but there's no "int5_t".
typedef int32_t Count;  // The number of times a word has been encountered.
// These are 4 separate arrays in Knuth's implementation.
struct Node {
  Pointer link;  // From a parent node to its children's "header", or from a header back to parent.
  Pointer sibling;  // Previous sibling, cyclically. (From smallest child to header, and header to largest child.)
  Count count;  // The number of times this word has been encountered.
  Char ch;  // EMPTY, or 1..26, or HEADER. (For nodes with ch=EMPTY, the link/sibling/count fields mean nothing.)
} node[TRIE_SIZE + 1];
// Special values for `ch`: EMPTY (free, can insert child there) and HEADER (start of family).
const Char EMPTY = 0, HEADER = 27;

const Pointer T = TRIE_SIZE - 52;
Pointer x;  // The `n`th time we need a node, we'll start trying at x_n = (alpha * n) mod T. This holds current `x_n`.
// A header can only be in T (=TRIE_SIZE-52) positions namely [27..TRIE_SIZE-26].
// This transforms a "h" from range [0..T) to the above range namely [27..T+27).
Pointer rerange(Pointer n) {
  n = (n % T) + 27;
  // assert(27 <= n && n <= TRIE_SIZE - 26);
  return n;
}

// Convert trie node to string, by walking up the trie.
std::string word_for(Pointer p) {
  std::string word;
  while (p != 0) {
    Char c = node[p].ch;  // assert(1 <= c && c <= 26);
    word = static_cast<char>('a' - 1 + c) + word;
    // assert(node[p - c].ch == HEADER);
    p = (p - c) ? node[p - c].link : 0;
  }
  return word;
}

// Increment `x`, and declare `h` (the first position to try) and `last_h` (the last position to try). {§24}
#define PREPARE_X_H_LAST_H x = (x + ALPHA) % T; Pointer h = rerange(x); Pointer last_h = rerange(x + kTolerance);
// Increment `h`, being careful to account for `last_h` and wraparound. {§25}
#define INCR_H { if (h == last_h) { std::cerr << "Hit tolerance limit unfortunately" << std::endl; exit(1); } h = (h == TRIE_SIZE - 26) ? 27 : h + 1; }

// `p` has no children. Create `p`s family of children, with only child `c`. {§27}
Pointer create_child(Pointer p, int8_t c) {
  // Find `h` such that there's room for both header and child c.
  PREPARE_X_H_LAST_H;
  while (!(node[h].ch == EMPTY and node[h + c].ch == EMPTY)) INCR_H;
  // Now create the family, with header at h and child at h + c.
  node[h]     = {.link = p, .sibling = h + c, .count = 0, .ch = HEADER};
  node[h + c] = {.link = 0, .sibling = h,     .count = 0, .ch = c};
  node[p].link = h;
  return h + c;
}

// Move `p`'s family of children to a place where child `c` will also fit. {§29}
void move_family_for(const Pointer p, Char c) {
  // Part 1: Find such a place: need room for `c` and also all existing children. {§31}
  PREPARE_X_H_LAST_H;
  while (true) {
    INCR_H;
    if (node[h + c].ch != EMPTY) continue;
    Pointer r = node[p].link;
    int delta = h - r;  // We'd like to move each child by `delta`
    while (node[r + delta].ch == EMPTY and node[r].sibling != node[p].link) {
      r = node[r].sibling;
    }
    if (node[r + delta].ch == EMPTY) break;  // There's now space for everyone.
  }

  // Part 2: Now actually move the whole family to start at the new `h`.
  Pointer r = node[p].link;
  int delta = h - r;
  do {
    Pointer sibling = node[r].sibling;
    // Move node from current position (r) to new position (r + delta), and free up old position (r).
    node[r + delta] = {.ch = node[r].ch, .count = node[r].count, .link = node[r].link, .sibling = node[r].sibling + delta};
    if (node[r].link != 0) node[node[r].link].link = r + delta;
    node[r].ch = EMPTY;
    r = sibling;
  } while (node[r].ch != EMPTY);
}

// Advance `p` to its `c`th child. If necessary, add the child, or even move `p`'s family. {§21}
Pointer find_child(Pointer p, Char c) {
  // assert(1 <= c && c <= 26);
  if (p == 0) return c;  // Special case for first char.
  if (node[p].link == 0) return create_child(p, c);  // If `p` currently has *no* children.
  Pointer q = node[p].link + c;
  if (node[q].ch == c) return q;  // Easiest case: `p` already has a `c`th child.
  // Make sure we have room to insert a `c`th child for `p`, by moving its family if necessary.
  if (node[q].ch != EMPTY) {
    move_family_for(p, c);
    q = node[p].link + c;
  }
  // Insert child `c` into `p`'s family of children (at `q`), with correct siblings. {§28}
  Pointer h = node[p].link;
  while (node[h].sibling > q) h = node[h].sibling;
  node[q] = {.ch = c, .count = 0, .link = 0, .sibling = node[h].sibling};
  node[h].sibling = q;
  return q;
}

// Largest descendant. {§18}
Pointer last_suffix(Pointer p) {
  while (node[p].link != 0) p = node[node[p].link].sibling;
  return p;
}

// The largest count beyond which we'll put all words in the same (last) bucket.
// We do an insertion sort (potentially slow) in last bucket, so increase this if the program takes a long time to walk trie.
const int MAX_BUCKET = 10000;
Pointer sorted[MAX_BUCKET + 1];  // The head of each list.

// Records the count `n` of `p`, by inserting `p` in the list that starts at `sorted[n]`.
// Overwrites the value of node[p].sibling (uses the field to mean its successor in the `sorted` list).
void record_count(Pointer p) {
  // assert(node[p].ch != HEADER);
  // assert(node[p].ch != EMPTY);
  Count f = node[p].count;
  if (f == 0) return;
  if (f < MAX_BUCKET) {
    // Insert at head of list.
    node[p].sibling = sorted[f];
    sorted[f] = p;
  } else {
    Pointer r = sorted[MAX_BUCKET];
    if (node[p].count >= node[r].count) {
      // Insert at head of list
      node[p].sibling = r;
      sorted[MAX_BUCKET] = p;
    } else {
      // Find right place by count. This step can be SLOW if there are too many words with count >= MAX_BUCKET
      while (node[p].count < node[node[r].sibling].count) r = node[r].sibling;
      node[p].sibling = node[r].sibling;
      node[r].sibling = p;
    }
  }
}

// Walk the trie, going over all words in reverse-alphabetical order. {§37}
// Calls "record_count" for each word found.
void walk_trie() {
  // assert(node[0].ch == HEADER);
  Pointer p = node[0].sibling;
  while (p != 0) {
    Pointer q = node[p].sibling;  // Saving this, as `record_count(p)` will overwrite it.
    record_count(p);
    // Move down to last descendant of `q` if any, else up to parent of `q`.
    p = (node[q].ch == HEADER) ? node[q].link : last_suffix(q);
  }
}

int main(int, char** argv) {
  // Program startup
  std::ios::sync_with_stdio(false);

  // Set initial values {§19}
  for (Char i = 1; i <= 26; ++i) node[i] = {.ch = i, .count = 0, .link = 0, .sibling = i - 1};
  node[0] = {.ch = HEADER, .count = 0, .link = 0, .sibling = 26};

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

  // Loop over file contents: the bulk of the time is spent here.
  Pointer p = 0;
  for (int i = 0; i < dataLength; ++i) {
    Char c = (data[i] | 32) - 'a' + 1;  // 1 to 26, for 'a' to 'z' or 'A' to 'Z'
    if (1 <= c && c <= 26) {
      p = find_child(p, c);
    } else {
      ++node[p].count;
      p = 0;
    }
  }
  node[0].count = 0;

  walk_trie();

  const int max_words_to_print = atoi(argv[2]);
  int num_printed = 0;
  for (Count f = MAX_BUCKET; f >= 0 && num_printed <= max_words_to_print; --f) {
    for (Pointer p = sorted[f]; p != 0 && num_printed < max_words_to_print; p = node[p].sibling) {
      std::cout << word_for(p) << " " << node[p].count << std::endl;
      ++num_printed;
    }
  }

  return 0;
}

Differenze dal programma di Knuth:

  • Ho combinato di Knuth 4 array link, sibling, countech in una matrice di unastruct Node (trovo più facile da capire in questo modo).
  • Ho modificato la inclusione testuale di sezioni di programmazione alfabetica (stile WEB) in chiamate di funzioni più convenzionali (e un paio di macro).
  • Non abbiamo bisogno di usare le strane convenzioni / restrizioni I / O di Pascal standard, quindi usando freadedata[i] | 32 - 'a' come nelle altre risposte qui, invece della soluzione alternativa di Pascal.
  • Nel caso in cui superiamo i limiti (a corto di spazio) mentre il programma è in esecuzione, il programma originale di Knuth lo affronta con grazia facendo cadere le parole successive e stampando un messaggio alla fine. (Non è del tutto corretto affermare che McIlroy "ha criticato la soluzione di Knuth in quanto non è nemmeno in grado di elaborare un testo completo della Bibbia"; stava solo sottolineando che a volte parole frequenti possono capitare molto tardi in un testo, come la parola "Gesù "nella Bibbia, quindi la condizione di errore non è innocua.) Ho adottato l'approccio più rumoroso (e comunque più semplice) di semplicemente terminare il programma.
  • Il programma dichiara un TRIE_SIZE costante per controllare l'utilizzo della memoria, che ho verificato. (La costante di 32767 era stata scelta per i requisiti originali - "un utente dovrebbe essere in grado di trovare le 100 parole più frequenti in un documento tecnico di venti pagine (all'incirca un file di 50 K byte)" e perché Pascal si occupa bene dell'intero con intervallo li digita e li impacchetta in modo ottimale. Abbiamo dovuto aumentarlo da 25x a 800.000 poiché l'input del test è ora 20 milioni di volte più grande.)
  • Per la stampa finale delle stringhe, possiamo semplicemente camminare sul trie e fare un'appendice di stringa stupida (forse anche quadratica).

A parte questo, questo è praticamente esattamente il programma di Knuth (usando il suo hash trie / impacchettato struttura di dati trie e il bucket bucket), e fa praticamente le stesse operazioni (come farebbe il programma Pascal di Knuth) mentre scorre tutti i caratteri nell'input; si noti che non utilizza algoritmi esterni o librerie di strutture dati e che le parole di uguale frequenza verranno stampate in ordine alfabetico.

sincronizzazione

Compilato con

clang++ -std=c++17 -O2 ptrie-walktrie.cc 

Quando corro sul più grande testcase qui ( giganovelcon 100.000 parole richieste) e confrontato con il programma più veloce pubblicato qui finora, lo trovo leggermente ma costantemente più veloce:

target/release/frequent:   4.809 ±   0.263 [ 4.45.. 5.62]        [... 4.63 ...  4.75 ...  4.88...]
ptrie-walktrie:            4.547 ±   0.164 [ 4.35.. 4.99]        [... 4.42 ...   4.5 ...  4.68...]

(La linea superiore è la soluzione Rust di Anders Kaseorg; la parte inferiore è il programma sopra. Questi sono i tempi di 100 corse, con media, min, max, mediana e quartili.)

Analisi

Perché è più veloce? Non è che C ++ sia più veloce di Rust o che il programma di Knuth sia il più veloce possibile - in effetti, il programma di Knuth è più lento sugli inserimenti (come menziona) a causa del trie-packing (per conservare la memoria). Il motivo, sospetto, è legato a qualcosa di cui Knuth si è lamentato nel 2008 :

Una fiamma di puntatori a 64 bit

È assolutamente idiota avere puntatori a 64 bit quando compilo un programma che utilizza meno di 4 gigabyte di RAM. Quando tali valori di puntatore compaiono all'interno di una struttura, non solo sprecano metà della memoria, ma eliminano effettivamente metà della cache.

Il programma sopra utilizza indici di array a 32 bit (non puntatori a 64 bit), quindi la struttura "Nodo" occupa meno memoria, quindi ci sono più nodi nello stack e meno errori nella cache. (In effetti, c'è stato un po 'di lavoro su questo come l' ABI x32 , ma sembra non essere in buono stato anche se l'idea è ovviamente utile, ad esempio vedere il recente annuncio di compressione del puntatore in V8 . Oh bene.) Quindi giganovel, questo programma utilizza 12,8 MB per il trie (compresso), rispetto ai 32,18 MB del programma Rust per il suo trie (sugiganovel ). Potremmo aumentare di 1000 volte (da "giganovel" a "teranovel") e non superare comunque gli indici a 32 bit, quindi questa sembra una scelta ragionevole.

Variante più veloce

Siamo in grado di ottimizzare la velocità e rinunciare all'imballaggio, in modo da poter effettivamente utilizzare il trie (non imballato) come nella soluzione Rust, con indici anziché puntatori. Questo dà qualcosa che è più veloce e non ha limiti prestabiliti sul numero di parole distinte, caratteri ecc:

#include <iostream>
#include <cassert>
#include <vector>
#include <algorithm>

typedef int32_t Pointer;  // [0..node.size()), an index into the array of Nodes
typedef int32_t Count;
typedef int8_t Char;  // We'll usually just have 1 to 26.
struct Node {
  Pointer link;  // From a parent node to its children's "header", or from a header back to parent.
  Count count;  // The number of times this word has been encountered. Undefined for header nodes.
};
std::vector<Node> node; // Our "arena" for Node allocation.

std::string word_for(Pointer p) {
  std::vector<char> drow;  // The word backwards
  while (p != 0) {
    Char c = p % 27;
    drow.push_back('a' - 1 + c);
    p = (p - c) ? node[p - c].link : 0;
  }
  return std::string(drow.rbegin(), drow.rend());
}

// `p` has no children. Create `p`s family of children, with only child `c`.
Pointer create_child(Pointer p, Char c) {
  Pointer h = node.size();
  node.resize(node.size() + 27);
  node[h] = {.link = p, .count = -1};
  node[p].link = h;
  return h + c;
}

// Advance `p` to its `c`th child. If necessary, add the child.
Pointer find_child(Pointer p, Char c) {
  assert(1 <= c && c <= 26);
  if (p == 0) return c;  // Special case for first char.
  if (node[p].link == 0) return create_child(p, c);  // Case 1: `p` currently has *no* children.
  return node[p].link + c;  // Case 2 (easiest case): Already have the child c.
}

int main(int, char** argv) {
  auto start_c = std::clock();

  // Program startup
  std::ios::sync_with_stdio(false);

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

  node.reserve(dataLength / 600);  // Heuristic based on test data. OK to be wrong.
  node.push_back({0, 0});
  for (Char i = 1; i <= 26; ++i) node.push_back({0, 0});

  // Loop over file contents: the bulk of the time is spent here.
  Pointer p = 0;
  for (long i = 0; i < dataLength; ++i) {
    Char c = (data[i] | 32) - 'a' + 1;  // 1 to 26, for 'a' to 'z' or 'A' to 'Z'
    if (1 <= c && c <= 26) {
      p = find_child(p, c);
    } else {
      ++node[p].count;
      p = 0;
    }
  }
  ++node[p].count;
  node[0].count = 0;

  // Brute-force: Accumulate all words and their counts, then sort by frequency and print.
  std::vector<std::pair<int, std::string>> counts_words;
  for (Pointer i = 1; i < static_cast<Pointer>(node.size()); ++i) {
    int count = node[i].count;
    if (count == 0 || i % 27 == 0) continue;
    counts_words.push_back({count, word_for(i)});
  }
  auto cmp = [](auto x, auto y) {
    if (x.first != y.first) return x.first > y.first;
    return x.second < y.second;
  };
  std::sort(counts_words.begin(), counts_words.end(), cmp);
  const int max_words_to_print = std::min<int>(counts_words.size(), atoi(argv[2]));
  for (int i = 0; i < max_words_to_print; ++i) {
    auto [count, word] = counts_words[i];
    std::cout << word << " " << count << std::endl;
  }

  return 0;
}

Questo programma, nonostante faccia qualcosa di molto più stupido per l'ordinamento rispetto alle soluzioni qui, utilizza (per giganovel) solo 12,2 MB per il suo trie e riesce ad essere più veloce. Tempi di questo programma (ultima riga), rispetto ai tempi precedenti menzionati:

target/release/frequent:   4.809 ±   0.263 [ 4.45.. 5.62]        [... 4.63 ...  4.75 ...  4.88...]
ptrie-walktrie:            4.547 ±   0.164 [ 4.35.. 4.99]        [... 4.42 ...   4.5 ...  4.68...]
itrie-nolimit:             3.907 ±   0.127 [ 3.69.. 4.23]        [... 3.81 ...   3.9 ...   4.0...]

Sarei ansioso di vedere cosa vorrebbe questo (o il programma hash-trie) se tradotto in Rust . :-)

Maggiori dettagli

  1. Informazioni sulla struttura dei dati qui utilizzata: una spiegazione dei tentativi di "impacchettare" è fornita in modo teso nell'esercizio 4 della sezione 6.3 (Ricerca digitale, ovvero tentativi) nel volume 3 del TAOCP, e anche nella tesi dello studente di Knuth Frank Liang sulla sillabazione in TeX : Word Hy-phen-a -tion di Com-put-er .

  2. Il contesto delle colonne di Bentley, il programma di Knuth e la recensione di McIlroy (solo una piccola parte della quale riguardava la filosofia di Unix) è più chiaro alla luce delle colonne precedenti e successive , e della precedente esperienza di Knuth con compilatori, TAOCP e TeX.

  3. C'è un intero libro Esercizi in stile di programmazione , che mostra diversi approcci a questo particolare programma, ecc.

Ho un post sul blog incompiuto che elabora i punti sopra; potrebbe modificare questa risposta al termine. Nel frattempo, pubblicando questa risposta qui, comunque, in occasione (10 gennaio) del compleanno di Knuth. :-)


Eccezionale! Non solo qualcuno ha finalmente pubblicato la soluzione di Knuth (avevo intenzione di farlo, ma in Pascal) con una grande analisi e prestazioni che battono alcuni dei migliori post precedenti, ma ha anche stabilito un nuovo record di velocità con un altro programma C ++! Meraviglioso.
Andriy Makukha, il

Gli unici due commenti che ho: 1) il tuo secondo programma attualmente fallisce Segmentation fault: 11per casi di test con parole e lacune arbitrariamente grandi; 2) anche se potrebbe sembrare che io simpatizzi per la "critica" di McIlroy, sono ben consapevole che l'intenzione di Knuth era solo quella di mostrare la sua tecnica di programmazione letteraria, mentre McIlroy la criticava dal punto di vista ingegneristico. Lo stesso McIlroy in seguito ha ammesso che non era una cosa giusta da fare.
Andriy Makukha,

@AndriyMakukha Oh oops, quello era il ricorsivo word_for; risolto ora. Sì, McIlroy, in quanto inventore delle pipe Unix, ha colto l'occasione per evangelizzare la filosofia Unix di comporre piccoli utensili. È una buona filosofia, rispetto all'approccio monolitico frustrante di Knuth (se stai cercando di leggere i suoi programmi), ma nel contesto era un po 'ingiusto, anche per un'altra ragione: oggi la via Unix è ampiamente disponibile, ma nel 1986 era confinata a Bell Labs, Berkeley, ecc. ("la sua azienda produce i migliori prefabbricati del settore")
ShreevatsaR,

Lavori! Complimenti al nuovo re :-P Per quanto riguarda Unix e Knuth, non gli piaceva molto il sistema, perché c'era ed è poca unità tra strumenti diversi (ad esempio molti strumenti definiscono le regex in modo diverso).
Andriy Makukha

1

Python 3

Questa implementazione con un semplice dizionario è leggermente più veloce di quella che utilizza Counter uno sul mio 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
Ho potuto testare solo con giganovel sul mio sistema e ci vuole molto tempo (~ 90sec). Il progetto gutenberg è bloccato in Germania per motivi legali ...
movatica,

Interessante. O heapqnon aggiunge alcuna prestazione al Counter.most_commonmetodo o enumerate(sorted(...))utilizza anche heapqinternamente.
Andriy Makukha,

Ho provato con Python 2 e le prestazioni sono state simili, quindi, immagino, l'ordinamento funziona alla stessa velocità Counter.most_common.
Andriy Makukha,

Sì, forse era solo un jitter sul mio sistema ... Almeno non è più lento :) Ma la ricerca di regex è molto più veloce di iterare sui personaggi. Sembra essere implementato abbastanza performante.
movatica,

1

[C] Prefisso albero + elenco collegato ordinato

Sono necessari due argomenti come input (percorso del file di testo e per k il numero delle parole più frequenti da elencare)

Sulla base dell'altra mia voce, questa versione è molto più veloce per valori più grandi di k ma con un costo minore delle prestazioni a valori inferiori di k.

Crea un albero che si ramifica su lettere di parole, quindi alle lettere foglia incrementa un contatore. Quindi controlla se il contatore delle foglie corrente è maggiore della parola più piccola più frequente nell'elenco delle parole più frequenti. (la dimensione dell'elenco è il numero determinato tramite l'argomento della riga di comando) In tal caso, promuovere la parola rappresentata dalla lettera a foglia come una delle più frequenti. Se è già una parola più frequente, scambia con la successiva più frequente se il conteggio delle parole è ora più alto, mantenendo così ordinato l'elenco. Tutto ciò si ripete fino a quando non si leggono più lettere. Dopo di che viene visualizzato l'elenco delle parole più frequenti.

Al momento, per impostazione predefinita è impostato il tempo di elaborazione, ma per motivi di coerenza con altri invii, disabilitare la definizione TIMING nel codice sorgente.

// 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;
}

Non restituisce molto di uscita per k = 100.000 allineati: 12 eroilk 111 iennoa 10 yttelen 110 engyt.
Andriy Makukha,

Penso di avere un'idea del motivo. Il mio pensiero è che dovrò iterare le parole di swap nell'elenco per verificare se la parola più alta successiva della parola corrente. Quando avrò tempo controllerò
Moogie il

hmm beh sembra che la semplice correzione di cambiare un if to while funzioni, tuttavia rallenta significativamente l'algoritmo per valori più grandi di k. Potrei dover pensare a una soluzione più intelligente.
Moogie,

1

C #

Questo dovrebbe funzionare con gli ultimi SDK .net .

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);
    }
}

Ecco un esempio di output.

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

All'inizio, ho provato a usare un dizionario con le chiavi di stringa, ma era troppo lento. Penso che sia perché le stringhe .net sono rappresentate internamente con una codifica a 2 byte, il che è un po 'dispendioso per questa applicazione. Quindi sono passato a byte puri e a una brutta macchina a stati in stile goto. La conversione dei casi è un operatore bit a bit. Il controllo dell'intervallo di caratteri viene eseguito in un unico confronto dopo la sottrazione. Non ho fatto alcuno sforzo per ottimizzare l'ordinamento finale poiché ho scoperto che utilizza meno dello 0,1% del tempo di esecuzione.

Correzione: l'algoritmo era sostanzialmente corretto, ma riportava in eccesso le parole totali, contando tutti i prefissi di parole. Poiché il conteggio totale delle parole non è un requisito del problema, ho rimosso quell'output. Per poter emettere tutte le k parole, ho anche regolato l'output. Alla fine ho deciso di usare string.Join()e poi scrivere l'intero elenco in una sola volta. Sorprendentemente questo è circa un secondo più veloce sulla mia macchina che scrivere ogni parola separatamente per 100k.


1
Molto impressionante! Mi piacciono i tuoi tolowertrucchi di confronto bit a bit e singoli. Tuttavia, non capisco perché il tuo programma riporti parole più distinte del previsto. Inoltre, secondo la descrizione del problema originale, il programma deve emettere tutte le k parole in ordine decrescente di frequenza, quindi non ho conteggiato il tuo programma verso l'ultimo test, che deve produrre 100.000 parole più frequenti.
Andriy Makukha,

@AndriyMakukha: Vedo che sto contando anche i prefissi di parole che non si sono mai verificati nel conteggio finale. Ho evitato di scrivere tutto l'output perché l'output della console è piuttosto lento in Windows. Posso scrivere l'output su un file?
ricorsivo il

Basta stamparlo come standard output, per favore. Per k = 10, dovrebbe essere veloce su qualsiasi macchina. Puoi anche reindirizzare l'output in un file da una riga di comando. Ti piace questa .
Andriy Makukha,

@AndriyMakukha: credo di aver affrontato tutti i problemi. Ho trovato un modo per produrre tutto l'output richiesto senza molti costi di runtime.
ricorsivo

Questa uscita è velocissima! Molto bella. Ho modificato il tuo programma per stampare anche i conteggi delle frequenze, come fanno altre soluzioni.
Andriy Makukha,

1

Ruby 2.7.0-preview1 con tally

L'ultima versione di Ruby ha un nuovo metodo chiamato tally. Dalle note di rilascio :

Enumerable#tallyè aggiunto. Conta il verificarsi di ciascun elemento.

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

Questo quasi risolve l'intero compito per noi. Dobbiamo solo leggere prima il file e trovare il massimo in seguito.

Ecco il tutto:

k = ARGV.shift.to_i

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

modifica: aggiunto kcome argomento della riga di comando

Può essere eseguito ruby k filename.rb input.txtutilizzando la versione 2.7.0-preview1 di Ruby. Questo può essere scaricato da vari collegamenti nella pagina delle note di rilascio o installato con rbenv usandorbenv install 2.7.0-dev .

Esempio eseguito sul mio vecchio computer malconcio:

$ 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
Ho installato Ruby da fonti. Funziona all'incirca alla velocità della tua macchina (15 secondi contro 17).
Andriy Makukha,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.