Виклик кодування Бентлі: k найчастіші слова


18

Це, мабуть, одна з класичних проблем кодування, яка отримала певний резонанс у 1986 році, коли оглядач Джон Бентлі попросив Дональда Кнута написати програму, яка знайде k найчастіших слів у файлі. Кнут реалізував швидке рішення, використовуючи хеш-спроби в 8-сторінковій програмі, щоб проілюструвати його грамотну техніку програмування. Дуглас Макілрой з Bell Labs розкритикував рішення Кнута, що навіть не в змозі опрацювати повний текст Біблії, і відповів однокласником, що це не так швидко, але виконує роботу:

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

У 1987 році була опублікована наступна стаття з ще одним рішенням, цього разу професором Прінстона. Але це не могло навіть повернути результат для єдиної Біблії!

Опис проблеми

Оригінальний опис проблеми:

З огляду на текстовий файл і ціле число k, ви повинні друкувати k найчастіших слів у файлі (та кількість їх виникнення) зі зменшенням частоти.

Додаткові роз'яснення проблеми:

  • Кнут визначив слова як рядки з латинських літер: [A-Za-z]+
  • всі інші символи ігноруються
  • великі і малі літери вважаються еквівалентними ( WoRd== word)
  • немає обмеження щодо розміру файлу та довжини слова
  • відстані між послідовними словами можуть бути довільно великими
  • найшвидша програма - це та, яка використовує найменший загальний час процесора (багатопотокове читання, ймовірно, не допоможе)

Зразки тестових кейсів

Тест 1: Уліс Джеймс Джойс об'єднав 64 рази (файл 96 Мб).

  • Завантажте Улісс з проекту Гутенберг:wget http://www.gutenberg.org/files/4300/4300-0.txt
  • З'єднайте його 64 рази: for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
  • Найчастіше це слово "the" з 968832 появами.

Тест 2: Спеціально генерований випадковий текст giganovel(близько 1 ГБ).

  • Тут скрипт генератора Python 3 .
  • Текст містить 148391 виразних слів, подібних до природних мов.
  • Найчастіші слова: «е» (11309 виступів) та «їх» (11290 виступів).

Тест на загальність: довільно великі слова з довільно великими прогалинами.

Довідкові реалізації

Подивившись в Rosetta кодекс цієї проблеми і зрозуміти , що багато реалізації неймовірно повільно (повільніше , ніж сценарій оболонки!), Я перевірив кілька реалізацій хорошою тут . Нижче представлено виконання ulysses64разом із часовою складністю:

                                     ulysses64      Time complexity
C++ (prefix trie + heap)             4.145          O((N + k) log k)
Python (Counter)                     10.547         O(N + k log Q)
AWK + sort                           20.606         O(N + Q log Q)
McIlroy (tr + sort + uniq)           43.554         O(N log N)

Ви можете це перемогти?

Тестування

Продуктивність буде оцінена за допомогою MacBook Pro 2017 13 "зі стандартною timeкомандою Unix (час" користувача "). Якщо можливо, будь ласка, використовуйте сучасні компілятори (наприклад, використовуйте останню версію Haskell, а не стару).

Поки рейтинги

Терміни, включаючи довідкові програми:

                                              k=10                  k=100K
                                     ulysses64      giganovel      giganovel
C++ (trie) by ShreevatsaR            0.671          4.227          4.276
C (trie + bins) by Moogie            0.704          9.568          9.459
C (trie + list) by Moogie            0.767          6.051          82.306
C++ (hash trie) by ShreevatsaR       0.788          5.283          5.390
C (trie + sorted list) by Moogie     0.804          7.076          x
Rust (trie) by Anders Kaseorg        0.842          6.932          7.503
J by miles                           1.273          22.365         22.637
C# (trie) by recursive               3.722          25.378         24.771
C++ (trie + heap)                    4.145          42.631         72.138
APL (Dyalog Unicode) by Adám         7.680          x              x
Python (dict) by movatica            9.387          99.118         100.859
Python (Counter)                     10.547         102.822        103.930
Ruby (tally) by daniero              15.139         171.095        171.551
AWK + sort                           20.606         213.366        222.782
McIlroy (tr + sort + uniq)           43.554         715.602        750.420

Сукупний рейтинг * (%, найкращий можливий бал - 300):

