Hogyan lehet kiszámítani a Levenshtein távolságot Java-ban?
1. Bemutatkozás
Ebben a cikkben leírjuk a Levenshtein távolságot, más néven a Szerkesztés távolságot. Az itt ismertetett algoritmust egy orosz tudós, Vladimir Levenshtein találta ki 1965-ben.
Biztosítunk egy iteratív és egy rekurzív Java implementációt ennek az algoritmusnak.
2. Mekkora a Levenshtein távolság?
A Levenshtein-távolság a kettő közötti különbség mértéke Húrok. Matematikailag adott kettő Húrokx és y, a távolság az átalakításhoz szükséges karakterszerkesztések minimális számát méri x -ba y.
Általában háromféle szerkesztés engedélyezett:
- Karakter beszúrása c
- Karakter törlése c
- Karakter cseréje c val vel c‘
Példa: Ha x = „lövés” és y = „folt”, a kettő közötti szerkesztési távolság 1, mert 'lövés' konvertálható 'folt' a „h' nak nek 'o‘.
A probléma bizonyos alosztályaiban az egyes szerkesztéstípusokhoz kapcsolódó költségek eltérőek lehetnek.
Például kevesebb költsége van a billentyűzet közelében lévő karakterrel való helyettesítésnek, máskülönben több. Az egyszerűség kedvéért ebben a cikkben az összes költséget egyenlőnek tekintjük.
A szerkesztési távolság néhány alkalmazása a következő:
- Helyesírás-ellenőrző - a szöveg helyesírási hibáinak észlelése és a szótárban megtalálja a legközelebbi helyesírást
- Plágium észlelése (hivatkozás - IEEE papír)
- DNS-elemzés - hasonlóság megtalálása két szekvencia között
- Beszédfelismerés (hivatkozás - Microsoft Research)
3. Algoritmus megfogalmazása
Vegyünk kettőt Karakterláncok x és y hosszúságúak m és n illetőleg. Jelölhetjük mindegyiket Húr mint x [1: m] és y [1: n].
Tudjuk, hogy az átalakulás végén mindkettő Húrok azonos hosszúságúak lesznek, és minden pozícióban megfelelő karakterek lesznek. Tehát, ha figyelembe vesszük mindegyik első karakterét Húr, három lehetőségünk van:
- Helyettesítés:
- Határozza meg a költséget (D1) helyettesítés x [1] val vel y [1]. Ennek a lépésnek a költsége nulla lenne, ha mindkét karakter azonos. Ha nem, akkor a költség egy lenne
- Az 1.1 lépés után tudjuk, hogy mindkettő Húrok ugyanazzal a karakterrel kezdje. Ezért a teljes költség most az 1.1 lépés költségeinek és a többi elem átalakításának költsége lesz Karakterlánc x [2: m] -ba y [2: n]
- Beillesztés:
- Karakter beszúrása ide x hogy illeszkedjen az első karakterhez y, ennek a lépésnek a költsége egy lenne
- 2.1 után egy karaktert dolgoztunk fel a y. Ezért az összköltség most a 2.1 lépés költségeinek (azaz az 1. lépésnek) és a teljes x [1: m] hogy maradjon y (y [2: n])
- Törlés:
- Törölje az első karaktert a x, ennek a lépésnek a költsége egy lenne
- 3.1 után egy karaktert dolgoztunk fel a x, de a teljes y még feldolgozásra vár. A teljes költség a 3,1 (azaz 1) költségének és a fennmaradó átalakítás költségének összege lenne x teljes mértékben y
A megoldás következő része az, hogy kitaláljuk, melyik opció közül válasszon ebből a háromból. Mivel nem tudjuk, melyik megoldás vezetne minimális költséghez a végén, ki kell próbálnunk az összes lehetőséget, és ki kell választanunk a legjobbat.
4. Naiv rekurzív megvalósítás
Láthatjuk, hogy a 3. szakasz minden egyes opciójának második lépése többnyire ugyanaz a távolság szerkesztési probléma, de az eredeti részhúrjain Húrok. Ez azt jelenti, hogy minden iteráció után ugyanaz a probléma jelentkezik, de kisebb Húrok.
Ez a megfigyelés a kulcs a rekurzív algoritmus megfogalmazásához. Az ismétlődési összefüggés a következőképpen határozható meg:
D (x [1: m], y [1: n]) = min {
D (x [2: m], y [2: n]) + Az x [1] y [1] -re cseréjének költsége,
D (x [1: m], y [2: n]) + 1,
D (x [2: m], y [1: n]) + 1
}
Meg kell határoznunk a rekurzív algoritmusunk alapeseteit is, amely esetünkben az, ha az egyik vagy mindkettő Húrok kiürül:
- Amikor mindkettő Húrok üresek, akkor a köztük lévő távolság nulla
- Amikor az egyik Húrok üres, akkor a köztük lévő szerkesztési távolság megegyezik a másik hosszával Húr, mivel annyi számú beszúrásra / törlésre van szükségünk egymás átalakításához:
- Példa: ha egy Húr van "kutya" és egyéb Húr van “” (üres), vagy három beillesztésre van szükségünk üresen Húr hogy elkészítsem "kutya", vagy három törlésre van szükségünk a "kutya" hogy üres legyen. Ezért a köztük lévő szerkesztési távolság 3
Az algoritmus naiv, rekurzív megvalósítása:
public class EditDistanceRecursive {static int calc (String x, String y) {if (x.isEmpty ()) {return y.length (); } if (y.isEmpty ()) {return x.length (); } int szubsztitúció = kiszámítás (x.substring (1), y.substring (1)) + costOfSubstitution (x.charAt (0), y.charAt (0)); int beillesztés = számítás (x, y.szubstring (1)) + 1; int törlés = számítás (x.substring (1), y) + 1; visszatérési perc (helyettesítés, inszerció, törlés); } public static int costOfSubstitution (char a, char b) {return a == b? 0: 1; } public static int min (int ... számok) {return tömbök.stream (számok) .min (). vagyElse (egész szám.MAX_VALUE); }}
Ennek az algoritmusnak van exponenciális összetettsége. Minden lépésben három rekurzív hívásra bontunk szét egy épületet O (3 ^ n) bonyolultság.
A következő részben megtudjuk, hogyan lehetne ezen javítani.
5. Dinamikus programozási megközelítés
A rekurzív hívások elemzésénél megfigyelhetjük, hogy az alproblémák argumentumai az eredeti utótagjai Húrok. Ez azt jelenti, hogy csak ilyenek lehetnek m * n egyedi rekurzív hívások (ahol m és n számos utótagja x és y). Ezért az optimális megoldás komplexitásának kvadratikusnak kell lennie, O (m * n).
Vizsgáljuk meg néhány részproblémát (a 4. szakaszban meghatározott ismétlődési reláció szerint):
- A részproblémák D (x [1: m], y [1: n]) vannak D (x [2: m], y [2: n]), D (x [1: m], y [2: n]) és D (x [2: m], y [1: n])
- A részproblémák D (x [1: m], y [2: n]) vannak D (x [2: m], y [3: n]), D (x [1: m], y [3: n]) és D (x [2: m], y [2: n])
- A részproblémák D (x [2: m], y [1: n]) vannak D (x [3: m], y [2: n]), D (x [2: m], y [2: n]) és D (x [3: m], y [1: n])
Mindhárom esetben az egyik részprobléma az D (x [2: m], y [2: n]). Ahelyett, hogy ezt háromszor kiszámolnánk, mint a naiv megvalósításban, egyszer kiszámíthatjuk ezt, és szükség esetén újra felhasználhatjuk az eredményt.
Ennek a problémának sok átfedő részproblémája van, de ha ismerjük az alproblémák megoldását, könnyen megtalálhatjuk a választ az eredeti problémára. Ezért mindkettőnk rendelkezik a dinamikus programozási megoldás megfogalmazásához szükséges tulajdonságokkal, azaz Átfedő részproblémák és Optimális alstruktúra.
Optimalizálhatjuk a naiv megvalósítást a memoizálás bevezetésével, vagyis az alproblémák eredményét egy tömbben tárolhatjuk, és a gyorsítótárazott eredményeket újra felhasználhatjuk.
Alternatív megoldásként ezt iteratív módon is megvalósíthatjuk táblázatos megközelítés használatával:
statikus int kiszámítása (String x, String y) {int [] [] dp = új int [x.hossz () + 1] [y.length () + 1]; for (int i = 0; i <= x.hossz (); i ++) {for (int j = 0; j <= y.hossz (); 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.hossz ()] [y.hossz ()]; }
Ez az algoritmus lényegesen jobban teljesít, mint a rekurzív megvalósítás. Jelentős memóriafogyasztással jár azonban.
Ez tovább optimalizálható annak megfigyelésével, hogy a táblázatban csak három szomszédos cella értékére van szükségünk az aktuális cella értékének megtalálásához.
6. Következtetés
Ebben a cikkben leírtuk, hogy mi a Levenshtein távolság, és hogyan lehet kiszámítani rekurzív és dinamikus programozáson alapuló megközelítéssel.
A Levenshtein távolság csak a húr hasonlóságának egyik mérőszáma, a többi mutató egy része a Kozinusz hasonlóság (amely token alapú megközelítést alkalmaz és a húrokat vektoroknak tekinti), Dice Coefficient stb.
Mint mindig, a példák teljes megvalósítása megtalálható a GitHub oldalon.