Bentleyのコーディングの課題:k最も頻繁な単語


17

これはおそらく、コラムニストのJon BentleyがDonald Knuthにファイル内でk個の最も頻繁な単語を見つけるプログラムを書くように依頼した1986年に共鳴した古典的なコーディングの課題の1つです。Knuthは、8ページの長さのプログラムでハッシュトライを使用して、リテラシーのプログラミングテクニックを説明する高速ソリューション実装しました。Bell LabsのDouglas McIlroyは、Knuthのソリューションが聖書の全文を処理することさえできないと批判し、ワンライナーで答えました。

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

1987年には、プリンストン教授による別のソリューションを含むフォローアップ記事が公開されました。しかし、単一の聖書の結果を返すことさえできませんでした!

問題の説明

元の問題の説明:

テキストファイルと整数kが与えられた場合、ファイル内のk個の最も一般的な単語(およびその出現回数)を頻度を減らして出力します。

追加の問題の説明:

  • Knuthは単語をラテン文字の文字列として定義しました: [A-Za-z]+
  • 他のすべての文字は無視されます
  • 大文字と小文字は同等と見なされます(WoRd== word
  • ファイルサイズにも語長にも制限はありません
  • 連続する単語間の距離は任意に大きくすることができます
  • 最速のプログラムは、合計CPU時間を最小限に抑えるプログラムです(おそらくマルチスレッド化は役に立たないでしょう)

サンプルテストケース

テスト1: James JoyceによるUlyssesは 64回連結しました(96 MBファイル)。

  • Project GutenbergからUlyssesをダウンロードします。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 GB)。

  • Python 3ジェネレータースクリプトはこちら
  • テキストには、自然言語と同様に表示される148391個の異なる単語が含まれています。
  • 最も頻繁に使用される単語:「e」(11309回出現)および「ihit」(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)

あなたはそれを打つことができますか?

テスト中

パフォーマンスは、2017 13 "MacBook Proを標準のUnix timeコマンド(「ユーザー」時間)で評価します。可能であれば、最新のコンパイラーを使用してください(たとえば、レガシーではなく最新のHaskellバージョンを使用してください)。

これまでのランキング

参照プログラムを含むタイミング:

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

累積ランキング*(%、最高得点– 300):

#     Program                         Score  Generality
 1  Rust (trie) by Anders Kaseorg       334     Yes
 2  C (trie + bins) by Moogie           384      x
 3  J by miles                          852     Yes
 4  C# (trie) by recursive             1278      x
 5  C (trie + list) by Moogie          1306      x
 6  C++ (trie + heap)                  2255      x
 7  Python (dict) by movatica          4316     Yes
 8  Python (Counter)                   4583     Yes
 9  Ruby (tally) by daniero            7264     Yes
10  AWK + sort                         9422     Yes
11  McIlroy (tr + sort + uniq)        28014     Yes

* 3つのテストのそれぞれにおける最高のプログラムに対する相対的な時間パフォーマンス。

ベストプログラム:ここ


スコアはユリシーズの時間ですか?暗示されているように見えますが、明示的には言われていません
ウィートウィザード

@ SriotchilismO'Zaic、今のところ、はい。しかし、より大きなテストケースが後に続く可能性があるため、最初のテストケースに依存しないでください。ulysses64には反復性があるという明らかな欠点があります。ファイルの1/64の後に新しい単語は現れません。したがって、これはあまり良いテストケースではありませんが、配布(または複製)は簡単です。
Andriy Makukha

3
あなたが以前話していた隠されたテストケースを意味しました。実際のテキストを公開するときにハッシュを今すぐ投稿すると、回答に対して公平であり、あなたが王様ではないことを保証できます。Ulyssesのハッシュはいくらか役立つと思いますが。
小麦ウィザード

1
@tshそれは私の理解です。たとえば、2つの単語eとgになります
Moogie

1
@AndriyMakukhaああ、ありがとう。それらは単なるバグでした。それらを修正しました。
アンデルスカセオルグ

回答:


4

[C]

以下は、2.8 Ghz Xeon W3530のテスト1で1.6秒未満で実行されます。Windows 7でMinGW.org GCC-6.3.0-1を使用して構築:

入力として2つの引数を取ります(テキストファイルへのパスと、最も頻繁に使用される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)になるため、k = 100,000のgiganovelの参照C ++プログラムよりも実行速度が遅くなります。しかし、k << Nの場合、明確な勝者です。
Andriy Makukha

1
@AndriyMakukhaありがとう!このような単純な実装で大きな速度が得られることに少し驚きました。リストをソートすることで、kの値が大きい場合に改善できます。(リストの順序はゆっくりと変化するため、ソートは高すぎてはなりません)が、これは複雑さを増し、kの値が小さいほど速度に影響を与える可能性があります。実験する必要があります
Moogie

ええ、私も驚きました。参照プログラムが多くの関数呼び出しを使用し、コンパイラーがそれを適切に最適化できないことが原因である可能性があります。
Andriy Makukha

