Проблема кодирования в Bentley: 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 МБ).

  • Скачать Ulysses от Project Gutenberg:wget http://www.gutenberg.org/files/4300/4300-0.txt
  • Объединить это 64 раза: for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
  • Чаще всего встречается слово «968832».

Тест 2: специально сгенерированный случайный текст giganovel(около 1 ГБ).

  • Сценарий генератора Python 3 здесь .
  • Текст содержит 148391 различных слов, похожих на естественные языки.
  • Наиболее часто встречающиеся слова: «е» (11309 появлений) и «их» (11290 появлений).

Тест на общность: произвольно большие слова с произвольно большими пробелами.

Реализованные реализации

После изучения Rosetta Code для этой проблемы и понимания того, что многие реализации невероятно медленны (медленнее, чем сценарий оболочки!), Я протестировал несколько хороших реализаций здесь . Ниже приведена производительность, 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)

Вы можете победить это?

тестирование

Производительность будет оценена с использованием 13-дюймового MacBook Pro 2017 года со стандартным Unix time командой («пользовательское» время). Если возможно, используйте современные компиляторы (например, используйте последнюю версию 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
@ SriotchilismO'Zaic, пока что да. Но вы не должны полагаться на первый контрольный пример, потому что могут последовать большие контрольные примеры У ulysses64 есть очевидный недостаток: он повторяется: новые слова не появляются после 1/64 файла. Таким образом, это не очень хороший тестовый пример, но его легко распространять (или воспроизводить).
Андрей Макуха
3
Я имел в виду скрытые тестовые случаи, о которых вы говорили ранее. Если вы публикуете хеши сейчас, когда вы открываете фактические тексты, мы можем убедиться, что это справедливо по отношению к ответам, и вы не король. Хотя я полагаю, что хеш для Ulysses несколько полезен.
Пост Рок Гарф Хантер
1
@tsh Это мое понимание: например, будет два слова e и g
Муги
1
@AndriyMakukha Ах, спасибо. Это были просто ошибки; Я исправил их.
Андерс Касеорг

Ответы:

5

[С]

Следующий тест выполняется менее чем за 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
Moogie
источник
Впечатляет! Использование списка предположительно делает его O (Nk) в худшем случае, поэтому он работает медленнее, чем эталонная программа C ++ для гигановела с k = 100000. Но для k << N это явный победитель.
Андрей Макуха
1
@AndriyMakukha Спасибо! Я был немного удивлен, что такая простая реализация дала большую скорость. Я мог бы сделать это лучше для больших значений k, отсортировав список. (сортировка не должна быть слишком дорогой, так как порядок списка будет меняться медленно), но это добавляет сложности и, вероятно, повлияет на скорость при более низких значениях k.
Придется
да, я тоже был удивлен. Это может быть связано с тем, что эталонная программа использует много вызовов функций, а компилятор не может правильно ее оптимизировать.
Андрей Макуха
Другое преимущество в производительности, вероятно, связано с полустатическим распределением 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

Ржавчина

На моем компьютере он работает с гигановелом 100000 примерно на 42% быстрее (10,64 с против 18,24 с), чем C-решение Moogie «префикс-дерево + корзины». Также он не имеет предопределенных ограничений (в отличие от решения 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
Superb! Очень хорошая производительность по всем трем параметрам. Я был буквально посреди просмотра недавнего разговора Кэрол Николс о Rust :) Несколько необычный синтаксис, но я очень рад изучать язык: кажется, единственный язык из системных языков пост-C ++, который не жертвовать высокой производительностью, делая жизнь разработчика намного проще.
Андрей Макуха
Очень быстрый! Я впечатлен! Интересно, даст ли лучший вариант компилятора для C (дерево + bin) аналогичный результат?
Муги
@Moogie Я уже проверял твои -O3, и -Ofastне измерил разницу.
Андерс Касеорг
@ Moogie, я собирал твой код как gcc -O3 -march=native -mtune=native program.c.
Андрей Макуха
@ Андрей Макуха ах. Это объясняет большую разницу в скорости между результатами, которые вы получаете, по сравнению с моими результатами: вы уже применяли флаги оптимизации. Я не думаю, что осталось много больших оптимизаций кода. Я не могу проверить использование карты, как предлагали другие, так как у mingw нет реализации ... И это даст только 5% увеличение. Я думаю, что мне придется уступить удивительной записи Андерса. Отлично сработано!
Муги
3

APL (Dyalog Unicode)

Следующее выполняется менее чем за 8 секунд на моем 2.6 ГГц i7-4720HQ с использованием 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 (сортировка только уникальных слов). Если вы не отсортируете все слова, как в сценарии оболочки Макилроя, это не должно быть O (N log N).
Андрей Макуха
@AndriyMakukha Это оценивает все счета. Вот что написал мне наш специалист по производительности : временная сложность равна O (N log N), если вы не верите некоторым теоретически сомнительным вещам в хеш-таблицах, в данном случае это O (N).
Адам
Что ж, когда я запускаю ваш код против 8, 16 и 32 Ulysses, он замедляется точно линейно. Возможно, вашему партнеру по производительности нужно пересмотреть свои взгляды на временную сложность хеш-таблиц :) Кроме того, этот код не работает для больших тестовых случаев. Возвращает WS FULL, хотя я увеличил рабочее пространство до 6 ГБ.
Андрей Макуха
2

