Bagaimana Mengira Jarak Levenshtein di Jawa?

1. Pengenalan

Dalam artikel ini, kami menerangkan jarak Levenshtein, atau dikenali sebagai jarak Edit. Algoritma yang dijelaskan di sini dibuat oleh seorang saintis Rusia, Vladimir Levenshtein, pada tahun 1965.

Kami akan menyediakan pelaksanaan Java berulang dan algoritma ini.

2. Berapakah Jarak Levenshtein?

Jarak Levenshtein adalah ukuran perbezaan antara dua String. Secara matematik, diberi dua String x dan y , jarak mengukur bilangan minimum penyuntingan watak yang diperlukan untuk mengubah x menjadi y .

Biasanya tiga jenis suntingan dibenarkan:

  1. Penyisipan watak c
  2. Penghapusan watak c
  3. Penggantian watak c dengan c '

Contoh: Jika x = 'tembakan' dan y = 'spot' , jarak pengeditan antara keduanya adalah 1 kerana 'tembakan' dapat ditukar menjadi 'spot' dengan menggantikan ' h ' ke ' p '.

Dalam sub-kelas masalah tertentu, kos yang berkaitan dengan setiap jenis suntingan mungkin berbeza.

Contohnya, kos penggantian yang lebih rendah dengan watak yang terletak berdekatan dengan papan kekunci dan lebih banyak kos sebaliknya. Untuk kesederhanaan, kami akan menganggap semua kos sama dalam artikel ini.

Beberapa aplikasi jarak edit adalah:

  1. Pemeriksa Ejaan - mengesan kesalahan ejaan dalam teks dan cari ejaan yang betul yang paling hampir dalam kamus
  2. Pengesanan Plagiarisme (rujuk - Kertas IEEE)
  3. Analisis DNA - mencari persamaan antara dua urutan
  4. Pengecaman Ucapan (rujuk - Microsoft Research)

3. Rumusan Algoritma

Mari kita ambil dua String x dan y panjang m dan n masing-masing. Kita boleh menandakan setiap String sebagai x [1: m] dan y [1: n].

Kami tahu bahawa pada akhir transformasi, kedua-dua String akan sama panjang dan mempunyai watak yang sepadan pada setiap kedudukan. Oleh itu, jika kita mempertimbangkan watak pertama dari setiap String, kita mempunyai tiga pilihan:

  1. Penggantian:
    1. Tentukan kos ( D1 ) untuk menggantikan x [1] dengan y [1] . Kos langkah ini akan menjadi sifar jika kedua-dua watak itu sama. Sekiranya tidak, kosnya akan menjadi satu
    2. Selepas langkah 1.1, kita tahu bahawa kedua-dua String bermula dengan watak yang sama. Oleh itu, jumlah kos sekarang adalah jumlah kos langkah 1.1 dan kos mengubah String x yang lain [2: m] menjadi y [2: n]
  2. Penyisipan:
    1. Masukkan watak dalam x agar sepadan dengan watak pertama dalam y , kos langkah ini adalah satu
    2. Selepas 2.1, kami telah memproses satu watak dari y . Oleh itu, jumlah kos sekarang adalah jumlah kos langkah 2.1 (iaitu, 1) dan kos mengubah x penuh [1: m] menjadi baki y (y [2: n])
  3. Pemadaman:
    1. Padamkan watak pertama dari x , kos langkah ini akan menjadi satu
    2. Selepas 3.1, kami telah memproses satu watak dari x , tetapi y penuh masih perlu diproses. Jumlah kos adalah jumlah kos 3.1 (iaitu, 1) dan kos mengubah baki x menjadi y penuh

Bahagian penyelesaian seterusnya adalah untuk mengetahui pilihan mana yang harus dipilih daripada ketiga-tiga ini. Oleh kerana kita tidak tahu pilihan mana yang akan menghasilkan kos minimum pada akhirnya, kita mesti mencuba semua pilihan dan memilih yang terbaik.

4. Pelaksanaan rekursif naif

Kita dapat melihat bahawa langkah kedua dari setiap pilihan di bahagian # 3 kebanyakannya adalah masalah jarak edit yang sama tetapi pada sub-rentetan dari String yang asal . Ini bermaksud selepas setiap lelaran, kita berakhir dengan masalah yang sama tetapi dengan Rentetan yang lebih kecil .

Pemerhatian ini adalah kunci untuk merumuskan algoritma rekursif. Hubungan berulang boleh didefinisikan sebagai:

D (x [1: m], y [1: n]) = min {

D (x [2: m], y [2: n]) + Kos Mengganti x [1] hingga y [1],

D (x [1: m], y [2: n]) + 1,

D (x [2: m], y [1: n]) + 1

}