#     Program                         Score  Generality
 1  C++ (trie) by ShreevatsaR           300     Yes
 2  C++ (hash trie) by ShreevatsaR      368      x
 3  Rust (trie) by Anders Kaseorg       465     Yes
 4  C (trie + bins) by Moogie           552      x
 5  J by miles                         1248     Yes
 6  C# (trie) by recursive             1734      x
 7  C (trie + list) by Moogie          2182      x
 8  C++ (trie + heap)                  3313      x
 9  Python (dict) by movatica          6103     Yes
10  Python (Counter)                   6435     Yes
11  Ruby (tally) by daniero           10316     Yes
12  AWK + sort                        13329     Yes
13  McIlroy (tr + sort + uniq)        40970     Yes

* Сума результативності часу щодо найкращих програм у кожному з трьох тестів.

Найкраща програма поки що: тут (друге рішення)


Оцінка - це саме час на Улісса? Здається, мається на увазі, але прямо не сказано
Post Rock Garf Hunter

@ SriotchilismO'Zaic, поки що, так. Але ви не повинні покладатися на перший тестовий випадок, оскільки можливі більші тестові випадки. ulysses64 має очевидний недолік повторюваності: після 1/64 файлу не з’являються нові слова. Так, це не дуже хороший тестовий випадок, але його легко розподілити (або відтворити).
Андрій Макуха

3
Я мав на увазі приховані тестові випадки, про які ви говорили раніше. Якщо ви опублікуєте хеші зараз, коли ви розкриєте фактичні тексти, ми можемо переконатися, що відповіді справедливі, а ви не є королевицями. Хоча я вважаю, що хеш для Улісса є дещо корисним.
Опублікувати Rock Garf Hunter

1
@tsh Це моє розуміння: наприклад, було б два слова e і g
Moogie

1
@AndriyMakukha Ах, спасибі Це були просто помилки; Я їх виправляв.
Anders Kaseorg

Відповіді:


5

[C]

Далі працює протягом 1,6 секунди для тесту 1 на моєму 2,8 ГГц Xeon W3530. Створено за допомогою MinGW.org GCC-6.3.0-1 для Windows 7:

Він бере два аргументи як вхідні дані (шлях до текстового файлу та k кількість найчастіших слів до списку)

Він просто створює гілля дерева на літери слів, потім на листкових листах збільшує лічильник. Потім перевіряє, чи поточний лічильник аркушів більший за найменше найчастіше слово у списку найчастіших слів. (розмір списку - це число, яке визначається аргументом командного рядка) Якщо так, то рекламуйте слово, представлене листком, як одне з найпоширеніших. Це все повторюється, поки не прочитають більше літер. Після цього список найчастіших слів виводиться за допомогою неефективного ітеративного пошуку найчастішого слова зі списку найчастіших слів.

Наразі він за замовчуванням виводить час обробки, але для узгодження з іншими поданнями вимикає визначення TIMING у вихідному коді.

Також я подав це з робочого комп’ютера та не зміг завантажити текст тесту 2. Він повинен працювати з цим тестом 2 без змін, проте значення 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;
}

Для тесту 1, а також для топ-10 найчастіших слів і з увімкненою хронологією слід надрукувати:

 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

Вражає! Використання списку нібито робить його O (Nk) в гіршому випадку, тому він працює повільніше, ніж референтна програма C ++ для giganovel з k = 100,000. Але для k << N це явний переможець.
Андрій Макуха

1
@AndriyMakukha Дякую! Я трохи здивувався, що така проста реалізація дала велику швидкість. Я міг би покращити великі значення k, відвідавши список. (сортування не повинно бути надто дорогим, оскільки порядок списку змінюватиметься повільно), але це додає складності та, ймовірно, вплине на швидкість для нижчих значень k. Доведеться експериментувати
Moogie

так, я також був здивований. Це може бути тому, що довідкова програма використовує багато функціональних викликів, і компілятор не в змозі оптимізувати її належним чином.
Андрій Макуха

Інша перевага від продуктивності, ймовірно, походить від семістатичного розподілу lettersмасиву, в той час як опорна реалізація розподіляє вузли дерев динамічно.
Андрій Макуха

mmap-ву повинен бути швидше (~ 5% на моєму ноутбуці Linux): #include<sys/mman.h>, <sys/stat.h>, <fcntl.h>, замініть файл для читання з int d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);і закоментуйтеfree(data);
NGN

4

Іржа

