Ketika berbicara tentang bahasa favorit, saya biasanya mengatakan bahwa, semua hal lain dianggap sama, saya lebih suka C ++ untuk penghancur angka dan Haskell untuk yang lainnya. Berguna untuk memeriksa secara berkala apakah divisi ini dapat dibenarkan, dan baru-baru ini muncul pertanyaan sederhana dan sangat sederhana: bagaimana jumlah semua pembagi angka akan berperilaku dengan pertumbuhan angka ini, katakanlah, untuk angka miliar pertama. Sangat mudah untuk mengintimidasi tugas ini (memalukan menyebutnya sebagai penggiling nomor yang dihasilkan), jadi sepertinya ini adalah pilihan yang bagus untuk pemeriksaan semacam itu.
Selain itu, saya masih tidak memiliki kemampuan untuk secara akurat memprediksi kinerja kode Haskell, jadi sangat berguna untuk mencoba pendekatan yang secara sadar buruk untuk melihat bagaimana kinerja akan menurun.
Nah, di samping itu, Anda dapat dengan mudah memamerkan algoritma yang lebih efisien daripada pencarian frontal untuk pembagi untuk setiap nomor dari 1 sebelumnya n .
Algoritma
Jadi, mari kita mulai dengan algoritme.
Cara menemukan jumlah semua pembagi nomor n ? Anda bisa melewati semua itu k_1 \ in \ {1 \ dots \ lfloor \ sqrt n \ rfloor \}k_1 \ in \ {1 \ dots \ lfloor \ sqrt n \ rfloor \} dan untuk semua itu k1 periksa sisa divisi n pada k1 . Jika sisanya 0 , lalu tambahkan ke baterai k1+k2 dimana k2= fracnk1 jika k1 neqk2 , dan adil k1 jika tidak.
Bisakah algoritma ini diterapkan n kali, untuk setiap nomor dari 1 sebelumnya n ? Tentu saja bisa. Apa kesulitannya? Mudah melihat pesanan itu O(n frac32) divisi - untuk setiap angka kami membuat persis akar divisi, dan kami memiliki angka n . Bisakah kita berbuat lebih baik? Ternyata ya.
Salah satu masalah dengan metode ini adalah bahwa kita membuang terlalu banyak usaha. Terlalu banyak divisi tidak menuntun kita menuju kesuksesan, memberikan sisa yang tidak nol. Wajar untuk mencoba sedikit lebih malas dan mendekati tugas dari sisi lain: mari kita menghasilkan semua jenis kandidat untuk pembagi dan melihat angka apa yang mereka puaskan?
Jadi, sekarang mari kita perlu satu gerakan untuk setiap nomor dari 1 sebelumnya n menghitung jumlah semua pembagi nya. Untuk melakukan ini, lakukan semua k_1 \ in \ {1 \ dots \ lfloor \ sqrt n \ rfloor \} , dan untuk masing-masing k1 mari kita lalui semua k_2 \ dalam \ {k_1 \ dots \ lfloor \ frac {n} {k} \ rfloor \} . Untuk setiap pasangan (k1,k2) tambahkan ke sel dengan indeks k1 cdotk2 nilai k1+k2 jika k1 neqk2 , dan k1 jika tidak.
Algoritma ini tidak tepat n frac12 divisi, dan setiap multiplikasi (yang lebih murah daripada divisi) menuntun kita menuju kesuksesan: pada setiap iterasi kita meningkatkan sesuatu. Ini jauh lebih efektif daripada pendekatan frontal.
Selain itu, dengan memiliki pendekatan frontal yang sama ini, Anda dapat membandingkan kedua implementasi dan memastikan bahwa mereka memberikan hasil yang sama untuk angka yang cukup kecil, yang seharusnya menambah sedikit kepercayaan diri.
Implementasi pertama
Dan omong-omong, ini secara langsung hampir merupakan kode semu dari implementasi awal di Haskell:
module Divisors.Multi(divisorSums) where import Data.IntMap.Strict as IM divisorSums :: Int -> Int divisorSums n = IM.fromListWith (+) premap IM.! n where premap = [ (k1 * k2, if k1 /= k2 then k1 + k2 else k1) | k1 <- [ 1 .. floor $ sqrt $ fromIntegral n ] , k2 <- [ k1 .. n `quot` k1 ] ]
Main
modulnya sederhana, dan saya tidak membawanya.
Selain itu, di sini kami menunjukkan jumlah yang paling banyak n untuk memudahkan perbandingan dengan implementasi lainnya. Terlepas dari kenyataan bahwa Haskell adalah bahasa yang malas, dalam hal ini semua jumlah akan dihitung (meskipun pembenaran penuh untuk ini berada di luar ruang lingkup artikel ini), jadi tidak berhasil bahwa kami tidak akan menghitung apa pun secara tidak sengaja.
Seberapa cepat kerjanya? Pada i7 3930k saya, dalam satu aliran, 100.000 elemen dikerjakan dalam 0,4 detik. Dalam hal ini, 0,15 detik dihabiskan untuk perhitungan dan 0,25 detik untuk GC. Dan kami menempati sekitar 8 megabyte memori, meskipun, karena ukuran int adalah 8 byte, idealnya kita harus memiliki 800 kilobyte.
Bagus (tidak juga). Bagaimana angka-angka ini tumbuh dengan meningkatnya, um, angka? Untuk 1'000'000 elemen, telah bekerja selama sekitar 7,5 detik, menghabiskan tiga detik untuk komputasi dan 4,5 detik untuk GC, dan juga menempati 80 megabita (10 kali lebih banyak dari yang diperlukan). Dan bahkan jika kita berpura-pura sebagai Pengembang Perangkat Lunak Java Senior selama sedetik dan mulai menyetel GC, kita tidak akan mengubah gambar secara signifikan. Terlalu buruk Tampaknya kita tidak akan pernah menunggu satu miliar angka, dan kita juga tidak akan masuk ke memori: hanya ada 64 gigabytes RAM di mesin saya, dan itu akan memakan waktu sekitar 80 jika tren berlanjut.
Sepertinya ini waktunya
Opsi C ++
Mari kita coba untuk mendapatkan ide tentang apa yang masuk akal untuk diperjuangkan, dan untuk ini kita akan menulis kode pada plus.
Nah, karena kita sudah memiliki algoritma debug, maka semuanya sederhana:
#include <vector> #include <string> #include <cmath> #include <iostream> int main(int argc, char **argv) { if (argc != 2) { std::cerr << "Usage: " << argv[0] << " maxN" << std::endl; return 1; } int64_t n = std::stoi(argv[1]); std::vector<int64_t> arr; arr.resize(n + 1); for (int64_t k1 = 1; k1 <= static_cast<int64_t>(std::sqrt(n)); ++k1) { for (int64_t k2 = k1; k2 <= n / k1; ++k2) { auto val = k1 != k2 ? k1 + k2 : k1; arr[k1 * k2] += val; } } std::cout << arr.back() << std::endl; }
Jika Anda tiba-tiba ingin menulis sesuatu tentang kode iniCompiler melakukan gerakan kode invarian loop-besar dalam hal ini, menghitung root sekali dalam kehidupan program, dan menghitung n / k1
sekali per iterasi loop luar.
Dan spoiler tentang kesederhanaanKode ini tidak berfungsi untuk saya pertama kali, meskipun saya menyalin algoritma yang ada. Saya membuat beberapa kesalahan yang sangat bodoh yang, tampaknya, tidak berhubungan langsung dengan tipe, tetapi tetap saja mereka dibuat. Tetapi, pikiran itu keras.
-O3 -march=native
, dentang 8, sejuta elemen diproses dalam 0,024 detik, menempati memori yang dialokasikan 8 megabita. Miliar - 155 detik, 8 gigabytes memori, seperti yang diharapkan. Aduh Haskell tidak baik. Haskell harus diusir. Hanya faktorial dan prepromorfisme di dalamnya dan tulis! Atau tidak?
Opsi kedua
Jelas, untuk menjalankan semua data yang dihasilkan melalui IntMap
, yaitu, pada kenyataannya, peta yang relatif biasa - dengan kata lain, bukan keputusan yang paling bijaksana (ya, ini adalah opsi yang jelas-jelas buruk yang sama yang disebutkan di awal). Mengapa kita tidak menggunakan array seperti dalam kode C ++?
Mari kita coba:
module Divisors.Multi(divisorSums) where import qualified Data.Array.IArray as A import qualified Data.Array.Unboxed as A divisorSums :: Int -> Int divisorSums n = arr A.! n where arr = A.accumArray (+) 0 (1, n) premap :: A.UArray Int Int premap = [ (k1 * k2, if k1 /= k2 then k1 + k2 else k1) | k1 <- [ 1 .. floor bound ] , k2 <- [ k1 .. n `quot` k1 ] ] bound = sqrt $ fromIntegral n :: Double
Di sini kita segera menggunakan versi array yang tidak kotak, karena Int
cukup sederhana, dan kita tidak perlu kemalasan di dalamnya. Versi kotak hanya akan berbeda dalam tipe arr
, jadi kami tidak kehilangan idiom juga. Selain itu, mengikat untuk bound
secara terpisah dibuat di sini, tetapi bukan karena kompiler bodoh dan tidak melakukan LICM, tetapi karena Anda dapat secara eksplisit menentukan jenisnya dan menghindari peringatan dari kompiler tentang default dari argumen floor
.
0,045 detik untuk sejuta elemen (hanya dua kali lebih buruk daripada plus!). Memori 8 megabita, nol milidetik dalam GC (!). Pada ukuran yang lebih besar, tren berlanjut - sekitar dua kali lebih lambat dari C ++, dan jumlah memori yang sama. Hasil yang bagus! Tetapi bisakah kita melakukan yang lebih baik?
Ternyata ya. accumArray
memeriksa indeks, yang kita tidak perlu lakukan dalam hal ini - indeks sudah benar dalam konstruksi. Mari kita coba mengganti panggilan untuk accumArray
dengan unsafeAccumArray
:
module Divisors.Multi(divisorSums) where import qualified Data.Array.Base as A import qualified Data.Array.IArray as A import qualified Data.Array.Unboxed as A divisorSums :: Int -> Int divisorSums n = arr A.! (n - 1) where arr = A.unsafeAccumArray (+) 0 (0, n - 1) premap :: A.UArray Int Int premap = [ (k1 * k2 - 1, if k1 /= k2 then k1 + k2 else k1) | k1 <- [ 1 .. floor bound ] , k2 <- [ k1 .. n `quot` k1 ] ] bound = sqrt $ fromIntegral n :: Double
Seperti yang Anda lihat, perubahannya minimal, kecuali kebutuhan untuk diindeks dari awal (yang, menurut saya, adalah bug di API perpustakaan, tetapi ini adalah pertanyaan lain). Apa kinerjanya?
Satu juta elemen - 0,021 dtk (wow, dalam margin of error, tetapi lebih cepat dari pro!). Secara alami, memori yang sama 8 megabyte, sama dengan 0 ms dalam GC.
Billion elemen - 152 s (sepertinya ini benar-benar lebih cepat daripada plus!). Sedikit kurang dari 8 gigabytes. 0 ms dalam GC. Kode masih idiomatik. Saya pikir kita dapat mengatakan bahwa ini adalah kemenangan.
Kesimpulannya
Pertama, saya terkejut bahwa mengganti accumArray
dengan versi yang unsafe
akan memberikan peningkatan seperti itu. Akan lebih masuk akal untuk mengharapkan 10-20 persen (setelah semua, dalam plus, mengganti operator[]
dengan at()
tidak memberikan penurunan kinerja yang signifikan), tetapi tidak sampai setengahnya!
Kedua, menurut saya, sangat keren bahwa kode bersih yang cukup idiomatis tanpa menonjol keluar mencapai tingkat kinerja ini.
Ketiga, tentu saja, optimasi lebih lanjut dimungkinkan, dan di semua tingkatan. Saya yakin, misalnya, bahwa Anda dapat memeras sedikit lebih banyak dari kode di plus. Namun, menurut saya, dalam semua tolok ukur seperti itu, keseimbangan antara upaya dikeluarkan (dan jumlah kode) dan pembuangan yang dihasilkan adalah penting. Kalau tidak, semuanya pada akhirnya menyatu dengan tantangan LLVM JIT atau sesuatu seperti itu. Selain itu, tentu saja ada algoritma yang lebih efisien untuk menyelesaikan masalah ini, tetapi hasil dari pemikiran singkat yang disajikan di sini juga akan bekerja untuk petualangan hari Minggu kecil ini.
Keempat, favorit saya: sistem tipe perlu dikembangkan. unsafe
tidak diperlukan di sini, sebagai seorang programmer saya dapat membuktikan bahwa k_1 * k_2 <= n
untuk semua k_1, k_2
ditemukan dalam loop. Dalam dunia ideal bahasa yang diketik secara dependen, saya akan membuat bukti ini secara statis dan meneruskannya ke fungsi yang sesuai, yang akan menghilangkan kebutuhan untuk pemeriksaan dalam runtime. Tetapi, sayangnya, di Haskell tidak ada zavtipov yang lengkap, dan dalam bahasa di mana ada zavtipy (dan yang saya tahu), tidak ada array
dan analog.
Kelima, saya tidak tahu bahasa pemrograman lain yang cukup untuk memenuhi syarat untuk tolok ukur dekat dalam bahasa ini, tetapi salah satu teman saya menulis analog dengan python. Hampir persis seratus kali lebih lambat, dan lebih buruk dari ingatan. Dan algoritma itu sendiri sangat sederhana, jadi jika seseorang yang berpengetahuan menulis analog di Go, Rust, Julia, D, Java, Malbolge di komentar atau sesuatu yang lain dan berbagi perbandingan, misalnya, dengan C ++ - kode pada mesin mereka - mungkin akan menjadi besar .
PS: Maaf untuk header sedikit clickbait. Saya tidak bisa menemukan yang lebih baik.