Mohon maaf jika jawaban saya tampaknya berlebihan, tetapi saya menerapkan algoritma Ukkonen baru-baru ini, dan mendapati diri saya berjuang dengan itu selama berhari-hari; Saya harus membaca beberapa makalah tentang subjek untuk memahami mengapa dan bagaimana beberapa aspek inti dari algoritma.
Saya menemukan pendekatan 'aturan' dari jawaban sebelumnya tidak membantu untuk memahami alasan yang mendasarinya , jadi saya telah menulis semuanya di bawah ini hanya berfokus pada pragmatik. Jika Anda kesulitan mengikuti penjelasan lain, seperti yang saya lakukan, mungkin penjelasan tambahan saya akan membuatnya 'klik' untuk Anda.
Saya menerbitkan implementasi C # saya di sini: https://github.com/baratgabor/SuffixTree
Harap perhatikan bahwa saya bukan ahli dalam hal ini, jadi bagian berikut mungkin mengandung ketidakakuratan (atau lebih buruk). Jika Anda menemukan sesuatu, silakan edit.
Prasyarat
Titik awal dari penjelasan berikut mengasumsikan Anda terbiasa dengan konten dan penggunaan pohon sufiks, dan karakteristik algoritma Ukkonen, misalnya bagaimana Anda memperluas karakter pohon sufiks dengan karakter, dari awal hingga akhir. Pada dasarnya, saya menganggap Anda sudah membaca beberapa penjelasan lainnya.
(Namun, saya memang harus menambahkan beberapa narasi dasar untuk alurnya, sehingga awalnya mungkin terasa berlebihan.)
Bagian yang paling menarik adalah penjelasan tentang perbedaan antara menggunakan tautan sufiks dan pemindaian ulang dari root . Inilah yang memberi saya banyak bug dan sakit kepala dalam implementasi saya.
Node daun terbuka dan keterbatasannya
Saya yakin Anda sudah tahu bahwa 'trik' paling mendasar adalah menyadari bahwa kita bisa membiarkan akhir sufiks 'terbuka', yaitu merujuk panjang string saat ini daripada mengatur akhir ke nilai statis. Dengan cara ini ketika kita menambahkan karakter tambahan, karakter-karakter itu akan ditambahkan secara implisit ke semua label sufiks, tanpa harus mengunjungi dan memperbarui mereka semua.
Namun akhiran sufiks yang terbuka ini - untuk alasan yang jelas - hanya berfungsi untuk simpul yang mewakili ujung string, yaitu simpul daun dalam struktur pohon. Operasi percabangan yang kami jalankan di pohon (penambahan node cabang baru dan node daun) tidak akan menyebar secara otomatis di mana pun mereka perlu.
Ini mungkin elementer, dan tidak perlu disebutkan, bahwa substring berulang tidak muncul secara eksplisit di pohon, karena pohon sudah mengandung ini berdasarkan mereka pengulangan; Namun, ketika substring berulang berakhir dengan menemukan karakter yang tidak berulang, kita perlu membuat percabangan pada titik itu untuk mewakili perbedaan dari titik itu dan seterusnya.
Sebagai contoh dalam kasus string 'ABCXABCY' (lihat di bawah), percabangan ke X dan Y perlu ditambahkan ke tiga sufiks yang berbeda, ABC , BC dan C ; kalau tidak, itu tidak akan menjadi pohon sufiks yang valid, dan kami tidak dapat menemukan semua substring dari string dengan mencocokkan karakter dari root ke bawah.
Sekali lagi, untuk menekankan - operasi apa pun yang kita jalankan pada sufiks di pohon perlu direfleksikan oleh sufiks berturut-turut juga (mis. ABC> BC> C), jika tidak maka sufiks tersebut tidak lagi menjadi sufiks yang valid.
Tetapi bahkan jika kita menerima bahwa kita harus melakukan pembaruan manual ini, bagaimana kita tahu berapa banyak sufiks yang perlu diperbarui? Karena, ketika kita menambahkan karakter berulang A (dan karakter berulang lainnya secara berurutan), kita belum tahu kapan / di mana kita perlu membagi sufiks menjadi dua cabang. Kebutuhan untuk memisahkan dipastikan hanya ketika kita menemukan karakter non-berulang pertama, dalam hal ini Y (bukan X yang sudah ada di pohon).
Apa yang bisa kita lakukan adalah mencocokkan string berulang yang kita bisa, dan menghitung berapa banyak sufiksnya yang perlu kita perbarui nanti. Ini adalah singkatan dari 'sisanya' .
Konsep 'sisa' dan 'pemindaian ulang'
Variabel remainder
memberitahu kita berapa banyak karakter berulang yang kita tambahkan secara implisit, tanpa bercabang; yaitu berapa banyak sufiks yang perlu kita kunjungi untuk mengulang operasi percabangan begitu kita menemukan karakter pertama yang tidak dapat kita cocokkan. Ini pada dasarnya sama dengan berapa banyak karakter 'dalam' kita di pohon dari akarnya.
Jadi, dengan tetap menggunakan contoh sebelumnya dari string ABCXABCY , kami mencocokkan bagian ABC berulang 'secara implisit', bertambah remainder
setiap kali, yang menghasilkan sisa dari 3. Kemudian kita menemukan karakter 'Y' yang tidak berulang . Di sini kita membagi menambahkan sebelumnya ABCX ke ABC -> X dan ABC -> Y . Lalu kami mengurangi remainder
3 menjadi 2, karena kami sudah mengurus cabang ABC . Sekarang kita ulangi operasi dengan mencocokkan 2 karakter terakhir - BC - dari root untuk mencapai titik di mana kita perlu membagi, dan kita membagi BCX juga menjadi BC-> X dan BC -> Y . Sekali lagi, kami mengurangi remainder
ke 1, dan mengulangi operasi; sampai remainder
is 0. Terakhir, kita perlu menambahkan karakter saat ini ( Y ) itu sendiri ke root juga.
Operasi ini, mengikuti akhiran berturut-turut dari root hanya untuk mencapai titik di mana kita perlu melakukan operasi adalah apa yang disebut 'rescanning' dalam algoritma Ukkonen, dan biasanya ini adalah bagian paling mahal dari algoritma. Bayangkan string yang lebih panjang di mana Anda perlu 'memindai ulang' substring panjang, di banyak lusinan node (kita akan membahas ini nanti), berpotensi ribuan kali.
Sebagai solusi, kami memperkenalkan apa yang kami sebut 'tautan akhiran' .
Konsep 'tautan akhiran'
Tautan sufiks pada dasarnya menunjuk ke posisi yang biasanya harus kita 'pindai ulang' , jadi alih-alih operasi pemindaian ulang yang mahal, kita dapat langsung beralih ke posisi tertaut, melakukan pekerjaan, melompat ke posisi tertaut berikutnya, dan mengulangi - sampai di sana tidak ada lagi posisi untuk diperbarui.
Tentu saja satu pertanyaan besar adalah bagaimana cara menambahkan tautan ini. Jawaban yang ada adalah bahwa kita dapat menambahkan tautan ketika kita memasukkan node cabang baru, memanfaatkan fakta bahwa, dalam setiap ekstensi pohon, node cabang secara alami dibuat satu demi satu dalam urutan yang tepat yang kita perlukan untuk menghubungkan mereka bersama-sama . Padahal, kita harus menautkan dari simpul cabang yang dibuat terakhir (akhiran terpanjang) ke yang dibuat sebelumnya, jadi kita perlu men-cache yang terakhir kita buat, menghubungkan itu ke yang berikutnya kita buat, dan cache yang baru dibuat.
Salah satu konsekuensinya adalah bahwa kita sebenarnya sering tidak memiliki tautan sufiks untuk diikuti, karena simpul cabang yang diberikan baru saja dibuat. Dalam kasus-kasus ini kita masih harus kembali ke 'pemindaian ulang' dari root. Inilah sebabnya, setelah penyisipan, Anda diperintahkan untuk menggunakan tautan suffix, atau lompat ke root.
(Atau sebagai alternatif, jika Anda menyimpan pointer orangtua di node, Anda dapat mencoba mengikuti orang tua, periksa apakah mereka memiliki tautan, dan menggunakannya. Saya menemukan bahwa ini sangat jarang disebutkan, tetapi penggunaan tautan akhiran tidak diatur dalam batu. Ada beberapa pendekatan yang mungkin, dan jika Anda memahami mekanisme yang mendasarinya Anda dapat menerapkan yang paling sesuai dengan kebutuhan Anda.)
Konsep 'titik aktif'
Sejauh ini kami membahas beberapa alat yang efisien untuk membangun pohon, dan secara samar-samar merujuk melintasi beberapa sisi dan simpul, tetapi belum mengeksplorasi konsekuensi dan kompleksitas yang sesuai.
Konsep 'sisa' yang dijelaskan sebelumnya berguna untuk melacak di mana kita berada di pohon, tetapi kita harus menyadari itu tidak menyimpan informasi yang cukup.
Pertama, kita selalu berada di tepi spesifik node, jadi kita perlu menyimpan informasi edge. Kami akan menyebutnya 'tepi aktif' .
Kedua, bahkan setelah menambahkan informasi tepi, kami masih tidak memiliki cara untuk mengidentifikasi posisi yang lebih jauh di pohon, dan tidak terhubung langsung ke simpul akar . Jadi kita perlu menyimpan node juga. Sebut ini 'simpul aktif' .
Terakhir, kita dapat melihat bahwa 'sisa' tidak memadai untuk mengidentifikasi posisi di tepi yang tidak terhubung langsung ke root, karena 'sisa' adalah panjang dari seluruh rute; dan kami mungkin tidak ingin repot mengingat dan mengurangi panjang dari tepi sebelumnya. Jadi kita perlu representasi yang pada dasarnya adalah sisa di tepi saat ini . Inilah yang kami sebut 'panjang aktif' .
Ini mengarah ke apa yang kita sebut 'titik aktif' - paket tiga variabel yang berisi semua informasi yang perlu kita pertahankan tentang posisi kita di pohon:
Active Point = (Active Node, Active Edge, Active Length)
Anda dapat mengamati pada gambar berikut bagaimana rute yang cocok dari ABCABD terdiri dari 2 karakter di tepi AB (dari root ), ditambah 4 karakter di tepi CABDABCABD (dari node 4) - menghasilkan 'sisa' 6 karakter. Jadi, posisi kami saat ini dapat diidentifikasi sebagai Node 4 Aktif, Tepi Aktif C, Panjang Aktif 4 .
Peran penting lain dari 'titik aktif' adalah memberikan lapisan abstraksi untuk algoritme kami, yang berarti bahwa bagian dari algoritme kami dapat melakukan pekerjaan mereka pada 'titik aktif' , terlepas dari apakah titik aktif itu di root atau di tempat lain . Ini membuatnya mudah untuk menerapkan penggunaan tautan sufiks dalam algoritme kami dengan cara yang bersih dan mudah.
Perbedaan pemindaian ulang vs menggunakan tautan sufiks
Sekarang, bagian yang sulit, sesuatu yang - dalam pengalaman saya - dapat menyebabkan banyak bug dan sakit kepala, dan tidak dijelaskan dengan baik di sebagian besar sumber, adalah perbedaan dalam pemrosesan kasus sambungan sufiks vs kasus pemindaian ulang.
Perhatikan contoh string 'AAAABAAAABAAC' berikut :
Anda dapat mengamati di atas bagaimana 'sisa' dari 7 sesuai dengan jumlah total karakter dari root, sedangkan 'panjang aktif' dari 4 sesuai dengan jumlah karakter yang cocok dari tepi aktif dari simpul aktif.
Sekarang, setelah menjalankan operasi percabangan pada titik aktif, simpul aktif kami mungkin atau mungkin tidak mengandung tautan sufiks.
Jika tautan sufiks ada: Kita hanya perlu memproses bagian 'panjang aktif' . The 'sisanya' tidak relevan, karena simpul mana kita melompat ke melalui link akhiran sudah mengkodekan benar 'sisa' secara implisit , hanya berdasarkan berada di pohon di mana itu.
Jika tautan sufiks TIDAK ada: Kita perlu 'memindai ulang' dari nol / root, yang berarti memproses sufiks keseluruhan dari awal. Untuk tujuan ini kita harus menggunakan seluruh 'sisa' sebagai dasar untuk memindai ulang.
Contoh perbandingan pemrosesan dengan dan tanpa tautan sufiks
Pertimbangkan apa yang terjadi pada langkah selanjutnya dari contoh di atas. Mari kita bandingkan cara mencapai hasil yang sama - yaitu berpindah ke akhiran berikutnya untuk diproses - dengan dan tanpa tautan akhiran.
Menggunakan 'tautan akhiran'
Perhatikan bahwa jika kita menggunakan tautan sufiks, kita secara otomatis 'di tempat yang tepat'. Yang sering tidak sepenuhnya benar karena kenyataan bahwa 'panjang aktif' dapat 'tidak kompatibel' dengan posisi baru.
Dalam kasus di atas, karena 'panjang aktif' adalah 4, kami bekerja dengan sufiks ' ABAA' , mulai dari Node 4. yang ditautkan. Namun setelah menemukan tepi yang sesuai dengan karakter pertama dari akhiran ( 'A' ), kami perhatikan bahwa 'panjang aktif' kami melebihi tepi ini sebanyak 3 karakter. Jadi kita melompati tepi penuh, ke simpul berikutnya, dan mengurangi 'panjang aktif' oleh karakter yang kita konsumsi dengan lompatan.
Kemudian, setelah kami menemukan tepi berikutnya 'B' , sesuai dengan sufiks akhiran 'BAA ', kami akhirnya mencatat bahwa panjang tepi lebih besar daripada 'panjang aktif' tersisa 3, yang berarti kami menemukan tempat yang tepat.
Harap perhatikan bahwa tampaknya operasi ini biasanya tidak disebut sebagai 'pemindaian ulang', meskipun bagi saya tampaknya ini setara langsung dengan pemindaian ulang, hanya dengan panjang yang lebih pendek dan titik awal non-root.
Menggunakan 'pindai ulang'
Perhatikan bahwa jika kita menggunakan operasi 'pindai ulang' tradisional (di sini berpura-pura kita tidak memiliki tautan sufiks), kita mulai di bagian atas pohon, di root, dan kita harus bekerja turun lagi ke tempat yang tepat, mengikuti sepanjang panjang akhiran saat ini.
Panjang akhiran ini adalah 'sisa' yang kita bahas sebelumnya. Kita harus mengkonsumsi keseluruhan dari sisa ini, hingga mencapai nol. Ini mungkin (dan seringkali memang) termasuk melompat melalui beberapa node, pada setiap lompatan mengurangi sisanya pada panjang tepi yang kita lewati. Kemudian akhirnya, kami mencapai tepi yang lebih panjang dari 'sisa' kami yang tersisa ; di sini kita mengatur tepi aktif ke tepi yang diberikan, mengatur 'panjang aktif' untuk sisa 'sisa ', dan kita selesai.
Perhatikan, bagaimanapun, bahwa variabel 'sisa' yang sebenarnya perlu dipertahankan, dan hanya dikurangi setelah setiap penyisipan simpul. Jadi apa yang saya jelaskan di atas diasumsikan menggunakan variabel terpisah diinisialisasi ke 'sisa' .
Catatan tentang tautan suffix & rescans
1) Perhatikan bahwa kedua metode mengarah ke hasil yang sama. Namun, lompatan tautan sufiks secara signifikan lebih cepat dalam banyak kasus; itulah dasar pemikiran di balik tautan sufiks.
2) Implementasi algoritmik yang sebenarnya tidak perlu berbeda. Seperti yang saya sebutkan di atas, bahkan dalam kasus menggunakan tautan suffix, 'panjang aktif' sering tidak kompatibel dengan posisi tertaut, karena cabang pohon itu mungkin mengandung cabang tambahan. Jadi pada dasarnya Anda hanya perlu menggunakan 'panjang aktif' alih-alih 'sisa' , dan jalankan logika pemindaian ulang yang sama sampai Anda menemukan tepi yang lebih pendek dari panjang sufiks yang tersisa.
3) Satu komentar penting yang berkaitan dengan kinerja adalah bahwa tidak perlu memeriksa setiap karakter selama pemindaian ulang. Karena cara pohon sufiks yang valid dibangun, kita dapat dengan aman mengasumsikan bahwa karakternya cocok. Jadi, Anda sebagian besar menghitung panjang, dan satu-satunya kebutuhan untuk pengecekan kesetaraan karakter muncul ketika kita melompat ke tepi baru, karena tepi diidentifikasi oleh karakter pertama mereka (yang selalu unik dalam konteks node yang diberikan). Ini berarti bahwa logika 'rescanning' berbeda dari logika pencocokan string penuh (yaitu mencari substring di pohon).
4) Tautan akhiran asli yang dijelaskan di sini hanyalah salah satu dari pendekatan yang mungkin . Misalnya NJ Larsson et al. menamakan pendekatan ini sebagai Node-Oriented Top-Down , dan membandingkannya dengan Node-Oriented Bottom-Up dan dua varietas Edge-Oriented . Pendekatan yang berbeda memiliki kinerja kasus yang khas dan terburuk, persyaratan, batasan, dll., Tetapi secara umum tampaknya pendekatan Edge-Oriented adalah peningkatan keseluruhan terhadap yang asli.