Kami menulis pencarian substring lebih baik daripada di buku teks



Kehidupan seorang insinyur penuh dengan kejutan: terutama ketika Anda harus berurusan dengan produktivitas. Misalnya, apa yang terjadi jika Anda mencoba menjalankan kode Java ini? Terlihat sangat polos:

//   String.repeat  JDK 11  :
final var needle = "A".repeat(500000) + "B";
final var haystack = "A".repeat(1000000) + "B";
System.out.println(haystack.indexOf(needle));

Kita tunggu, tunggu, tunggu ... Setidaknya pada laptop OpenJDK 13 2015 saya, menemukan jarum di tumpukan jerami membutuhkan waktu sekitar satu menit. JVM lama kami yang baik telah melalui puluhan tahun tuning kinerja, ia telah secara efektif mengimplementasikan intrinsik untuk String.indexOfdan seterusnya. Apa yang salah?
Ini adalah awal dari serangkaian beberapa artikel milik penulis mereka, Linas Medžiūnas , dan awalnya diterbitkan di blog WiX Engineering .


Perhatikan lebih dekat apa input: data dipilih secara khusus untuk mencapai kinerja kuadrat dalam kasus terburuk (di O(nm)mana npanjang haystackdan mpanjang needle) untuk algoritma pencarian substring naif. Kami menjalankan semua karakter dalam haystack, dan jika mereka bertepatan dengan karakter pertama needle, kami mulai berjalan needledi dalam lingkaran dalam - dan seterusnya sampai karakter yang tidak cocok pertama.

Anda mungkin berpendapat bahwa contoh ini tidak berguna, karena data input tersebut dirancang dan diajukan secara khusus, dalam praktiknya Anda tidak akan menemukan ini. Berpikir dua kali. Bagaimana jika Anda bekerja pada layanan web yang penggunanya dapat memuat string acak, dan di suatu tempat di belakang layanan ada kode yang berjalanindexOfpada baris ini? Kemudian hanya beberapa permintaan jahat seperti yang di atas akan membuat layanan Anda berlutut. Perlu diketahui, setidaknya, tentang kasus terburuk untuk data input.

Untungnya, ada algoritma pencarian substring yang memiliki kompleksitas linier ( O(n+m)). Mereka tidak memiliki masalah dengan data dari contoh di atas. Misalnya, kode Scala berikut melakukan hal yang sama, tetapi berjalan dalam milidetik di komputer yang sama, JVM yang sama, dan menggunakan persis sama di bawah tenda java.lang.String:

val needle = "A" * 500000 + "B"
val haystack = "A" * 1000000 + "B"
println(haystack.indexOfSlice(needle))

Rahasia perbedaan besar ada di dalam metode indexOfSlice, yang merupakan bagian dari perpustakaan standar Scala . Ini mengimplementasikan algoritma Knut-Morris-Pratt linear yang pintar . Dan tidak, saya tidak mengatakan bahwa bahasa X lebih baik daripada bahasa Y. Sayangnya, semuanya jauh lebih rumit di sini! Misalnya, indexOfSlicedalam Scala, ini adalah metode umum yang bekerja tidak hanya dengan string, tetapi juga dalam koleksi berurutan lainnya, dan dapat membandingkan tidak hanya karakter, tetapi juga elemen dari tipe lain. Seharusnya jauh lebih lambat daripadaString.indexOfdari Jawa dalam huruf besar (kami akan membicarakannya nanti). Dengan demikian, kami memiliki algoritma yang efisien dengan kinerja yang jauh lebih baik dalam kasus terburuk, tetapi rata-rata lebih lambat karena memiliki bagian konstan yang jauh lebih besar. Dilema seperti ini adalah masalah khas dalam kinerja tuning. Tidak ada pil ajaib yang akan menyelesaikan semua masalah - Anda perlu menganalisis masalah dengan cermat dan membuat tolok ukur mikro yang tepat.



Apakah Anda masih dengan saya Baik! Anda lihat, ini hanyalah sebuah pengantar. Saya ingin memotivasi Anda untuk berurusan dengan kompleksitas teoretis dan kinerja praktis dari algoritma. Di sisa artikel ini, kita akan melihat beberapa implementasi dari beberapa algoritma pencarian substring dan tolok ukurnya.

Kami akan mengeksplorasi tiga algoritma pencarian substring. Semuanya bekerja dalam waktu linier dan membutuhkan preprocessing, secara linear tergantung pada panjangnya needle. Perhitungan yang sama needlediperlukan hanya sekali, dan kemudian dapat digunakan kembali dalam beberapa upaya pencarian. Ini masuk akal, karena dalam banyak kasus kita perlu mencari baris yang sama lagi dan lagi. Dan bahkan jika kita tidak melakukan ini, precomputing bukanlah operasi yang mahal.