На моєму комп’ютері цей запуск giganovel 100000 приблизно на 42% швидше (10,64 с порівняно з 18,24 с), ніж C-префікс C Moogie «дерево + бункери» C. Крім того, він не має заздалегідь визначених обмежень (на відміну від рішення C, яке визначає обмеження довжини слова, унікальних слів, повторних слів тощо).

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

Використання

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

1
Чудово! Дуже хороші показники в усіх трьох налаштуваннях. Я був буквально посеред перегляду нещодавньої розмови Керол Ніколс про Іржа :) Дещо незвичний синтаксис, але я захоплююсь вивчати мову: здається, це єдина мова з мов системи після C ++, яка не робить жертвувати великою продуктивністю, значно полегшуючи життя розробника.
Андрій Макуха

Дуже швидко! я перебуваю під враженням! Цікаво, чи краще варіант компілятора для C (дерево + бін) дасть подібний результат?
Moogie

@Moogie Я вже перевіряв ваші -O3, і -Ofastне змінює значущих змін.
Anders Kaseorg

@Moogie, я складав ваш код, як gcc -O3 -march=native -mtune=native program.c.
Андрій Макуха

@Андрій Макуха ах. Це пояснило б велику різницю у швидкості між результатами, які ви отримуєте від моїх результатів: ви вже застосовували прапори оптимізації. Я не думаю, що залишилось багато великих оптимізацій коду. Я не можу перевірити використання карти, як запропонували інші, оскільки mingw dies не має реалізації ... І дав би лише збільшення на 5%. Я думаю, що мені доведеться поступитися приголомшливим записом Андерса. Молодці!
Moogie

3

APL (Діалог Unicode)

На моєму 2,6 ГГц i7-4720HQ працює за 8 секунд, використовуючи 64-бітний Dyalog APL 17.0 для Windows 10:

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

Спочатку підкаже ім'я файлу, потім для k. Зауважте, що значна частина часу роботи (близько 1 секунди) просто читає файл у.

Щоб встигнути цього, ви повинні мати можливість передавати наступне у свій dyalogвиконуваний файл (для десяти найпоширеніших слів):

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

Він повинен надрукувати:

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

Дуже хороша! Він б’є Python. Це спрацювало найкраще після export MAXWS=4096M. Я думаю, він використовує хеш-таблиці? Оскільки зменшення розміру робочої області до 2 ГБ робить це повільніше протягом 2 секунд.
Андрій Макуха

@AndriyMakukha Так, використовується хеш-таблиця відповідно до цього , і я впевнений, що робить і внутрішнє.
Адам

Чому це O (N log N)? Мені більше схоже на рішення Python (k разів відновлення купи всіх унікальних слів) або рішення AWK (сортування лише унікальних слів). Якщо ви не сортуєте всі слова, як, наприклад, у сценарії оболонки McIlroy, це не повинно бути O (N log N).
Андрій Макуха

@AndriyMakukha Вона сорти все відліки. Ось що мені написав наш хлопець з виступу : Складність часу - це O (N log N), якщо ви не вірите в деякі теоретично сумнівні речі щодо хеш-таблиць, в цьому випадку це O (N).
Адам

Що ж, коли я запускаю ваш код проти 8, 16 і 32 Уліс, він сповільнюється рівно лінійно. Можливо, вашому хлопцеві виступу потрібно переглянути свої погляди на часові складності хеш-таблиць :) Також цей код не працює для більшого тестового випадку. Він повертається WS FULL, хоча я збільшив робочий простір до 6 ГБ.
Андрій Макуха

2

[C] Префікс дерево + Bins

ПРИМІТКА: Використовуваний компілятор суттєво впливає на швидкість виконання програми! Я використав gcc (MinGW.org GCC-8.2.0-3) 8.2.0. При використанніперемикача -Ofast програма працює майже на 50% швидше, ніж звичайно складена програма.

Складність алгоритму

З тих пір я зрозумів, що сортування Бін, який я виконую, є формою сортування Голуба, це означає, що я можу отримати велику складність цього рішення.

Я обчислюю, що це:

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

Складність побудови дерева еквівалентна обходу дерева, тому що на будь-якому рівні правильний вузол, до якого слід перейти, є O (1) (оскільки кожна літера відображається безпосередньо у вузол, і ми завжди переходимо лише один рівень дерева для кожної літери)