別のパフォーマンス上の利点はおそらく、letters配列の準静的な割り当てによるものですが、リファレンス実装はツリーノードを動的に割り当てます。
Andriy Makukha

mmap-ingは速く(私のLinuxラップトップ上〜5%)でなければなりません:#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

3

APL(Dyalog Unicode)

以下は、Windows 10で64ビットDyalog APL 17.0を使用する2.6 Ghz i7-4720HQで8秒未満で実行されます。

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

最初にファイル名を要求し、次にkを要求します。実行時間の大部分(約1秒)がファイルの読み取りのみであることに注意してください。

時間を計るには、dyalog実行可能ファイルに次のパイプを渡すことができます(最も頻繁に使用される10の単語について)。

⎕{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 GBに減らすと、全体で2秒遅くなります。
Andriy Makukha

@AndriyMakukhaはい、あたりとしてハッシュテーブルを使用して、この、と私はかなり確信している、あまりにも内部的に行います。
アダム

なぜO(N log N)ですか?Python(すべてのユニークな単語のヒープを復元するk回)またはAWK(ユニークな単語のみを並べ替える)ソリューションのように見えます。McIlroyのシェルスクリプトのようにすべての単語を並べ替えない限り、O(N log N)であってはなりません。
Andriy Makukha

@AndriyMakukha すべてのカウントを評価します。ここでは何である私たちのパフォーマンスの男が私を書いた:あなたは、ハッシュテーブルについてのいくつかの理論的には怪しげなものを信じていない限り、時間の複雑さは、それはO(N)です。その場合には、O(NログN)です。
アダム

さて、8、16、および32個のUlyssesに対してコードを実行すると、正確に直線的に遅くなります。たぶん、あなたのパフォーマンス担当者は、ハッシュテーブルの時間の複雑さに関する彼の見解を再考する必要があります:)また、このコードは、より大きなテストケースでは機能しません。WS FULL作業スペースを6 GBに増やしたにもかかわらず、戻ります。
Andriy Makukha

3

さび

私のコンピューターでは、これはgiganovel 100000をMoogieのC「プレフィックスツリー+ビン」Cソリューションよりも約42%高速(10.64秒対18.24秒)で実行します。また、単語の長さ、一意の単語、繰り返される単語などの制限を事前定義する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
見事!3つの設定すべてで非常に良好なパフォーマンス。私は文字通り、キャロル・ニコルズによるRustについての最近の講演を見ている最中でした:)ちょっと変わった構文ですが、私は言語を学ぶことに興奮しています。開発者の生活をより簡単にする一方で、パフォーマンスを犠牲にします。
Andriy Makukha

とても速い!私は感銘を受けて!C(ツリー+ビン)のより優れたコンパイラオプションが同様の結果をもたらすのだろうか
ムージー

@Moogie私はすでにあなたのものを-O3でテスト-Ofastしていましたが、大きな違いはありません。
Anders Kaseorg

@Moogie、私はあなたのコードを次のようにコンパイルしていましたgcc -O3 -march=native -mtune=native program.c
Andriy Makukha

@Andriy Makukhaああ。それはあなたが得ている結果と私の結果の間の速度の大きな違いを説明するでしょう:あなたはすでに最適化フラグを適用していました。大きなコード最適化が残っているとは思わない。mingwダイは実装されていないため、他の人が提案するmapを使用してテストすることはできません。Andersの素晴らしいエントリーに屈する必要があると思います。よくやった!
ムージー

2

[C]プレフィックスツリー+ビン

注:使用するコンパイラは、プログラムの実行速度に大きな影響を及ぼします! gcc(MinGW.org GCC-8.2.0-3)8.2.0を使用しました。-Ofastスイッチを使用すると、プログラムは通常コンパイルされたプログラムよりもほぼ50%高速に実行されます。

アルゴリズムの複雑さ

それ以来、私が実行しているBinソートは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)です(各文字はノードに直接マッピングされ、各文字で常にツリーの1レベルのみをトラバースするため)

ピジョンホールの並べ替えはO(N + n)です。nはキー値の範囲ですが、この問題では、すべての値を並べ替える必要はありません。k番号のみであるため、最悪の場合はO(N + k)になります。

一緒に結合すると、O(1 + N + k)が得られます。

ツリー構築の空間の複雑さは、データがM個の文字を持つ1つの単語で構成され、各ノードに26個のノード(つまり、アルファベットの文字)がある場合、最悪のケースは26 * Mノードです。したがって、O(26 * M)= O(M)

ピジョンホールのソートでは、O(N + n)のスペースの複雑さがあります。

一緒に結合すると、O(26 * M + N + n)= O(M + N + n)が得られます

アルゴリズム

