Ini mungkin salah satu tantangan pengkodean klasik yang mendapat resonansi pada tahun 1986, ketika kolumnis Jon Bentley meminta Donald Knuth untuk menulis sebuah program yang akan menemukan kata-kata yang paling sering k dalam sebuah file. Knuth mengimplementasikan solusi cepat menggunakan hash mencoba dalam program sepanjang 8 halaman untuk menggambarkan teknik pemrograman melek hurufnya. Douglas McIlroy dari Bell Labs mengkritik solusi Knuth yang bahkan tidak dapat memproses teks lengkap Alkitab, dan menjawab dengan satu kalimat, yang tidak secepat, tetapi menyelesaikan pekerjaan:
tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q
Pada tahun 1987, artikel lanjutan diterbitkan dengan solusi lain, kali ini oleh seorang profesor Princeton. Tetapi itu juga tidak bisa mengembalikan hasil untuk satu Alkitab!
Deskripsi masalah
Deskripsi masalah asli:
Diberikan file teks dan bilangan bulat k, Anda harus mencetak kata-kata k yang paling umum dalam file (dan jumlah kemunculannya) dalam mengurangi frekuensi.
Klarifikasi masalah tambahan:
- Knuth mendefinisikan kata sebagai serangkaian huruf Latin:
[A-Za-z]+
- semua karakter lain diabaikan
- huruf besar dan kecil dianggap setara (
WoRd
==word
) - tidak ada batasan ukuran file atau panjang kata
- jarak antara kata-kata berurutan bisa besar secara sewenang-wenang
- program tercepat adalah yang menggunakan total waktu CPU paling sedikit (multithreading mungkin tidak akan membantu)
Contoh uji kasus
Tes 1: Ulysses oleh James Joyce digabung 64 kali (file 96 MB).
- Unduh Ulysses dari Project Gutenberg:
wget http://www.gutenberg.org/files/4300/4300-0.txt
- Menggabungkannya 64 kali:
for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
- Kata yang paling sering adalah "the" dengan 968832 penampilan.
Tes 2: Teks acak yang dihasilkan khusus giganovel
(sekitar 1 GB).
- Skrip generator Python 3 di sini .
- Teks berisi 148391 kata-kata berbeda yang muncul mirip dengan bahasa alami.
- Kata yang paling sering: "e" (11309 penampakan) dan "ihit" (11290 penampakan).
Tes umum: kata-kata besar sembarangan dengan kesenjangan besar sembarang.
Implementasi referensi
Setelah melihat Rosetta Code untuk masalah ini dan menyadari bahwa banyak implementasi yang sangat lambat (lebih lambat dari skrip shell!), Saya menguji beberapa implementasi yang baik di sini . Di bawah ini adalah kinerja untuk ulysses64
bersama dengan kompleksitas waktu:
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)
Bisakah kamu mengalahkan itu?
Pengujian
Kinerja akan dievaluasi menggunakan MacBook Pro 2017 13 "dengan Unix standar time
perintah (waktu" pengguna "). Jika memungkinkan, silakan gunakan kompiler modern (mis., Gunakan versi Haskell terbaru, bukan versi lawas).
Peringkat sejauh ini
Pengaturan waktu, termasuk program referensi:
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
Peringkat kumulatif * (%, skor terbaik - 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
* Jumlah kinerja waktu relatif terhadap program terbaik di masing-masing dari tiga tes.
Program terbaik sejauh ini: di sini (solusi kedua)
sumber
Jawaban:
[C]
Berikut ini berjalan di bawah 1,6 detik untuk Uji 1 pada 2,8 Ghz Xeon W3530 saya. Dibangun menggunakan MinGW.org GCC-6.3.0-1 pada Windows 7:
Dibutuhkan dua argumen sebagai input (jalur ke file teks dan untuk k jumlah kata yang paling sering didaftar)
Ini hanya membuat pohon bercabang pada huruf kata-kata, kemudian pada huruf daun itu menambah counter. Kemudian periksa untuk melihat apakah penghitung daun saat ini lebih besar dari kata yang paling sering terkecil dalam daftar kata yang paling sering. (daftar ukuran adalah angka yang ditentukan melalui argumen baris perintah) Jika demikian maka promosikan kata yang diwakili oleh huruf daun menjadi salah satu yang paling sering. Ini semua berulang sampai tidak ada lagi huruf yang dibaca. Setelah itu, daftar kata yang paling sering dihasilkan melalui pencarian berulang yang tidak efisien untuk kata yang paling sering dari daftar kata yang paling sering.
Saat ini default untuk output waktu pemrosesan, tetapi untuk keperluan konsistensi dengan kiriman lainnya, nonaktifkan definisi TIMING dalam kode sumber.
Juga, saya telah mengirimkan ini dari komputer kerja dan belum dapat mengunduh teks Uji 2. Seharusnya berfungsi dengan Tes 2 ini tanpa modifikasi, namun nilai MAX_LETTER_INSTANCES mungkin perlu ditingkatkan.
Untuk Uji 1, dan untuk 10 kata paling sering dan dengan pengaturan waktu diaktifkan itu harus dicetak:
sumber
letters
array, sedangkan implementasi referensi mengalokasikan node pohon secara dinamis.mmap
-ing harus lebih cepat (~ 5% pada laptop linux saya):#include<sys/mman.h>
,<sys/stat.h>
,<fcntl.h>
, ganti file membaca denganint d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);
dan komentar keluarfree(data);
Karat
Di komputer saya, ini menjalankan giganovel 100000 sekitar 42% lebih cepat (10,64 detik vs 18,24 detik) daripada solusi C "prefix tree + bins" C Moogie. Juga tidak memiliki batas yang telah ditentukan (tidak seperti solusi C yang menentukan batas pada panjang kata, kata-kata unik, kata-kata yang diulang, dll.).
src/main.rs
Cargo.toml
Pemakaian
sumber
-O3
, dan-Ofast
tidak membuat perbedaan yang terukur.gcc -O3 -march=native -mtune=native program.c
.APL (Dyalog Unicode)
Berikut ini berjalan di bawah 8 detik pada 2,6 Ghz i7-4720HQ saya menggunakan 64-bit Dyalog APL 17.0 pada Windows 10:
Pertama meminta nama file, lalu untuk k. Perhatikan bahwa sebagian besar waktu berjalan (sekitar 1 detik) hanya membaca file dalam.
Untuk menentukan waktu, Anda harus dapat menyalurkan berikut ini ke dalam
dyalog
executable Anda (untuk sepuluh kata yang paling sering):Itu harus mencetak:
sumber
export MAXWS=4096M
. Saya kira, menggunakan tabel hash? Karena mengurangi ukuran ruang kerja menjadi 2 GB membuatnya lebih lambat 2 detik penuh.∊
menggunakan tabel hash seperti ini , dan saya cukup yakin⌸
melakukannya secara internal juga.WS FULL
, meskipun saya menambah ruang kerja menjadi 6 GB.[C] Pohon Awalan + Bins
CATATAN: Kompiler yang digunakan memiliki efek signifikan pada kecepatan eksekusi program! Saya telah menggunakan gcc (MinGW.org GCC-8.2.0-3) 8.2.0. Saat menggunakansakelar -Ofast , program ini menjalankan hampir 50% lebih cepat daripada program yang biasanya dikompilasi.
Kompleksitas Algoritma
Sejak itu saya menyadari bahwa penyortiran Bin yang saya lakukan adalah bentuk semacam Pigeonhost. Ini berarti bahwa saya dapat mengurangi kompleksitas Big O dari solusi ini.
Saya menghitungnya menjadi:
Kompleksitas konstruksi pohon setara dengan traversal pohon sehingga karena pada tingkat mana pun simpul yang benar untuk dilintasi adalah O (1) (karena setiap huruf dipetakan langsung ke simpul dan kami selalu hanya melintasi satu tingkat pohon untuk setiap huruf)
Penyortiran Pigeon Hole adalah O (N + n) di mana n adalah rentang nilai kunci, namun untuk masalah ini, kita tidak perlu menyortir semua nilai, hanya angka k sehingga yang terburuk adalah O (N + k).
Menggabungkan bersama-sama menghasilkan O (1 + N + k).
Kompleksitas ruang untuk konstruksi pohon disebabkan oleh fakta bahwa kasus terburuk adalah 26 * M node jika data terdiri dari satu kata dengan jumlah huruf M dan bahwa setiap node memiliki 26 node (yaitu untuk huruf-huruf alfabet). Jadi O (26 * M) = O (M)
Untuk Pigeon Hole sorting memiliki kompleksitas ruang O (N + n)
Menggabungkan bersama-sama menghasilkan O (26 * M + N + n) = O (M + N + n)
Algoritma
Dibutuhkan dua argumen sebagai input (jalur ke file teks dan untuk k jumlah kata yang paling sering didaftar)
Berdasarkan entri saya yang lain, versi ini memiliki biaya waktu yang sangat kecil dengan peningkatan nilai k dibandingkan dengan solusi saya yang lain. Tetapi terasa lebih lambat untuk nilai-nilai rendah k tetapi harus jauh lebih cepat untuk nilai-nilai k yang lebih besar.
Itu membuat pohon bercabang pada huruf kata-kata, kemudian pada huruf daun itu menambah counter. Kemudian menambahkan kata ke nampan kata-kata dengan ukuran yang sama (setelah terlebih dahulu menghapus kata dari nampan itu sudah tinggal di). Ini semua diulang sampai tidak ada lagi huruf yang dibaca. Setelah itu tempat sampah dibalik k kali dimulai dari nampan terbesar dan kata-kata dari masing-masing nampan adalah output.
Saat ini default untuk output waktu pemrosesan, tetapi untuk keperluan konsistensi dengan kiriman lainnya, nonaktifkan definisi TIMING dalam kode sumber.
EDIT: sekarang menunda nampan-nampan penduduk sampai setelah pohon dibangun dan mengoptimalkan konstruksi output.
EDIT2: sekarang menggunakan aritmatika pointer bukan akses array untuk optimasi kecepatan.
sumber
ulysses64
sekali, jadi itu adalah pemimpin saat ini.J
Jalankan sebagai skrip dengan
jconsole <script> <input> <k>
. Misalnya, output darigiganovel
dengank=100K
:Tidak ada batasan kecuali untuk jumlah memori sistem yang tersedia.
sumber
...
terjadi karena pemotongan keluaran per baris. Saya menambahkan satu baris di awal untuk menonaktifkan semua pemotongan. Ini melambat pada giganovel karena menggunakan lebih banyak memori karena ada kata-kata yang lebih unik.C ++ (a la Knuth)
Saya ingin tahu bagaimana program Knuth akan berjalan, jadi saya menerjemahkan programnya (awalnya Pascal) ke dalam C ++.
Meskipun tujuan utama Knuth bukanlah kecepatan tetapi untuk menggambarkan sistem WEB pemrograman terpelajarnya, program ini secara mengejutkan kompetitif, dan mengarah ke solusi yang lebih cepat daripada jawaban di sini sejauh ini. Inilah terjemahan saya dari programnya (nomor "bagian" yang sesuai dari program WEB disebutkan dalam komentar seperti "
{§24}
"):Perbedaan dari program Knuth:
link
,sibling
,count
danch
menjadi sebuah array daristruct Node
(merasa lebih mudah untuk memahami cara ini).fread
dandata[i] | 32 - 'a'
seperti pada jawaban lain di sini, alih-alih solusi Pascal.Terlepas dari itu, ini persis seperti program Knuth (menggunakan hash trie / struktur data trie yang dikemas dan bucket sort), dan melakukan operasi yang hampir sama (seperti yang dilakukan oleh program Pascal Knuth) sambil memutar semua karakter dalam input; perhatikan bahwa tidak menggunakan algoritma eksternal atau struktur data perpustakaan, dan juga kata-kata dengan frekuensi yang sama akan dicetak dalam urutan abjad.
Pengaturan waktu
Disusun dengan
Ketika dijalankan di testcase terbesar di sini (
giganovel
dengan 100.000 kata yang diminta), dan dibandingkan dengan program tercepat yang diposting di sini sejauh ini, saya merasa sedikit tetapi secara konsisten lebih cepat:(Baris teratas adalah solusi Rust Anders Kaseorg; bagian bawah adalah program di atas. Ini adalah timing dari 100 run, dengan mean, min, max, median, dan kuartil.)
Analisis
Mengapa ini lebih cepat? Bukan karena C ++ lebih cepat dari Rust, atau bahwa program Knuth adalah yang tercepat mungkin - pada kenyataannya, program Knuth lebih lambat pada penyisipan (seperti yang dia sebutkan) karena pengepakan trie (untuk menghemat memori). Alasannya, saya curiga, terkait dengan sesuatu yang dikeluhkan Knuth pada 2008 :
Program di atas menggunakan indeks array 32-bit (bukan 64-bit pointer), sehingga struct "Node" menempati lebih sedikit memori, sehingga ada lebih banyak Node pada stack dan lebih sedikit cache misses. (Bahkan, ada beberapa pekerjaan pada ini sebagai x32 ABI , tetapi tampaknya tidak dalam kondisi baik meskipun ide ini jelas berguna, misalnya melihat pengumuman baru dari kompresi pointer di V8 . Oh well.) Jadi pada
giganovel
, program ini menggunakan 12,8 MB untuk trie (penuh), versus 32,18 MB program Rust untuk trie (aktifgiganovel
). Kita dapat meningkatkan 1000x (dari "giganovel" ke "teranovel" katakan) dan masih tidak melebihi indeks 32-bit, jadi ini sepertinya pilihan yang masuk akal.Varian lebih cepat
Kami dapat mengoptimalkan kecepatan dan melepaskan pengepakan, sehingga kami benar-benar dapat menggunakan trie (tidak dikemas) seperti pada solusi Rust, dengan indeks bukan pointer. Ini memberikan sesuatu yang lebih cepat dan tidak memiliki batasan pra-tetap pada jumlah kata, karakter dll:
Program ini, walaupun melakukan banyak hal untuk menyortir daripada solusi di sini, hanya menggunakan (untuk
giganovel
) 12,2MB untuk ketiganya, dan mengelola untuk menjadi lebih cepat. Pengaturan waktu dari program ini (baris terakhir), dibandingkan dengan penentuan waktu sebelumnya yang disebutkan:Saya ingin sekali melihat apa yang diinginkan (atau program hash-trie) ini jika diterjemahkan ke dalam Rust . :-)
Keterangan lebih lanjut
Tentang struktur data yang digunakan di sini: penjelasan tentang percobaan "pengepakan" diberikan secara ringkas dalam Latihan 4 dari Bagian 6.3 (Pencarian Digital, yaitu percobaan) dalam Volume 3 TAOCP, dan juga dalam tesis siswa Knuth, Frank Liang tentang hyphenation in TeX : Word Hy-phen-a-tion oleh Com-put-er .
Konteks kolom Bentley, program Knuth, dan ulasan McIlroy (hanya sebagian kecil tentang filosofi Unix) lebih jelas mengingat kolom sebelumnya dan kemudian , dan pengalaman Knuth sebelumnya termasuk kompiler, TAOCP, dan TeX.
Ada seluruh buku Latihan dalam Gaya Pemrograman , yang menunjukkan berbagai pendekatan untuk program khusus ini, dll.
Saya memiliki posting blog yang belum selesai menguraikan poin-poin di atas; mungkin mengedit jawaban ini setelah selesai. Sementara itu, memposting jawaban ini di sini, pada kesempatan (10 Januari) di hari ulang tahun Knuth. :-)
sumber
Segmentation fault: 11
untuk kasus uji dengan kata-kata besar dan kesenjangan; 2) walaupun mungkin saya merasa bersimpati pada "kritik" McIlroy, saya sadar betul bahwa niat Knuth hanya untuk memamerkan teknik pemrograman terpelajarnya, sementara McIlroy mengkritiknya dari perspektif teknik. McIlroy sendiri kemudian mengakui bahwa itu tidak adil untuk dilakukan.word_for
; memperbaikinya sekarang. Ya McIlroy, sebagai penemu pipa Unix, mengambil kesempatan untuk menginjili filosofi Unix dalam menyusun alat-alat kecil. Ini adalah filosofi yang baik, dibandingkan dengan pendekatan monolitik Knuth yang frustasi (jika Anda mencoba membaca programnya), tetapi dalam konteksnya itu agak tidak adil, juga karena alasan lain: hari ini cara Unix tersedia secara luas, tetapi pada tahun 1986 terbatas. ke Bell Labs, Berkeley, dll ("perusahaannya membuat prefab terbaik dalam bisnis")Python 3
Implementasi ini dengan kamus sederhana sedikit lebih cepat daripada yang menggunakan
Counter
pada sistem saya.sumber
heapq
itu tidak menambah kinerja apa pun untukCounter.most_common
metode ini, atauenumerate(sorted(...))
juga menggunakanheapq
internal.Counter.most_common
.[C] Pohon Awalan + Daftar Linked Sorted
Dibutuhkan dua argumen sebagai input (jalur ke file teks dan untuk k jumlah kata yang paling sering didaftar)
Berdasarkan entri saya yang lain, versi ini jauh lebih cepat untuk nilai k yang lebih besar tetapi dengan biaya kinerja yang kecil dengan nilai k yang lebih rendah.
Itu membuat pohon bercabang pada huruf kata-kata, kemudian pada huruf daun itu menambah counter. Kemudian periksa untuk melihat apakah penghitung daun saat ini lebih besar dari kata yang paling sering terkecil dalam daftar kata yang paling sering. (daftar ukuran adalah jumlah yang ditentukan melalui argumen baris perintah) Jika demikian maka promosikan kata yang diwakili oleh huruf daun menjadi salah satu yang paling sering. Jika sudah menjadi kata yang paling sering, maka tukar dengan kata yang paling sering berikutnya jika jumlah kata sekarang lebih tinggi, sehingga daftar tetap diurutkan. Ini semua berulang sampai tidak ada lagi huruf yang dibaca. Setelah itu daftar kata yang paling sering adalah output.
Saat ini default untuk output waktu pemrosesan, tetapi untuk keperluan konsistensi dengan kiriman lainnya, nonaktifkan definisi TIMING dalam kode sumber.
sumber
12 eroilk 111 iennoa 10 yttelen 110 engyt
.C #
Yang ini harus bekerja dengan .net SDK terbaru .
Berikut ini contoh keluaran.
Pada awalnya, saya mencoba menggunakan kamus dengan tombol string, tapi itu terlalu lambat. Saya pikir itu karena. Net string secara internal diwakili dengan pengkodean 2-byte, yang agak boros untuk aplikasi ini. Jadi kemudian saya hanya beralih ke byte murni, dan mesin negara gaya goto jelek. Konversi kasus adalah operator bitwise. Pemeriksaan rentang karakter dilakukan dalam satu perbandingan setelah pengurangan. Saya tidak menghabiskan usaha mengoptimalkan jenis terakhir karena saya menemukan itu menggunakan kurang dari 0,1% dari runtime.
Perbaiki: Algoritma ini pada dasarnya benar, tetapi itu melaporkan total kata secara berlebihan, dengan menghitung semua awalan kata. Karena jumlah total kata bukan persyaratan masalah, saya menghapus output itu. Untuk mengeluarkan semua kata k, saya juga menyesuaikan hasilnya. Saya akhirnya memutuskan untuk menggunakan
string.Join()
dan kemudian menulis seluruh daftar sekaligus. Anehnya ini sekitar satu detik lebih cepat di mesin saya yang menulis setiap kata secara terpisah untuk 100rb.sumber
tolower
trik perbandingan bitwise dan tunggal Anda. Namun, saya tidak mengerti mengapa program Anda melaporkan lebih banyak kata yang berbeda dari yang diharapkan. Juga, menurut uraian masalah awal, program perlu mengeluarkan semua kata k dalam urutan frekuensi yang lebih rendah, jadi saya tidak menghitung program Anda pada pengujian terakhir, yang perlu mengeluarkan 100.000 kata paling sering.Ruby 2.7.0-preview1 dengan
tally
Versi terbaru dari Ruby memiliki metode baru yang disebut
tally
. Dari catatan rilis :Ini hampir menyelesaikan seluruh tugas kami. Kami hanya perlu membaca file terlebih dahulu dan menemukan maks nanti.
Ini semuanya:
sunting: Ditambahkan
k
sebagai argumen baris perintahItu dapat dijalankan dengan
ruby k filename.rb input.txt
menggunakan versi 2.7.0-preview1 Ruby. Ini dapat diunduh dari berbagai tautan pada halaman catatan rilis, atau diinstal dengan rbenv menggunakanrbenv install 2.7.0-dev
.Contoh dijalankan di komputer lama saya yang usang:
sumber