Сортування отворів Голуб є O (N + n), де n - діапазон ключових значень, однак для цієї проблеми нам не потрібно сортувати всі значення, лише k число, тому найгіршим випадком буде O (N + k).

Об’єднання разом дає вихід O (1 + N + k).

Складність простору для побудови дерева пояснюється тим, що найгірший випадок - це 26 * М вузлів, якщо дані складаються з одного слова з М числом букв і що кожен вузол має 26 вузлів (тобто для літер алфавіту). Таким чином, O (26 * M) = O (M)

Для сортування отворів Голуба є складність простору O (N + n)

Об'єднання разом дає вихід O (26 * M + N + n) = O (M + N + n)

Алгоритм

Він бере два аргументи як вхідні дані (шлях до текстового файлу та k кількість найчастіших слів до списку)

Виходячи з інших моїх записів, у цієї версії є дуже невелика рампова вартість часу із збільшенням значень k порівняно з іншими моїми рішеннями. Але помітно повільніше для низьких значень k, однак це має бути набагато швидше для більших значень k.

Він створює гілля дерева на літери слів, потім на листкових листах збільшує лічильник. Потім додає слово до кошика слів однакового розміру (після першого вилучення слова з біна воно вже мешкало). Це все повторюється до тих пір, поки не буде прочитано більше літер. Після цього бункери зворотно повторюються k разів, починаючи з найбільшого сміття, і виводяться слова кожного біна.

Наразі він за замовчуванням виводить час обробки, але для узгодження з іншими поданнями вимикає визначення TIMING у вихідному коді.

// 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: тепер відстрочення заповнення бункерів до моменту побудови дерева та оптимізація побудови результатів.

EDIT2: тепер використовуйте арифметику вказівника замість доступу до масиву для оптимізації швидкості.


Оце Так! 100 000 найчастіших слів з 1 ГБ файлу за 11 секунд ... Це схоже на якусь магічну хитрість.
Андрій Макуха

Ніяких хитрощів ... Просто торгуйте процесорним часом для ефективного використання пам'яті. Я здивований вашому результату ... На моєму старшому ПК це займає понад 60 секунд. Я помітив, що я роблю непотрібні порівняння і можу відкласти бінінг, поки файл не буде оброблений. Це повинно зробити це ще швидше. Я спробую це скоро і оновлю свою відповідь.
Moogie

@AndriyMakukha Я відклав заселення контейнерів до тих пір, поки всі слова не будуть оброблені і не побудовано дерево. Це дозволяє уникнути зайвих порівнянь та маніпулювання елементами бін. Я також змінив спосіб побудови випуску, коли я виявив, що друк займає значну кількість часу!
Moogie

На моїй машині це оновлення не робить помітних змін. Однак, вона справді виступила дуже швидко ulysses64, тому зараз вона є лідером.
Андрій Макуха

Це повинно бути унікальною проблемою з моїм ПК тоді :) Я помітив 5-секундну швидкість при використанні цього нового алгоритму виводу
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

Запустити як сценарій із jconsole <script> <input> <k>. Наприклад, вихід giganovelз 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

Обмежень, крім кількості доступної системної пам'яті, немає.


Дуже швидко для меншої тестової справи! Приємно! Однак для довільно великих слів він урізує слова у висновку. Я не впевнений, чи існує обмеження кількості символів у слові чи це просто зробити висновок більш стислим.
Андрій Макуха

@AndriyMakukha Так, це ...відбувається через усічення виходу на рядок. Я додав один рядок на початку, щоб відключити усі усічення. Він сповільнюється на giganovel, оскільки він використовує набагато більше пам’яті, оскільки є більше унікальних слів.
милі

Чудово! Тепер він проходить тест на загальність. І це не сповільнилось на моїй машині. Насправді було незначне прискорення.
Андрій Макуха

2

C ++ (a la Knuth)

Мені було цікаво, як пройде програма Кнут, тому я переклав його (спочатку Паскаль) програму на C ++.

Незважаючи на те, що основною метою Кнута була не швидкість, а проілюструвати його WEB-систему грамотного програмування, програма напрочуд є конкурентоспроможною і призводить до швидшого вирішення, ніж будь-який з відповідей тут. Ось мій переклад його програми (відповідні номери розділу "WEB-програми" згадуються в коментарях типу " {§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;
}