Semua algoritma di bawah memotong setiap karakter dihaystackhanya sekali dalam satu baris (tidak ada akses acak oleh indeks), sehingga mereka semua berfungsi dengan baik dalam mode streaming. Artikel ini muncul selama kerja nyata pada server proxy untuk produksi berdasarkan kerangka kerja Netty , dan ini memengaruhi beberapa keputusan desain API. Selain itu, karena kami perlu melakukan pencarian pada byte byte, kodenya akan berfungsi Byte, bukan dengan Char.



Knut-Morris-Pratt (Algoritma KMP)


Ini adalah algoritma pencarian substring terkenal sejak 70-an abad terakhir. Ini dijelaskan dengan baik dalam literatur , jadi saya tidak akan menjelaskannya di sini secara rinci. ILC didasarkan pada mesin negara - selama fase perhitungan pendahuluan, sebuah array indeks tautan dibangun berdasarkan needle. Selama pencarian, mesin menerima karakter haystacksatu per satu di input , dan memperbarui keadaan internalnya sesuai (dan negara hanya ada indeks di tabel relasi).

Berikut ini adalah implementasi pada Scala .

Algoritma pencarian biner substring


Awalnya, saya harus secara independen menemukan nama algoritma ini: Saya belum pernah melihat yang seperti ini di literatur. Akibatnya, saya datang ke nama "Topeng Bit Pergeseran". Kemudian ternyata bahwa algoritma ini dan variasinya telah dikenal sejak 1964 dengan berbagai nama bahasa Inggris seperti "Bitap", "Shift-or", "Shift-and", "Baeza-Yates - Gonnet". Terima kasih kepada para pembaca yang telah menemukannya untuk saya. Artikel ini ditulis jauh sebelum berita ini.

Algoritma ini didasarkan pada ide yang sangat sederhana dan bekerja dengan sangat baik, karena hampir tidak ada lompatan, dan didasarkan pada beberapa operasi biner primitif. Karena itu, ia memiliki batas pada panjang needleyang akan kita cari: tidak boleh lebih dari 64 byte. Jumlah ini diambil hanya dengan jumlah bit dalamLongdi JVM. Keterbatasan ini cukup murah hati untuk sejumlah besar tugas nyata.

Karena saya awalnya mengembangkan algoritma ini sendiri, saya akan mencoba untuk membicarakannya secara lebih rinci. Pertama, kami melakukan pra-komputasi konteks pencarian untuk yang diinginkan needle:

  def computeBitMasks(needle: Array[Byte]): Array[Long] = {
    require(needle.length <= 64, "Maximum supported search pattern length is 64.")
    val bitMasks = Array.ofDim[Long](256)
    var bit = 1L
    for (c <- needle) {
      bitMasks(toUnsignedInt(c)) |= bit
      bit <<= 1
    }
    bitMasks
  }

Kami melakukan pra-komputasi bitMask(64-bit Long) untuk setiap nilai byte yang memungkinkan (256 buah bitMask). Untuk beberapa nilai byte X, ini bitmaskberisi berisi unit di semua tempat di mana ia Xberada needle. Misalnya, inilah topeng bit untuk string "abracadabra": Selain itu, Anda perlu melakukan pra-komputasi , yang akan membantu untuk memahami bahwa kami menemukan kecocokan yang tepat. Ini terlihat seperti nilai , dengan sedikit posisi :



successBitMaskLong1needle.length — 1

  def computeSuccessBitMask(needle: Array[Byte]): Long = {
    1L << (needle.length - 1)
  }

Dan akhirnya, Anda perlu melakukan, pada kenyataannya, pencarian. Satu-satunya keadaan yang bisa berubah yang ingin kita simpan adalah currentMask( Long) Untuk setiap byte di haystackkami bergeser sedikit ke currentMaskkiri 1, atur bit terkecilnya 1, dan lakukan bitwise di andantara hasilnya dan bitMask, dihitung untuk nilai byte yang diproses saat ini dari haystack(ini andmembersihkan semua bit di tempat-tempat currentMaskyang tidak cocok dengan byte yang diproses saat ini).