入力として2つの引数を取ります(テキストファイルへのパスと、最も頻繁に使用される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:速度最適化のために配列アクセスの代わりにポインター演算を使用するようになりました。


うわー!11 GBのファイルから11秒で最も頻繁に使用される100,000語...これは、ある種の手品のように見えます。
Andriy Makukha

トリックはありません... CPU時間を効率的なメモリ使用と引き換えます。私はあなたの結果に驚いています...私の古いパソコンでは60秒以上かかります。IIが不必要な比較を行っており、ファイルが処理されるまでビニングを延期できることに気付きました。それはさらに速くなるはずです。すぐに試して、回答を更新します。
ムージー

@AndriyMakukhaこれで、すべての単語が処理されてツリーが構築されるまで、ビンの設定を延期しました。これにより、不必要な比較とビン要素の操作が回避されます。また、印刷にかなりの時間がかかっていたため、出力の構成方法も変更しました!
Moogie

私のマシンでは、この更新によって目立った違いは生じません。ただし、ulysses641回で非常に高速に実行されたため、現在のリーダーです。
Andriy Makukha

私のPCに固有の問題である必要があります:)この新しい出力アルゴリズム
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>ます。たとえば、giganovelwith からの出力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

使用可能なシステムメモリの量を除いて制限はありません。


小さなテストケースでは非常に高速です!いいね!ただし、任意の大きな単語の場合、出力内の単語は切り捨てられます。単語の文字数に制限があるのか​​、それとも出力をより簡潔にするだけなのかわかりません。
Andriy Makukha

@AndriyMakukhaええ、...これは行ごとの出力の切り捨てが原因で発生します。すべての切り捨てを無効にするために、最初に1行追加しました。giganovelの場合は、固有の単語が多いほど多くのメモリを使用するため、速度が低下します。
マイル

すごい!これで、一般性テストに合格しました。そして、それは私のマシンで遅くなりませんでした。実際、わずかなスピードアップがありました。
Andriy Makukha

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)

1
私のシステムではgiganovelでしかテストできず、非常に長い時間(〜90秒)かかります。gutenbergprojectは法的な理由でドイツでブロックされています...
movatica

面白い。メソッドにheapqパフォーマンスを追加しないかCounter.most_common、内部的にenumerate(sorted(...))も使用しheapqます。
Andriy Makukha

私はPython 2でテストしましたが、パフォーマンスはほぼ同じCounter.most_commonでした。
Andriy Makukha

ええ、多分それは私のシステム上でただのジッターでした...少なくとも遅くはありません:)しかし、正規表現の検索は文字を繰り返すよりもずっと速いです。かなり高性能に実装されているようです。
movatica

1

[C]プレフィックスツリー+ソート済みリンクリスト

入力として2つの引数を取ります(テキストファイルへのパスと、最も頻繁に使用される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
Andriy Makukha

私は理由について考えていると思います。私の考えは、現在の単語の次に高い単語かどうかをチェックするとき、リスト内のスワップ単語を繰り返す必要があるだろうということです。時間があるときに確認します
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バイトのエンコーディングで表現されているためだと思いますが、これはこのアプリケーションにとっては無駄です。それで、純粋なバイトとgoいgotoスタイルのステートマシンに切り替えました。大文字小文字変換はビットごとの演算子です。文字範囲のチェックは、減算後の単一の比較で行われます。ランタイムの0.1%未満しか使用していないことがわかったため、最終ソートの最適化に労力を費やしませんでした。

修正:アルゴリズムは本質的には正しいものでしたが、単語のすべてのプレフィックスをカウントすることで、単語の合計を過剰に報告していました。総単語数は問題の要件ではないため、その出力を削除しました。すべてのkワードを出力するために、出力も調整しました。最終的に、string.Join()リスト全体を一度に使用して作成することに決めました。驚いたことに、これは私のマシンで約1秒速くなり、各単語を個別に100kで書き込みます。


1
非常に印象的!私はあなたのビットごとのtolower比較テクニックが好きです。ただし、プログラムが予想よりも明確な単語を報告する理由がわかりません。また、元の問題の説明によると、プログラムはkワードすべてを頻度の降順で出力する必要があるため、プログラムを最後のテストにカウントしませんでした。
Andriy Makukha

@AndriyMakukha:最後のカウントでは発生しなかった単語のプレフィックスもカウントしていることがわかります。Windowsではコンソール出力がかなり遅いため、すべての出力を書き込むことは避けました。出力をファイルに書き込むことはできますか?
再帰的

標準出力を印刷してください。k = 10の場合、どのマシンでも高速になります。コマンドラインから出力をファイルにリダイレクトすることもできます。このように
Andriy Makukha

@AndriyMakukha:すべての問題に対処したと思います。実行時のコストをかけずに、必要なすべての出力を生成する方法を見つけました。
再帰的

この出力は非常に高速です!非常に素晴らしい。他のソリューションと同様に、頻度カウントも印刷するようにプログラムを変更しました。
Andriy Makukha

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)

編集:kコマンドライン引数として追加

ruby k filename.rb input.txtRubyの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秒vs 17)。
Andriy Makukha
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.