Відмінності від програми Knuth:

  • Я об'єднав 4 масиви Кнута link, sibling, countі chв масив struct Node(знайти його легше зрозуміти цей шлях).
  • Я змінив текст-програмування (у WEB-стилі) текстуального включення розділів на більш звичайні виклики функцій (і пару макросів).
  • Нам не потрібно використовувати стандартні конвенції / обмеження щодо вводу / виводу Паскаля, тому використовуючи, як freadі data[i] | 32 - 'a'в інших відповідях тут, замість методу Pascal.
  • Якщо ми перевищуємо обмеження (не вистачає місця) під час роботи програми, оригінальна програма Knuth вирішує це витончено, видаляючи пізніші слова та друкуючи повідомлення в кінці. (Не зовсім правильно сказати, що Макілрой "критикував рішення Кнута як навіть не в змозі опрацювати повний текст Біблії"; він лише вказував, що іноді в тексті можуть зустрічатися часті слова, наприклад слово "Ісус "в Біблії, тому умова помилки не є нешкідливим.) Я прийняв шумніший (і в будь-якому випадку легший) підхід просто припинити програму.
  • Програма оголошує постійний TRIE_SIZE для управління використанням пам'яті, яку я наткнувся. (Константа 32767 була обрана для оригінальних вимог - "користувач повинен мати можливість знайти 100 найчастіших слів у технічному документі на двадцяти сторінках (приблизно 50-байтний байт)" і тому, що Pascal добре справляється з величиною цілого числа типи та пакети їх оптимально. Нам довелося збільшити його в 25 разів до 800 000, оскільки тестовий вклад зараз у 20 мільйонів разів більший.)
  • Для остаточного друку рядків ми можемо просто пройтися по трійці і зробити тупу (можливо навіть квадратичну) рядок.

Крім цього, це майже точно програма Knuth (використовуючи його хеш-трійку / упаковану структуру даних трие та сортування відра), і робить майже ті ж операції (як програма Pascal Kn Knut), перебираючи всі символи на вході; зауважте, що він не використовує зовнішніх бібліотек алгоритму чи структури даних, а також, що слова однакової частоти друкуються в алфавітному порядку.

Хронометраж

Укладено з

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

Коли я запускаю найбільший тестовий зразок тут ( giganovelзі 100 000 запитуваних слів) і порівнюючи з найшвидшою програмою, розміщеною тут, я вважаю це трохи, але стабільно швидше:

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

(Верхній рядок - це рішення іржі Андерса Касеорга; нижнє - вищевказана програма. Це таймінги від 100 пробігів із середнім значенням, хв, макс, медіаною та четвертилами.)

Аналіз

Чому це швидше? Справа не в тому, що C ++ швидше, ніж Руст, або що програма Knuth є найшвидшою - насправді програма Knuth повільніше вставляє (як він згадує) через пакування трійки (для збереження пам'яті). Я підозрюю, що причина пов’язана з чимось скаргою на Кнут у 2008 році :

Полум'я про 64-бітні покажчики

Абсолютно ідіотським є наявність 64-бітних покажчиків, коли я складаю програму, яка використовує менше 4 гігабайт оперативної пам’яті. Коли такі значення вказівника з’являються всередині структури, вони не лише витрачають половину пам’яті, вони ефективно викидають половину кешу.

Програма, що описана вище, використовує 32-бітні індекси масиву (не 64-бітні вказівники), тому структура "Node" займає менше пам'яті, тому на стеку є більше вузлів і менше пропусків кешу. (Насправді, була якась робота над цим як ABI x32 , але, здається, він не в хорошому стані хоча ідея, очевидно, корисна, наприклад, див. Нещодавнє оголошення про стиснення покажчика у V8 . Ну добре.) Так далі giganovel, ця програма використовує 12,8 Мб для (упакованого) тріє, порівняно з 32,18 МБ програми Руста для його трие (включеного giganovel). Ми могли б збільшити масштаб 1000x (від "giganovel" до "teranovel" скажімо) і все ж не перевищувати 32-бітні індекси, тому це видається розумним вибором.

Швидший варіант

Ми можемо оптимізувати швидкість та відмовитись від упаковки, тому ми можемо фактично використовувати трійник (не упакований), як у рішенні Rust, із індексами замість покажчиків. Це дає щось швидше і не має заздалегідь встановлених обмежень на кількість чітких слів, символів тощо:

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

Ця програма, незважаючи на те, що для сортування робиться щось набагато тупіше, ніж рішення, тут використовується (для giganovel ) лише 12,2 Мб для свого тріє, і вдається бути швидшим. Терміни дії цієї програми (останній рядок) порівняно з попередніми часовими таймінгами:

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

Я б з нетерпінням побачив, що хотіла б ця (або хеш-три-програма), якби перекласти її на Rust . :-)