Jadi, setelah memproses setiap byte, hanya bit-bit yang berada di posisi yang sesuai yang akan bertahan. Dan dengan setiap byte diproses, semua bit digeser ke kiri oleh satu posisi. Jika bit "bertahan" selama jumlah iterasi sama dengan panjangnyaneedle- kami menemukan kecocokan! Dan kami dapat memverifikasi ini dengan successBitMask:

  def process(value: Byte): Boolean = {
    currentMask = ((currentMask << 1) | 1) & bitMasks(toUnsignedInt(value))
    (currentMask & successBitMask) == 0
  }

Catatan: metode yang dijelaskan di atas mengembalikan falsejika ada sesuatu yang ditemukan, dan itu terlihat berlawanan dengan intuisi. Ini dapat dipahami sehingga nilainya trueberarti kebutuhan untuk melanjutkan pencarian, tetapi falsemenghentikannya - ini disebabkan oleh fakta bahwa, seperti yang saya tulis di atas, API dibuat kompatibel dengan Netty. Jika Anda bertanya-tanya bagaimana menjalankan pencarian, berikut adalah contohnya.

Akibatnya, semua logika bermuara pada hanya beberapa instruksi prosesor sederhana. Sayangnya, masih ada pemeriksaan yang sama sekali tidak berguna dari batas-batas indeks array bitMasks, yang tidak dapat dihapus oleh JDK (dan saya melihat assembler yang dihasilkan oleh beberapa JDK yang berbeda).

Berikut ini adalah implementasi penuh pada Scala .

Aho korasik


Ini adalah algoritma populer lainnya yang dikenal sejak 1975. Fitur yang membedakan (dan kadang-kadang sangat berguna) adalah kemampuan untuk mencari beberapa sekaligus needlepada saat yang sama, sementara semua karakter dari haystackdilewati tepat sekali (saya pikir itu hebat!). Gagasan bahwa semua ini bekerja adalah perpanjangan dari algoritma KMP, mesin keadaan terbatas menggunakan pohon awalan (yang dibangun berdasarkan beberapa needle), berisi tautan ke tautan (bandingkan dengan array satu dimensi dari KMP). Berdasarkan tautan ini, keadaan internal otomat dialihkan antara node dari pohon awalan setelah setiap simbol yang diproses, dan beberapa node menunjukkan hasil pencarian positif untuk suatu tertentuneedle. Fase persiapan di sini agak rumit, tetapi fase pencarian di luar dugaan sangat sederhana.

Berikut ini tautan ke implementasi kerja pada Scala .



Ini adalah daftar yang sepenuhnya tidak lengkap dari algoritma pencarian substring. Kami juga mencoba algoritma Rabin-Karp dan algoritma Boyer-Moore . Dari keduanya, Boyer-Moore menunjukkan kinerja yang sebanding, tetapi keduanya tidak kompatibel dengan streaming (menggunakan akses acak haystackmenurut indeks), jadi saya menjatuhkan mereka dari penyelidikan ini.



Tolak ukur


Kami akan membandingkan tiga algoritma yang dijelaskan di atas, dan sebagai tambahan, lihat hasil untuk metode String.indexOf(Java) dan indexOfSlice(Scala). Sejujurnya, ini bukan perbandingan yang sepenuhnya benar, karena ia String.indexOfbekerja dengan string, dan semua metode lain berada pada array byte. Tetapi ini tampaknya tidak membatalkan hasil perbandingan semacam itu. Selain itu, saya juga memasukkan hasil untuk Bytes.indexOfdari Jambu Biji (v.28.1). Metode ini bekerja pada array byte. Dan mereka menulisnya di Google - semua yang mereka tulis di sana berfungsi sangat cepat, bukan?

Menulis tolok ukur selalu sulit, karena Anda dapat mengirim data yang sama sekali berbeda ke input, mengubahnya dengan berbagai cara - tidak hanya panjang needledanhaystack, tetapi juga oleh konten internal dari baris-baris ini (yang dapat sangat mempengaruhi beberapa algoritma). Dalam praktiknya, selalu layak memeriksa data input yang paling mirip dengan data dari tugas nyata Anda (ini adalah apa yang kami lakukan dalam proyek kami).

Untuk mempersingkat artikel ini, saya hanya menggunakan 2 jenis input. Salah satunya dimaksudkan untuk mencerminkan kasus sebenarnya: haystacksekitar 1,5 KB (dengan teks yang dapat dibaca manusia di dalamnya) needle- 9 byte, dan tidak dalam haystackurutan ini (ini diperlukan untuk memaksa algoritma untuk melakukan pemindaian penuh).