[C] Префикс Дерево + Бункеры

ПРИМЕЧАНИЕ. Используемый компилятор значительно влияет на скорость выполнения программы! Я использовал gcc (MinGW.org GCC-8.2.0-3) 8.2.0. При использовании ключа -Ofast программа работает почти на 50% быстрее, чем нормально скомпилированная программа.

Сложность алгоритма

С тех пор я осознал, что сортировка бинов, которую я выполняю, является формой сортировки Pigeonhost, это означает, что я могу получить сложность Big O этого решения.

Я рассчитываю это быть:

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 * M узлам, если данные состоят из одного слова с числом букв M и что каждый узел имеет 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;
}

РЕДАКТИРОВАТЬ: теперь отложить заполнение бинов до тех пор, пока дерево не будет построено и оптимизировать построение вывода.

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

Moogie
источник
Вот это да! 100 000 самых частых слов из файла размером 1 ГБ за 11 секунд ... Это похоже на какую-то волшебную хитрость.
Андрей Макуха
Нет трюков ... Просто тратить время процессора на эффективное использование памяти. Я удивлен вашим результатом ... На моем старшем компьютере это занимает более 60 секунд. Я заметил, что я делаю ненужные сравнения и могу отложить биннинг, пока файл не будет обработан. Это должно сделать это еще быстрее. Я скоро попробую и обновлю свой ответ.
Муги
@AndriyMakukha Теперь я отложил заполнение ящиков до тех пор, пока все слова не будут обработаны и дерево не будет построено. Это позволяет избежать ненужных сравнений и манипуляций с элементами bin. Я также изменил способ вывода, так как обнаружил, что печать занимает много времени!
Муги
На моей машине это обновление не имеет заметной разницы. Тем не менее, он работал очень быстро на ulysses64один раз, поэтому сейчас он является лидером.
Андрей Макуха
Это должно быть уникальная проблема с моим ПК :) Я заметил, что при использовании этого нового алгоритма вывода
ускорилось
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 Да, это ...происходит из-за усечения вывода в строке. Я добавил одну строку в начале, чтобы отключить все усечения. Это замедляет работу гигановела, поскольку он использует гораздо больше памяти, поскольку в нем больше уникальных слов.
миль
Большой! Теперь он проходит тест общности. И это не замедлило на моей машине. На самом деле было небольшое ускорение.
Андрей Макуха
2

C ++ (а-ля Кнут)

Мне было любопытно, как продвинется программа Кнута, поэтому я перевел его (первоначально Паскаль) программу на 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;
}

Отличия от программы Кнута:

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

Кроме того, это в значительной степени в точности программа Кнута (использующая его структуру хеш-данных / упакованных данных и сортировку блоков), и она выполняет почти те же операции (что и программа Кнута на языке Паскаль) во время циклического перебора всех символов на входе; обратите внимание, что он не использует внешний алгоритм или библиотеки структур данных, а также что слова одинаковой частоты будут печататься в алфавитном порядке.

тайминг

Составлено с

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

(Верхняя строка - это решение Anders Kaseorg по Rust; нижняя часть - вышеуказанная программа. Это временные интервалы из 100 запусков со средним, минимальным, максимальным, медианным и квартилями.)

Анализ

Почему это быстрее? Дело не в том, что C ++ быстрее, чем Rust, или в том, что программа Кнута является самой быстрой из возможных - на самом деле, программа Кнута медленнее при вставках (как он упоминает) из-за трип-упаковки (для сохранения памяти). Я подозреваю, что причина в том, что Кнут пожаловался в 2008 году :

Пламя о 64-битных указателях