Подальші деталі

  1. Про структуру даних, що використовується тут: пояснення "упаковки" спроб подано чітко у вправі 4 розділу 6.3 (Цифровий пошук, тобто спроби) в томі 3 TAOCP, а також у тезі студента Кнута Френка Лянга про перенесення переносу в TeX : Слово Hy-fen-a-ція від Com-put-er .

  2. Контекст колонок Бентлі, програми Кнут та рецензії Макілроя (лише незначна частина яких стосувалася філософії Unix) чіткіше з огляду на попередні та пізніші колонки , а також попереднього досвіду Knuth, включаючи компілятори, TAOCP та TeX.

  3. Існує ціла книга вправ у стилі програмування , де показані різні підходи до цієї конкретної програми тощо.

У мене незавершена публікація в блозі, яка детально розглядає моменти вище; може змінити цю відповідь, коли це буде зроблено. Тим часом, опублікувавши цю відповідь у будь-якому випадку, з нагоди (10 січня) дня народження Кнут. :-)


Дивовижно! Не тільки хтось нарешті опублікував рішення Knuth (я мав намір це зробити, але в Паскалі) з чудовим аналізом та продуктивністю, яка перемагає деякі найкращі попередні публікації, але і встановив новий рекорд швидкості за допомогою іншої програми C ++! Чудовий.
Андрій Макуха

Єдині два коментарі, які я маю: 1) ваша друга програма наразі не спрацьовує Segmentation fault: 11для тестових випадків із довільно великими словами та пробілами; 2) хоч може здатися, що я співчуваю "критиці" Макілроя, я добре знаю, що намір Кнута полягав лише у тому, щоб показати його грамотну техніку програмування, а Макілрой критикував це з інженерної перспективи. Сам Макілрой пізніше зізнався, що це було не чесно.
Андрій Макуха

@AndriyMakukha О, ой, це було рекурсивно word_for; виправити це зараз. Так, Макілрой, як винахідник труб Unix, скористався можливістю євангелізувати філософію Unix щодо створення невеликих інструментів. Це гарна філософія, порівняно з розчаруванням Кнута (якщо ви намагаєтеся читати його програми) монолітний підхід, але в контексті це було трохи несправедливо, ще й з іншої причини: сьогодні шлях Unix широко доступний, але в 1986 році був обмежений до Bell Labs, Berkeley тощо ("його фірма робить найкращі збірки в бізнесі")
ShreevatsaR

Працює! Поздоровлення новому королю: - Що стосується Unix і Knuth, він, схоже, не дуже подобається системі, тому що між різними інструментами була і є невелика єдність (наприклад, багато інструментів визначають регулярні вирази по різному).
Андрій Макуха

1

Пітон 3

Ця реалізація за допомогою простого словника трохи швидша, ніж та, яка використовує Counterмою систему.

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
Я міг протестувати лише з giganovel на своїй системі, і це займає досить тривалий час (~ 90 сек). gutenbergproject заблокований у Німеччині з юридичних причин ...
movatica

Цікаво. Він або heapqне додає ефективності Counter.most_commonметоду, а enumerate(sorted(...))також використовує heapqвнутрішньо.
Андрій Макуха

Я тестував з Python 2, і продуктивність була схожа, тому, я думаю, сортування працює так само швидко Counter.most_common.
Андрій Макуха

Так, можливо, це було просто тремтіння в моїй системі ... Принаймні, це не повільніше :) Але пошук регулярного вибору набагато швидше, ніж ітерація над символами. Здається, реалізовано досить ефективно.
movatica

1

[C] Префікс дерево + відсортований список пов'язаних

Він бере два аргументи як вхідні дані (шлях до текстового файлу та k кількість найчастіших слів до списку)

Виходячи з мого іншого запису, ця версія набагато швидша для більших значень k, але при незначних витратах при менших значеннях k.