Kita juga mesti menentukan kes asas untuk algoritma rekursif kita, yang dalam kes kita adalah ketika satu atau kedua String menjadi kosong:

  1. Apabila kedua-dua String kosong, maka jarak di antara mereka adalah sifar
  2. Apabila salah satu daripada Strings kosong, maka jarak edit di antara mereka ialah panjang yang lain String, kerana kami memerlukan nombor sisipan / penghapusan untuk mengubah satu ke yang lain:
    • Contoh: jika satu String adalah "dog" dan String yang lain adalah "" (kosong), kita memerlukan tiga sisipan dalam String kosong untuk menjadikannya "dog" , atau kita memerlukan tiga penghapusan di "dog" untuk membuatnya kosong. Oleh itu jarak pengeditan antara mereka adalah 3

Pelaksanaan berulang algoritma naif:

public class EditDistanceRecursive { static int calculate(String x, String y) { if (x.isEmpty()) { return y.length(); } if (y.isEmpty()) { return x.length(); } int substitution = calculate(x.substring(1), y.substring(1)) + costOfSubstitution(x.charAt(0), y.charAt(0)); int insertion = calculate(x, y.substring(1)) + 1; int deletion = calculate(x.substring(1), y) + 1; return min(substitution, insertion, deletion); } public static int costOfSubstitution(char a, char b) { return a == b ? 0 : 1; } public static int min(int... numbers) { return Arrays.stream(numbers) .min().orElse(Integer.MAX_VALUE); } }

Algoritma ini mempunyai kerumitan eksponensial. Pada setiap langkah, kami melakukan tiga panggilan berulang, membina kerumitan O (3 ^ n) .

Di bahagian seterusnya, kita akan melihat bagaimana memperbaiki perkara ini.

5. Pendekatan Pengaturcaraan Dinamik

Semasa menganalisis panggilan berulang, kami melihat bahawa argumen untuk sub-masalah adalah akhiran dari String yang asal . Ini bermakna hanya boleh ada panggilan rekursif m * n yang unik (di mana m dan n adalah sejumlah akhiran x dan y ). Oleh itu kerumitan penyelesaian optimum harus kuadratik, O (m * n) .

Mari kita lihat beberapa sub-masalah (mengikut hubungan berulang yang ditentukan dalam bahagian # 4):

  1. Sub-masalah D (x [1: m], y [1: n]) ialah D (x [2: m], y [2: n]), D (x [1: m], y [2 : n]) dan D (x [2: m], y [1: n])
  2. Sub-problems of D(x[1:m], y[2:n]) are D(x[2:m], y[3:n]), D(x[1:m], y[3:n]) and D(x[2:m], y[2:n])
  3. Sub-problems of D(x[2:m], y[1:n]) are D(x[3:m], y[2:n]), D(x[2:m], y[2:n]) and D(x[3:m], y[1:n])

In all three cases, one of the sub-problems is D(x[2:m], y[2:n]). Instead of calculating this three times like we do in the naive implementation, we can calculate this once and reuse the result whenever needed again.

This problem has a lot of overlapping sub-problems, but if we know the solution to the sub-problems, we can easily find the answer to the original problem. Therefore, we have both of the properties needed for formulating a dynamic programming solution, i.e., Overlapping Sub-Problems and Optimal Substructure.

We can optimize the naive implementation by introducing memoization, i.e., store the result of the sub-problems in an array and reuse the cached results.

Alternatively, we can also implement this iteratively by using a table based approach:

static int calculate(String x, String y) { int[][] dp = new int[x.length() + 1][y.length() + 1]; for (int i = 0; i <= x.length(); i++) { for (int j = 0; j <= y.length(); j++) { if (i == 0) { dp[i][j] = j; } else if (j == 0) { dp[i][j] = i; } else { dp[i][j] = min(dp[i - 1][j - 1] + costOfSubstitution(x.charAt(i - 1), y.charAt(j - 1)), dp[i - 1][j] + 1, dp[i][j - 1] + 1); } } } return dp[x.length()][y.length()]; } 

This algorithm performs significantly better than the recursive implementation. However, it involves significant memory consumption.

This can further be optimized by observing that we only need the value of three adjacent cells in the table to find the value of the current cell.

6. Conclusion

Dalam artikel ini, kami menerangkan berapa jarak Levenshtein dan bagaimana ia dapat dikira menggunakan pendekatan berdasarkan pengaturcaraan dan pengaturcaraan dinamik.

Jarak Levenshtein hanyalah salah satu ukuran kesamaan tali, beberapa metrik lain adalah Kesamaan Cosine (yang menggunakan pendekatan berdasarkan token dan menganggap rentetan sebagai vektor), Pekali Dadu, dll.

Seperti biasa, pelaksanaan contoh dapat dilihat di GitHub.