Jenis input lain diperlukan untuk mendapatkan perilaku terburuk dari algoritma kuadratik. Ini jauh lebih pendek daripada data dari awal artikel ini: kalau tidak kita harus menunggu sebentar, ingat? Himpunanhaystackdiatur dalam format "AA...AAB"(panjang yang sama dengan tipe data pertama), dan needle- 64-byte (terutama untuk algoritma pencarian substring biner untuk mengatasinya) array dari tipe yang sama (pertandingan hanya di bagian paling akhir haystack).

Patokan yang tertulis dalam kerangka JMH dapat ditemukan di sini . Jika Anda memiliki ide lain tentang apa dan bagaimana mengukur di sini - Anda dapat mengkloning repositori ini, mengubah sesuatu dan memposting komentar.

Atas saran Vladimir Sitnikov , saya menambahkan hasil benchmark untuk java.util.regex.Pattern, ia menggunakan algoritma Boyer-Moore di bawah tenda.


(Catatan Penerjemah: omong-omong, Vladimir Sitnikov adalah anggota dari beberapa komite program di Grup JUG Ru dan membuat laporan yang menarik sendiri. Misalnya, video dari laporannya dari JPoint 2019 berjudul “Jawa melambat: Edisi CodeCache” tersedia di tautan ).

Hasil benchmark


Hasilnya diberikan dalam milidetik, lebih sedikit lebih baik: Di sini semuanya seperti yang diharapkan:

# JMH version: 1.21
# VM version: JDK 13.0.1, OpenJDK 64-Bit Server VM, 13.0.1+9
Benchmark (searchInput) Mode Cnt Score Error Units
javaIndexOf REGULAR avgt 5 0.622 ± 0.002 us/op
shiftingBitMask REGULAR avgt 5 1.982 ± 0.017 us/op
regexPattern REGULAR avgt 5 2.184 ± 0.006 us/op
kmp REGULAR avgt 5 2.635 ± 0.016 us/op
scalaIndexOfSlice REGULAR avgt 5 3.202 ± 0.009 us/op
guavaIndexOf REGULAR avgt 5 3.696 ± 0.095 us/op
ahoCorasic REGULAR avgt 5 7.063 ± 0.040 us/op
shiftingBitMask WORST_CASE avgt 5 1.986 ± 0.010 us/op
kmp WORST_CASE avgt 5 5.120 ± 0.006 us/op
ahoCorasic WORST_CASE avgt 5 6.892 ± 0.025 us/op
scalaIndexOfSlice WORST_CASE avgt 5 8.765 ± 0.007 us/op
regexPattern WORST_CASE avgt 5 11.566 ± 0.086 us/op
javaIndexOf WORST_CASE avgt 5 23.029 ± 0.124 us/op
guavaIndexOf WORST_CASE avgt 5 52.927 ± 0.275 us/op



  • Untuk data biasa, ia mendominasi javaIndexOf, karena ia menggunakan intrinsik berkinerja tinggi di dalamnya, karena bagian konstannya kecil;
  • , : , (O(nm)) javaIndexOf, — , shiftingBitMask ( ) .
  • guavaIndexOf , javaIndexOf; , 2 , shiftingBitMask;
  • scalaIndexOfSlice - , knuthMorrisPratt, , — , ;
  • kinerja bukan fitur terkuat ahoCorasic(atau setidaknya implementasinya; saya harus mengakui bahwa saya tidak benar-benar mencoba membuat optimasi mikro di dalamnya, karena saya menambahkannya hanya karena fitur yang membedakan: kemampuan untuk mencari di beberapa baris sekaligus, dan ini mirip dengan topik untuk artikel terpisah);
  • input data (dan panjang needle) tidak mempengaruhi kinerja shiftingBitMaskdan ahoCorasic.

temuan


Dalam kasus yang berbeda, tolok ukur dapat bekerja dengan cara yang berbeda. Terlepas dari kenyataan bahwa hasil di atas tampak sangat indikatif, Anda harus selalu melakukan pengukuran sendiri dan data yang mencerminkan tugas nyata Anda.

Berdasarkan data yang disajikan, saya membuat kesimpulan berikut:

  • String- , , String.indexOf ( java.util.regex.Pattern — );
  • , needle 64 , ;
  • , --;
  • Scala - ( ), indexOfSlice — ;
  • , -.

Itu saja! Jika Anda senang membaca tentang algoritma, kinerja, dan sejenisnya (dan juga tentang Scala, JVM, dan Java secara umum), berlangganan penulis artikel ini, Linas Medziunas ( Medium , Twitter ).

Repositori github dengan semua kode dalam artikel ini ada di sini .



Terjemahan artikel diterbitkan dengan dukungan Grup JUG Ru dan Konferensi JPoint .


All Articles