Він створює гілля дерева на літери слів, потім на листкових листах збільшує лічильник. Потім перевіряє, чи поточний лічильник аркушів більший за найменше найчастіше слово у списку найчастіших слів. (розмір списку - це число, яке визначається аргументом командного рядка) Якщо так, то рекламуйте слово, представлене листком, як одне з найпоширеніших. Якщо це вже найчастіше слово, то поміняйте місцями на наступне найчастіше, якщо кількість слів зараз вище, таким чином зберігаючи список відсортованим. Це все повторюється, доки не буде прочитано більше літер. Після цього виводиться список найчастіших слів.

Наразі він за замовчуванням виводить час обробки, але для узгодження з іншими поданнями вимикає визначення TIMING у вихідному коді.

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

Він повертає не дуже відсортований вихід для k = 100 000: 12 eroilk 111 iennoa 10 yttelen 110 engyt .
Андрій Макуха

Я думаю, що у мене є ідея щодо причини. Думаю, що мені потрібно буде переглядати підмінені слова у списку під час перевірки, чи є наступне найвище слово поточного слова. Коли встигну, перевірю
встигну

хм добре, здається, що просте виправлення зміни if на час працює, однак це також значно уповільнює алгоритм для більших значень k. Можливо, мені доведеться придумати більш розумне рішення.
Moogie

1

C #

Цей повинен працювати з останніми .net SDK .

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

Ось зразок виводу.

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

Спочатку я намагався використовувати словник зі строковими клавішами, але це було занадто повільно. Я думаю, це тому, що рядки .net внутрішньо представлені 2-байтовим кодуванням, що є марнотратним для цього додатка. Тоді я просто перейшов на чисті байти та потворну державну машину. Перетворення випадків є побітним оператором. Перевірка діапазону символів проводиться в одному порівнянні після віднімання. Я не витрачав жодних зусиль на оптимізацію остаточного сортування, оскільки знайшов, що він використовує менше 0,1% часу виконання.

Виправлення: Алгоритм був по суті правильним, але він переоцінював загальні слова, підраховуючи всі префікси слів. Оскільки загальна кількість слів не є вимогою проблеми, я видалив цей результат. Для виведення всіх k слів я також скоригував вихід. Я врешті-решт вирішив використати, string.Join()а потім написати цілий список одразу. Дивно, але це на секунду швидше на моїй машині, яка пише кожне слово окремо за 100 к.


1
Дуже вражає! Мені подобаються ваші побіжні tolowerта одинарні фокуси порівняння. Однак я не розумію, чому ваша програма повідомляє більш чіткі слова, ніж очікувалося. Крім того, згідно з оригінальним описом проблеми, програмі потрібно виводити всі k слова у порядку зменшення частоти, тому я не рахував вашу програму до останнього тесту, який повинен вивести 100 000 найчастіших слів.
Андрій Макуха

@AndriyMakukha: Я бачу, що я також рахую префікси слів, які ніколи не виникали в остаточному підрахунку. Я уникав писати весь вихід, оскільки консольний вихід у Windows досить повільний. Чи можу я записати вихід у файл?
рекурсивна

Будь ласка, надрукуйте його стандартний вихід. Для k = 10 він повинен бути швидким на будь-якій машині. Ви також можете перенаправити вихід у файл із командного рядка. Як це .
Андрій Макуха

@AndriyMakukha: Я вважаю, що я вирішив усі проблеми. Я знайшов спосіб отримати весь необхідний вихід без особливих витрат на виконання.
рекурсивна

Цей вихід швидко палає! Дуже хороша. Я змінив вашу програму для друку частотних підрахунків, як це роблять інші рішення.
Андрій Макуха

1

Ruby 2.7.0-попередній перегляд1 с tally

В останній версії Ruby є новий метод, який називається tally. Від приміток випуску :

Enumerable#tallyдодається. Він рахує виникнення кожного елемента.

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

Це майже вирішує всю задачу для нас. Нам просто потрібно прочитати файл спочатку і знайти пізніше.

Ось вся справа:

k = ARGV.shift.to_i

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

редагувати: Додано k як аргумент командного рядка

Це можна запустити ruby k filename.rb input.txt допомогою версії Ruby 2.7.0-preview1. Це можна завантажити з різних посилань на сторінці приміток до випуску або встановити за допомогою rbenv rbenv install 2.7.0-dev.

Приклад запуску на моєму старий комп'ютер:

$ 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
Я встановив Ruby з джерел. Він працює приблизно так само швидко, як і на вашій машині (15 секунд проти 17).
Андрій Макуха
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.