Совершенно идиотично иметь 64-битные указатели, когда я компилирую программу, которая использует менее 4 гигабайт оперативной памяти. Когда такие значения указателя появляются внутри структуры, они не только тратят впустую половину памяти, они фактически отбрасывают половину кэша.

В вышеприведенной программе используются 32-битные индексы массивов (а не 64-битные указатели), поэтому структура «Узел» занимает меньше памяти, поэтому в стеке больше узлов и меньше пропусков кэша. (На самом деле была некоторая работа над этим, как с x32 ABI , но он, кажется, не в хорошем состоянии, хотя идея, очевидно, полезна, например, см. Недавнее объявление о сжатии указателей в V8 . Ну, хорошо.) Так далее giganovelэта программа использует 12,8 МБ для (упакованного) времени, по сравнению с 32,18 МБ программы Rust для его времени (вкл giganovel). Мы можем увеличить масштаб в 1000 раз (скажем, от «гигановела» до «терановела») и все же не превысить 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-Phen-a -tion от Com-put-er .

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

  3. Там целая книга Упражнения в стиле программирования , в которой показаны различные подходы к этой конкретной программе и т. Д.

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

ShreevatsaR
источник
Потрясающие! Не только кто-то наконец-то опубликовал решение Кнута (я собирался это сделать, но на Паскале) с отличным анализом и производительностью, который превосходит некоторые из лучших предыдущих публикаций, но также установил новый рекорд скорости с другой программой C ++! Замечательный.
Андрей Макуха
У меня есть только два комментария: 1) ваша вторая программа в настоящее время не подходит Segmentation fault: 11для тестовых случаев со сколь угодно большими словами и пробелами; 2) хотя мне может показаться, что я сочувствую «критике» Макилроя, я хорошо знаю, что намерение Кнута заключалось только в том, чтобы показать его грамотную технику программирования, в то время как Макилрой критиковал ее с инженерной точки зрения. Сам Макилрой позже признал, что это было нечестно.
Андрей Макуха
@AndriyMakukha Ой, это было рекурсивно word_for; исправил это сейчас. Да, Макилрой, как изобретатель труб Unix, воспользовался возможностью, чтобы евангелизировать философию Unix по созданию небольших инструментов. Это хорошая философия, по сравнению с разочаровывающим (если вы пытаетесь читать его программы) монолитическим подходом Кнута, но в контексте это было немного несправедливо, в том числе и по другой причине: сегодня способ Unix широко доступен, но в 1986 был ограничен в Bell Labs, Berkeley и др. («его фирма делает лучшие сборные в бизнесе»)
ShreevatsaR
Работает! Поздравляю нового короля :-P Что касается Unix и Кнута, то, похоже, ему не очень понравилась система, потому что между различными инструментами было и остается мало единства (например, многие инструменты по-разному определяют регулярные выражения).
Андрей Макуха
1

Python 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)
movatica
источник
1
Я мог тестировать только с гигановелем в моей системе, и это занимает довольно много времени (~ 90 секунд). Гутенбергпроект заблокирован в Германии по юридическим причинам ...
movatica
Интересный. Он либо heapqне добавляет производительности к Counter.most_commonметоду, либо enumerate(sorted(...))использует его heapqвнутренне.
Андрей Макуха
Я тестировал с Python 2, и производительность была похожа, так что, я думаю, сортировка работает так же быстро, как и Counter.most_common.
Андрей Макуха
Да, возможно, это был просто джиттер в моей системе ... По крайней мере, он не медленнее :) Но поиск регулярных выражений намного быстрее, чем итерации по символам. Вроде бы реализовано довольно производительно.
моватика
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;
}
Moogie
источник
Он возвращает не очень отсортирован выход для к = 100000: 12 eroilk 111 iennoa 10 yttelen 110 engyt.
Андрей Макуха
Я думаю, у меня есть идея относительно причины. Я думаю, что мне нужно будет перебирать слова подстановки в списке при проверке, является ли текущее слово следующим самым высоким словом. Когда у меня будет время, я проверю
Moogie
хм, хорошо, кажется, что простое исправление изменения if to while работает, однако это также значительно замедляет алгоритм для больших значений k. Возможно, мне придется подумать о более умном решении.
Муги
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-preview1 с 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)

edit: добавлено kв качестве аргумента командной строки

Это может быть запущено с ruby k filename.rb input.txt использованием версии 2.7.0-preview1 Ruby. Его можно скачать по различным ссылкам на странице заметок о выпуске или установить с помощью 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
daniero
источник
1
Я установил Ruby из источников. Он работает примерно так же быстро, как на вашей машине (15 секунд против 17).
Андрей Макуха